export const APPEARANCE_PREFERENCES = ["system", "light", "dark"] as const; export type AppearancePreference = (typeof APPEARANCE_PREFERENCES)[number]; export type ResolvedAppearance = "light" | "dark"; export const UI_APPEARANCE_STORAGE_KEY = "rakazo.uiAppearance"; /** * Semantic palette shared by web, Electron, and Expo. Names follow the shadcn * convention so the same slot means the same thing on every surface: a `border` * is always a border and never a fill. */ export type ColorTokens = { background: string; foreground: string; card: string; cardForeground: string; popover: string; popoverForeground: string; primary: string; primaryForeground: string; secondary: string; secondaryForeground: string; chatUser: string; chatUserForeground: string; muted: string; mutedForeground: string; accent: string; accentForeground: string; destructive: string; destructiveForeground: string; border: string; input: string; ring: string; sidebar: string; sidebarForeground: string; sidebarBorder: string; sidebarAccent: string; sidebarAccentForeground: string; link: string; success: string; warning: string; overlay: string; scrollbar: string; scrollbarHover: string; }; export const darkTokens = { background: "#07080A", foreground: "#F3F4F6", card: "#0F1115", cardForeground: "#F3F4F6", popover: "#12151B", popoverForeground: "#F3F4F6", primary: "#FFFFFF", primaryForeground: "#07080A", secondary: "#151820", secondaryForeground: "#F3F4F6", chatUser: "#161B26", chatUserForeground: "#F9FAFB", muted: "#12151B", mutedForeground: "#A1A8B5", accent: "#1A1E29", accentForeground: "#FFFFFF", destructive: "#FF453A", destructiveForeground: "#FFFFFF", border: "#1C202B", input: "#13161D", ring: "#0A84FF", sidebar: "#0B0C10", sidebarForeground: "#F3F4F6", sidebarBorder: "#161820", sidebarAccent: "#171A23", sidebarAccentForeground: "#FFFFFF", link: "#0A84FF", success: "#30D158", warning: "#FFD60A", overlay: "rgba(3, 4, 6, 0.78)", scrollbar: "#1C202B", scrollbarHover: "#2C3242", } as const satisfies ColorTokens; export const lightTokens = { background: "#FFFFFF", foreground: "#1A1A1A", card: "#FFFFFF", cardForeground: "#1A1A1A", popover: "#FFFFFF", popoverForeground: "#1A1A1A", primary: "#1A1A1A", primaryForeground: "#FFFFFF", secondary: "#F4F4F1", secondaryForeground: "#1A1A1A", chatUser: "#EFEFEB", chatUserForeground: "#1A1A1A", muted: "#F4F4F1", mutedForeground: "#67676C", accent: "#ECECE8", accentForeground: "#1A1A1A", destructive: "#DC2626", destructiveForeground: "#FFFFFF", border: "#E5E5E0", input: "#DADAD4", ring: "#9C9C97", sidebar: "#F5F5F2", sidebarForeground: "#1A1A1A", sidebarBorder: "#E8E8E3", sidebarAccent: "#FFFFFF", sidebarAccentForeground: "#1A1A1A", link: "#2563EB", success: "#1E7F37", warning: "#A16207", overlay: "rgba(24, 24, 22, 0.28)", scrollbar: "#D3D3CD", scrollbarHover: "#B3B3AC", } as const satisfies ColorTokens; /** Dark palette. Prefer `tokensForAppearance` when theme-aware. */ export const tokens = darkTokens; export const RADIUS = "0.75rem"; /** * Shared UI scale for typography, icons, controls, and spacing. Values are * appearance-neutral; light and dark themes use the same scale. */ export type ScaleTokens = { fontSizeCaption: string; fontSizeLabel: string; fontSizeBodySm: string; fontSizeBody: string; fontSizeReading: string; fontSizeTitleSm: string; fontSizeTitle: string; lineHeightBody: string; lineHeightReading: string; lineHeightTight: string; iconSm: string; iconMd: string; iconLg: string; controlHeightXs: string; controlHeightSm: string; controlHeightMd: string; controlHeightLg: string; spacingXs: string; spacingSm: string; spacingMd: string; spacingLg: string; spacingXl: string; spacingShell: string; spacingSidebarRowX: string; spacingSidebarRowY: string; }; /** Comfortable UI scale — body chrome ~15.5px, reading ~17px, touch controls 44px. */ export const scaleTokens = { fontSizeCaption: "0.75rem", fontSizeLabel: "0.84375rem", fontSizeBodySm: "0.875rem", fontSizeBody: "0.96875rem", fontSizeReading: "1.0625rem", fontSizeTitleSm: "1.125rem", fontSizeTitle: "1.25rem", lineHeightBody: "1.5", lineHeightReading: "1.65", lineHeightTight: "1.3", iconSm: "1rem", iconMd: "1.125rem", iconLg: "1.25rem", controlHeightXs: "1.75rem", controlHeightSm: "2.25rem", controlHeightMd: "2.5rem", controlHeightLg: "2.75rem", spacingXs: "0.25rem", spacingSm: "0.5rem", spacingMd: "0.75rem", spacingLg: "1rem", spacingXl: "1.25rem", spacingShell: "0.625rem", spacingSidebarRowX: "0.75rem", spacingSidebarRowY: "0.625rem", } as const satisfies ScaleTokens; export const botColors = [ "#3EC5A8", "#F5A03C", "#6A6BF5", "#9B5CF6", "#3B82F6", "#F2622A", "#D9508A", ] as const; export function isAppearancePreference( value: string | null | undefined, ): value is AppearancePreference { return value === "system" || value === "light" || value === "dark"; } export function normalizeAppearancePreference( raw: string | null | undefined, ): AppearancePreference { return isAppearancePreference(raw) ? raw : "system"; } export type ResolveAppearancePreferenceOptions = { stored?: string | null; storage?: Pick | null; }; function getLocalStorage(): Pick | null { try { return typeof localStorage !== "undefined" ? localStorage : null; } catch { return null; } } export function resolveAppearancePreference( options: ResolveAppearancePreferenceOptions = {}, ): AppearancePreference { const stored = options.stored !== undefined ? options.stored : readStoredAppearance(options.storage ?? getLocalStorage()); return normalizeAppearancePreference(stored); } export function persistAppearancePreference( preference: AppearancePreference, storage: Pick | null = getLocalStorage(), ): void { if (!storage) return; try { storage.setItem(UI_APPEARANCE_STORAGE_KEY, preference); } catch { // Ignore quota / private-mode failures; in-memory preference still applies. } } export function resolveAppearance( preference: AppearancePreference, system: ResolvedAppearance = "dark", ): ResolvedAppearance { if (preference === "system") return system; return preference; } export function tokensForAppearance(appearance: ResolvedAppearance): ColorTokens { return appearance === "light" ? lightTokens : darkTokens; } function readStoredAppearance(storage: Pick | null | undefined): string | null { if (!storage) return null; try { return storage.getItem(UI_APPEARANCE_STORAGE_KEY); } catch { return null; } } /** `cardForeground` -> `--card-foreground` */ export function cssVariableName(token: keyof ColorTokens): string { return `--${token.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; } /** `fontSizeBody` -> `--font-size-body` */ export function scaleCssVariableName(token: keyof ScaleTokens): string { return `--${token.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; } function renderScaleLines() { return (Object.keys(scaleTokens) as (keyof ScaleTokens)[]).map( (token) => ` ${scaleCssVariableName(token)}: ${scaleTokens[token]};`, ); } function renderBlock(selector: string, colorScheme: ResolvedAppearance, palette: ColorTokens) { const colorLines = (Object.keys(palette) as (keyof ColorTokens)[]).map( (token) => ` ${cssVariableName(token)}: ${palette[token].toLowerCase()};`, ); const scaleLines = renderScaleLines(); return `${selector} {\n color-scheme: ${colorScheme};\n${colorLines.join("\n")}\n --radius: ${RADIUS};\n${scaleLines.join("\n")}\n}`; } /** The CSS in `tokens.css`. Generated from the TS palette so both stay in sync. */ export function renderTokensCss(): string { return `${[ "/* Generated by `pnpm --filter @rakazo/ui-tokens generate`. Edit src/index.ts instead. */", renderBlock(':root,\n[data-theme="dark"]', "dark", darkTokens), renderBlock('[data-theme="light"]', "light", lightTokens), ].join("\n\n")}\n`; }