* 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 * refactor(ui-web): leave the cn() fix to #115 #115 fixes the same tailwind-merge bug more completely (text, control, icon and spacing scales, guarded against styles.css) and adds the same two files, so this branch drops its narrower copy to avoid the overlap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016or6oqCqP8wbNafvhnbLQT --------- Co-authored-by: Claude <noreply@anthropic.com>
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`;
|
|
}
|