The web shell hardcoded dark surfaces (panel hex, white/black alphas, white text), so switching to light left the sidebar, chat, composer and side panel dark with ink text on top. Shell surfaces now use the shared tokens: light gets a warm paper frame with white panels, hairline borders and short soft shadows; dark keeps its look via dark: variants. - Light palette: visible borders (border was equal to muted), white primary-foreground, lighter user bubble, readable warning/success. - Selected sidebar row lives in one place (roster-row-style). - Markdown, inline code, code blocks, quotes and scrollbars follow tokens. - cn() now knows the text-ui-* scale is a font size; tailwind-merge treated it as a color and dropped labels' color on resized buttons (sign-up submit rendered ink on ink). - Stage bloom was offset left by a double translateX. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016or6oqCqP8wbNafvhnbLQT
295 lines
8.1 KiB
TypeScript
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: "#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<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`;
|
|
}
|