Claude b757523a77
fix(ui): make the light theme a real light theme
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
2026-10-03 13:00:00 +00: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: "#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`;
}