Something went wrong. Try again.
a love letter to tangled (android, iOS, and a search API)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136import { computed, readonly, ref } from 'vue'import { applyScheme, contrastRatio, mapSchemeToTokens, parseBase16Scheme } from './base16'import type { Base16Scheme } from './base16'import { BUNDLED_SCHEMES, DEFAULT_SCHEME_ID } from './schemes'
export const THEME_STORAGE_KEY = 'twisted.theme.v1'
interface ThemePreferences { selectedId: string imports: Base16Scheme[]}
type ThemeReader = Pick<Storage, 'getItem'>type ThemeStorage = Pick<Storage, 'setItem'>
/** Reads preferences defensively and discards invalid imports or selections. */export function loadThemePreferences( storage: ThemeReader | undefined, bundled: readonly Base16Scheme[] = BUNDLED_SCHEMES,): ThemePreferences { const fallback = { selectedId: DEFAULT_SCHEME_ID, imports: [] } if (storage === undefined) { return fallback }
try { const raw = storage.getItem(THEME_STORAGE_KEY) if (raw === null) { return fallback } const saved = JSON.parse(raw) as unknown if (!isRecord(saved)) { return fallback }
const bundledIds = new Set(bundled.map(({ id }) => id)) const imports = Array.isArray(saved.imports) ? saved.imports.flatMap((candidate) => { try { const imported = parseBase16Scheme(candidate) return bundledIds.has(imported.id) || !hasAccessibleControls(imported) ? [] : [imported] } catch { return [] } }) : [] const availableIds = new Set([...bundledIds, ...imports.map(({ id }) => id)]) const selectedId = typeof saved.selectedId === 'string' && availableIds.has(saved.selectedId) ? saved.selectedId : DEFAULT_SCHEME_ID
return { selectedId, imports } } catch { return fallback }}
export function saveThemePreferences(storage: ThemeStorage, preferences: ThemePreferences): void { storage.setItem(THEME_STORAGE_KEY, JSON.stringify(preferences))}
const bundledSchemes = [...BUNDLED_SCHEMES]const importedSchemes = ref<Base16Scheme[]>([])const selectedId = ref(DEFAULT_SCHEME_ID)const schemes = computed(() => [...bundledSchemes, ...importedSchemes.value])const activeScheme = computed(() => schemes.value.find(({ id }) => id === selectedId.value) ?? BUNDLED_SCHEMES[0])
function persist(): void { if (typeof window !== 'undefined') { saveThemePreferences(window.localStorage, { selectedId: selectedId.value, imports: importedSchemes.value }) }}
function applyActiveScheme(): void { if (typeof document !== 'undefined') { applyScheme(activeScheme.value) }}
/** Initializes the singleton theme state before Vue mounts to avoid an unthemed frame. */export function initializeTheme(): void { const preferences = loadThemePreferences(typeof window === 'undefined' ? undefined : window.localStorage) importedSchemes.value = preferences.imports selectedId.value = preferences.selectedId applyActiveScheme()}
export function useTheme() { function selectTheme(id: string): boolean { if (!schemes.value.some((scheme) => scheme.id === id)) { return false } selectedId.value = id applyActiveScheme() persist() return true }
function importTheme(input: unknown): Base16Scheme { const imported = parseBase16Scheme(input) if (bundledSchemes.some(({ id }) => id === imported.id)) { throw new TypeError(`A bundled theme already uses the id “${imported.id}”`) } if (!hasAccessibleControls(imported)) { throw new TypeError('The imported theme does not provide enough contrast for readable controls') } importedSchemes.value = [...importedSchemes.value.filter(({ id }) => id !== imported.id), imported] selectedId.value = imported.id applyActiveScheme() persist() return imported }
return { schemes: readonly(schemes), activeScheme: readonly(activeScheme), selectedId: readonly(selectedId), selectTheme, importTheme, }}
function hasAccessibleControls(scheme: Base16Scheme): boolean { const tokens = mapSchemeToTokens(scheme) return ( contrastRatio(tokens['--app-text'], tokens['--app-background']) >= 4.5 && contrastRatio(tokens['--app-text-muted'], tokens['--app-background']) >= 4.5 && contrastRatio(tokens['--app-accent'], tokens['--app-background']) >= 4.5 && contrastRatio(tokens['--app-accent-contrast'], tokens['--app-accent']) >= 4.5 && contrastRatio(tokens['--app-border'], tokens['--app-surface']) >= 3 )}
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value)}