cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

295 lines
8.1 KiB
TypeScript

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: "#FAFAF8",
foreground: "#1A1A1A",
card: "#FFFFFF",
cardForeground: "#1A1A1A",
popover: "#FFFFFF",
popoverForeground: "#1A1A1A",
primary: "#1A1A1A",
primaryForeground: "#F1F1EF",
secondary: "#F0F0ED",
secondaryForeground: "#1A1A1A",
chatUser: "#E2E2DC",
chatUserForeground: "#1A1A1A",
muted: "#F0F0ED",
mutedForeground: "#6C6C70",
accent: "#EAEAE6",
accentForeground: "#1A1A1A",
destructive: "#DC2626",
destructiveForeground: "#FFFFFF",
border: "#F0F0ED",
input: "#EAEAE6",
ring: "#6C6C70",
sidebar: "#ECECE9",
sidebarForeground: "#1A1A1A",
sidebarBorder: "#E8E8E4",
sidebarAccent: "#FFFFFF",
sidebarAccentForeground: "#1A1A1A",
link: "#2563EB",
success: "#228B3B",
warning: "#B7791F",
overlay: "rgba(20, 20, 22, 0.45)",
scrollbar: "#C8C8C4",
scrollbarHover: "#A8A8A4",
} 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<Storage, "getItem"> | null;
};
function getLocalStorage(): Pick<Storage, "getItem" | "setItem"> | 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<Storage, "setItem"> | 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<Storage, "getItem"> | 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`;
}