From 0129b21b6268a21ccf6f79ae3f641ca5cf4f06fb Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:45:38 -0700 Subject: [PATCH 1/2] feat(appearance): presets you can save, share and import Route the surfaces that were still hard-coded through theme tokens (message bubbles, the composer box, the status bar, the code font in messages, message text size and density), give code blocks a per-mode Shiki theme, and add a Presets section to Appearance: seven bundled looks, density and text knobs, and JSON import/export. A preset is one strictly validated document of tokens, font ids and code theme ids, with a schema version and a size cap. It cannot carry CSS or script; custom CSS stays a local escape hatch and is never exported. The pre-paint script applies the new tokens like the existing ones, so a preset paints before first paint, and the transcript only re-renders when the code theme itself changes. Every bundled preset passes a WCAG AA contrast check on its text and surface pairs in both modes, measured against globals.css. --- src/app/globals.css | 65 +- src/components/ai-elements/code-block.tsx | 62 +- src/components/ai-elements/message.tsx | 23 +- src/components/ai-elements/reasoning.tsx | 3 + src/components/appearance-provider.test.tsx | 248 ++++- src/components/appearance-provider.tsx | 180 +++- src/components/chat/message-input.tsx | 15 +- .../conversation-detail-panel-layout.test.ts | 8 +- .../files/markdown-document-preview.tsx | 3 + src/components/layout/status-bar.tsx | 7 +- .../appearance-presets-section.test.tsx | 277 +++++ .../settings/appearance-presets-section.tsx | 688 +++++++++++++ .../settings/appearance-settings.tsx | 4 + .../settings/custom-style-section.tsx | 29 + .../settings/font-settings-section.tsx | 20 + src/hooks/use-appearance.ts | 60 +- src/i18n/messages/ar.json | 115 +++ src/i18n/messages/de.json | 115 +++ src/i18n/messages/en.json | 115 +++ src/i18n/messages/es.json | 115 +++ src/i18n/messages/fr.json | 115 +++ src/i18n/messages/ja.json | 115 +++ src/i18n/messages/ko.json | 115 +++ src/i18n/messages/pt.json | 115 +++ src/i18n/messages/zh-CN.json | 115 +++ src/i18n/messages/zh-TW.json | 115 +++ src/lib/appearance-preset.test.ts | 415 ++++++++ src/lib/appearance-preset.ts | 958 ++++++++++++++++++ src/lib/appearance-presets-bundled.test.ts | 281 +++++ src/lib/appearance-presets-bundled.ts | 639 ++++++++++++ src/lib/appearance-script.test.ts | 90 +- src/lib/appearance-script.ts | 25 + src/lib/code-themes.test.ts | 69 ++ src/lib/code-themes.ts | 144 +++ src/lib/color-contrast.test.ts | 133 +++ src/lib/color-contrast.ts | 318 ++++++ src/lib/custom-style.test.ts | 34 +- src/lib/custom-style.ts | 56 +- src/lib/font-presets.ts | 7 + src/lib/theme-presets.test.ts | 17 +- 40 files changed, 5979 insertions(+), 49 deletions(-) create mode 100644 src/components/settings/appearance-presets-section.test.tsx create mode 100644 src/components/settings/appearance-presets-section.tsx create mode 100644 src/lib/appearance-preset.test.ts create mode 100644 src/lib/appearance-preset.ts create mode 100644 src/lib/appearance-presets-bundled.test.ts create mode 100644 src/lib/appearance-presets-bundled.ts create mode 100644 src/lib/code-themes.test.ts create mode 100644 src/lib/code-themes.ts create mode 100644 src/lib/color-contrast.test.ts create mode 100644 src/lib/color-contrast.ts diff --git a/src/app/globals.css b/src/app/globals.css index 15fed69e81..f8ca004f17 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -55,6 +55,43 @@ 在任何面板透明度下都稳定。全族统一用中性 --muted 染色(不再按角色分 card/secondary/primary), 配 backdrop 磨砂 + 1px 描边(见下方 ws-msg-* 各规则),在忙碌背景图上仍看得清、分得出边界。 */ --ws-msg-alpha: 0.5; + + /* Preset-driven surfaces (custom-style.ts SURFACE_COLOR_TOKENS and + LAYOUT_THEME_TOKENS). Every default points back at a shadcn token or at the + value the surface was hard-coded to before it had a token, so a theme that + never mentions these renders byte-for-byte as it did. An appearance preset + (or the Custom Style editor) overrides them as inline custom properties on + , exactly the way it overrides --primary, and the pre-paint script + applies the stored values before first paint. + + --font-mono shadows Tailwind's theme variable of the same name with the + same stack (MONO_FALLBACK in font-presets.ts), so `font-mono` utilities and + Streamdown's code blocks follow the "code in messages" font preference + while the editor and terminal keep their own settings. + + --bubble-assistant-radius is 0 on purpose: the assistant column is + `overflow-hidden`, so a radius would clip the corners of full-width content + even with a transparent background. */ + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + --chat-font-size: 0.875rem; + --chat-line-height: calc(1.25 / 0.875); + --bubble-user-bg: var(--secondary); + --bubble-user-fg: var(--foreground); + --bubble-user-border: transparent; + --bubble-user-radius: var(--radius); + --bubble-assistant-bg: transparent; + --bubble-assistant-fg: var(--foreground); + --bubble-assistant-border: transparent; + --bubble-assistant-radius: 0px; + --bubble-assistant-padding: 0px; + --composer-bg: transparent; + --composer-border: color-mix(in oklab, var(--foreground) 20%, transparent); + --composer-radius: calc(var(--radius) * 1.4); + --status-bar-bg: var(--muted); + --status-bar-fg: var(--muted-foreground); + --status-bar-border: var(--border); } /* =========================================================================== @@ -1043,6 +1080,15 @@ font-size: 0.625rem; /* 10px @100% */ } +/* Message text (user bubble + assistant markdown). The stock values equal + Tailwind's `text-sm` (0.875rem in a 1.25rem line box), so this is a rename at + 100%; a preset moves the reading size of messages through the two variables + without touching the rest of the interface, which the window zoom scales. */ +@utility chat-text { + font-size: var(--chat-font-size); + line-height: var(--chat-line-height); +} + @layer base { * { @apply border-border outline-ring/50; @@ -1076,6 +1122,12 @@ .ws-surface-sidebar { background-color: var(--sidebar); } +/* Status bar. Same contract as .ws-surface-muted (which it replaced there), + with the colour routed through --status-bar-bg so a preset can give the bar + its own surface; the default is --muted, so nothing moves without one. */ +.ws-surface-status { + background-color: var(--status-bar-bg); +} [data-workspace-bg="on"] .ws-surface { background-color: color-mix( @@ -1104,6 +1156,15 @@ -webkit-backdrop-filter: blur(var(--ws-surface-blur)); backdrop-filter: blur(var(--ws-surface-blur)); } +[data-workspace-bg="on"] .ws-surface-status { + background-color: color-mix( + in oklch, + var(--status-bar-bg) calc(var(--ws-surface-alpha) * 100%), + transparent + ); + -webkit-backdrop-filter: blur(var(--ws-surface-blur)); + backdrop-filter: blur(var(--ws-surface-blur)); +} /* Card 表面(面板级,磨砂,组合式):无 base 规则,与元素上的 bg-card 配对——关图时保留 bg-card 原样(含 hover:bg-* 等 layered 工具类,零回归),开图才转半透明磨砂。属 ws-surface-* 面板级家族,跟随「面板不透明度」滑块(--ws-surface-alpha)。消息流内的卡片改用 ws-msg-card @@ -1325,6 +1386,7 @@ [data-workspace-bg="on"] .ws-surface, [data-workspace-bg="on"] .ws-surface-muted, [data-workspace-bg="on"] .ws-surface-sidebar, + [data-workspace-bg="on"] .ws-surface-status, [data-workspace-bg="on"] .ws-surface-card { -webkit-backdrop-filter: none; backdrop-filter: none; @@ -2796,8 +2858,7 @@ div.monaco-diff-editor.side-by-side .editor.modified { 0%, 100% { box-shadow: - var(--tw-ring-offset-shadow, 0 0 #0000), - var(--tw-ring-shadow, 0 0 #0000); + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000); } 50% { box-shadow: diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx index 169cc62790..887d4431f6 100644 --- a/src/components/ai-elements/code-block.tsx +++ b/src/components/ai-elements/code-block.tsx @@ -16,6 +16,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { useCodeTheme } from "@/hooks/use-appearance" import { cn, copyTextToClipboard } from "@/lib/utils" import { CheckIcon, CopyIcon } from "lucide-react" import { @@ -129,16 +130,30 @@ const tokensCache = new Map() // Subscribers for async token updates const subscribers = new Map void>>() -const getTokensCacheKey = (code: string, language: BundledLanguage) => { +/** `[light, dark]` Shiki theme ids; see `useCodeTheme`. */ +export type CodeThemeTuple = [BundledTheme, BundledTheme] + +const themesKey = (themes: CodeThemeTuple) => `${themes[0]}|${themes[1]}` + +const getTokensCacheKey = ( + code: string, + language: BundledLanguage, + themes: CodeThemeTuple +) => { const start = code.slice(0, 100) const end = code.length > 100 ? code.slice(-100) : "" - return `${language}:${code.length}:${start}:${end}` + return `${themesKey(themes)}:${language}:${code.length}:${start}:${end}` } +// One highlighter per (theme pair, language): a theme change is a rare, +// user-driven event, so a fresh instance per pair is simpler than mutating a +// shared one under in-flight highlights. const getHighlighter = ( - language: BundledLanguage + language: BundledLanguage, + themes: CodeThemeTuple ): Promise> => { - const cached = highlighterCache.get(language) + const cacheKey = `${themesKey(themes)}:${language}` + const cached = highlighterCache.get(cacheKey) if (cached) { return cached } @@ -151,11 +166,11 @@ const getHighlighter = ( const highlighterPromise = import("shiki").then(({ createHighlighter }) => createHighlighter({ langs: [language], - themes: ["github-light", "github-dark"], + themes: [themes[0], themes[1]], }) ) - highlighterCache.set(language, highlighterPromise) + highlighterCache.set(cacheKey, highlighterPromise) return highlighterPromise } @@ -179,10 +194,11 @@ const createRawTokens = (code: string): TokenizedCode => ({ export const highlightCode = ( code: string, language: BundledLanguage, + themes: CodeThemeTuple, // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks) callback?: (result: TokenizedCode) => void ): TokenizedCode | null => { - const tokensCacheKey = getTokensCacheKey(code, language) + const tokensCacheKey = getTokensCacheKey(code, language, themes) // Return cached result if available const cached = tokensCache.get(tokensCacheKey) @@ -199,7 +215,7 @@ export const highlightCode = ( } // Start highlighting in background - fire-and-forget async pattern - getHighlighter(language) + getHighlighter(language, themes) // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) .then((highlighter) => { const availableLangs = highlighter.getLoadedLanguages() @@ -208,8 +224,8 @@ export const highlightCode = ( const result = highlighter.codeToTokens(code, { lang: langToUse, themes: { - dark: "github-dark", - light: "github-light", + dark: themes[1], + light: themes[0], }, }) @@ -389,19 +405,25 @@ export const CodeBlockContent = ({ language: BundledLanguage showLineNumbers?: boolean }) => { + // The user's code colours (a preset setting). Its own context, so this only + // re-renders when the theme ids change. + const themes = useCodeTheme() + const themeKey = themesKey(themes) + // Memoized raw tokens for immediate display const rawTokens = useMemo(() => createRawTokens(code), [code]) - // Synchronous cached-or-raw value, recomputed when code/language changes + // Synchronous cached-or-raw value, recomputed when code/language/theme changes const syncTokenized = useMemo( - () => highlightCode(code, language) ?? rawTokens, - [code, language, rawTokens] + () => highlightCode(code, language, themes) ?? rawTokens, + [code, language, themes, rawTokens] ) - // Async highlighted result, tagged with its source code/language + // Async highlighted result, tagged with its source code/language/theme const [asyncState, setAsyncState] = useState<{ code: string language: string + themeKey: string tokenized: TokenizedCode } | null>(null) @@ -409,20 +431,22 @@ export const CodeBlockContent = ({ let cancelled = false // Subscribe to async highlighting result - highlightCode(code, language, (result) => { + highlightCode(code, language, themes, (result) => { if (!cancelled) { - setAsyncState({ code, language, tokenized: result }) + setAsyncState({ code, language, themeKey, tokenized: result }) } }) return () => { cancelled = true } - }, [code, language]) + }, [code, language, themes, themeKey]) - // Use async result only if it matches current code/language + // Use async result only if it matches current code/language/theme const tokenized = - asyncState?.code === code && asyncState?.language === language + asyncState?.code === code && + asyncState?.language === language && + asyncState?.themeKey === themeKey ? asyncState.tokenized : syncTokenized diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index 2db4c106c1..457f7250d1 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -11,6 +11,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" +import { useCodeTheme } from "@/hooks/use-appearance" import { cn } from "@/lib/utils" import { useTranslations } from "next-intl" import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react" @@ -67,15 +68,22 @@ export const MessageContent = ({ }: MessageContentProps) => (
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-3 [&_ol]:pl-3", // Streamdown gives `blockquote` its own `border-l-4 diff --git a/src/components/ai-elements/reasoning.tsx b/src/components/ai-elements/reasoning.tsx index a652ce7c83..f516c09a53 100644 --- a/src/components/ai-elements/reasoning.tsx +++ b/src/components/ai-elements/reasoning.tsx @@ -9,6 +9,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/instant-collapsible" +import { useCodeTheme } from "@/hooks/use-appearance" import { cn } from "@/lib/utils" import { BrainIcon, ChevronDownIcon } from "lucide-react" import { @@ -255,6 +256,7 @@ export const ReasoningContent = memo( [children] ) const plugins = useStreamdownPlugins(normalized) + const shikiTheme = useCodeTheme() return ( { }) }) +describe("appearance presets", () => { + const warmTerminal = BUNDLED_PRESET_BY_ID["warm-terminal"] + const denseIde = BUNDLED_PRESET_BY_ID["dense-ide"] + + function PresetProbe() { + const { applyPreset, appliedPreset, codeTheme } = useAppearancePresets() + const { monoFont } = useMonoFont() + return ( + <> + + + {appliedPreset?.id ?? "none"} + + {codeTheme.light}/{codeTheme.dark} + + {monoFont.id} + + ) + } + + function rootVar(name: string) { + return document.documentElement.style.getPropertyValue(name) + } + + beforeEach(() => { + localStorage.clear() + document.documentElement.removeAttribute("data-theme") + }) + + it("applies a preset through the same keys a hand edit writes, before and after a reload", () => { + render( + + + + ) + + fireEvent.click(screen.getByText("apply-warm")) + act(() => { + vi.advanceTimersByTime(500) // the custom-theme debounce + }) + + const plan = presetToApplication(warmTerminal) + // Live DOM: base palette, both kinds of token, fonts. + expect(document.documentElement.getAttribute("data-theme")).toBe("stone") + expect(rootVar("--primary")).toBe(plan.customTheme.light.primary) + expect(rootVar("--status-bar-bg")).toBe( + plan.customTheme.light["status-bar-bg"] + ) + expect(rootVar("--spacing")).toBe("0.2375rem") + expect(rootVar("--chat-font-size")).toBe("0.8125rem") + expect(rootVar("--font-sans")).toContain("JetBrains Mono") + expect(rootVar("--font-mono")).toContain("JetBrains Mono") + expect(screen.getByTestId("code").textContent).toBe( + "vitesse-light/vitesse-dark" + ) + expect(screen.getByTestId("applied").textContent).toBe("warm-terminal") + + // Storage: exactly what the pre-paint script and a fresh provider read. + expect(localStorage.getItem(STORAGE_KEY_THEME_COLOR)).toBe("stone") + expect(JSON.parse(localStorage.getItem(STORAGE_KEY_CUSTOM_THEME)!)).toEqual( + plan.customTheme + ) + expect(localStorage.getItem(STORAGE_KEY_CUSTOM_THEME_ENABLED)).toBe("1") + expect(localStorage.getItem(STORAGE_KEY_UI_FONT)).toBe("jetbrains-mono") + expect(localStorage.getItem(STORAGE_KEY_MONO_FONT)).toBe("jetbrains-mono") + expect(localStorage.getItem(STORAGE_KEY_MONO_FONT_STACK)).toContain( + "JetBrains Mono" + ) + expect(JSON.parse(localStorage.getItem(STORAGE_KEY_CODE_THEME)!)).toEqual({ + light: "vitesse-light", + dark: "vitesse-dark", + }) + expect( + JSON.parse(localStorage.getItem(STORAGE_KEY_APPEARANCE_PRESET)!).id + ).toBe("warm-terminal") + }) + + it("re-enables custom colours when a preset is applied", () => { + localStorage.setItem(STORAGE_KEY_CUSTOM_THEME_ENABLED, "0") + render( + + + + ) + expect(rootVar("--primary")).toBe("") + + fireEvent.click(screen.getByText("apply-dense")) + + expect(rootVar("--primary")).toBe( + presetToApplication(denseIde).customTheme.light.primary + ) + expect(localStorage.getItem(STORAGE_KEY_CUSTOM_THEME_ENABLED)).toBe("1") + }) + + it("follows a preset applied in another window", () => { + render( + + + + ) + const plan = presetToApplication(denseIde) + + act(() => { + for (const [key, value] of [ + [STORAGE_KEY_THEME_COLOR, "slate"], + [STORAGE_KEY_CUSTOM_THEME, JSON.stringify(plan.customTheme)], + [STORAGE_KEY_MONO_FONT, "jetbrains-mono"], + [STORAGE_KEY_CODE_THEME, JSON.stringify(plan.codeTheme)], + [STORAGE_KEY_APPEARANCE_PRESET, JSON.stringify(denseIde)], + ] as const) { + localStorage.setItem(key, value) + window.dispatchEvent( + new StorageEvent("storage", { key, newValue: value }) + ) + } + }) + + expect(document.documentElement.getAttribute("data-theme")).toBe("slate") + expect(rootVar("--spacing")).toBe("0.2125rem") + expect(rootVar("--font-mono")).toContain("JetBrains Mono") + expect(screen.getByTestId("mono").textContent).toBe("jetbrains-mono") + expect(screen.getByTestId("code").textContent).toBe("light-plus/dark-plus") + expect(screen.getByTestId("applied").textContent).toBe("dense-ide") + }) + + it("does not re-render messages for a preset that keeps the code colours", () => { + // A transcript reads only the code-theme context. Everything else a + // preset changes is a CSS variable, so switching presets must cost the + // message list nothing; only a change of Shiki themes may re-render it, + // because the tokens really are different then. + // Counted from an effect (one commit per render here: no StrictMode, no + // suspense), which keeps the probe's render body pure. + const renders = { count: 0 } + const Message = memo(function Message() { + const [light, dark] = useCodeTheme() + useEffect(() => { + renders.count += 1 + }) + return ( + + {light}/{dark} + + ) + }) + // Apply "ink" first, then "high-contrast": both change palette, fonts, + // spacing and bubbles, and only the second changes the code theme. + const ink = BUNDLED_PRESET_BY_ID["ink"] + const highContrast = BUNDLED_PRESET_BY_ID["high-contrast"] + function Controls() { + const { applyPreset } = useAppearancePresets() + return ( + <> + + + + + ) + } + render( + + + + + + ) + expect(renders.count).toBe(2) + + fireEvent.click(screen.getByText("ink")) + // ink sets min-light / min-dark: a real change, one render per message. + expect(renders.count).toBe(4) + expect(screen.getAllByTestId("message")[0].textContent).toBe( + "min-light/min-dark" + ) + + // Re-applying ink writes every token again and keeps the code theme: no + // message renders. + fireEvent.click(screen.getByText("ink")) + expect(renders.count).toBe(4) + + // Presets that do change the code theme render each message once. + fireEvent.click(screen.getByText("hc")) + expect(renders.count).toBe(6) + fireEvent.click(screen.getByText("warm")) + expect(renders.count).toBe(8) + // And a zoom change, which rewrites the appearance context, costs nothing. + act(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "=", + ctrlKey: true, + bubbles: true, + cancelable: true, + }) + ) + }) + expect(renders.count).toBe(8) + }) + + it("writes a shared token to both modes and clears it from both", () => { + function SharedProbe() { + const { setSharedThemeToken, customTheme } = useAppearancePresets() + return ( + <> + + + + {customTheme.light.spacing ?? "-"}/{customTheme.dark.spacing ?? "-"} + + + ) + } + render( + + + + ) + + fireEvent.click(screen.getByText("set")) + expect(screen.getByTestId("both").textContent).toBe("0.2125rem/0.2125rem") + expect(rootVar("--spacing")).toBe("0.2125rem") + + fireEvent.click(screen.getByText("clear")) + expect(screen.getByTestId("both").textContent).toBe("-/-") + expect(rootVar("--spacing")).toBe("") + }) +}) + describe("window zoom keys", () => { function startAt(zoom: number) { document.documentElement.style.fontSize = `${(16 * zoom) / 100}px` diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 6ca6f137ad..e544745052 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -17,6 +17,7 @@ import { type ZoomLevel, stepZoom, } from "@/lib/theme-presets" +import type { BundledTheme } from "shiki" import { resolveFontStack, isValidFontId, @@ -24,6 +25,7 @@ import { DEFAULT_UI_FONT_ID, DEFAULT_EDITOR_FONT_ID, DEFAULT_TERMINAL_FONT_ID, + DEFAULT_MONO_FONT_ID, DEFAULT_EDITOR_FONT_SIZE, DEFAULT_TERMINAL_FONT_SIZE, type FontSize, @@ -44,6 +46,11 @@ import { STORAGE_KEY_TERMINAL_FONT_CUSTOM, STORAGE_KEY_TERMINAL_FONT_SIZE, STORAGE_KEY_TERMINAL_LIGATURES, + STORAGE_KEY_MONO_FONT, + STORAGE_KEY_MONO_FONT_CUSTOM, + STORAGE_KEY_MONO_FONT_STACK, + STORAGE_KEY_CODE_THEME, + STORAGE_KEY_APPEARANCE_PRESET, STORAGE_KEY_WORKSPACE_BG_ENABLED, STORAGE_KEY_WORKSPACE_BG_MASK, STORAGE_KEY_WORKSPACE_BG_BLUR, @@ -66,6 +73,19 @@ import { type CustomTheme, type CustomThemeToken, } from "@/lib/custom-style" +import { + DEFAULT_CODE_THEME, + parseStoredCodeTheme, + sanitizeCodeTheme, + codeThemeTuple, + type CodeThemePair, +} from "@/lib/code-themes" +import { + parseStoredAppearancePreset, + presetToApplication, + serializeAppearancePreset, + type AppearancePreset, +} from "@/lib/appearance-preset" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { isShortcutRecorderArmed, @@ -137,6 +157,28 @@ type AppearanceContextValue = { setEditorWordWrap: (on: boolean) => void terminalLigatures: boolean setTerminalLigatures: (on: boolean) => void + /** 消息内代码字体(驱动 --font-mono)。编辑器 / 终端字体各自独立,不受它影响。 */ + monoFont: FontSelection + setMonoFont: (id: string, custom?: string) => void + /** Shiki themes for code blocks in messages, one per mode. */ + codeTheme: CodeThemePair + setCodeTheme: (pair: CodeThemePair) => void + /** + * The last applied appearance preset (bundled or imported), or null when + * none was ever applied. Remembered so the settings page can tell "Warm + * terminal" from "Warm terminal (modified)" and name an imported preset; it + * is not what renders (the token keys are). + */ + appliedPreset: AppearancePreset | null + /** + * Apply a validated preset: base palette, both modes' token overrides (the + * preset's shape / density / text settings fanned out to both), fonts and + * code colours. Turns custom colours on, since a preset is nothing without + * them. The light / dark mode is a window setting applied by the caller. + */ + applyPreset: (preset: AppearancePreset) => void + /** Write one token to BOTH modes (layout tokens are mode-independent); null clears it. */ + setSharedThemeToken: (token: CustomThemeToken, value: string | null) => void /** Workspace 背景图片总开关。关闭时不加载图片、不触发任何表面半透明。 */ workspaceBgEnabled: boolean setWorkspaceBgEnabled: (on: boolean) => void @@ -191,6 +233,22 @@ export const AppearanceContext = createContext( null ) +/** + * The `[light, dark]` Shiki theme tuple, in a context of its own. Every + * rendered message reads it (Streamdown's `shikiTheme` prop), and the main + * context's value changes on every appearance edit (zoom, a colour swatch, + * a background slider); subscribing the transcript to that would re-render + * every message for changes that only touch CSS variables. This value is only + * replaced when the theme ids change, so a preset switch that keeps the same + * code colours re-renders no message at all (see the provider test). + * + * The default is Streamdown's own, so a tree rendered without the provider + * (tests, previews) highlights exactly as before. + */ +export const CodeThemeContext = createContext<[BundledTheme, BundledTheme]>( + codeThemeTuple(DEFAULT_CODE_THEME) +) + function persist(key: string, value: string) { try { localStorage.setItem(key, value) @@ -408,6 +466,24 @@ export function AppearanceProvider({ const [terminalLigatures, setTerminalLigaturesState] = useState(() => readBool(STORAGE_KEY_TERMINAL_LIGATURES, false) ) + const [monoFont, setMonoFontState] = useState(() => + readFontSelection( + STORAGE_KEY_MONO_FONT, + STORAGE_KEY_MONO_FONT_CUSTOM, + DEFAULT_MONO_FONT_ID + ) + ) + + // Code colours and the last applied preset. Neither needs the pre-paint + // script: highlighting is asynchronous anyway, and the preset document only + // labels the settings page. + const [codeTheme, setCodeThemeState] = useState(() => + parseStoredCodeTheme(readStored(STORAGE_KEY_CODE_THEME)) + ) + const [appliedPreset, setAppliedPresetState] = + useState(() => + parseStoredAppearancePreset(readStored(STORAGE_KEY_APPEARANCE_PRESET)) + ) // Workspace 背景图片配置(图片 URL 异步加载,初始 null)。 const [workspaceBgEnabled, setWorkspaceBgEnabledState] = useState( @@ -567,6 +643,23 @@ export function AppearanceProvider({ persist(STORAGE_KEY_TERMINAL_LIGATURES, on ? "1" : "0") }, []) + // Same shape as setUiFont: the resolved stack goes inline on now and + // into storage for the pre-paint script. + const setMonoFont = useCallback((id: string, custom = "") => { + setMonoFontState({ id, custom }) + const stack = resolveFontStack(id, custom, "mono") + document.documentElement.style.setProperty("--font-mono", stack) + persist(STORAGE_KEY_MONO_FONT, id) + persist(STORAGE_KEY_MONO_FONT_CUSTOM, custom) + persist(STORAGE_KEY_MONO_FONT_STACK, stack) + }, []) + + const setCodeTheme = useCallback((pair: CodeThemePair) => { + const next = sanitizeCodeTheme(pair) + setCodeThemeState(next) + persist(STORAGE_KEY_CODE_THEME, JSON.stringify(next)) + }, []) + // enabled 与 panelOpacity 的 DOM 应用(data-workspace-bg 属性 + --ws-surface-alpha) // 统一交给下方一个 effect,覆盖 mount、重启后 re-enable、跨标签所有路径。setter 只 // 更新 state + 持久化,避免 --ws-surface-alpha 与 state 失同步(否则重启后 re-enable @@ -618,11 +711,47 @@ export function AppearanceProvider({ setCustomThemeState(sanitizeCustomTheme(theme)) }, []) + const setSharedThemeToken = useCallback( + (token: CustomThemeToken, value: string | null) => { + setCustomThemeState((prev) => { + const light = { ...prev.light } + const dark = { ...prev.dark } + if (value === null) { + delete light[token] + delete dark[token] + } else { + light[token] = value + dark[token] = value + } + return { light, dark } + }) + }, + [] + ) + const setCustomThemeEnabled = useCallback((on: boolean) => { setCustomThemeEnabledState(on) persist(STORAGE_KEY_CUSTOM_THEME_ENABLED, on ? "1" : "0") }, []) + // Applying a preset is the same set of writes a hand edit would make, in + // the same order the pre-paint script reads them, so a reload lands on the + // identical look. Fonts the preset leaves out keep the user's choice. + const applyPreset = useCallback( + (preset: AppearancePreset) => { + const plan = presetToApplication(preset) + setThemeColor(plan.themeColor) + setCustomThemeState(sanitizeCustomTheme(plan.customTheme)) + setCustomThemeEnabled(true) + if (plan.uiFont) setUiFont(plan.uiFont) + if (plan.monoFont) setMonoFont(plan.monoFont) + if (plan.codeTheme) setCodeTheme(plan.codeTheme) + setAppliedPresetState(preset) + persist(STORAGE_KEY_APPEARANCE_PRESET, serializeAppearancePreset(preset)) + }, + [setThemeColor, setCustomThemeEnabled, setUiFont, setMonoFont, setCodeTheme] + ) + // 传进来的必须是 sanitizeCustomCss 处理过的文本:存的即是注入的,预水合脚本 // 才能原样使用而不必在 inline 脚本里重跑一遍 CSSOM 校验。 const setCustomCss = useCallback((css: string) => { @@ -751,6 +880,16 @@ export function AppearanceProvider({ document.documentElement.style.setProperty("--font-sans", sans) persist(STORAGE_KEY_UI_FONT_STACK, sans) } + // The code font only ever writes when the user chose one: with no stored + // id the :root default is the stack, so there is nothing to reconcile and + // no storage event to raise. + if (readStored(STORAGE_KEY_MONO_FONT)) { + const mono = resolveFontStack(monoFont.id, monoFont.custom, "mono") + if (readStored(STORAGE_KEY_MONO_FONT_STACK) !== mono) { + document.documentElement.style.setProperty("--font-mono", mono) + persist(STORAGE_KEY_MONO_FONT_STACK, mono) + } + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -894,6 +1033,9 @@ export function AppearanceProvider({ STORAGE_KEY_TERMINAL_FONT_CUSTOM, STORAGE_KEY_TERMINAL_FONT_SIZE, STORAGE_KEY_TERMINAL_LIGATURES, + STORAGE_KEY_MONO_FONT, + STORAGE_KEY_MONO_FONT_CUSTOM, + STORAGE_KEY_MONO_FONT_STACK, ]) const rehydrateFonts = () => { const ui = readFontSelection( @@ -923,11 +1065,22 @@ export function AppearanceProvider({ setEditorLigaturesState(readBool(STORAGE_KEY_EDITOR_LIGATURES, false)) setEditorWordWrapState(readBool(STORAGE_KEY_EDITOR_WORD_WRAP, false)) setTerminalLigaturesState(readBool(STORAGE_KEY_TERMINAL_LIGATURES, false)) - // 仅界面字体落到 --font-sans;编辑器/终端字体由各自组件读取 provider 状态后应用。 + // 界面字体落到 --font-sans,消息代码字体落到 --font-mono;编辑器/终端字体由 + // 各自组件读取 provider 状态后应用。 document.documentElement.style.setProperty( "--font-sans", resolveFontStack(ui.id, ui.custom, "sans") ) + const mono = readFontSelection( + STORAGE_KEY_MONO_FONT, + STORAGE_KEY_MONO_FONT_CUSTOM, + DEFAULT_MONO_FONT_ID + ) + setMonoFontState(mono) + document.documentElement.style.setProperty( + "--font-mono", + resolveFontStack(mono.id, mono.custom, "mono") + ) } const onStorage = (e: StorageEvent) => { @@ -1037,6 +1190,13 @@ export function AppearanceProvider({ readBool(STORAGE_KEY_CUSTOM_STYLE_SUSPENDED, false) ) } + // Preset-level settings written by the settings window. + if (e.key === STORAGE_KEY_CODE_THEME) { + setCodeThemeState(parseStoredCodeTheme(e.newValue)) + } + if (e.key === STORAGE_KEY_APPEARANCE_PRESET) { + setAppliedPresetState(parseStoredAppearancePreset(e.newValue)) + } // Sync appearance mode to Tauri DB when changed in another window if (e.key === "theme") { syncAppearanceMode(e.newValue ?? "system") @@ -1050,6 +1210,13 @@ export function AppearanceProvider({ resetCustomCssBaseline, ]) + // Keyed on the two ids, not the pair object: setCodeTheme always allocates + // a fresh pair, and the tuple must only change when the colours do. + const codeThemeTupleValue = useMemo<[BundledTheme, BundledTheme]>( + () => [codeTheme.light, codeTheme.dark], + [codeTheme.light, codeTheme.dark] + ) + return ( - {children} + + {children} + ) } diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 0e228f1e92..551fbdfe78 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -1846,12 +1846,15 @@ export function MessageInput({ // blank areas (padding, the dead space below a short message, the // action-bar gaps) so the whole input reads as clickable-to-type; // interactive controls re-assert their own cursor (see globals.css). - // Resting border uses `border-foreground/20` (a touch darker than - // the default `border-input`, which is near-invisible at rest and - // vanishes over a workspace background image); it adapts per theme - // (dark ink in light mode, light ink in dark) and stays legible. - // Focus still swaps to `border-ring` below. - "codeg-composer-chrome @container relative flex flex-col rounded-xl border border-foreground/20 bg-transparent transition-colors", + // Resting border, surface and radius come from the composer + // tokens (globals.css :root). Their defaults reproduce the + // previous literals: a `foreground/20` border (a touch darker + // than `border-input`, which is near-invisible at rest and + // vanishes over a workspace background image; adapts per theme + // and stays legible), a transparent surface and `rounded-xl`. + // An appearance preset restyles the box through them. Focus + // still swaps to `border-ring` below. + "codeg-composer-chrome @container relative flex flex-col rounded-(--composer-radius) border border-(--composer-border) bg-(--composer-bg) transition-colors", // Standard focus ring — always shown when the composer is // focused (the plain default input style). `bg-background // ws-transparent-bg`: opaque surface normally, but with a diff --git a/src/components/conversations/conversation-detail-panel-layout.test.ts b/src/components/conversations/conversation-detail-panel-layout.test.ts index 9ec85562d2..028f265859 100644 --- a/src/components/conversations/conversation-detail-panel-layout.test.ts +++ b/src/components/conversations/conversation-detail-panel-layout.test.ts @@ -195,10 +195,12 @@ describe("ConversationDetailPanel new conversation layout", () => { // attached folder-branch-picker treatment still adds a solid surface // (`bg-background`, which goes transparent to reveal a workspace-bg image via // `ws-transparent-bg` instead of frosting) + the inset focus ring on top. - // The resting border is `border-foreground/20` (a touch darker than the - // near-invisible default `border-input`, and legible over a background image). + // Radius, resting border and surface are the composer tokens (globals.css + // `:root`), whose defaults are the previous literals: `rounded-xl`, a + // `foreground/20` border (a touch darker than the near-invisible default + // `border-input`, and legible over a background image) and no fill. expect(messageInputSource).toContain( - "rounded-xl border border-foreground/20 bg-transparent transition-colors" + "rounded-(--composer-radius) border border-(--composer-border) bg-(--composer-bg) transition-colors" ) expect(messageInputSource).toContain( '"bg-background ws-transparent-bg focus-within:border-ring focus-within:ring-[3px] focus-within:ring-inset focus-within:ring-ring/50"' diff --git a/src/components/files/markdown-document-preview.tsx b/src/components/files/markdown-document-preview.tsx index 8ced353e5e..50afac9174 100644 --- a/src/components/files/markdown-document-preview.tsx +++ b/src/components/files/markdown-document-preview.tsx @@ -7,6 +7,7 @@ import { normalizeMathDelimiters } from "@/components/ai-elements/message" import { mermaidComponents } from "@/components/ai-elements/mermaid-block" import { useStreamdownPlugins } from "@/components/ai-elements/streamdown-plugins" import { BrowserLink } from "@/components/ui/browser-link" +import { useCodeTheme } from "@/hooks/use-appearance" import { isUncPath, normalizeAbsPath } from "@/lib/file-open-target" import { cn } from "@/lib/utils" @@ -249,6 +250,7 @@ export function MarkdownDocumentPreview({ : content ) const plugins = useStreamdownPlugins(preprocessed) + const shikiTheme = useCodeTheme() return (
+
@@ -33,8 +33,11 @@ export function StatusBar() { ) } + // Surface, text and top hairline come from the status-bar tokens (defaults: + // --muted / --muted-foreground / --border, so the stock bar is unchanged); + // an appearance preset can give the bar its own colours. return ( -
+
{/* The branch selector, context-window circle and agent connection status moved to the below-composer folder/branch row; the left side now carries the quick-actions launcher (the window's bottom-left corner) diff --git a/src/components/settings/appearance-presets-section.test.tsx b/src/components/settings/appearance-presets-section.test.tsx new file mode 100644 index 0000000000..f97337d345 --- /dev/null +++ b/src/components/settings/appearance-presets-section.test.tsx @@ -0,0 +1,277 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const toastMock = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})) +vi.mock("sonner", () => ({ toast: toastMock })) + +const setThemeMock = vi.hoisted(() => vi.fn()) +vi.mock("next-themes", () => ({ + useTheme: () => ({ theme: "light", setTheme: setThemeMock }), +})) + +const saveTextFileMock = vi.hoisted(() => vi.fn()) +vi.mock("@/lib/save-file", () => ({ saveTextFile: saveTextFileMock })) + +import { AppearancePresetsSection } from "./appearance-presets-section" +import { AppearanceProvider } from "@/components/appearance-provider" +import enMessages from "@/i18n/messages/en.json" +import { + MAX_PRESET_BYTES, + parseAppearancePreset, + presetToApplication, + serializeAppearancePreset, + type AppearancePreset, +} from "@/lib/appearance-preset" +import { BUNDLED_PRESET_BY_ID } from "@/lib/appearance-presets-bundled" +import { + STORAGE_KEY_CUSTOM_THEME, + STORAGE_KEY_THEME_COLOR, +} from "@/lib/appearance-script" + +function renderSection() { + return render( + + + + + + ) +} + +function rootVar(name: string) { + return document.documentElement.style.getPropertyValue(name) +} + +function chooseFile(text: string, name = "look.codeg-preset.json") { + const input = screen.getByTestId("preset-file-input") as HTMLInputElement + const file = new File([text], name, { type: "application/json" }) + fireEvent.change(input, { target: { files: [file] } }) +} + +const imported: AppearancePreset = { + schemaVersion: 1, + id: "shared-look", + name: "Shared look", + description: "Came in from a file.", + base: "rose", + mode: "dark", + colors: { light: { primary: "#336699" }, dark: { primary: "#99ccff" } }, + density: { spacing: 0.92 }, + code: { light: "one-light", dark: "one-dark-pro" }, +} + +beforeEach(() => { + localStorage.clear() + document.documentElement.removeAttribute("style") + document.documentElement.removeAttribute("data-theme") + document.documentElement.classList.remove("dark") + saveTextFileMock.mockReset() + saveTextFileMock.mockResolvedValue("saved") + setThemeMock.mockReset() + toastMock.success.mockReset() + toastMock.error.mockReset() +}) + +afterEach(() => { + document.documentElement.removeAttribute("style") +}) + +describe("AppearancePresetsSection", () => { + it("lights the stock look until a preset is applied, then the applied one", () => { + renderSection() + + const defaultCard = screen.getByRole("button", { name: "Apply Default" }) + const warmCard = screen.getByRole("button", { + name: "Apply Warm terminal", + }) + expect(defaultCard).toHaveAttribute("aria-pressed", "true") + expect(warmCard).toHaveAttribute("aria-pressed", "false") + expect(screen.getByText("Current: Default")).toBeInTheDocument() + + fireEvent.click(warmCard) + + const plan = presetToApplication(BUNDLED_PRESET_BY_ID["warm-terminal"]) + expect(document.documentElement.getAttribute("data-theme")).toBe("stone") + expect(rootVar("--primary")).toBe(plan.customTheme.light.primary) + expect(rootVar("--spacing")).toBe("0.2375rem") + expect(warmCard).toHaveAttribute("aria-pressed", "true") + expect(defaultCard).toHaveAttribute("aria-pressed", "false") + expect(screen.getByText("Current: Warm terminal")).toBeInTheDocument() + // The preset asks for dark mode; that goes through next-themes. + expect(setThemeMock).toHaveBeenCalledWith("dark") + expect(toastMock.success).toHaveBeenCalledWith( + "Preset applied: Warm terminal" + ) + }) + + it("reports a tweaked preset as modified rather than pretending it still matches", () => { + renderSection() + fireEvent.click(screen.getByRole("button", { name: "Apply Ink" })) + expect(screen.getByText("Current: Ink")).toBeInTheDocument() + + // A hand edit in another window (or the Custom Style section) changes a + // token the preset set. + const stored = presetToApplication(BUNDLED_PRESET_BY_ID["ink"]).customTheme + const edited = JSON.stringify({ + ...stored, + light: { ...stored.light, primary: "#ff0000" }, + }) + act(() => { + localStorage.setItem(STORAGE_KEY_CUSTOM_THEME, edited) + window.dispatchEvent( + new StorageEvent("storage", { + key: STORAGE_KEY_CUSTOM_THEME, + newValue: edited, + }) + ) + }) + + expect(screen.getByText("Current: Ink (modified)")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Apply Ink" })).toHaveAttribute( + "aria-pressed", + "false" + ) + }) + + it("imports a valid file through a preview, and applies it on confirm", async () => { + renderSection() + + chooseFile(serializeAppearancePreset(imported)) + + const dialog = await screen.findByRole("dialog") + expect(within(dialog).getByText("Shared look")).toBeInTheDocument() + expect(within(dialog).getByText("Came in from a file.")).toBeInTheDocument() + expect(within(dialog).getByText("Rose")).toBeInTheDocument() + expect(within(dialog).getByText("1 light, 1 dark")).toBeInTheDocument() + expect( + within(dialog).getByText("One Light / One Dark Pro") + ).toBeInTheDocument() + expect(within(dialog).getByText("92%")).toBeInTheDocument() + // Nothing applied yet: previewing is not applying. + expect(document.documentElement.getAttribute("data-theme")).toBeNull() + + fireEvent.click( + within(dialog).getByRole("button", { name: "Apply preset" }) + ) + + expect(document.documentElement.getAttribute("data-theme")).toBe("rose") + expect(rootVar("--primary")).toBe("#336699") + expect(rootVar("--spacing")).toBe("0.23rem") + expect(localStorage.getItem(STORAGE_KEY_THEME_COLOR)).toBe("rose") + expect(setThemeMock).toHaveBeenCalledWith("dark") + expect(screen.queryByRole("dialog")).toBeNull() + expect(screen.getByText("Current: Shared look")).toBeInTheDocument() + }) + + it("refuses a bad file with the field that is wrong, and changes nothing", async () => { + renderSection() + + chooseFile( + JSON.stringify({ + ...imported, + colors: { dark: { primry: "#000000", primary: "plum" } }, + density: { spacing: 4 }, + extra: true, + }) + ) + + const dialog = await screen.findByRole("dialog") + expect( + within(dialog).getByText("This file cannot be applied") + ).toBeInTheDocument() + const items = within(dialog) + .getAllByRole("listitem") + .map((li) => li.textContent) + expect(items).toEqual([ + "extra: Unknown field", + "colors.dark.primry: Not a theme token", + "colors.dark.primary: Not a color (use hex, rgb, hsl or oklch)", + "density.spacing: Out of range (0.75 to 1.25)", + ]) + expect(document.documentElement.getAttribute("data-theme")).toBeNull() + expect(rootVar("--primary")).toBe("") + expect( + within(dialog).queryByRole("button", { name: "Apply preset" }) + ).toBeNull() + }) + + it("refuses a file over the size cap without reading it", async () => { + renderSection() + + chooseFile("x".repeat(MAX_PRESET_BYTES + 1)) + + const dialog = await screen.findByRole("dialog") + expect( + within(dialog).getByText("File is too large (limit 32 KB)") + ).toBeInTheDocument() + }) + + it("exports the current look as a file that imports back to the same state", async () => { + renderSection() + fireEvent.click(screen.getByRole("button", { name: "Apply Midnight" })) + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Export preset…" })) + }) + + expect(saveTextFileMock).toHaveBeenCalledTimes(1) + const call = saveTextFileMock.mock.calls[0][0] + expect(call.suggestedName).toBe("midnight.codeg-preset.json") + expect(call.mimeType).toBe("application/json") + const parsed = parseAppearancePreset(call.content) + expect(parsed.ok).toBe(true) + if (!parsed.ok) return + // Exact round trip of the bundled document, plus the window's mode. + expect(parsed.preset).toEqual({ + ...BUNDLED_PRESET_BY_ID["midnight"], + mode: "light", + }) + expect(toastMock.success).toHaveBeenCalledWith("Preset saved", { + description: undefined, + }) + }) + + it("never exports custom CSS", async () => { + // The security boundary in one assertion: whatever is in the custom CSS + // store, the preset file does not contain it. + localStorage.setItem("codeg-custom-css", ".secret { color: red }") + localStorage.setItem("codeg-custom-css-enabled", "1") + renderSection() + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Export preset…" })) + }) + + const content: string = saveTextFileMock.mock.calls[0][0].content + expect(content).not.toContain("secret") + expect(content).not.toContain("css") + }) + + it("drives density and message text through the shared tokens", () => { + renderSection() + expect(rootVar("--spacing")).toBe("") + + // Radix Select in jsdom: pointer events are simulated through keyboard + // navigation on the trigger, which the existing tests also rely on. + const density = screen.getByRole("combobox", { name: "Density" }) + fireEvent.keyDown(density, { key: "ArrowDown" }) + fireEvent.click(screen.getByRole("option", { name: "Compact" })) + expect(rootVar("--spacing")).toBe("0.2125rem") + + const chatText = screen.getByRole("combobox", { name: "Message text" }) + fireEvent.keyDown(chatText, { key: "ArrowDown" }) + fireEvent.click(screen.getByRole("option", { name: "Large" })) + expect(rootVar("--chat-font-size")).toBe("0.9375rem") + + // Back to the defaults clears the tokens instead of pinning stock values. + fireEvent.keyDown(screen.getByRole("combobox", { name: "Density" }), { + key: "ArrowDown", + }) + fireEvent.click(screen.getByRole("option", { name: "Default" })) + expect(rootVar("--spacing")).toBe("") + }) +}) diff --git a/src/components/settings/appearance-presets-section.tsx b/src/components/settings/appearance-presets-section.tsx new file mode 100644 index 0000000000..1048c92210 --- /dev/null +++ b/src/components/settings/appearance-presets-section.tsx @@ -0,0 +1,688 @@ +"use client" + +import { useCallback, useMemo, useRef, useState } from "react" +import { useTranslations } from "next-intl" +import { useTheme } from "next-themes" +import { toast } from "sonner" +import { Download, SwatchBook, Upload } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useAppearancePresets, useCustomStyle } from "@/hooks/use-appearance" +import { + BUNDLED_PRESETS, + isBundledPresetId, +} from "@/lib/appearance-presets-bundled" +import { + DEFAULT_CHAT_FONT_SIZE_REM, + MAX_PRESET_BYTES, + PRESET_MODES, + findMatchingPreset, + parseAppearancePreset, + parseCssLength, + presetFileName, + presetMatches, + serializeAppearancePreset, + snapshotToPreset, + spacingMultiplierFromToken, + spacingToken, + type AppearanceCurrent, + type AppearancePreset, + type PresetIssue, + type PresetMode, +} from "@/lib/appearance-preset" +import { CODE_THEMES, codeThemeLabel, isCodeThemeId } from "@/lib/code-themes" +import { FONT_BY_ID } from "@/lib/font-presets" +import { saveTextFile } from "@/lib/save-file" +import { THEME_COLOR_PREVIEW, THEME_COLOR_TITLE } from "@/lib/theme-presets" +import { cn } from "@/lib/utils" + +/** The density rungs; `1` is the stock layout and clears the token. */ +const DENSITY_OPTIONS = [ + { key: "compact", value: 0.85 }, + { key: "snug", value: 0.92 }, + { key: "default", value: 1 }, + { key: "relaxed", value: 1.1 }, +] as const + +/** Message text rungs in rem; the default clears the token. */ +const CHAT_TEXT_OPTIONS = [ + { key: "small", value: 0.8125 }, + { key: "default", value: DEFAULT_CHAT_FONT_SIZE_REM }, + { key: "large", value: 0.9375 }, + { key: "larger", value: 1 }, +] as const + +const CUSTOM_OPTION = "custom" + +/** Files are `.json`; the suffix in the export name is a convention, not a gate. */ +const PRESET_ACCEPT = ".json,application/json" + +const LIGHT_CODE_THEMES = CODE_THEMES.filter((t) => t.type === "light") +const DARK_CODE_THEMES = CODE_THEMES.filter((t) => t.type === "dark") + +type ImportState = + | { kind: "preview"; preset: AppearancePreset; warnings: PresetIssue[] } + | { kind: "errors"; issues: PresetIssue[] } + | null + +function isPresetMode(value: string | undefined): value is PresetMode { + return !!value && (PRESET_MODES as readonly string[]).includes(value) +} + +/** + * Four swatches that stand for a preset in the gallery, for the mode the app + * is in. Explicit preset colours win; the fallbacks are the base palette's + * primary and the neutral surfaces, which is close for every built-in base + * (their backgrounds differ by a hair of chroma). + */ +function presetSwatches(preset: AppearancePreset, dark: boolean): string[] { + const colors = (dark ? preset.colors.dark : preset.colors.light) ?? {} + return [ + colors.background ?? (dark ? "oklch(0.145 0 0)" : "oklch(1 0 0)"), + colors.sidebar ?? (dark ? "oklch(0.205 0 0)" : "oklch(0.985 0 0)"), + colors.primary ?? + (dark + ? THEME_COLOR_TITLE[preset.base].dark + : THEME_COLOR_PREVIEW[preset.base]), + colors.foreground ?? (dark ? "oklch(0.985 0 0)" : "oklch(0.145 0 0)"), + ] +} + +function readFileText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result ?? "")) + reader.onerror = () => reject(reader.error ?? new Error("read failed")) + reader.readAsText(file) + }) +} + +function syncAppearanceMode(mode: string) { + if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) + return + import("@/lib/tauri").then((t) => + t.updateAppearanceMode(mode).catch(() => {}) + ) +} + +export function AppearancePresetsSection() { + const t = useTranslations("AppearanceSettings") + const { theme, setTheme } = useTheme() + const { + themeColor, + customTheme, + customThemeEnabled, + uiFont, + monoFont, + codeTheme, + setCodeTheme, + appliedPreset, + applyPreset, + setSharedThemeToken, + } = useAppearancePresets() + const { isDarkMode, customStyleSuppressed } = useCustomStyle() + + const fileInputRef = useRef(null) + const [importState, setImportState] = useState(null) + + const current = useMemo( + () => ({ + themeColor, + customTheme, + uiFontId: uiFont.id, + monoFontId: monoFont.id, + codeTheme, + }), + [themeColor, customTheme, uiFont.id, monoFont.id, codeTheme] + ) + + // Which card is lit is a comparison, not a stored flag: the state is what + // renders, and a preset is "current" exactly when the state equals it. + const matchedBundled = useMemo( + () => findMatchingPreset(BUNDLED_PRESETS, current), + [current] + ) + + // Bundled presets are named through i18n; an imported one carries its own + // name in the file. + const bundledName = useCallback( + (preset: AppearancePreset) => { + const id = preset.id + return isBundledPresetId(id) + ? t(`presets.bundled.${id}.name`) + : preset.name + }, + [t] + ) + const bundledDescription = useCallback( + (preset: AppearancePreset) => { + const id = preset.id + return isBundledPresetId(id) + ? t(`presets.bundled.${id}.description`) + : preset.description + }, + [t] + ) + + const summary = useMemo(() => { + if (matchedBundled) { + return t("presets.current", { name: bundledName(matchedBundled) }) + } + if (appliedPreset) { + const name = bundledName(appliedPreset) + return presetMatches(appliedPreset, current) + ? t("presets.current", { name }) + : t("presets.currentModified", { name }) + } + return t("presets.currentCustom") + }, [matchedBundled, appliedPreset, current, bundledName, t]) + + // ─── Apply ─── + + const onApply = useCallback( + (preset: AppearancePreset) => { + applyPreset(preset) + if (preset.mode) { + setTheme(preset.mode) + syncAppearanceMode(preset.mode) + } + toast.success(t("presets.toasts.applied", { name: bundledName(preset) })) + }, + [applyPreset, setTheme, bundledName, t] + ) + + // ─── Export ─── + + const onExport = useCallback(async () => { + const meta = matchedBundled ?? { + id: "custom", + name: t("presets.customName"), + } + const { preset, warnings } = snapshotToPreset( + { + ...current, + mode: isPresetMode(theme) ? theme : null, + }, + meta + ) + try { + const result = await saveTextFile({ + content: serializeAppearancePreset(preset), + suggestedName: presetFileName(preset), + mimeType: "application/json", + filterName: "codeg preset", + ext: "json", + }) + if (result === "cancelled") return + toast.success(t("presets.toasts.exported"), { + description: + warnings.length > 0 + ? t("presets.toasts.partialExport", { + items: warnings.map((w) => w.path).join(", "), + }) + : undefined, + }) + } catch { + toast.error(t("presets.toasts.exportFailed")) + } + }, [matchedBundled, current, theme, t]) + + // ─── Import ─── + + const onChooseFile = useCallback( + async (file: File) => { + if (file.size > MAX_PRESET_BYTES) { + setImportState({ + kind: "errors", + issues: [{ path: "", code: "too-large" }], + }) + return + } + let text: string + try { + text = await readFileText(file) + } catch { + toast.error(t("presets.toasts.readFailed")) + return + } + const result = parseAppearancePreset(text) + setImportState( + result.ok + ? { + kind: "preview", + preset: result.preset, + warnings: result.warnings, + } + : { kind: "errors", issues: result.issues } + ) + }, + [t] + ) + + const onConfirmImport = useCallback(() => { + if (importState?.kind !== "preview") return + onApply(importState.preset) + setImportState(null) + }, [importState, onApply]) + + // ─── Preset-level knobs ─── + + const densityValue = useMemo(() => { + const multiplier = spacingMultiplierFromToken(customTheme.light.spacing) + if (multiplier === null) { + return customTheme.light.spacing ? CUSTOM_OPTION : "1" + } + const option = DENSITY_OPTIONS.find((o) => o.value === multiplier) + return option ? String(option.value) : CUSTOM_OPTION + }, [customTheme.light.spacing]) + + const chatTextValue = useMemo(() => { + const raw = customTheme.light["chat-font-size"] + if (!raw) return String(DEFAULT_CHAT_FONT_SIZE_REM) + const parsed = parseCssLength(raw) + if (!parsed || parsed.unit !== "rem") return CUSTOM_OPTION + const option = CHAT_TEXT_OPTIONS.find((o) => o.value === parsed.value) + return option ? String(option.value) : CUSTOM_OPTION + }, [customTheme.light]) + + const onDensityChange = useCallback( + (value: string) => { + if (value === CUSTOM_OPTION) return + const multiplier = Number(value) + setSharedThemeToken( + "spacing", + multiplier === 1 ? null : spacingToken(multiplier) + ) + }, + [setSharedThemeToken] + ) + + const onChatTextChange = useCallback( + (value: string) => { + if (value === CUSTOM_OPTION) return + const rem = Number(value) + setSharedThemeToken( + "chat-font-size", + rem === DEFAULT_CHAT_FONT_SIZE_REM ? null : `${rem}rem` + ) + }, + [setSharedThemeToken] + ) + + const knobsDisabled = !customThemeEnabled || customStyleSuppressed + const fieldLabel = "text-xs font-medium text-muted-foreground" + + const previewPreset = + importState?.kind === "preview" ? importState.preset : null + const modeLabel = (mode: PresetMode) => t(`presets.modes.${mode}`) + const fontLabel = (id: string | undefined) => + id ? (FONT_BY_ID[id]?.label ?? id) : t("presets.preview.unchanged") + + return ( +
+
+ +

{t("presets.sectionTitle")}

+
+ +

+ {t("presets.sectionDescription")} +

+ + {/* ===== Gallery ===== */} +
+ {BUNDLED_PRESETS.map((preset) => { + const active = matchedBundled?.id === preset.id + const name = bundledName(preset) + return ( + + ) + })} +
+ +

{summary}

+ {knobsDisabled && ( +

+ {t("presets.disabledHint")} +

+ )} + + {/* ===== Code colours ===== */} +
+ +
+ + +
+

+ {t("presets.codeThemeHint")} +

+
+ + {/* ===== Density + message text ===== */} +
+
+ + +

+ {t("presets.densityHint")} +

+
+
+ + +

+ {t("presets.chatTextHint")} +

+
+
+ + {/* ===== Import / export ===== */} +
+ { + const file = e.target.files?.[0] + if (file) void onChooseFile(file) + e.target.value = "" + }} + /> + + +
+

+ {t("presets.sharingNote")} +

+ + { + if (!open) setImportState(null) + }} + > + + {previewPreset ? ( + <> + + {t("presets.importTitle")} + + {t("presets.importPreview")} + + +
+

{previewPreset.name}

+ {previewPreset.description && ( +

+ {previewPreset.description} +

+ )} +
+
+ {t("presets.preview.base")} +
+
{t(`themeColor.options.${previewPreset.base}`)}
+
+ {t("presets.preview.mode")} +
+
+ {previewPreset.mode + ? modeLabel(previewPreset.mode) + : t("presets.preview.unchanged")} +
+
+ {t("presets.preview.overrides")} +
+
+ {t("presets.preview.overridesValue", { + light: Object.keys(previewPreset.colors.light ?? {}) + .length, + dark: Object.keys(previewPreset.colors.dark ?? {}).length, + })} +
+
+ {t("presets.preview.fonts")} +
+
+ {fontLabel(previewPreset.typography?.ui)} /{" "} + {fontLabel(previewPreset.typography?.mono)} +
+
+ {t("presets.preview.code")} +
+
+ {previewPreset.code + ? `${codeThemeLabel(previewPreset.code.light)} / ${codeThemeLabel(previewPreset.code.dark)}` + : t("presets.preview.unchanged")} +
+
+ {t("presets.preview.density")} +
+
+ {previewPreset.density?.spacing !== undefined + ? `${Math.round(previewPreset.density.spacing * 100)}%` + : t("presets.preview.unchanged")} +
+
+ {t("presets.preview.chatText")} +
+
+ {previewPreset.typography?.chatFontSize !== undefined + ? `${previewPreset.typography.chatFontSize}rem` + : t("presets.preview.unchanged")} +
+
+
+ + + + + + ) : ( + <> + + {t("presets.importErrorsTitle")} + + {t("presets.importErrorsDescription")} + + +
    + {(importState?.kind === "errors" ? importState.issues : []).map( + (issue, index) => ( +
  • + {issue.path ? `${issue.path}: ` : ""} + {t(`presets.errors.${issue.code}`, { + limit: Math.round(MAX_PRESET_BYTES / 1024), + })} + {issue.detail ? ` (${issue.detail})` : ""} +
  • + ) + )} +
+ + + + + )} +
+
+
+ ) +} diff --git a/src/components/settings/appearance-settings.tsx b/src/components/settings/appearance-settings.tsx index c5b9d00d8e..80449db6d8 100644 --- a/src/components/settings/appearance-settings.tsx +++ b/src/components/settings/appearance-settings.tsx @@ -30,6 +30,7 @@ import { PetManagerSection } from "./pet-manager-section" import { FontSettingsSection } from "./font-settings-section" import { WorkspaceBackgroundSection } from "./workspace-background-section" import { CustomStyleSection } from "./custom-style-section" +import { AppearancePresetsSection } from "./appearance-presets-section" type ThemeMode = "system" | "light" | "dark" @@ -165,6 +166,9 @@ export function AppearanceSettings() {

+ {/* ===== Presets (whole looks, import / export) ===== */} + + {/* ===== Custom style (token overrides + free-form CSS) ===== */} diff --git a/src/components/settings/custom-style-section.tsx b/src/components/settings/custom-style-section.tsx index e99e3e2822..543911e416 100644 --- a/src/components/settings/custom-style-section.tsx +++ b/src/components/settings/custom-style-section.tsx @@ -39,6 +39,7 @@ import { BASIC_THEME_TOKENS, CUSTOM_THEME_TOKENS, EMPTY_CUSTOM_THEME, + SURFACE_COLOR_TOKENS, isColorToken, isEmptyCustomTheme, isValidTokenValue, @@ -179,6 +180,7 @@ export function CustomStyleSection() { const [importOpen, setImportOpen] = useState(false) const [importDraft, setImportDraft] = useState("") const [advancedOpen, setAdvancedOpen] = useState(false) + const [surfacesOpen, setSurfacesOpen] = useState(false) const [effective, setEffective] = useState>({}) const overrides: TokenOverrides = isDarkMode @@ -434,6 +436,33 @@ export function CustomStyleSection() { {renderTokens(ADVANCED_THEME_TOKENS)} + + {/* codeg's own surfaces (message bubbles, composer, status bar). + Their defaults follow the shadcn tokens above, so they only need + a value when a look wants them to differ. */} + + + + + + {renderTokens(SURFACE_COLOR_TOKENS)} + +
{/* ===== 自由 CSS ===== */} diff --git a/src/components/settings/font-settings-section.tsx b/src/components/settings/font-settings-section.tsx index 02e171daee..a295b466fd 100644 --- a/src/components/settings/font-settings-section.tsx +++ b/src/components/settings/font-settings-section.tsx @@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" import { useEditorFont, + useMonoFont, useTerminalFont, useUiFont, } from "@/hooks/use-appearance" @@ -169,6 +170,7 @@ function LigatureRow({ export function FontSettingsSection() { const t = useTranslations("AppearanceSettings") const { uiFont, setUiFont, uiFontStack } = useUiFont() + const { monoFont, setMonoFont } = useMonoFont() const { editorFont, setEditorFont, @@ -226,6 +228,24 @@ export function FontSettingsSection() { />
+ {/* ===== 消息内代码字体(--font-mono)===== + Code blocks and inline code in messages. Editor and terminal keep their + own pickers below; this one is what an appearance preset sets. */} +
+ + +
+ {/* ===== 编辑器字体 ===== */}
diff --git a/src/hooks/use-appearance.ts b/src/hooks/use-appearance.ts index 6468b07e02..9a4d2f5668 100644 --- a/src/hooks/use-appearance.ts +++ b/src/hooks/use-appearance.ts @@ -1,7 +1,11 @@ "use client" import { useContext } from "react" -import { AppearanceContext } from "@/components/appearance-provider" +import type { BundledTheme } from "shiki" +import { + AppearanceContext, + CodeThemeContext, +} from "@/components/appearance-provider" import { resolveFontStack } from "@/lib/font-presets" export function useAppearance() { @@ -91,6 +95,60 @@ export function useTerminalFont() { } } +/** 消息内代码字体(--font-mono)。stack 已解析。 */ +export function useMonoFont() { + const { monoFont, setMonoFont } = useAppearance() + return { + monoFont, + setMonoFont, + monoFontStack: resolveFontStack(monoFont.id, monoFont.custom, "mono"), + } +} + +/** + * The `[light, dark]` Shiki theme tuple for Streamdown's `shikiTheme` prop and + * the ai-elements code block. Reads its own context (not the appearance + * context), so a message re-renders for a change of code colours and for + * nothing else the appearance provider does. Safe without the provider: the + * default tuple is Streamdown's own. + */ +export function useCodeTheme(): [BundledTheme, BundledTheme] { + return useContext(CodeThemeContext) +} + +/** + * Appearance presets: everything the presets section needs to show which + * preset is active, apply one, export the current look, and drive the + * preset-level knobs (code colours, density, message text) that write the same + * tokens a preset does. + */ +export function useAppearancePresets() { + const { + themeColor, + customTheme, + customThemeEnabled, + uiFont, + monoFont, + codeTheme, + setCodeTheme, + appliedPreset, + applyPreset, + setSharedThemeToken, + } = useAppearance() + return { + themeColor, + customTheme, + customThemeEnabled, + uiFont, + monoFont, + codeTheme, + setCodeTheme, + appliedPreset, + applyPreset, + setSharedThemeToken, + } +} + /** * 自定义样式:主题 token 覆盖 + 自由 CSS + 逃生舱状态。 * diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 437945dcb1..01bc4d956d 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -106,6 +106,7 @@ "radius": "استدارة الزوايا", "radiusHint": "يشتق منها مقياس الاستدارة كاملًا من sm إلى 4xl، لذا يكفي شريط واحد لتغيير استدارة التطبيق بأكمله.", "advanced": "متقدم ({count} متغيرات إضافية)", + "surfaces": "أسطح المحادثة ({count} متغيرات إضافية)", "enableCss": "تفعيل CSS المخصص", "cssRisk": "ميزة متقدمة. يتجاوز CSS المخصص جميع الأنماط المدمجة وقد يجعل الواجهة غير قابلة للاستخدام.", "editCss": "تحرير CSS…", @@ -144,6 +145,119 @@ "hintTokens": "تلميح: الألوان التي تجاوزتها متاحة عبر var(--primary) و var(--background) وغيرها." } }, + "presets": { + "sectionTitle": "الإعدادات المسبقة", + "sectionDescription": "مظهر كامل بنقرة واحدة: لوحة الألوان والخطوط والتباعد وفقاعات الرسائل وألوان الكود. كل ما يضبطه الإعداد المسبق يبقى قابلًا للتعديل في الأقسام أدناه، ويمكن حفظ النتيجة كملف لمشاركتها.", + "current": "الحالي: {name}", + "currentModified": "الحالي: {name} (مُعدَّل)", + "currentCustom": "الحالي: مظهر مخصص", + "customName": "مخصص", + "apply": "تطبيق {name}", + "disabledHint": "الألوان المخصصة متوقفة في «نمط مخصص»، لذا لن تُطبَّق ألوان الإعداد المسبق وتباعده وحجم نصه حتى تعيد تشغيلها.", + "codeTheme": "ألوان الكود", + "codeThemeLight": "ألوان الكود في الوضع الفاتح", + "codeThemeDark": "ألوان الكود في الوضع الداكن", + "codeThemeGroupLight": "سمات فاتحة", + "codeThemeGroupDark": "سمات داكنة", + "codeThemeHint": "ألوان الصياغة لكتل الكود داخل الرسائل، سمة واحدة لكل وضع.", + "density": "الكثافة", + "densityHint": "تُغيّر مقياس كل فجوة وحشوة وارتفاع عنصر تحكم في الواجهة.", + "densityOptions": { + "compact": "مضغوطة", + "snug": "متقاربة", + "default": "افتراضية", + "relaxed": "متباعدة", + "custom": "مخصصة" + }, + "chatText": "نص الرسائل", + "chatTextHint": "حجم القراءة للرسائل. أما تكبير النافذة فيغيّر مقياس الواجهة كلها.", + "chatTextOptions": { + "small": "صغير", + "default": "افتراضي", + "large": "كبير", + "larger": "أكبر", + "custom": "مخصص" + }, + "import": "استيراد إعداد مسبق…", + "export": "تصدير إعداد مسبق…", + "sharingNote": "يحمل ملف الإعداد المسبق الألوان والخطوط والتباعد وأسماء سمات الكود المضمّنة فقط، لا غير. CSS المخصص لا يغادر هذا الجهاز أبدًا.", + "importTitle": "استيراد إعداد مسبق", + "importPreview": "راجع ما سيغيّره هذا الملف ثم طبّقه.", + "importErrorsTitle": "لا يمكن تطبيق هذا الملف", + "importErrorsDescription": "لم يتغيّر شيء. أصلح المشكلات أدناه أو اختر ملفًا آخر.", + "importApply": "تطبيق الإعداد المسبق", + "cancel": "إلغاء", + "preview": { + "base": "اللوحة الأساسية", + "mode": "الوضع", + "overrides": "تجاوزات الألوان", + "overridesValue": "{light} للفاتح، {dark} للداكن", + "fonts": "الخطوط (الواجهة / الكود)", + "code": "ألوان الكود", + "density": "الكثافة", + "chatText": "نص الرسائل", + "unchanged": "بدون تغيير" + }, + "modes": { + "light": "فاتح", + "dark": "داكن", + "system": "اتباع النظام" + }, + "toasts": { + "applied": "تم تطبيق الإعداد المسبق: {name}", + "exported": "تم حفظ الإعداد المسبق", + "exportFailed": "تعذّر حفظ الإعداد المسبق", + "readFailed": "تعذّرت قراءة ذلك الملف", + "partialExport": "لم يُضمَّن في الملف: {items}" + }, + "errors": { + "invalid-json": "ليس JSON صالحًا", + "too-large": "الملف كبير جدًا (الحد {limit} كيلوبايت)", + "not-object": "كان المتوقع كائنًا", + "unknown-key": "حقل غير معروف", + "missing": "حقل مطلوب مفقود", + "wrong-type": "نوع خاطئ", + "invalid-value": "قيمة غير مسموح بها", + "unsupported-version": "مكتوب لإصدار مختلف من صيغة الإعداد المسبق", + "unknown-token": "ليس رمز سمة", + "invalid-color": "ليس لونًا (استخدم hex أو rgb أو hsl أو oklch)", + "invalid-length": "ليس طولًا (استخدم rem أو px أو em)", + "out-of-range": "خارج النطاق", + "unknown-font": "ليس خطًا مضمّنًا ولا خط نظام", + "unknown-code-theme": "ليست سمة كود مضمّنة", + "unknown-base": "ليست لوحة ألوان مدمجة" + }, + "bundled": { + "default": { + "name": "الافتراضي", + "description": "مظهر codeg الأصلي: لوحة محايدة، وخط Inter، وألوان كود GitHub." + }, + "warm-terminal": { + "name": "طرفية دافئة", + "description": "فحمي دافئ ولمسات كهرمانية وواجهة بخط أحادي المسافة، كما يبدو وكيل وُلد في الطرفية. الوضع الفاتح ورقٌ دافئ." + }, + "ink": { + "name": "حبر", + "description": "أحادي اللون ومربع الزوايا: أسود وأبيض وبضع درجات رمادية، فقاعات بإطار فقط، ولا لون تمييز في أي مكان." + }, + "dense-ide": { + "name": "IDE مضغوطة", + "description": "تباعد مضغوط ونص صغير ولمسات زرقاء وشريط حالة ملوّن، مثل محرر مزوّد بلوحة محادثة." + }, + "high-contrast": { + "name": "تباين عالٍ", + "description": "أسود خالص على أبيض (وأبيض على أسود)، حدود قوية، نص أكبر، وألوان كود عالية التباين." + }, + "midnight": { + "name": "منتصف الليل", + "description": "أسطح زرقاء بنفسجية عميقة وفقاعات مستديرة ناعمة ولمسة ليلكية، أجواء عميل محادثة في وقت متأخر من الليل." + }, + "paper": { + "name": "ورق", + "description": "سطح قراءة فاتح ودافئ بتباعد رحب ونص أكبر. الوضع الداكن غسقٌ خافت بدلًا من الأسود." + } + } + }, "zoomLevel": { "sectionTitle": "تكبير النافذة", "sectionDescription": "تكبير أو تصغير الواجهة بالكامل. يتم تطبيقه فوراً ويُحفظ لكل جهاز على حدة. ⌘/Ctrl + و - ينتقلان بين نفس مستويات التكبير في هذه القائمة (⌘/Ctrl 0 يعيد الضبط إلى 100%).", @@ -157,6 +271,7 @@ "interface": "الواجهة", "editor": "المحرّر", "terminal": "الطرفية", + "code": "الكود داخل الرسائل", "groupSans": "بلا زوائد", "groupMono": "ثابت العرض", "custom": "مخصّص…", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9e80735d19..8619993d27 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -106,6 +106,7 @@ "radius": "Eckenradius", "radiusHint": "Daraus leitet sich die gesamte Radiusskala von sm bis 4xl ab – ein Regler rundet die ganze App.", "advanced": "Erweitert ({count} weitere Variablen)", + "surfaces": "Chat-Flächen ({count} weitere Variablen)", "enableCss": "Eigenes CSS aktivieren", "cssRisk": "Funktion für Fortgeschrittene. Eigenes CSS überschreibt sämtliche eingebauten Stile und kann die Oberfläche unbrauchbar machen.", "editCss": "CSS bearbeiten…", @@ -144,6 +145,119 @@ "hintTokens": "Tipp: Deine überschriebenen Farben sind als var(--primary), var(--background) usw. verfügbar." } }, + "presets": { + "sectionTitle": "Voreinstellungen", + "sectionDescription": "Ein kompletter Look mit einem Klick: Palette, Schriften, Abstände, Nachrichtenblasen und Code-Farben. Alles, was eine Voreinstellung setzt, bleibt in den Abschnitten darunter änderbar, und das Ergebnis lässt sich als Datei speichern und teilen.", + "current": "Aktuell: {name}", + "currentModified": "Aktuell: {name} (geändert)", + "currentCustom": "Aktuell: eigener Look", + "customName": "Eigener Look", + "apply": "{name} anwenden", + "disabledHint": "Eigene Farben sind unter „Eigener Stil“ ausgeschaltet, deshalb greifen Farben, Abstände und Textgröße einer Voreinstellung erst, wenn du sie wieder einschaltest.", + "codeTheme": "Code-Farben", + "codeThemeLight": "Code-Farben im hellen Modus", + "codeThemeDark": "Code-Farben im dunklen Modus", + "codeThemeGroupLight": "Helle Themes", + "codeThemeGroupDark": "Dunkle Themes", + "codeThemeHint": "Syntaxfarben für Codeblöcke in Nachrichten, ein Theme pro Modus.", + "density": "Dichte", + "densityHint": "Skaliert jeden Abstand, jede Innenfläche und jede Steuerelementhöhe in der Oberfläche.", + "densityOptions": { + "compact": "Kompakt", + "snug": "Eng", + "default": "Standard", + "relaxed": "Locker", + "custom": "Eigener Wert" + }, + "chatText": "Nachrichtentext", + "chatTextHint": "Lesegröße für Nachrichten. Der Fensterzoom skaliert stattdessen die ganze Oberfläche.", + "chatTextOptions": { + "small": "Klein", + "default": "Standard", + "large": "Groß", + "larger": "Größer", + "custom": "Eigener Wert" + }, + "import": "Voreinstellung importieren…", + "export": "Voreinstellung exportieren…", + "sharingNote": "Eine Voreinstellungsdatei enthält Farben, Schriften, Abstände und die Namen mitgelieferter Code-Themes, sonst nichts. Eigenes CSS verlässt dieses Gerät nie.", + "importTitle": "Voreinstellung importieren", + "importPreview": "Prüfe, was diese Datei ändert, und wende sie dann an.", + "importErrorsTitle": "Diese Datei lässt sich nicht anwenden", + "importErrorsDescription": "Es wurde nichts geändert. Behebe die Probleme unten oder wähle eine andere Datei.", + "importApply": "Voreinstellung anwenden", + "cancel": "Abbrechen", + "preview": { + "base": "Basispalette", + "mode": "Modus", + "overrides": "Farbüberschreibungen", + "overridesValue": "{light} hell, {dark} dunkel", + "fonts": "Schriften (Oberfläche / Code)", + "code": "Code-Farben", + "density": "Dichte", + "chatText": "Nachrichtentext", + "unchanged": "Unverändert" + }, + "modes": { + "light": "Hell", + "dark": "Dunkel", + "system": "System folgen" + }, + "toasts": { + "applied": "Voreinstellung angewendet: {name}", + "exported": "Voreinstellung gespeichert", + "exportFailed": "Die Voreinstellung konnte nicht gespeichert werden", + "readFailed": "Die Datei konnte nicht gelesen werden", + "partialExport": "Nicht in die Datei übernommen: {items}" + }, + "errors": { + "invalid-json": "Kein gültiges JSON", + "too-large": "Datei ist zu groß (Limit {limit} KB)", + "not-object": "Ein Objekt wurde erwartet", + "unknown-key": "Unbekanntes Feld", + "missing": "Pflichtfeld fehlt", + "wrong-type": "Falscher Typ", + "invalid-value": "Kein erlaubter Wert", + "unsupported-version": "Für eine andere Version des Voreinstellungsformats geschrieben", + "unknown-token": "Kein Theme-Token", + "invalid-color": "Keine Farbe (hex, rgb, hsl oder oklch verwenden)", + "invalid-length": "Keine Länge (rem, px oder em verwenden)", + "out-of-range": "Außerhalb des Bereichs", + "unknown-font": "Keine mitgelieferte oder System-Schrift", + "unknown-code-theme": "Kein mitgeliefertes Code-Theme", + "unknown-base": "Keine eingebaute Palette" + }, + "bundled": { + "default": { + "name": "Standard", + "description": "Der gewohnte codeg-Look: neutrale Palette, Inter, GitHub-Code-Farben." + }, + "warm-terminal": { + "name": "Warmes Terminal", + "description": "Warmes Anthrazit, Bernstein-Akzente und eine Monospace-Oberfläche, wie sich ein terminalgeborener Agent anfühlt. Der helle Modus ist warmes Papier." + }, + "ink": { + "name": "Tinte", + "description": "Monochrom und kantig: Schwarz, Weiß und ein paar Grautöne, umrandete Blasen, nirgends eine Akzentfarbe." + }, + "dense-ide": { + "name": "Dichte IDE", + "description": "Kompakte Abstände, kleiner Text, blaue Akzente und eine farbige Statusleiste, wie ein Editor mit Chat-Panel." + }, + "high-contrast": { + "name": "Hoher Kontrast", + "description": "Reines Schwarz auf Weiß (und Weiß auf Schwarz), kräftige Rahmen, größerer Text und kontrastreiche Code-Farben." + }, + "midnight": { + "name": "Mitternacht", + "description": "Tiefe blauviolette Flächen, weich gerundete Blasen und ein Flieder-Akzent, die Stimmung eines Chat-Clients spät in der Nacht." + }, + "paper": { + "name": "Papier", + "description": "Eine warme, helle Lesefläche mit großzügigen Abständen und größerem Text. Der dunkle Modus ist gedämpfte Dämmerung statt Schwarz." + } + } + }, "zoomLevel": { "sectionTitle": "Fensterzoom", "sectionDescription": "Skaliert die gesamte Oberfläche. Wird sofort übernommen und pro Gerät gespeichert. ⌘/Ctrl + und - durchlaufen dieselben Zoomstufen wie dieses Menü (⌘/Ctrl 0 setzt auf 100% zurück).", @@ -157,6 +271,7 @@ "interface": "Oberfläche", "editor": "Editor", "terminal": "Terminal", + "code": "Code in Nachrichten", "groupSans": "Serifenlos", "groupMono": "Monospace", "custom": "Benutzerdefiniert…", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 90f4db8c68..b853f80540 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -106,6 +106,7 @@ "radius": "Corner radius", "radiusHint": "Drives the whole radius scale from sm to 4xl, so one slider rounds the entire app.", "advanced": "Advanced ({count} more variables)", + "surfaces": "Chat surfaces ({count} more variables)", "enableCss": "Enable custom CSS", "cssRisk": "Advanced feature. Custom CSS overrides every built-in style and can make the interface unusable.", "editCss": "Edit CSS…", @@ -144,6 +145,119 @@ "hintTokens": "Tip: your color overrides are available as var(--primary), var(--background) and so on." } }, + "presets": { + "sectionTitle": "Presets", + "sectionDescription": "A whole look in one click: palette, fonts, spacing, message bubbles and code colors. Everything a preset sets stays editable in the sections below, and the result can be saved as a file to share.", + "current": "Current: {name}", + "currentModified": "Current: {name} (modified)", + "currentCustom": "Current: custom look", + "customName": "Custom", + "apply": "Apply {name}", + "disabledHint": "Custom colors are switched off in Custom Style, so a preset's colors, spacing and text size do not apply until you turn them back on.", + "codeTheme": "Code colors", + "codeThemeLight": "Light mode code colors", + "codeThemeDark": "Dark mode code colors", + "codeThemeGroupLight": "Light themes", + "codeThemeGroupDark": "Dark themes", + "codeThemeHint": "Syntax colors for code blocks in messages, one theme per mode.", + "density": "Density", + "densityHint": "Scales every gap, padding and control height in the interface.", + "densityOptions": { + "compact": "Compact", + "snug": "Snug", + "default": "Default", + "relaxed": "Relaxed", + "custom": "Custom" + }, + "chatText": "Message text", + "chatTextHint": "Reading size for messages. Window zoom scales the whole interface instead.", + "chatTextOptions": { + "small": "Small", + "default": "Default", + "large": "Large", + "larger": "Larger", + "custom": "Custom" + }, + "import": "Import preset…", + "export": "Export preset…", + "sharingNote": "A preset file carries colors, fonts, spacing and the names of bundled code themes, nothing else. Custom CSS never leaves this device.", + "importTitle": "Import preset", + "importPreview": "Review what this file changes, then apply it.", + "importErrorsTitle": "This file cannot be applied", + "importErrorsDescription": "Nothing was changed. Fix the problems below or pick another file.", + "importApply": "Apply preset", + "cancel": "Cancel", + "preview": { + "base": "Base palette", + "mode": "Mode", + "overrides": "Color overrides", + "overridesValue": "{light} light, {dark} dark", + "fonts": "Fonts (interface / code)", + "code": "Code colors", + "density": "Density", + "chatText": "Message text", + "unchanged": "Unchanged" + }, + "modes": { + "light": "Light", + "dark": "Dark", + "system": "Follow system" + }, + "toasts": { + "applied": "Preset applied: {name}", + "exported": "Preset saved", + "exportFailed": "Could not save the preset", + "readFailed": "Could not read that file", + "partialExport": "Left out of the file: {items}" + }, + "errors": { + "invalid-json": "Not valid JSON", + "too-large": "File is too large (limit {limit} KB)", + "not-object": "Expected an object", + "unknown-key": "Unknown field", + "missing": "Required field is missing", + "wrong-type": "Wrong type", + "invalid-value": "Not an allowed value", + "unsupported-version": "Written for a different version of the preset format", + "unknown-token": "Not a theme token", + "invalid-color": "Not a color (use hex, rgb, hsl or oklch)", + "invalid-length": "Not a length (use rem, px or em)", + "out-of-range": "Out of range", + "unknown-font": "Not a bundled or system font", + "unknown-code-theme": "Not a bundled code theme", + "unknown-base": "Not a built-in palette" + }, + "bundled": { + "default": { + "name": "Default", + "description": "The stock codeg look: neutral palette, Inter, GitHub code colors." + }, + "warm-terminal": { + "name": "Warm terminal", + "description": "Warm charcoal, amber accents and a monospace interface, the way a terminal-first agent feels. Light mode is warm paper." + }, + "ink": { + "name": "Ink", + "description": "Monochrome and square: black, white and a few grays, outlined bubbles, no accent color anywhere." + }, + "dense-ide": { + "name": "Dense IDE", + "description": "Compact spacing, small text, blue accents and a colored status bar, like an editor with a chat panel." + }, + "high-contrast": { + "name": "High contrast", + "description": "Pure black on white (and white on black), strong borders, larger text and high-contrast code colors." + }, + "midnight": { + "name": "Midnight", + "description": "Deep blue-violet surfaces, soft rounded bubbles and a lilac accent, the mood of a late-night chat client." + }, + "paper": { + "name": "Paper", + "description": "A warm, light reading surface with roomy spacing and larger text. Dark mode is a dim dusk instead of black." + } + } + }, "zoomLevel": { "sectionTitle": "Window zoom", "sectionDescription": "Scale the entire interface. Applies immediately and persists per device. ⌘/Ctrl + and - move through the same zoom levels as this menu (⌘/Ctrl 0 resets to 100%).", @@ -157,6 +271,7 @@ "interface": "Interface", "editor": "Editor", "terminal": "Terminal", + "code": "Code in messages", "groupSans": "Sans-serif", "groupMono": "Monospace", "custom": "Custom…", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 2bce52a8bf..a0da79cea7 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -106,6 +106,7 @@ "radius": "Radio de esquina", "radiusHint": "De él deriva toda la escala de radios, de sm a 4xl, así que un único control redondea toda la aplicación.", "advanced": "Avanzado ({count} variables más)", + "surfaces": "Superficies del chat ({count} variables más)", "enableCss": "Activar CSS personalizado", "cssRisk": "Función avanzada. El CSS personalizado anula todos los estilos integrados y puede dejar la interfaz inutilizable.", "editCss": "Editar CSS…", @@ -144,6 +145,119 @@ "hintTokens": "Consejo: tus colores anulados están disponibles como var(--primary), var(--background), etc." } }, + "presets": { + "sectionTitle": "Preajustes", + "sectionDescription": "Un aspecto completo con un clic: paleta, fuentes, espaciado, burbujas de mensaje y colores del código. Todo lo que fija un preajuste sigue siendo editable en las secciones de abajo, y el resultado puede guardarse como archivo para compartirlo.", + "current": "Actual: {name}", + "currentModified": "Actual: {name} (modificado)", + "currentCustom": "Actual: aspecto personalizado", + "customName": "Personalizado", + "apply": "Aplicar {name}", + "disabledHint": "Los colores personalizados están desactivados en Estilo personalizado, así que los colores, el espaciado y el tamaño de texto de un preajuste no se aplican hasta que los vuelvas a activar.", + "codeTheme": "Colores del código", + "codeThemeLight": "Colores del código en modo claro", + "codeThemeDark": "Colores del código en modo oscuro", + "codeThemeGroupLight": "Temas claros", + "codeThemeGroupDark": "Temas oscuros", + "codeThemeHint": "Colores de sintaxis de los bloques de código en los mensajes, un tema por modo.", + "density": "Densidad", + "densityHint": "Escala todos los espacios, rellenos y alturas de controles de la interfaz.", + "densityOptions": { + "compact": "Compacta", + "snug": "Ajustada", + "default": "Predeterminada", + "relaxed": "Holgada", + "custom": "Personalizada" + }, + "chatText": "Texto de los mensajes", + "chatTextHint": "Tamaño de lectura de los mensajes. El zoom de la ventana escala toda la interfaz.", + "chatTextOptions": { + "small": "Pequeño", + "default": "Predeterminado", + "large": "Grande", + "larger": "Más grande", + "custom": "Personalizado" + }, + "import": "Importar preajuste…", + "export": "Exportar preajuste…", + "sharingNote": "Un archivo de preajuste lleva colores, fuentes, espaciado y los nombres de los temas de código incluidos, nada más. El CSS personalizado nunca sale de este dispositivo.", + "importTitle": "Importar preajuste", + "importPreview": "Revisa qué cambia este archivo y luego aplícalo.", + "importErrorsTitle": "Este archivo no se puede aplicar", + "importErrorsDescription": "No se cambió nada. Corrige los problemas de abajo o elige otro archivo.", + "importApply": "Aplicar preajuste", + "cancel": "Cancelar", + "preview": { + "base": "Paleta base", + "mode": "Modo", + "overrides": "Colores anulados", + "overridesValue": "{light} en claro, {dark} en oscuro", + "fonts": "Fuentes (interfaz / código)", + "code": "Colores del código", + "density": "Densidad", + "chatText": "Texto de los mensajes", + "unchanged": "Sin cambios" + }, + "modes": { + "light": "Claro", + "dark": "Oscuro", + "system": "Seguir al sistema" + }, + "toasts": { + "applied": "Preajuste aplicado: {name}", + "exported": "Preajuste guardado", + "exportFailed": "No se pudo guardar el preajuste", + "readFailed": "No se pudo leer ese archivo", + "partialExport": "Quedó fuera del archivo: {items}" + }, + "errors": { + "invalid-json": "No es JSON válido", + "too-large": "El archivo es demasiado grande (límite {limit} KB)", + "not-object": "Se esperaba un objeto", + "unknown-key": "Campo desconocido", + "missing": "Falta un campo obligatorio", + "wrong-type": "Tipo incorrecto", + "invalid-value": "Valor no permitido", + "unsupported-version": "Escrito para otra versión del formato de preajuste", + "unknown-token": "No es un token del tema", + "invalid-color": "No es un color (usa hex, rgb, hsl u oklch)", + "invalid-length": "No es una longitud (usa rem, px o em)", + "out-of-range": "Fuera de rango", + "unknown-font": "No es una fuente incluida ni del sistema", + "unknown-code-theme": "No es un tema de código incluido", + "unknown-base": "No es una paleta integrada" + }, + "bundled": { + "default": { + "name": "Predeterminado", + "description": "El aspecto original de codeg: paleta neutra, Inter y colores de código de GitHub." + }, + "warm-terminal": { + "name": "Terminal cálida", + "description": "Carbón cálido, acentos ámbar e interfaz monoespaciada, como se siente un agente nacido en la terminal. El modo claro es papel cálido." + }, + "ink": { + "name": "Tinta", + "description": "Monocromo y cuadrado: negro, blanco y unos pocos grises, burbujas con contorno, sin ningún color de acento." + }, + "dense-ide": { + "name": "IDE densa", + "description": "Espaciado compacto, texto pequeño, acentos azules y una barra de estado de color, como un editor con panel de chat." + }, + "high-contrast": { + "name": "Alto contraste", + "description": "Negro puro sobre blanco (y blanco sobre negro), bordes marcados, texto más grande y colores de código de alto contraste." + }, + "midnight": { + "name": "Medianoche", + "description": "Superficies azul violeta profundo, burbujas redondeadas y un acento lila, el ambiente de un cliente de chat de madrugada." + }, + "paper": { + "name": "Papel", + "description": "Una superficie de lectura clara y cálida con espaciado amplio y texto más grande. El modo oscuro es un atardecer tenue en lugar de negro." + } + } + }, "zoomLevel": { "sectionTitle": "Zoom de ventana", "sectionDescription": "Escala toda la interfaz. Se aplica al instante y se guarda por dispositivo. ⌘/Ctrl + y - recorren los mismos niveles de zoom que este menú (⌘/Ctrl 0 vuelve al 100%).", @@ -157,6 +271,7 @@ "interface": "Interfaz", "editor": "Editor", "terminal": "Terminal", + "code": "Código en los mensajes", "groupSans": "Sans-serif", "groupMono": "Monoespaciada", "custom": "Personalizada…", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index adcf62f0dc..3c5ec74883 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -106,6 +106,7 @@ "radius": "Rayon des coins", "radiusHint": "Toute l'échelle de rayons, de sm à 4xl, en découle : un seul curseur arrondit l'application entière.", "advanced": "Avancé ({count} variables de plus)", + "surfaces": "Surfaces de la conversation ({count} variables de plus)", "enableCss": "Activer le CSS personnalisé", "cssRisk": "Fonction avancée. Le CSS personnalisé remplace tous les styles intégrés et peut rendre l'interface inutilisable.", "editCss": "Modifier le CSS…", @@ -144,6 +145,119 @@ "hintTokens": "Astuce : vos couleurs remplacées sont accessibles via var(--primary), var(--background), etc." } }, + "presets": { + "sectionTitle": "Préréglages", + "sectionDescription": "Un look complet en un clic : palette, polices, espacements, bulles de message et couleurs du code. Tout ce qu'un préréglage définit reste modifiable dans les sections ci-dessous, et le résultat peut être enregistré dans un fichier à partager.", + "current": "Actuel : {name}", + "currentModified": "Actuel : {name} (modifié)", + "currentCustom": "Actuel : look personnalisé", + "customName": "Personnalisé", + "apply": "Appliquer {name}", + "disabledHint": "Les couleurs personnalisées sont désactivées dans Style personnalisé : les couleurs, espacements et taille de texte d'un préréglage ne s'appliquent qu'une fois réactivées.", + "codeTheme": "Couleurs du code", + "codeThemeLight": "Couleurs du code en mode clair", + "codeThemeDark": "Couleurs du code en mode sombre", + "codeThemeGroupLight": "Thèmes clairs", + "codeThemeGroupDark": "Thèmes sombres", + "codeThemeHint": "Couleurs de syntaxe des blocs de code dans les messages, un thème par mode.", + "density": "Densité", + "densityHint": "Met à l'échelle chaque espacement, marge intérieure et hauteur de contrôle de l'interface.", + "densityOptions": { + "compact": "Compacte", + "snug": "Serrée", + "default": "Par défaut", + "relaxed": "Aérée", + "custom": "Personnalisée" + }, + "chatText": "Texte des messages", + "chatTextHint": "Taille de lecture des messages. Le zoom de la fenêtre agrandit toute l'interface.", + "chatTextOptions": { + "small": "Petit", + "default": "Par défaut", + "large": "Grand", + "larger": "Plus grand", + "custom": "Personnalisé" + }, + "import": "Importer un préréglage…", + "export": "Exporter le préréglage…", + "sharingNote": "Un fichier de préréglage contient les couleurs, les polices, les espacements et les noms des thèmes de code fournis, rien d'autre. Le CSS personnalisé ne quitte jamais cet appareil.", + "importTitle": "Importer un préréglage", + "importPreview": "Vérifiez ce que ce fichier modifie, puis appliquez-le.", + "importErrorsTitle": "Ce fichier ne peut pas être appliqué", + "importErrorsDescription": "Rien n'a été modifié. Corrigez les problèmes ci-dessous ou choisissez un autre fichier.", + "importApply": "Appliquer le préréglage", + "cancel": "Annuler", + "preview": { + "base": "Palette de base", + "mode": "Mode", + "overrides": "Couleurs remplacées", + "overridesValue": "{light} en clair, {dark} en sombre", + "fonts": "Polices (interface / code)", + "code": "Couleurs du code", + "density": "Densité", + "chatText": "Texte des messages", + "unchanged": "Inchangé" + }, + "modes": { + "light": "Clair", + "dark": "Sombre", + "system": "Suivre le système" + }, + "toasts": { + "applied": "Préréglage appliqué : {name}", + "exported": "Préréglage enregistré", + "exportFailed": "Impossible d'enregistrer le préréglage", + "readFailed": "Impossible de lire ce fichier", + "partialExport": "Non inclus dans le fichier : {items}" + }, + "errors": { + "invalid-json": "JSON invalide", + "too-large": "Fichier trop volumineux (limite {limit} Ko)", + "not-object": "Un objet était attendu", + "unknown-key": "Champ inconnu", + "missing": "Champ obligatoire manquant", + "wrong-type": "Mauvais type", + "invalid-value": "Valeur non autorisée", + "unsupported-version": "Écrit pour une autre version du format de préréglage", + "unknown-token": "Ce n'est pas un token de thème", + "invalid-color": "Ce n'est pas une couleur (utilisez hex, rgb, hsl ou oklch)", + "invalid-length": "Ce n'est pas une longueur (utilisez rem, px ou em)", + "out-of-range": "Hors limites", + "unknown-font": "Ni une police fournie ni une police système", + "unknown-code-theme": "Ce n'est pas un thème de code fourni", + "unknown-base": "Ce n'est pas une palette intégrée" + }, + "bundled": { + "default": { + "name": "Par défaut", + "description": "Le look d'origine de codeg : palette neutre, Inter, couleurs de code GitHub." + }, + "warm-terminal": { + "name": "Terminal chaleureux", + "description": "Anthracite chaud, accents ambrés et interface en monospace, comme un agent né dans le terminal. Le mode clair est un papier chaud." + }, + "ink": { + "name": "Encre", + "description": "Monochrome et carré : noir, blanc et quelques gris, bulles cerclées, aucune couleur d'accent." + }, + "dense-ide": { + "name": "IDE dense", + "description": "Espacements serrés, petit texte, accents bleus et barre d'état colorée, comme un éditeur avec un panneau de discussion." + }, + "high-contrast": { + "name": "Contraste élevé", + "description": "Noir pur sur blanc (et blanc sur noir), bordures marquées, texte plus grand et couleurs de code à fort contraste." + }, + "midnight": { + "name": "Minuit", + "description": "Surfaces bleu-violet profond, bulles arrondies et accent lilas, l'ambiance d'un client de discussion tard dans la nuit." + }, + "paper": { + "name": "Papier", + "description": "Une surface de lecture claire et chaude, avec des espacements généreux et un texte plus grand. Le mode sombre est un crépuscule tamisé plutôt que du noir." + } + } + }, "zoomLevel": { "sectionTitle": "Zoom de la fenêtre", "sectionDescription": "Met à l'échelle toute l'interface. S'applique immédiatement et est enregistré par appareil. ⌘/Ctrl + et - parcourent les mêmes niveaux de zoom que ce menu (⌘/Ctrl 0 rétablit 100%).", @@ -157,6 +271,7 @@ "interface": "Interface", "editor": "Éditeur", "terminal": "Terminal", + "code": "Code dans les messages", "groupSans": "Sans empattement", "groupMono": "Monospace", "custom": "Personnalisée…", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c2cf389d89..69995b890b 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -106,6 +106,7 @@ "radius": "角丸", "radiusHint": "sm から 4xl までの角丸スケール全体がこの値から導出されるため、スライダー 1 つでアプリ全体の角丸が変わります。", "advanced": "詳細(他 {count} 個の変数)", + "surfaces": "チャットのサーフェス(他 {count} 個の変数)", "enableCss": "カスタム CSS を有効にする", "cssRisk": "上級者向け機能です。カスタム CSS は組み込みスタイルをすべて上書きするため、画面が使用不能になることがあります。", "editCss": "CSS を編集…", @@ -144,6 +145,119 @@ "hintTokens": "ヒント: 上書きした色は var(--primary) や var(--background) などで参照できます。" } }, + "presets": { + "sectionTitle": "プリセット", + "sectionDescription": "配色、フォント、余白、メッセージの吹き出し、コードの配色をひとまとめにして一度に切り替えます。プリセットが設定した項目はすべて下のセクションで引き続き調整でき、結果をファイルとして保存して共有できます。", + "current": "現在:{name}", + "currentModified": "現在:{name}(変更あり)", + "currentCustom": "現在:カスタムの外観", + "customName": "カスタム", + "apply": "{name} を適用", + "disabledHint": "「カスタムスタイル」でカスタムカラーがオフになっているため、プリセットの配色・余白・文字サイズは再びオンにするまで反映されません。", + "codeTheme": "コードの配色", + "codeThemeLight": "ライトモードのコード配色", + "codeThemeDark": "ダークモードのコード配色", + "codeThemeGroupLight": "ライトテーマ", + "codeThemeGroupDark": "ダークテーマ", + "codeThemeHint": "メッセージ内のコードブロックのシンタックスカラー。モードごとにテーマを一つ選びます。", + "density": "密度", + "densityHint": "インターフェース内のすべての間隔、余白、コントロールの高さを比例して拡大縮小します。", + "densityOptions": { + "compact": "コンパクト", + "snug": "やや詰める", + "default": "デフォルト", + "relaxed": "ゆったり", + "custom": "カスタム" + }, + "chatText": "メッセージの文字", + "chatTextHint": "メッセージを読むときの文字サイズです。インターフェース全体はウィンドウのズームで拡大縮小します。", + "chatTextOptions": { + "small": "小", + "default": "デフォルト", + "large": "大", + "larger": "特大", + "custom": "カスタム" + }, + "import": "プリセットをインポート…", + "export": "プリセットをエクスポート…", + "sharingNote": "プリセットファイルに含まれるのは配色、フォント、余白、同梱コードテーマの名前だけです。カスタム CSS がこのデバイスの外に出ることはありません。", + "importTitle": "プリセットをインポート", + "importPreview": "このファイルが変更する内容を確認してから適用してください。", + "importErrorsTitle": "このファイルは適用できません", + "importErrorsDescription": "何も変更していません。以下の問題を修正するか、別のファイルを選んでください。", + "importApply": "プリセットを適用", + "cancel": "キャンセル", + "preview": { + "base": "ベースの配色", + "mode": "モード", + "overrides": "色の上書き", + "overridesValue": "ライト {light} 件、ダーク {dark} 件", + "fonts": "フォント(インターフェース / コード)", + "code": "コードの配色", + "density": "密度", + "chatText": "メッセージの文字", + "unchanged": "変更なし" + }, + "modes": { + "light": "ライト", + "dark": "ダーク", + "system": "システムに従う" + }, + "toasts": { + "applied": "プリセットを適用しました:{name}", + "exported": "プリセットを保存しました", + "exportFailed": "プリセットを保存できませんでした", + "readFailed": "そのファイルを読み込めませんでした", + "partialExport": "ファイルに含められなかった項目:{items}" + }, + "errors": { + "invalid-json": "有効な JSON ではありません", + "too-large": "ファイルが大きすぎます(上限 {limit} KB)", + "not-object": "オブジェクトである必要があります", + "unknown-key": "不明なフィールドです", + "missing": "必須フィールドがありません", + "wrong-type": "型が違います", + "invalid-value": "許可されていない値です", + "unsupported-version": "このプリセット形式のバージョンには対応していません", + "unknown-token": "テーマの変数ではありません", + "invalid-color": "色ではありません(hex、rgb、hsl、oklch を使ってください)", + "invalid-length": "長さではありません(rem、px、em を使ってください)", + "out-of-range": "範囲外です", + "unknown-font": "同梱フォントでもシステムフォントでもありません", + "unknown-code-theme": "同梱のコードテーマではありません", + "unknown-base": "組み込みの配色ではありません" + }, + "bundled": { + "default": { + "name": "デフォルト", + "description": "codeg 標準の外観:ニュートラルな配色、Inter、GitHub のコード配色。" + }, + "warm-terminal": { + "name": "ウォームターミナル", + "description": "暖かいチャコール、アンバーのアクセント、等幅フォントのインターフェース。ターミナル型エージェントの手触りです。ライトモードは暖かい紙色。" + }, + "ink": { + "name": "インク", + "description": "モノクロで角張った外観:黒、白、数段階のグレー、枠線だけの吹き出し、アクセントカラーなし。" + }, + "dense-ide": { + "name": "高密度 IDE", + "description": "詰めた余白、小さめの文字、青のアクセント、色付きのステータスバー。チャットパネル付きのエディターのように。" + }, + "high-contrast": { + "name": "ハイコントラスト", + "description": "白地に純黒(ダークでは黒地に純白)、強い境界線、大きめの文字、高コントラストのコード配色。" + }, + "midnight": { + "name": "ミッドナイト", + "description": "深い青紫のサーフェス、柔らかな角丸の吹き出し、ライラックのアクセント。深夜のチャットクライアントの雰囲気です。" + }, + "paper": { + "name": "ペーパー", + "description": "暖かく明るい読書向けのサーフェス。余白は広めで文字は大きめ。ダークモードは黒ではなく薄暗い夕暮れ。" + } + } + }, "zoomLevel": { "sectionTitle": "ウィンドウズーム", "sectionDescription": "インターフェイス全体を拡大・縮小します。すぐに反映され、デバイスごとに保存されます。⌘/Ctrl + と - で、このメニューと同じ段階で拡大縮小します(⌘/Ctrl 0 で 100% に戻します)。", @@ -157,6 +271,7 @@ "interface": "インターフェース", "editor": "エディター", "terminal": "ターミナル", + "code": "メッセージ内のコード", "groupSans": "サンセリフ", "groupMono": "等幅", "custom": "カスタム…", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 32f288ec3a..3af8708866 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -106,6 +106,7 @@ "radius": "모서리 둥글기", "radiusHint": "sm부터 4xl까지의 둥글기 척도 전체가 이 값에서 파생되므로 슬라이더 하나로 앱 전체가 바뀝니다.", "advanced": "고급 (변수 {count}개 더)", + "surfaces": "채팅 표면 ({count}개 변수 더 보기)", "enableCss": "사용자 지정 CSS 사용", "cssRisk": "고급 기능입니다. 사용자 지정 CSS는 모든 기본 스타일을 덮어쓰므로 화면을 사용할 수 없게 만들 수 있습니다.", "editCss": "CSS 편집…", @@ -144,6 +145,119 @@ "hintTokens": "팁: 재정의한 색상은 var(--primary), var(--background) 등으로 참조할 수 있습니다." } }, + "presets": { + "sectionTitle": "프리셋", + "sectionDescription": "색상 팔레트, 글꼴, 간격, 메시지 말풍선, 코드 색상까지 한 번에 바꿉니다. 프리셋이 정한 항목은 아래 섹션에서 계속 수정할 수 있고, 결과를 파일로 저장해 공유할 수 있습니다.", + "current": "현재: {name}", + "currentModified": "현재: {name} (수정됨)", + "currentCustom": "현재: 사용자 지정 모양", + "customName": "사용자 지정", + "apply": "{name} 적용", + "disabledHint": "사용자 지정 스타일에서 사용자 지정 색상이 꺼져 있어, 다시 켜기 전까지 프리셋의 색상, 간격, 글자 크기가 적용되지 않습니다.", + "codeTheme": "코드 색상", + "codeThemeLight": "라이트 모드 코드 색상", + "codeThemeDark": "다크 모드 코드 색상", + "codeThemeGroupLight": "라이트 테마", + "codeThemeGroupDark": "다크 테마", + "codeThemeHint": "메시지 안 코드 블록의 구문 색상입니다. 모드마다 테마를 하나씩 고릅니다.", + "density": "밀도", + "densityHint": "인터페이스의 모든 간격, 여백, 컨트롤 높이를 비율대로 조정합니다.", + "densityOptions": { + "compact": "조밀", + "snug": "약간 조밀", + "default": "기본", + "relaxed": "여유", + "custom": "사용자 지정" + }, + "chatText": "메시지 글자", + "chatTextHint": "메시지를 읽을 때의 글자 크기입니다. 인터페이스 전체는 창 확대/축소로 조정합니다.", + "chatTextOptions": { + "small": "작게", + "default": "기본", + "large": "크게", + "larger": "더 크게", + "custom": "사용자 지정" + }, + "import": "프리셋 가져오기…", + "export": "프리셋 내보내기…", + "sharingNote": "프리셋 파일에는 색상, 글꼴, 간격, 기본 제공 코드 테마의 이름만 담깁니다. 사용자 지정 CSS는 이 기기를 벗어나지 않습니다.", + "importTitle": "프리셋 가져오기", + "importPreview": "이 파일이 바꾸는 내용을 확인한 뒤 적용하세요.", + "importErrorsTitle": "이 파일은 적용할 수 없습니다", + "importErrorsDescription": "아무것도 바뀌지 않았습니다. 아래 문제를 고치거나 다른 파일을 고르세요.", + "importApply": "프리셋 적용", + "cancel": "취소", + "preview": { + "base": "기본 팔레트", + "mode": "모드", + "overrides": "색상 재정의", + "overridesValue": "라이트 {light}개, 다크 {dark}개", + "fonts": "글꼴 (인터페이스 / 코드)", + "code": "코드 색상", + "density": "밀도", + "chatText": "메시지 글자", + "unchanged": "변경 없음" + }, + "modes": { + "light": "라이트", + "dark": "다크", + "system": "시스템 따르기" + }, + "toasts": { + "applied": "프리셋을 적용했습니다: {name}", + "exported": "프리셋을 저장했습니다", + "exportFailed": "프리셋을 저장할 수 없습니다", + "readFailed": "그 파일을 읽을 수 없습니다", + "partialExport": "파일에 담지 못한 항목: {items}" + }, + "errors": { + "invalid-json": "유효한 JSON이 아닙니다", + "too-large": "파일이 너무 큽니다 (최대 {limit} KB)", + "not-object": "객체여야 합니다", + "unknown-key": "알 수 없는 필드입니다", + "missing": "필수 필드가 없습니다", + "wrong-type": "형식이 잘못되었습니다", + "invalid-value": "허용되지 않는 값입니다", + "unsupported-version": "지원하지 않는 프리셋 형식 버전입니다", + "unknown-token": "테마 변수가 아닙니다", + "invalid-color": "색상이 아닙니다 (hex, rgb, hsl, oklch를 사용하세요)", + "invalid-length": "길이가 아닙니다 (rem, px, em을 사용하세요)", + "out-of-range": "범위를 벗어났습니다", + "unknown-font": "기본 제공 글꼴이나 시스템 글꼴이 아닙니다", + "unknown-code-theme": "기본 제공 코드 테마가 아닙니다", + "unknown-base": "기본 제공 팔레트가 아닙니다" + }, + "bundled": { + "default": { + "name": "기본", + "description": "codeg 본래의 모습: 중립 팔레트, Inter, GitHub 코드 색상." + }, + "warm-terminal": { + "name": "따뜻한 터미널", + "description": "따뜻한 차콜, 앰버 강조, 고정폭 인터페이스. 터미널 기반 에이전트의 느낌입니다. 라이트 모드는 따뜻한 종이색." + }, + "ink": { + "name": "잉크", + "description": "단색에 각진 모양: 검정, 흰색, 몇 단계의 회색, 테두리만 있는 말풍선, 강조색 없음." + }, + "dense-ide": { + "name": "조밀한 IDE", + "description": "촘촘한 간격, 작은 글자, 파란 강조, 색이 있는 상태 표시줄. 채팅 패널이 달린 편집기처럼." + }, + "high-contrast": { + "name": "고대비", + "description": "흰 바탕에 순검정 (다크에서는 검은 바탕에 순백), 굵은 테두리, 큰 글자, 고대비 코드 색상." + }, + "midnight": { + "name": "미드나이트", + "description": "깊은 청보라 표면, 부드럽게 둥근 말풍선, 라일락 강조. 늦은 밤 채팅 클라이언트의 분위기." + }, + "paper": { + "name": "종이", + "description": "따뜻하고 밝은 읽기용 표면에 넉넉한 간격과 큰 글자. 다크 모드는 검정 대신 어스름한 저녁빛." + } + } + }, "zoomLevel": { "sectionTitle": "창 확대/축소", "sectionDescription": "전체 인터페이스를 확대하거나 축소합니다. 즉시 적용되며 장치별로 저장됩니다. ⌘/Ctrl +와 -는 이 메뉴와 같은 단계로 확대/축소합니다(⌘/Ctrl 0은 100%로 되돌립니다).", @@ -157,6 +271,7 @@ "interface": "인터페이스", "editor": "편집기", "terminal": "터미널", + "code": "메시지 안의 코드", "groupSans": "산세리프", "groupMono": "고정폭", "custom": "사용자 지정…", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 071e5867b3..a9d47693c6 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -106,6 +106,7 @@ "radius": "Raio dos cantos", "radiusHint": "Toda a escala de raios, de sm a 4xl, deriva dele: um único cursor arredonda a aplicação inteira.", "advanced": "Avançado (mais {count} variáveis)", + "surfaces": "Superfícies da conversa ({count} variáveis adicionais)", "enableCss": "Ativar CSS personalizado", "cssRisk": "Funcionalidade avançada. O CSS personalizado substitui todos os estilos integrados e pode tornar a interface inutilizável.", "editCss": "Editar CSS…", @@ -144,6 +145,119 @@ "hintTokens": "Dica: as cores que substituiu ficam disponíveis como var(--primary), var(--background), etc." } }, + "presets": { + "sectionTitle": "Predefinições", + "sectionDescription": "Um visual completo num clique: paleta, tipos de letra, espaçamento, balões de mensagem e cores do código. Tudo o que uma predefinição define continua editável nas secções abaixo, e o resultado pode ser guardado como ficheiro para partilhar.", + "current": "Atual: {name}", + "currentModified": "Atual: {name} (modificado)", + "currentCustom": "Atual: visual personalizado", + "customName": "Personalizado", + "apply": "Aplicar {name}", + "disabledHint": "As cores personalizadas estão desligadas em Estilo personalizado, por isso as cores, o espaçamento e o tamanho do texto de uma predefinição só se aplicam depois de as voltar a ligar.", + "codeTheme": "Cores do código", + "codeThemeLight": "Cores do código no modo claro", + "codeThemeDark": "Cores do código no modo escuro", + "codeThemeGroupLight": "Temas claros", + "codeThemeGroupDark": "Temas escuros", + "codeThemeHint": "Cores de sintaxe dos blocos de código nas mensagens, um tema por modo.", + "density": "Densidade", + "densityHint": "Escala todos os espaços, preenchimentos e alturas de controlos da interface.", + "densityOptions": { + "compact": "Compacta", + "snug": "Justa", + "default": "Predefinida", + "relaxed": "Folgada", + "custom": "Personalizada" + }, + "chatText": "Texto das mensagens", + "chatTextHint": "Tamanho de leitura das mensagens. O zoom da janela escala a interface inteira.", + "chatTextOptions": { + "small": "Pequeno", + "default": "Predefinido", + "large": "Grande", + "larger": "Maior", + "custom": "Personalizado" + }, + "import": "Importar predefinição…", + "export": "Exportar predefinição…", + "sharingNote": "Um ficheiro de predefinição transporta cores, tipos de letra, espaçamento e os nomes dos temas de código incluídos, nada mais. O CSS personalizado nunca sai deste dispositivo.", + "importTitle": "Importar predefinição", + "importPreview": "Reveja o que este ficheiro altera e depois aplique-o.", + "importErrorsTitle": "Este ficheiro não pode ser aplicado", + "importErrorsDescription": "Nada foi alterado. Corrija os problemas abaixo ou escolha outro ficheiro.", + "importApply": "Aplicar predefinição", + "cancel": "Cancelar", + "preview": { + "base": "Paleta base", + "mode": "Modo", + "overrides": "Cores substituídas", + "overridesValue": "{light} no claro, {dark} no escuro", + "fonts": "Tipos de letra (interface / código)", + "code": "Cores do código", + "density": "Densidade", + "chatText": "Texto das mensagens", + "unchanged": "Sem alterações" + }, + "modes": { + "light": "Claro", + "dark": "Escuro", + "system": "Seguir o sistema" + }, + "toasts": { + "applied": "Predefinição aplicada: {name}", + "exported": "Predefinição guardada", + "exportFailed": "Não foi possível guardar a predefinição", + "readFailed": "Não foi possível ler esse ficheiro", + "partialExport": "Ficou de fora do ficheiro: {items}" + }, + "errors": { + "invalid-json": "JSON inválido", + "too-large": "O ficheiro é demasiado grande (limite de {limit} KB)", + "not-object": "Era esperado um objeto", + "unknown-key": "Campo desconhecido", + "missing": "Falta um campo obrigatório", + "wrong-type": "Tipo errado", + "invalid-value": "Valor não permitido", + "unsupported-version": "Escrito para outra versão do formato de predefinição", + "unknown-token": "Não é um token do tema", + "invalid-color": "Não é uma cor (use hex, rgb, hsl ou oklch)", + "invalid-length": "Não é um comprimento (use rem, px ou em)", + "out-of-range": "Fora do intervalo", + "unknown-font": "Não é um tipo de letra incluído nem do sistema", + "unknown-code-theme": "Não é um tema de código incluído", + "unknown-base": "Não é uma paleta integrada" + }, + "bundled": { + "default": { + "name": "Predefinido", + "description": "O visual original do codeg: paleta neutra, Inter e cores de código do GitHub." + }, + "warm-terminal": { + "name": "Terminal quente", + "description": "Carvão quente, acentos âmbar e uma interface monoespaçada, como um agente nascido no terminal. O modo claro é papel quente." + }, + "ink": { + "name": "Tinta", + "description": "Monocromático e quadrado: preto, branco e alguns cinzentos, balões contornados, sem qualquer cor de destaque." + }, + "dense-ide": { + "name": "IDE densa", + "description": "Espaçamento compacto, texto pequeno, acentos azuis e uma barra de estado colorida, como um editor com painel de conversa." + }, + "high-contrast": { + "name": "Alto contraste", + "description": "Preto puro sobre branco (e branco sobre preto), contornos fortes, texto maior e cores de código de alto contraste." + }, + "midnight": { + "name": "Meia-noite", + "description": "Superfícies azul-violeta profundas, balões arredondados e um acento lilás, o ambiente de um cliente de conversa de madrugada." + }, + "paper": { + "name": "Papel", + "description": "Uma superfície de leitura clara e quente, com espaçamento amplo e texto maior. O modo escuro é um crepúsculo suave em vez de preto." + } + } + }, "zoomLevel": { "sectionTitle": "Zoom da janela", "sectionDescription": "Dimensiona toda a interface. Aplica imediatamente e é salvo por dispositivo. ⌘/Ctrl + e - percorrem os mesmos níveis de zoom deste menu (⌘/Ctrl 0 volta para 100%).", @@ -157,6 +271,7 @@ "interface": "Interface", "editor": "Editor", "terminal": "Terminal", + "code": "Código nas mensagens", "groupSans": "Sem serifa", "groupMono": "Monoespaçada", "custom": "Personalizada…", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b2fccaed4c..f7bcddf864 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -106,6 +106,7 @@ "radius": "圆角", "radiusHint": "从 sm 到 4xl 的整套圆角尺寸都由它派生,一个滑块即可改变全局圆角。", "advanced": "高级(另有 {count} 个变量)", + "surfaces": "会话表面(另 {count} 个变量)", "enableCss": "启用自定义 CSS", "cssRisk": "高级功能。自定义 CSS 会压过所有内置样式,写坏可能导致界面不可用。", "editCss": "编辑 CSS…", @@ -144,6 +145,119 @@ "hintTokens": "提示:你覆盖的颜色可以直接用 var(--primary)、var(--background) 等引用。" } }, + "presets": { + "sectionTitle": "外观预设", + "sectionDescription": "一键切换整套外观:配色、字体、间距、消息气泡与代码配色。预设设定的每一项仍可在下方各节继续调整,调好的结果可以另存为文件分享。", + "current": "当前:{name}", + "currentModified": "当前:{name}(已修改)", + "currentCustom": "当前:自定义外观", + "customName": "自定义", + "apply": "应用 {name}", + "disabledHint": "「自定义样式」里的自定义配色已关闭,预设的配色、间距与字号要等重新开启后才会生效。", + "codeTheme": "代码配色", + "codeThemeLight": "浅色模式代码配色", + "codeThemeDark": "深色模式代码配色", + "codeThemeGroupLight": "浅色主题", + "codeThemeGroupDark": "深色主题", + "codeThemeHint": "消息内代码块的语法高亮配色,明暗两种模式各选一套。", + "density": "密度", + "densityHint": "按比例缩放界面里的所有间距、内边距与控件高度。", + "densityOptions": { + "compact": "紧凑", + "snug": "略紧", + "default": "默认", + "relaxed": "宽松", + "custom": "自定义" + }, + "chatText": "消息文字", + "chatTextHint": "消息的阅读字号。整体缩放请用窗口缩放。", + "chatTextOptions": { + "small": "小", + "default": "默认", + "large": "大", + "larger": "更大", + "custom": "自定义" + }, + "import": "导入预设…", + "export": "导出预设…", + "sharingNote": "预设文件只包含配色、字体、间距以及内置代码主题的名字,不含其他内容。自定义 CSS 永远不会离开这台设备。", + "importTitle": "导入预设", + "importPreview": "先看看这个文件会改动什么,再决定是否应用。", + "importErrorsTitle": "这个文件无法应用", + "importErrorsDescription": "没有做任何改动。请修正下列问题,或换一个文件。", + "importApply": "应用预设", + "cancel": "取消", + "preview": { + "base": "基底配色", + "mode": "模式", + "overrides": "颜色覆盖", + "overridesValue": "浅色 {light} 项,深色 {dark} 项", + "fonts": "字体(界面 / 代码)", + "code": "代码配色", + "density": "密度", + "chatText": "消息文字", + "unchanged": "不改动" + }, + "modes": { + "light": "浅色", + "dark": "深色", + "system": "跟随系统" + }, + "toasts": { + "applied": "已应用预设:{name}", + "exported": "预设已保存", + "exportFailed": "无法保存预设", + "readFailed": "无法读取该文件", + "partialExport": "未写入文件的内容:{items}" + }, + "errors": { + "invalid-json": "不是有效的 JSON", + "too-large": "文件过大(上限 {limit} KB)", + "not-object": "应为对象", + "unknown-key": "未知字段", + "missing": "缺少必填字段", + "wrong-type": "类型不对", + "invalid-value": "不是允许的值", + "unsupported-version": "预设格式版本不受支持", + "unknown-token": "不是主题变量", + "invalid-color": "不是颜色(请用 hex、rgb、hsl 或 oklch)", + "invalid-length": "不是长度(请用 rem、px 或 em)", + "out-of-range": "超出范围", + "unknown-font": "不是内置字体或系统字体", + "unknown-code-theme": "不是内置代码主题", + "unknown-base": "不是内置配色" + }, + "bundled": { + "default": { + "name": "默认", + "description": "codeg 原本的样子:中性配色、Inter 字体、GitHub 代码配色。" + }, + "warm-terminal": { + "name": "暖色终端", + "description": "暖炭灰底、琥珀色强调与等宽字体界面,像终端里的 Agent。浅色模式是暖纸色。" + }, + "ink": { + "name": "墨", + "description": "单色且方正:黑、白和几级灰,描边气泡,没有任何强调色。" + }, + "dense-ide": { + "name": "紧凑 IDE", + "description": "紧凑的间距、小字号、蓝色强调与带色状态栏,像带聊天面板的编辑器。" + }, + "high-contrast": { + "name": "高对比度", + "description": "纯黑配纯白(深色模式则反过来),粗边框、更大的文字与高对比代码配色。" + }, + "midnight": { + "name": "午夜", + "description": "深蓝紫表面、柔和的圆角气泡与丁香色强调,深夜聊天客户端的气氛。" + }, + "paper": { + "name": "纸张", + "description": "温暖的浅色阅读面,间距宽裕、文字更大。深色模式是昏暗的暮色而非纯黑。" + } + } + }, "zoomLevel": { "sectionTitle": "窗口缩放", "sectionDescription": "整体放大或缩小界面,立即生效,按设备分别保存。⌘/Ctrl + 和 - 按与此菜单相同的档位调节(⌘/Ctrl 0 恢复为 100%)。", @@ -157,6 +271,7 @@ "interface": "界面", "editor": "编辑器", "terminal": "终端", + "code": "消息内代码", "groupSans": "无衬线", "groupMono": "等宽", "custom": "自定义…", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index f5e8ce93d3..4cd262b18d 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -106,6 +106,7 @@ "radius": "圓角", "radiusHint": "從 sm 到 4xl 的整套圓角尺寸都由它衍生,一個滑桿即可改變全域圓角。", "advanced": "進階(另有 {count} 個變數)", + "surfaces": "對話表面(另 {count} 個變數)", "enableCss": "啟用自訂 CSS", "cssRisk": "進階功能。自訂 CSS 會壓過所有內建樣式,寫壞可能導致介面無法使用。", "editCss": "編輯 CSS…", @@ -144,6 +145,119 @@ "hintTokens": "提示:你覆寫的顏色可以直接用 var(--primary)、var(--background) 等引用。" } }, + "presets": { + "sectionTitle": "外觀預設", + "sectionDescription": "一鍵切換整套外觀:配色、字型、間距、訊息氣泡與程式碼配色。預設設定的每一項仍可在下方各節繼續調整,調好的結果可以另存為檔案分享。", + "current": "目前:{name}", + "currentModified": "目前:{name}(已修改)", + "currentCustom": "目前:自訂外觀", + "customName": "自訂", + "apply": "套用 {name}", + "disabledHint": "「自訂樣式」裡的自訂配色已關閉,預設的配色、間距與字級要等重新開啟後才會生效。", + "codeTheme": "程式碼配色", + "codeThemeLight": "淺色模式程式碼配色", + "codeThemeDark": "深色模式程式碼配色", + "codeThemeGroupLight": "淺色主題", + "codeThemeGroupDark": "深色主題", + "codeThemeHint": "訊息內程式碼區塊的語法標示配色,明暗兩種模式各選一套。", + "density": "密度", + "densityHint": "按比例縮放介面裡的所有間距、內距與控制項高度。", + "densityOptions": { + "compact": "緊湊", + "snug": "略緊", + "default": "預設", + "relaxed": "寬鬆", + "custom": "自訂" + }, + "chatText": "訊息文字", + "chatTextHint": "訊息的閱讀字級。整體縮放請用視窗縮放。", + "chatTextOptions": { + "small": "小", + "default": "預設", + "large": "大", + "larger": "更大", + "custom": "自訂" + }, + "import": "匯入預設…", + "export": "匯出預設…", + "sharingNote": "預設檔只包含配色、字型、間距以及內建程式碼主題的名稱,不含其他內容。自訂 CSS 永遠不會離開這台裝置。", + "importTitle": "匯入預設", + "importPreview": "先看看這個檔案會改動什麼,再決定是否套用。", + "importErrorsTitle": "這個檔案無法套用", + "importErrorsDescription": "沒有做任何改動。請修正下列問題,或換一個檔案。", + "importApply": "套用預設", + "cancel": "取消", + "preview": { + "base": "基底配色", + "mode": "模式", + "overrides": "顏色覆寫", + "overridesValue": "淺色 {light} 項,深色 {dark} 項", + "fonts": "字型(介面 / 程式碼)", + "code": "程式碼配色", + "density": "密度", + "chatText": "訊息文字", + "unchanged": "不改動" + }, + "modes": { + "light": "淺色", + "dark": "深色", + "system": "跟隨系統" + }, + "toasts": { + "applied": "已套用預設:{name}", + "exported": "預設已儲存", + "exportFailed": "無法儲存預設", + "readFailed": "無法讀取該檔案", + "partialExport": "未寫入檔案的內容:{items}" + }, + "errors": { + "invalid-json": "不是有效的 JSON", + "too-large": "檔案過大(上限 {limit} KB)", + "not-object": "應為物件", + "unknown-key": "未知欄位", + "missing": "缺少必填欄位", + "wrong-type": "型別不對", + "invalid-value": "不是允許的值", + "unsupported-version": "預設格式版本不受支援", + "unknown-token": "不是主題變數", + "invalid-color": "不是顏色(請用 hex、rgb、hsl 或 oklch)", + "invalid-length": "不是長度(請用 rem、px 或 em)", + "out-of-range": "超出範圍", + "unknown-font": "不是內建字型或系統字型", + "unknown-code-theme": "不是內建程式碼主題", + "unknown-base": "不是內建配色" + }, + "bundled": { + "default": { + "name": "預設", + "description": "codeg 原本的樣子:中性配色、Inter 字型、GitHub 程式碼配色。" + }, + "warm-terminal": { + "name": "暖色終端機", + "description": "暖炭灰底、琥珀色強調與等寬字型介面,像終端機裡的 Agent。淺色模式是暖紙色。" + }, + "ink": { + "name": "墨", + "description": "單色且方正:黑、白和幾階灰,描邊氣泡,沒有任何強調色。" + }, + "dense-ide": { + "name": "緊湊 IDE", + "description": "緊湊的間距、小字級、藍色強調與帶色狀態列,像帶聊天面板的編輯器。" + }, + "high-contrast": { + "name": "高對比", + "description": "純黑配純白(深色模式則反過來),粗邊框、更大的文字與高對比程式碼配色。" + }, + "midnight": { + "name": "午夜", + "description": "深藍紫表面、柔和的圓角氣泡與丁香色強調,深夜聊天用戶端的氣氛。" + }, + "paper": { + "name": "紙張", + "description": "溫暖的淺色閱讀面,間距寬裕、文字更大。深色模式是昏暗的暮色而非純黑。" + } + } + }, "zoomLevel": { "sectionTitle": "視窗縮放", "sectionDescription": "整體放大或縮小介面,立即生效,依裝置分別儲存。⌘/Ctrl + 和 - 依與此選單相同的檔位調節(⌘/Ctrl 0 恢復為 100%)。", @@ -157,6 +271,7 @@ "interface": "介面", "editor": "編輯器", "terminal": "終端機", + "code": "訊息內程式碼", "groupSans": "無襯線", "groupMono": "等寬", "custom": "自訂…", diff --git a/src/lib/appearance-preset.test.ts b/src/lib/appearance-preset.test.ts new file mode 100644 index 0000000000..0c81ca17f0 --- /dev/null +++ b/src/lib/appearance-preset.test.ts @@ -0,0 +1,415 @@ +import { describe, expect, it } from "vitest" + +import { + MAX_PRESET_BYTES, + PRESET_SCHEMA_VERSION, + findMatchingPreset, + parseAppearancePreset, + parseStoredAppearancePreset, + presetFileName, + presetMatches, + presetToApplication, + serializeAppearancePreset, + snapshotToPreset, + spacingMultiplierFromToken, + spacingToken, + validateAppearancePreset, + type AppearancePreset, + type PresetIssue, +} from "./appearance-preset" +import { CUSTOM_FONT_ID } from "./font-presets" + +const valid: AppearancePreset = { + schemaVersion: 1, + id: "test-look", + name: "Test look", + description: "A preset used by the tests.", + author: "tests", + version: "1.0.0", + base: "slate", + mode: "dark", + colors: { + light: { primary: "#112233", "bubble-user-bg": "oklch(0.93 0.02 250)" }, + dark: { primary: "oklch(0.7 0.15 250)", "status-bar-bg": "transparent" }, + }, + shape: { + radius: "0.25rem", + userBubbleRadius: "4px", + composerRadius: "0.5rem", + }, + density: { spacing: 0.85 }, + typography: { + ui: "geist", + mono: "jetbrains-mono", + chatFontSize: 0.8125, + chatLineHeight: 1.4, + }, + code: { light: "light-plus", dark: "dark-plus" }, +} + +function issuesOf(input: unknown): PresetIssue[] { + const result = validateAppearancePreset(input) + return result.ok ? [] : result.issues +} + +function codesOf(input: unknown): string[] { + return issuesOf(input).map((i) => `${i.path}:${i.code}`) +} + +/** `valid` with one top-level field removed. */ +function without(key: keyof AppearancePreset): Record { + const copy: Record = { ...valid } + delete copy[key] + return copy +} + +describe("validateAppearancePreset", () => { + it("accepts a complete document and returns it normalized", () => { + const result = validateAppearancePreset(valid) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.preset).toEqual(valid) + expect(result.warnings).toEqual([]) + }) + + it("accepts the minimal document", () => { + const result = validateAppearancePreset({ + schemaVersion: 1, + id: "min", + name: "Min", + base: "neutral", + colors: {}, + }) + expect(result.ok).toBe(true) + }) + + it("names an unknown key by its path instead of dropping it", () => { + // A typo must not import as "worked". + expect(codesOf({ ...valid, colour: {} })).toEqual(["colour:unknown-key"]) + expect(codesOf({ ...valid, colors: { dark: { primry: "#000" } } })).toEqual( + ["colors.dark.primry:unknown-token"] + ) + expect( + codesOf({ ...valid, shape: { radius: "1rem", corner: "1rem" } }) + ).toEqual(["shape.corner:unknown-key"]) + expect(codesOf({ ...valid, colors: { light: {}, night: {} } })).toEqual([ + "colors.night:unknown-key", + ]) + }) + + it("rejects bad colours with the exact field", () => { + expect( + codesOf({ ...valid, colors: { light: { primary: "red; } body {" } } }) + ).toEqual(["colors.light.primary:invalid-color"]) + expect( + codesOf({ ...valid, colors: { light: { primary: "url(https://x/y)" } } }) + ).toEqual(["colors.light.primary:invalid-color"]) + expect( + codesOf({ ...valid, colors: { light: { primary: "plum" } } }) + ).toEqual(["colors.light.primary:invalid-color"]) + expect( + codesOf({ ...valid, colors: { light: { primary: "12px" } } }) + ).toEqual(["colors.light.primary:invalid-color"]) + expect(codesOf({ ...valid, colors: { light: { primary: 7 } } })).toEqual([ + "colors.light.primary:wrong-type", + ]) + // `radius` inside colors is a length (shadcn cssVars carry it there). + expect( + codesOf({ ...valid, colors: { light: { radius: "#fff" } } }) + ).toEqual(["colors.light.radius:invalid-length"]) + expect( + validateAppearancePreset({ + ...valid, + colors: { light: { radius: "0.5rem" } }, + }).ok + ).toBe(true) + }) + + it("range-checks lengths and numbers", () => { + expect(codesOf({ ...valid, shape: { radius: "9rem" } })).toEqual([ + "shape.radius:out-of-range", + ]) + expect(codesOf({ ...valid, shape: { radius: "10vw" } })).toEqual([ + "shape.radius:invalid-length", + ]) + expect(codesOf({ ...valid, density: { spacing: 3 } })).toEqual([ + "density.spacing:out-of-range", + ]) + expect(codesOf({ ...valid, density: { spacing: "1" } })).toEqual([ + "density.spacing:wrong-type", + ]) + expect(codesOf({ ...valid, typography: { chatFontSize: 5 } })).toEqual([ + "typography.chatFontSize:out-of-range", + ]) + expect(codesOf({ ...valid, typography: { chatLineHeight: 0.5 } })).toEqual([ + "typography.chatLineHeight:out-of-range", + ]) + }) + + it("only allows fonts and code themes the app ships", () => { + expect(codesOf({ ...valid, typography: { ui: "comic-sans" } })).toEqual([ + "typography.ui:unknown-font", + ]) + // A custom family is a local choice, never shareable. + expect(codesOf({ ...valid, typography: { ui: CUSTOM_FONT_ID } })).toEqual([ + "typography.ui:unknown-font", + ]) + // The code font must be monospace. + expect(codesOf({ ...valid, typography: { mono: "inter" } })).toEqual([ + "typography.mono:unknown-font", + ]) + expect( + codesOf({ ...valid, code: { light: "nope", dark: "nord" } }) + ).toEqual(["code.light:unknown-code-theme"]) + expect(codesOf({ ...valid, code: { light: "one-light" } })).toEqual([ + "code.dark:missing", + ]) + }) + + it("checks identity fields", () => { + expect(codesOf({ ...valid, id: "Not A Slug" })).toEqual([ + "id:invalid-value", + ]) + expect(codesOf({ ...valid, id: "" })).toEqual(["id:invalid-value"]) + expect(codesOf({ ...valid, name: " " })).toEqual(["name:invalid-value"]) + expect(codesOf({ ...valid, name: "x".repeat(81) })).toEqual([ + "name:out-of-range", + ]) + expect(codesOf({ ...valid, base: "teal" })).toEqual(["base:unknown-base"]) + expect(codesOf({ ...valid, mode: "auto" })).toEqual(["mode:invalid-value"]) + expect(codesOf(without("base"))).toEqual(["base:missing"]) + expect(codesOf(without("colors"))).toEqual(["colors:missing"]) + }) + + it("reports every problem at once, not just the first", () => { + const codes = codesOf({ + ...valid, + base: "teal", + colors: { light: { primary: "plum" } }, + density: { spacing: 9 }, + }) + expect(codes).toHaveLength(3) + expect(codes).toContain("base:unknown-base") + expect(codes).toContain("colors.light.primary:invalid-color") + expect(codes).toContain("density.spacing:out-of-range") + }) + + it("refuses non-objects", () => { + expect(codesOf(null)).toEqual([":wrong-type"]) + expect(codesOf([])).toEqual([":not-object"]) + expect(codesOf("nope")).toEqual([":not-object"]) + }) +}) + +describe("schema versions", () => { + it("requires schemaVersion and refuses documents from a newer format", () => { + expect(codesOf(without("schemaVersion"))).toEqual(["schemaVersion:missing"]) + expect(codesOf({ ...valid, schemaVersion: "1" })).toEqual([ + "schemaVersion:wrong-type", + ]) + expect( + codesOf({ ...valid, schemaVersion: PRESET_SCHEMA_VERSION + 1 }) + ).toEqual(["schemaVersion:unsupported-version"]) + expect(codesOf({ ...valid, schemaVersion: 0 })).toEqual([ + "schemaVersion:unsupported-version", + ]) + }) + + it("walks the migration chain up to the current version", () => { + // Version 1 is the first version, so the chain is the identity. When a + // version 2 lands, this is where its migration gets exercised. + const result = validateAppearancePreset({ ...valid, schemaVersion: 1 }) + expect(result.ok && result.preset.schemaVersion).toBe(PRESET_SCHEMA_VERSION) + }) +}) + +describe("parseAppearancePreset", () => { + it("refuses an oversized document before parsing it", () => { + const padded = JSON.stringify({ + ...valid, + description: "x".repeat(MAX_PRESET_BYTES), + }) + const result = parseAppearancePreset(padded) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.issues.map((i) => i.code)).toEqual(["too-large"]) + }) + + it("reports invalid JSON", () => { + const result = parseAppearancePreset("{not json") + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.issues[0].code).toBe("invalid-json") + }) + + it("round-trips through serialize", () => { + const text = serializeAppearancePreset(valid) + expect(text.endsWith("\n")).toBe(true) + const result = parseAppearancePreset(text) + expect(result.ok && result.preset).toEqual(valid) + expect(parseStoredAppearancePreset(text)).toEqual(valid) + expect(parseStoredAppearancePreset("{broken")).toBeNull() + expect(parseStoredAppearancePreset(null)).toBeNull() + }) + + it("names the file after the id", () => { + expect(presetFileName(valid)).toBe("test-look.codeg-preset.json") + }) +}) + +describe("presetToApplication", () => { + it("fans mode-independent settings out to both modes as tokens", () => { + const app = presetToApplication(valid) + expect(app.themeColor).toBe("slate") + expect(app.mode).toBe("dark") + expect(app.uiFont).toBe("geist") + expect(app.monoFont).toBe("jetbrains-mono") + expect(app.codeTheme).toEqual({ light: "light-plus", dark: "dark-plus" }) + for (const mode of ["light", "dark"] as const) { + const tokens = app.customTheme[mode] + expect(tokens.radius).toBe("0.25rem") + expect(tokens["bubble-user-radius"]).toBe("4px") + expect(tokens["composer-radius"]).toBe("0.5rem") + expect(tokens.spacing).toBe(spacingToken(0.85)) + expect(tokens["chat-font-size"]).toBe("0.8125rem") + expect(tokens["chat-line-height"]).toBe("1.4") + } + expect(app.customTheme.light.primary).toBe("#112233") + expect(app.customTheme.dark.primary).toBe("oklch(0.7 0.15 250)") + expect(app.customTheme.light["status-bar-bg"]).toBeUndefined() + }) + + it("leaves unspecified settings alone", () => { + const app = presetToApplication({ + schemaVersion: 1, + id: "bare", + name: "Bare", + base: "neutral", + colors: {}, + }) + expect(app).toEqual({ + themeColor: "neutral", + customTheme: { light: {}, dark: {} }, + }) + }) +}) + +describe("spacing tokens", () => { + it("maps the multiplier to a rem value and back", () => { + expect(spacingToken(1)).toBe("0.25rem") + expect(spacingToken(0.85)).toBe("0.2125rem") + expect(spacingMultiplierFromToken("0.2125rem")).toBe(0.85) + expect(spacingMultiplierFromToken("4px")).toBeNull() + expect(spacingMultiplierFromToken(undefined)).toBeNull() + }) +}) + +describe("snapshotToPreset", () => { + it("is the inverse of presetToApplication for a complete preset", () => { + const app = presetToApplication(valid) + const { preset, warnings } = snapshotToPreset( + { + themeColor: app.themeColor, + customTheme: app.customTheme, + uiFontId: app.uiFont!, + monoFontId: app.monoFont!, + codeTheme: app.codeTheme!, + mode: app.mode ?? null, + }, + { + id: valid.id, + name: valid.name, + description: valid.description, + author: valid.author, + version: valid.version, + } + ) + expect(warnings).toEqual([]) + expect(preset).toEqual(valid) + // And the export validates as a document in its own right. + expect(validateAppearancePreset(preset).ok).toBe(true) + }) + + it("leaves out what cannot travel and says so", () => { + const { preset, warnings } = snapshotToPreset( + { + themeColor: "neutral", + customTheme: { + light: { spacing: "13px", "chat-font-size": "0.875rem" }, + dark: { spacing: "13px", "chat-font-size": "0.875rem" }, + }, + uiFontId: CUSTOM_FONT_ID, + monoFontId: "system-mono", + codeTheme: { light: "github-light", dark: "github-dark" }, + mode: null, + }, + { id: "custom", name: "Custom" } + ) + expect(preset.density).toBeUndefined() + expect(preset.typography).toEqual({ + mono: "system-mono", + chatFontSize: 0.875, + }) + expect(preset.mode).toBeUndefined() + expect(warnings.map((w) => `${w.path}:${w.code}`)).toEqual([ + "density.spacing:out-of-range", + "typography.ui:unknown-font", + ]) + expect(validateAppearancePreset(preset).ok).toBe(true) + }) +}) + +describe("presetMatches / findMatchingPreset", () => { + const app = presetToApplication(valid) + const current = { + themeColor: app.themeColor, + customTheme: app.customTheme, + uiFontId: "geist", + monoFontId: "jetbrains-mono", + codeTheme: { light: "light-plus", dark: "dark-plus" } as const, + } + + it("matches the state a preset produces, and only that", () => { + expect(presetMatches(valid, current)).toBe(true) + expect( + presetMatches(valid, { + ...current, + customTheme: { + ...current.customTheme, + light: { ...current.customTheme.light, primary: "#000000" }, + }, + }) + ).toBe(false) + expect(presetMatches(valid, { ...current, themeColor: "zinc" })).toBe(false) + expect(presetMatches(valid, { ...current, uiFontId: "inter" })).toBe(false) + expect( + presetMatches(valid, { + ...current, + codeTheme: { light: "one-light", dark: "dark-plus" }, + }) + ).toBe(false) + }) + + it("ignores settings the preset does not specify", () => { + const bare: AppearancePreset = { + schemaVersion: 1, + id: "bare", + name: "Bare", + base: "neutral", + colors: {}, + } + const stock = { + themeColor: "neutral" as const, + customTheme: { light: {}, dark: {} }, + uiFontId: "inter", + monoFontId: "fira-code", + codeTheme: { light: "nord", dark: "nord" } as const, + } + expect(presetMatches(bare, stock)).toBe(true) + expect(findMatchingPreset([valid, bare], stock)).toBe(bare) + expect(findMatchingPreset([valid, bare], current)).toBe(valid) + expect( + findMatchingPreset([valid, bare], { ...stock, themeColor: "rose" }) + ).toBeNull() + }) +}) diff --git a/src/lib/appearance-preset.ts b/src/lib/appearance-preset.ts new file mode 100644 index 0000000000..0fc4b8c476 --- /dev/null +++ b/src/lib/appearance-preset.ts @@ -0,0 +1,958 @@ +// src/lib/appearance-preset.ts +// +// The shareable appearance preset: one JSON document that captures a whole +// look (base palette, token overrides for both modes, fonts, density, bubble +// and composer shape, code colours) and nothing else. +// +// Three rules shape the format: +// +// 1. Declarative tokens only. A preset carries values for an allowlist of +// tokens and names fonts / code themes by id from allowlists the app ships. +// There is no CSS and no script in the format, so importing a stranger's +// preset can change colours, fonts and spacing and nothing else. The +// free-form Custom CSS stays a local, per-device escape hatch: it is never +// written into a preset and a preset can never carry it. +// 2. Strict. Unknown keys anywhere are errors rather than silently dropped +// (a typo in `colors.dark.primry` should not import as "worked"), every +// value is syntax- and range-checked, and the document has a hard size +// cap. Every problem comes back with a JSON path so the UI can point at +// the exact field. +// 3. Versioned. `schemaVersion` is required. Older documents step forward +// through `MIGRATIONS`; a document from a newer app is refused with its own +// code rather than half-applied. +// +// The same document is what a preset gallery would serve later: an index of +// these files plus a name and a thumbnail is a marketplace, and validation on +// that path is this exact function. + +import { + CUSTOM_FONT_ID, + FONT_BY_ID, + MONO_FONTS, + type FontDef, +} from "./font-presets" +import { THEME_COLORS, type ThemeColor } from "./theme-presets" +import { + LAYOUT_THEME_TOKENS, + SHADCN_THEME_TOKENS, + SURFACE_COLOR_TOKENS, + byteLengthOf, + isValidTokenValue, + type CustomTheme, + type CustomThemeToken, + type TokenOverrides, +} from "./custom-style" +import { + DEFAULT_CODE_THEME, + isCodeThemeId, + type CodeThemePair, +} from "./code-themes" + +// ─── Format ─── + +export const PRESET_SCHEMA_VERSION = 1 as const + +/** + * A preset is a few kilobytes of tokens; anything larger is not a preset. + * The cap runs before JSON.parse so an oversized file is refused at the byte + * count, never parsed. + */ +export const MAX_PRESET_BYTES = 32 * 1024 + +/** Exported files end in this, so a folder of presets is grep-able. */ +export const PRESET_FILE_SUFFIX = ".codeg-preset.json" + +export const PRESET_MODES = ["light", "dark", "system"] as const +export type PresetMode = (typeof PRESET_MODES)[number] + +export type PresetColorToken = + | (typeof SHADCN_THEME_TOKENS)[number] + | (typeof SURFACE_COLOR_TOKENS)[number] + +export type PresetColors = Partial> + +export type PresetShape = { + /** Drives the whole radius scale (`--radius`). */ + radius?: string + userBubbleRadius?: string + assistantBubbleRadius?: string + assistantBubblePadding?: string + composerRadius?: string +} + +export type PresetDensity = { + /** Multiplier on the base spacing unit; 1 is the stock layout. */ + spacing?: number +} + +export type PresetTypography = { + /** Interface font id from the bundled / system allowlist. */ + ui?: string + /** Monospace font id for code in messages. */ + mono?: string + /** Message text size in rem (0.875 is the stock `text-sm`). */ + chatFontSize?: number + /** Unitless line height for message text. */ + chatLineHeight?: number +} + +export type AppearancePreset = { + schemaVersion: typeof PRESET_SCHEMA_VERSION + /** Stable slug, also the export file name. */ + id: string + name: string + description?: string + author?: string + /** Free-form, for the author's own bookkeeping. */ + version?: string + /** The built-in palette the token overrides sit on. */ + base: ThemeColor + /** Light / dark / follow system, applied when the preset is. */ + mode?: PresetMode + colors: { light?: PresetColors; dark?: PresetColors } + shape?: PresetShape + density?: PresetDensity + typography?: PresetTypography + code?: CodeThemePair +} + +export const PRESET_LIMITS = { + id: 64, + name: 80, + description: 280, + author: 80, + version: 32, + spacing: { min: 0.75, max: 1.25 }, + chatFontSize: { min: 0.7, max: 1.25 }, + chatLineHeight: { min: 1.1, max: 2 }, + lengthRem: 4, + lengthPx: 64, +} as const + +// ─── Issues ─── + +export type PresetIssueCode = + | "invalid-json" + | "too-large" + | "not-object" + | "unknown-key" + | "missing" + | "wrong-type" + | "invalid-value" + | "unsupported-version" + | "unknown-token" + | "invalid-color" + | "invalid-length" + | "out-of-range" + | "unknown-font" + | "unknown-code-theme" + | "unknown-base" + +/** One problem, addressed by a dotted JSON path (`colors.dark.primary`). */ +export type PresetIssue = { + path: string + code: PresetIssueCode + detail?: string +} + +export type PresetParseResult = + | { ok: true; preset: AppearancePreset; warnings: PresetIssue[] } + | { ok: false; issues: PresetIssue[] } + +// ─── Validation ─── + +const TOP_LEVEL_KEYS = [ + "$schema", + "schemaVersion", + "id", + "name", + "description", + "author", + "version", + "base", + "mode", + "colors", + "shape", + "density", + "typography", + "code", +] as const + +const SHAPE_KEYS = [ + "radius", + "userBubbleRadius", + "assistantBubbleRadius", + "assistantBubblePadding", + "composerRadius", +] as const + +const DENSITY_KEYS = ["spacing"] as const +const TYPOGRAPHY_KEYS = [ + "ui", + "mono", + "chatFontSize", + "chatLineHeight", +] as const +const CODE_KEYS = ["light", "dark"] as const +const MODE_KEYS = ["light", "dark"] as const + +const ID_RE = /^[a-z0-9][a-z0-9-]*$/ +const VERSION_RE = /^[0-9A-Za-z.+-]+$/ + +/** + * Colour syntaxes a preset may use for a colour token. Named colours other + * than `transparent` are refused: a preset is read by people as much as by + * the app, and `oklch(0.7 0.1 250)` says what it is where `plum` does not. + */ +const COLOR_SYNTAX_RE = + /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|transparent|(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb|color|color-mix)\(.+\))$/i + +const LENGTH_RE = /^(\d+\.?\d*|\.\d+)(rem|px|em)$/ + +const PRESET_COLOR_TOKEN_SET = new Set([ + ...SHADCN_THEME_TOKENS, + ...SURFACE_COLOR_TOKENS, +]) + +export function isPresetColorToken(name: unknown): name is PresetColorToken { + return typeof name === "string" && PRESET_COLOR_TOKEN_SET.has(name) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function join(path: string, key: string): string { + return path ? `${path}.${key}` : key +} + +class Collector { + readonly issues: PresetIssue[] = [] + readonly warnings: PresetIssue[] = [] + + add(path: string, code: PresetIssueCode, detail?: string): void { + this.issues.push(detail ? { path, code, detail } : { path, code }) + } + + /** Fails on any key outside `allowed`, naming each one. */ + keys( + record: Record, + allowed: readonly string[], + path: string + ) { + const allowedSet = new Set(allowed) + for (const key of Object.keys(record)) { + if (!allowedSet.has(key)) this.add(join(path, key), "unknown-key") + } + } + + object( + value: unknown, + path: string, + required: boolean + ): Record | null { + if (value === undefined) { + if (required) this.add(path, "missing") + return null + } + if (!isRecord(value)) { + this.add(path, value === null ? "wrong-type" : "not-object") + return null + } + return value + } + + string( + value: unknown, + path: string, + { + required, + max, + pattern, + }: { required: boolean; max: number; pattern?: RegExp } + ): string | undefined { + if (value === undefined) { + if (required) this.add(path, "missing") + return undefined + } + if (typeof value !== "string") { + this.add(path, "wrong-type") + return undefined + } + const trimmed = value.trim() + if (required && !trimmed) { + this.add(path, "invalid-value", "empty") + return undefined + } + if (trimmed.length > max) { + this.add(path, "out-of-range", `max ${max} characters`) + return undefined + } + if (pattern && trimmed && !pattern.test(trimmed)) { + this.add(path, "invalid-value") + return undefined + } + return trimmed + } + + number( + value: unknown, + path: string, + range: { min: number; max: number } + ): number | undefined { + if (value === undefined) return undefined + if (typeof value !== "number" || !Number.isFinite(value)) { + this.add(path, "wrong-type") + return undefined + } + if (value < range.min || value > range.max) { + this.add(path, "out-of-range", `${range.min} to ${range.max}`) + return undefined + } + return value + } + + /** A CSS length in rem / px / em within the layout ranges. */ + length(value: unknown, path: string): string | undefined { + if (value === undefined) return undefined + if (typeof value !== "string") { + this.add(path, "wrong-type") + return undefined + } + const trimmed = value.trim() + const parsed = parseCssLength(trimmed) + if (!parsed) { + this.add(path, "invalid-length") + return undefined + } + const max = + parsed.unit === "px" ? PRESET_LIMITS.lengthPx : PRESET_LIMITS.lengthRem + if (parsed.value > max) { + this.add(path, "out-of-range", `max ${max}${parsed.unit}`) + return undefined + } + return trimmed + } + + colors(value: unknown, path: string): PresetColors | undefined { + const record = this.object(value, path, false) + if (!record) return undefined + const out: PresetColors = {} + for (const [key, raw] of Object.entries(record)) { + const keyPath = join(path, key) + if (!isPresetColorToken(key)) { + this.add(keyPath, "unknown-token") + continue + } + if (typeof raw !== "string") { + this.add(keyPath, "wrong-type") + continue + } + const trimmed = raw.trim() + if (key === "radius") { + const radius = this.length(trimmed, keyPath) + if (radius !== undefined) out.radius = radius + continue + } + // The token charset guard first (declaration-escape defence), then + // the syntax check that says "this is a colour". + if (!isValidTokenValue(key, trimmed) || !COLOR_SYNTAX_RE.test(trimmed)) { + this.add(keyPath, "invalid-color") + continue + } + out[key] = trimmed + } + return out + } +} + +export type CssLength = { value: number; unit: "rem" | "px" | "em" } + +export function parseCssLength(value: string): CssLength | null { + const match = LENGTH_RE.exec(value.trim()) + if (!match) return null + const n = Number(match[1]) + if (!Number.isFinite(n)) return null + return { value: n, unit: match[2] as CssLength["unit"] } +} + +function isMonoFont(id: string): boolean { + return MONO_FONTS.some((font: FontDef) => font.id === id) +} + +/** + * Forward migrations, keyed by the version they migrate FROM. Empty while + * version 1 is the only version; adding a version means adding an entry here + * and bumping `PRESET_SCHEMA_VERSION`, and the migration test walks the chain. + */ +const MIGRATIONS: Record< + number, + (document: Record) => Record +> = {} + +function migrate( + document: Record, + collector: Collector +): Record | null { + const version = document.schemaVersion + if (version === undefined) { + collector.add("schemaVersion", "missing") + return null + } + if (typeof version !== "number" || !Number.isInteger(version)) { + collector.add("schemaVersion", "wrong-type") + return null + } + if (version > PRESET_SCHEMA_VERSION) { + collector.add( + "schemaVersion", + "unsupported-version", + `newer than ${PRESET_SCHEMA_VERSION}` + ) + return null + } + let current = document + for (let v = version; v < PRESET_SCHEMA_VERSION; v += 1) { + const step = MIGRATIONS[v] + if (!step) { + collector.add("schemaVersion", "unsupported-version", `no path from ${v}`) + return null + } + current = { ...step(current), schemaVersion: v + 1 } + } + if (version < 1) { + collector.add("schemaVersion", "unsupported-version") + return null + } + return current +} + +/** + * Validate an already-parsed value. Prefer {@link parseAppearancePreset} for + * text, which adds the size cap and JSON errors in front of this. + */ +export function validateAppearancePreset(input: unknown): PresetParseResult { + const c = new Collector() + const raw = c.object(input, "", true) + if (!raw) return { ok: false, issues: c.issues } + + const doc = migrate(raw, c) + if (!doc) return { ok: false, issues: c.issues } + + c.keys(doc, TOP_LEVEL_KEYS, "") + + if (doc.$schema !== undefined && typeof doc.$schema !== "string") { + c.add("$schema", "wrong-type") + } + + const id = c.string(doc.id, "id", { + required: true, + max: PRESET_LIMITS.id, + pattern: ID_RE, + }) + const name = c.string(doc.name, "name", { + required: true, + max: PRESET_LIMITS.name, + }) + const description = c.string(doc.description, "description", { + required: false, + max: PRESET_LIMITS.description, + }) + const author = c.string(doc.author, "author", { + required: false, + max: PRESET_LIMITS.author, + }) + const version = c.string(doc.version, "version", { + required: false, + max: PRESET_LIMITS.version, + pattern: VERSION_RE, + }) + + let base: ThemeColor | undefined + if (doc.base === undefined) { + c.add("base", "missing") + } else if (typeof doc.base !== "string") { + c.add("base", "wrong-type") + } else if (!(THEME_COLORS as readonly string[]).includes(doc.base)) { + c.add("base", "unknown-base") + } else { + base = doc.base as ThemeColor + } + + let mode: PresetMode | undefined + if (doc.mode !== undefined) { + if (typeof doc.mode !== "string") { + c.add("mode", "wrong-type") + } else if (!(PRESET_MODES as readonly string[]).includes(doc.mode)) { + c.add("mode", "invalid-value") + } else { + mode = doc.mode as PresetMode + } + } + + const colors: AppearancePreset["colors"] = {} + const colorsRecord = c.object(doc.colors, "colors", true) + if (colorsRecord) { + c.keys(colorsRecord, MODE_KEYS, "colors") + const light = c.colors(colorsRecord.light, "colors.light") + const dark = c.colors(colorsRecord.dark, "colors.dark") + if (light) colors.light = light + if (dark) colors.dark = dark + } + + let shape: PresetShape | undefined + const shapeRecord = c.object(doc.shape, "shape", false) + if (shapeRecord) { + c.keys(shapeRecord, SHAPE_KEYS, "shape") + shape = {} + for (const key of SHAPE_KEYS) { + const value = c.length(shapeRecord[key], join("shape", key)) + if (value !== undefined) shape[key] = value + } + } + + let density: PresetDensity | undefined + const densityRecord = c.object(doc.density, "density", false) + if (densityRecord) { + c.keys(densityRecord, DENSITY_KEYS, "density") + density = {} + const spacing = c.number( + densityRecord.spacing, + "density.spacing", + PRESET_LIMITS.spacing + ) + if (spacing !== undefined) density.spacing = spacing + } + + let typography: PresetTypography | undefined + const typographyRecord = c.object(doc.typography, "typography", false) + if (typographyRecord) { + c.keys(typographyRecord, TYPOGRAPHY_KEYS, "typography") + typography = {} + for (const key of ["ui", "mono"] as const) { + const value = typographyRecord[key] + if (value === undefined) continue + const path = join("typography", key) + if (typeof value !== "string") { + c.add(path, "wrong-type") + } else if ( + value === CUSTOM_FONT_ID || + !(value in FONT_BY_ID) || + (key === "mono" && !isMonoFont(value)) + ) { + c.add(path, "unknown-font") + } else { + typography[key] = value + } + } + const size = c.number( + typographyRecord.chatFontSize, + "typography.chatFontSize", + PRESET_LIMITS.chatFontSize + ) + if (size !== undefined) typography.chatFontSize = size + const lineHeight = c.number( + typographyRecord.chatLineHeight, + "typography.chatLineHeight", + PRESET_LIMITS.chatLineHeight + ) + if (lineHeight !== undefined) typography.chatLineHeight = lineHeight + } + + let code: CodeThemePair | undefined + const codeRecord = c.object(doc.code, "code", false) + if (codeRecord) { + c.keys(codeRecord, CODE_KEYS, "code") + const pair: Partial = {} + for (const key of CODE_KEYS) { + const value = codeRecord[key] + const path = join("code", key) + if (value === undefined) { + c.add(path, "missing") + } else if (typeof value !== "string") { + c.add(path, "wrong-type") + } else if (!isCodeThemeId(value)) { + c.add(path, "unknown-code-theme") + } else { + pair[key] = value + } + } + if (pair.light && pair.dark) code = { light: pair.light, dark: pair.dark } + } + + if (c.issues.length > 0 || !id || !name || !base) { + return { ok: false, issues: c.issues } + } + + const preset: AppearancePreset = { + schemaVersion: PRESET_SCHEMA_VERSION, + id, + name, + base, + colors, + } + if (description) preset.description = description + if (author) preset.author = author + if (version) preset.version = version + if (mode) preset.mode = mode + if (shape && Object.keys(shape).length > 0) preset.shape = shape + if (density && Object.keys(density).length > 0) preset.density = density + if (typography && Object.keys(typography).length > 0) { + preset.typography = typography + } + if (code) preset.code = code + return { ok: true, preset, warnings: c.warnings } +} + +/** Text from a file or the clipboard -> a validated preset, or every problem. */ +export function parseAppearancePreset(text: string): PresetParseResult { + const bytes = byteLengthOf(text) + if (bytes > MAX_PRESET_BYTES) { + return { + ok: false, + issues: [ + { + path: "", + code: "too-large", + detail: `${bytes} > ${MAX_PRESET_BYTES}`, + }, + ], + } + } + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + return { + ok: false, + issues: [ + { + path: "", + code: "invalid-json", + detail: error instanceof Error ? error.message : undefined, + }, + ], + } + } + return validateAppearancePreset(value) +} + +/** Stable, human-diffable serialization: 2-space JSON with a trailing newline. */ +export function serializeAppearancePreset(preset: AppearancePreset): string { + return `${JSON.stringify(preset, null, 2)}\n` +} + +/** + * The last applied preset as kept in localStorage. Runs the full validation, + * so a document written by an older build that no longer validates is simply + * forgotten rather than trusted. + */ +export function parseStoredAppearancePreset( + raw: string | null +): AppearancePreset | null { + if (!raw) return null + const result = parseAppearancePreset(raw) + return result.ok ? result.preset : null +} + +export function presetFileName(preset: Pick): string { + return `${preset.id}${PRESET_FILE_SUFFIX}` +} + +// ─── Preset <-> appearance state ─── + +/** The base spacing unit Tailwind ships (`--spacing: 0.25rem`). */ +export const BASE_SPACING_REM = 0.25 + +/** Stock message text: Tailwind `text-sm` (0.875rem / 1.25rem line box). */ +export const DEFAULT_CHAT_FONT_SIZE_REM = 0.875 +export const DEFAULT_CHAT_LINE_HEIGHT = 1.25 / 0.875 + +function formatNumber(n: number): string { + return String(Number(n.toFixed(4))) +} + +export function spacingToken(multiplier: number): string { + return `${formatNumber(BASE_SPACING_REM * multiplier)}rem` +} + +/** Inverse of {@link spacingToken}; null for a hand-edited non-rem value. */ +export function spacingMultiplierFromToken( + value: string | undefined +): number | null { + if (!value) return null + const parsed = parseCssLength(value) + if (!parsed || parsed.unit !== "rem") return null + return Number((parsed.value / BASE_SPACING_REM).toFixed(4)) +} + +function remFromToken(value: string | undefined): number | null { + if (!value) return null + const parsed = parseCssLength(value) + return parsed && parsed.unit === "rem" ? parsed.value : null +} + +function ratioFromToken(value: string | undefined): number | null { + if (!value) return null + const n = Number(value.trim()) + return Number.isFinite(n) ? n : null +} + +const SHAPE_TOKENS: Record = { + radius: "radius", + userBubbleRadius: "bubble-user-radius", + assistantBubbleRadius: "bubble-assistant-radius", + assistantBubblePadding: "bubble-assistant-padding", + composerRadius: "composer-radius", +} + +/** + * What applying a preset means in terms of the appearance state the provider + * already manages: a base palette, a light / dark token map (the preset's + * mode-independent settings fanned out to both modes), and the id-based + * settings that have their own storage. + */ +export type PresetApplication = { + themeColor: ThemeColor + customTheme: CustomTheme + uiFont?: string + monoFont?: string + codeTheme?: CodeThemePair + mode?: PresetMode +} + +export function presetToApplication( + preset: AppearancePreset +): PresetApplication { + const light: TokenOverrides = { ...(preset.colors.light ?? {}) } + const dark: TokenOverrides = { ...(preset.colors.dark ?? {}) } + const shared: TokenOverrides = {} + + if (preset.shape) { + for (const key of SHAPE_KEYS) { + const value = preset.shape[key] + if (value !== undefined) shared[SHAPE_TOKENS[key]] = value + } + } + if (preset.density?.spacing !== undefined) { + shared.spacing = spacingToken(preset.density.spacing) + } + if (preset.typography?.chatFontSize !== undefined) { + shared["chat-font-size"] = + `${formatNumber(preset.typography.chatFontSize)}rem` + } + if (preset.typography?.chatLineHeight !== undefined) { + shared["chat-line-height"] = formatNumber(preset.typography.chatLineHeight) + } + Object.assign(light, shared) + Object.assign(dark, shared) + + const application: PresetApplication = { + themeColor: preset.base, + customTheme: { light, dark }, + } + if (preset.typography?.ui) application.uiFont = preset.typography.ui + if (preset.typography?.mono) application.monoFont = preset.typography.mono + if (preset.code) application.codeTheme = { ...preset.code } + if (preset.mode) application.mode = preset.mode + return application +} + +/** Everything the current appearance state contributes to an export. */ +export type AppearanceSnapshot = { + themeColor: ThemeColor + customTheme: CustomTheme + uiFontId: string + monoFontId: string + codeTheme: CodeThemePair + /** The next-themes setting; null leaves `mode` out of the file. */ + mode: PresetMode | null +} + +export type PresetMeta = Pick< + AppearancePreset, + "id" | "name" | "description" | "author" | "version" +> + +function firstLayoutValue( + theme: CustomTheme, + token: CustomThemeToken +): string | undefined { + return theme.light[token] ?? theme.dark[token] +} + +/** + * Build a preset from the live state. Layout tokens are read from whichever + * mode has them (they are always written to both). Anything that cannot be + * shared, such as a custom font family installed on this machine or a + * hand-edited spacing in px, is left out and reported as a warning so the + * exporter can say what did not travel. + */ +export function snapshotToPreset( + snapshot: AppearanceSnapshot, + meta: PresetMeta +): { preset: AppearancePreset; warnings: PresetIssue[] } { + const warnings: PresetIssue[] = [] + const layoutSet = new Set(LAYOUT_THEME_TOKENS) + + const pickColors = (overrides: TokenOverrides): PresetColors => { + const out: PresetColors = {} + for (const [key, value] of Object.entries(overrides)) { + if (key === "radius" || layoutSet.has(key) || !value) continue + if (isPresetColorToken(key)) out[key] = value + } + return out + } + + const preset: AppearancePreset = { + schemaVersion: PRESET_SCHEMA_VERSION, + id: meta.id, + name: meta.name, + base: snapshot.themeColor, + colors: {}, + } + if (meta.description) preset.description = meta.description + if (meta.author) preset.author = meta.author + if (meta.version) preset.version = meta.version + if (snapshot.mode) preset.mode = snapshot.mode + + const light = pickColors(snapshot.customTheme.light) + const dark = pickColors(snapshot.customTheme.dark) + if (Object.keys(light).length > 0) preset.colors.light = light + if (Object.keys(dark).length > 0) preset.colors.dark = dark + + const shape: PresetShape = {} + for (const key of SHAPE_KEYS) { + const value = firstLayoutValue(snapshot.customTheme, SHAPE_TOKENS[key]) + if (value === undefined) continue + const parsed = parseCssLength(value) + const max = + parsed?.unit === "px" ? PRESET_LIMITS.lengthPx : PRESET_LIMITS.lengthRem + if (!parsed || parsed.value > max) { + warnings.push({ path: join("shape", key), code: "invalid-length" }) + continue + } + shape[key] = value + } + if (Object.keys(shape).length > 0) preset.shape = shape + + const spacingValue = firstLayoutValue(snapshot.customTheme, "spacing") + if (spacingValue !== undefined) { + const multiplier = spacingMultiplierFromToken(spacingValue) + if ( + multiplier === null || + multiplier < PRESET_LIMITS.spacing.min || + multiplier > PRESET_LIMITS.spacing.max + ) { + warnings.push({ path: "density.spacing", code: "out-of-range" }) + } else { + preset.density = { spacing: multiplier } + } + } + + const typography: PresetTypography = {} + for (const [key, fontId] of [ + ["ui", snapshot.uiFontId], + ["mono", snapshot.monoFontId], + ] as const) { + if (fontId === CUSTOM_FONT_ID || !(fontId in FONT_BY_ID)) { + warnings.push({ path: join("typography", key), code: "unknown-font" }) + continue + } + if (key === "mono" && !isMonoFont(fontId)) { + warnings.push({ path: "typography.mono", code: "unknown-font" }) + continue + } + typography[key] = fontId + } + const chatFontSize = remFromToken( + firstLayoutValue(snapshot.customTheme, "chat-font-size") + ) + if (chatFontSize !== null) { + if ( + chatFontSize < PRESET_LIMITS.chatFontSize.min || + chatFontSize > PRESET_LIMITS.chatFontSize.max + ) { + warnings.push({ path: "typography.chatFontSize", code: "out-of-range" }) + } else { + typography.chatFontSize = chatFontSize + } + } + const chatLineHeight = ratioFromToken( + firstLayoutValue(snapshot.customTheme, "chat-line-height") + ) + if (chatLineHeight !== null) { + if ( + chatLineHeight < PRESET_LIMITS.chatLineHeight.min || + chatLineHeight > PRESET_LIMITS.chatLineHeight.max + ) { + warnings.push({ path: "typography.chatLineHeight", code: "out-of-range" }) + } else { + typography.chatLineHeight = chatLineHeight + } + } + if (Object.keys(typography).length > 0) preset.typography = typography + + preset.code = { ...snapshot.codeTheme } + + return { preset, warnings } +} + +// ─── Matching ─── + +/** The live state a preset is compared against (mode is a window setting). */ +export type AppearanceCurrent = { + themeColor: ThemeColor + customTheme: CustomTheme + uiFontId: string + monoFontId: string + codeTheme: CodeThemePair +} + +function sameOverrides(a: TokenOverrides, b: TokenOverrides): boolean { + const keysA = Object.keys(a).filter((k) => a[k as CustomThemeToken]) + const keysB = Object.keys(b).filter((k) => b[k as CustomThemeToken]) + if (keysA.length !== keysB.length) return false + return keysA.every( + (k) => a[k as CustomThemeToken] === b[k as CustomThemeToken] + ) +} + +/** + * True when the live state is exactly what applying `preset` produces. Only + * the settings the preset specifies are compared: a preset that says nothing + * about fonts still matches whichever font the user picked. + */ +export function presetMatches( + preset: AppearancePreset, + current: AppearanceCurrent +): boolean { + const target = presetToApplication(preset) + if (target.themeColor !== current.themeColor) return false + if (!sameOverrides(target.customTheme.light, current.customTheme.light)) { + return false + } + if (!sameOverrides(target.customTheme.dark, current.customTheme.dark)) { + return false + } + if (target.uiFont && target.uiFont !== current.uiFontId) return false + if (target.monoFont && target.monoFont !== current.monoFontId) return false + if ( + target.codeTheme && + (target.codeTheme.light !== current.codeTheme.light || + target.codeTheme.dark !== current.codeTheme.dark) + ) { + return false + } + return true +} + +export function findMatchingPreset( + presets: readonly AppearancePreset[], + current: AppearanceCurrent +): AppearancePreset | null { + return presets.find((preset) => presetMatches(preset, current)) ?? null +} + +/** Default code theme, exported here so the UI has one import for the format. */ +export { DEFAULT_CODE_THEME } diff --git a/src/lib/appearance-presets-bundled.test.ts b/src/lib/appearance-presets-bundled.test.ts new file mode 100644 index 0000000000..3fbe5a3c7f --- /dev/null +++ b/src/lib/appearance-presets-bundled.test.ts @@ -0,0 +1,281 @@ +import { readFileSync } from "node:fs" +import { resolve as resolvePath } from "node:path" +import { describe, expect, it } from "vitest" + +import { + presetMatches, + presetToApplication, + snapshotToPreset, + validateAppearancePreset, + type AppearancePreset, +} from "./appearance-preset" +import { + BUNDLED_PRESETS, + BUNDLED_PRESET_IDS, + DEFAULT_PRESET_ID, + isBundledPresetId, +} from "./appearance-presets-bundled" +import { + contrastRatio, + resolveCssColor, + type LinearRgba, +} from "./color-contrast" +import { MONO_FALLBACK, resolveFontStack } from "./font-presets" +import { DEFAULT_CHAT_FONT_SIZE_REM } from "./appearance-preset" +import { THEME_COLORS, type ThemeColor } from "./theme-presets" + +// ─── globals.css as data ─── +// +// The base palettes and the surface defaults live in CSS, and only there. The +// checks below read the stylesheet rather than a copy, so a palette edit in +// globals.css is what the contrast check measures. + +// vitest runs from the repo root (`pnpm test`); under jsdom `import.meta.url` +// is an http URL, so resolve from the working directory instead. +const globalsCss = readFileSync( + resolvePath(process.cwd(), "src/app/globals.css"), + "utf8" +).replace(/\/\*[\s\S]*?\*\//g, "") + +type Declarations = Record + +function parseDeclarations(block: string): Declarations { + const out: Declarations = {} + for (const line of block.split(";")) { + const idx = line.indexOf(":") + if (idx < 0) continue + const name = line.slice(0, idx).trim() + if (!name.startsWith("--")) continue + out[name.slice(2)] = line + .slice(idx + 1) + .replace(/\s+/g, " ") + .trim() + } + return out +} + +function paletteBlock(color: ThemeColor, dark: boolean): Declarations { + const selector = `[data-theme="${color}"]${dark ? ".dark" : ""}` + const pattern = new RegExp( + `${selector.replace(/[[\]().*+?"]/g, (c) => `\\${c}`)}\\s*\\{([^}]*)\\}` + ) + const match = pattern.exec(globalsCss) + if (!match) throw new Error(`no ${selector} block in globals.css`) + return parseDeclarations(match[1]) +} + +const rootDefaults = parseDeclarations( + /:root\s*\{([^}]*)\}/.exec(globalsCss)![1] +) + +const PALETTES = Object.fromEntries( + THEME_COLORS.map((color) => [ + color, + { light: paletteBlock(color, false), dark: paletteBlock(color, true) }, + ]) +) as Record + +// ─── Contrast ─── + +/** Text on the surface it is written on: WCAG AA for normal text. */ +const TEXT_PAIRS: Array<[string, string]> = [ + ["foreground", "background"], + ["card-foreground", "card"], + ["popover-foreground", "popover"], + ["primary-foreground", "primary"], + ["secondary-foreground", "secondary"], + ["accent-foreground", "accent"], + ["sidebar-foreground", "sidebar"], + ["sidebar-accent-foreground", "sidebar-accent"], + ["sidebar-primary-foreground", "sidebar-primary"], + ["bubble-user-fg", "bubble-user-bg"], + ["bubble-assistant-fg", "bubble-assistant-bg"], + ["muted-foreground", "background"], +] + +/** + * De-emphasised text (`muted-foreground` on a muted surface, the status bar): + * held to 3:1, the AA floor for large text and UI components. shadcn's own + * neutral palette sits at 4.4:1 for muted-on-muted, so 4.5 here would fail + * the stock look; 3:1 is the honest bar for text that is dimmed on purpose. + */ +const MUTED_PAIRS: Array<[string, string]> = [ + ["muted-foreground", "muted"], + ["status-bar-fg", "status-bar-bg"], +] + +const AA_TEXT = 4.5 +const AA_MUTED = 3 + +type ContrastFailure = { mode: "light" | "dark"; pair: string; ratio: number } + +function resolver(preset: AppearancePreset, mode: "light" | "dark") { + const overrides = presetToApplication(preset).customTheme[mode] + const palette = PALETTES[preset.base][mode] + return (name: string): string | undefined => + overrides[name as keyof typeof overrides] ?? + palette[name] ?? + rootDefaults[name] +} + +function resolve( + lookup: (name: string) => string | undefined, + token: string +): LinearRgba { + const color = resolveCssColor(`var(--${token})`, lookup) + if (!color) throw new Error(`cannot resolve --${token} to a literal colour`) + return color +} + +function contrastFailures(preset: AppearancePreset): ContrastFailure[] { + const failures: ContrastFailure[] = [] + for (const mode of ["light", "dark"] as const) { + const lookup = resolver(preset, mode) + const background = resolve(lookup, "background") + const check = (pairs: Array<[string, string]>, floor: number) => { + for (const [fg, bg] of pairs) { + const ratio = contrastRatio( + resolve(lookup, fg), + resolve(lookup, bg), + background + ) + if (ratio < floor) { + failures.push({ mode, pair: `${fg} on ${bg}`, ratio }) + } + } + } + check(TEXT_PAIRS, AA_TEXT) + check(MUTED_PAIRS, AA_MUTED) + } + return failures +} + +describe("bundled presets", () => { + it("are valid documents with unique ids, and the default is one of them", () => { + const ids = BUNDLED_PRESETS.map((p) => p.id) + expect(new Set(ids).size).toBe(ids.length) + expect(ids).toEqual([...BUNDLED_PRESET_IDS]) + expect(ids).toContain(DEFAULT_PRESET_ID) + expect(isBundledPresetId("warm-terminal")).toBe(true) + expect(isBundledPresetId("stranger")).toBe(false) + for (const preset of BUNDLED_PRESETS) { + const result = validateAppearancePreset(preset) + expect(result.ok, preset.id).toBe(true) + if (result.ok) { + expect(result.preset, preset.id).toEqual(preset) + expect(result.warnings, preset.id).toEqual([]) + } + } + }) + + it("keep the default preset as the stock look", () => { + const stock = BUNDLED_PRESETS.find((p) => p.id === DEFAULT_PRESET_ID)! + expect(stock.base).toBe("neutral") + expect(stock.colors).toEqual({}) + expect(stock.shape).toBeUndefined() + expect(stock.density).toBeUndefined() + expect(stock.typography).toBeUndefined() + expect(stock.code).toEqual({ light: "github-light", dark: "github-dark" }) + }) + + it.each(BUNDLED_PRESETS.map((p) => [p.id, p] as const))( + "%s passes WCAG AA for text on every surface, in both modes", + (_id, preset) => { + expect(contrastFailures(preset)).toEqual([]) + } + ) + + it("the contrast check itself fails on an unreadable palette", () => { + // Guard against a vacuous check: the same code must reject a preset whose + // text is its background. + const unreadable: AppearancePreset = { + schemaVersion: 1, + id: "unreadable", + name: "Unreadable", + base: "neutral", + colors: { + light: { foreground: "oklch(0.96 0 0)" }, + dark: { "bubble-user-fg": "oklch(0.2 0 0)" }, + }, + } + const failures = contrastFailures(unreadable) + // The light foreground also feeds both bubbles through the surface + // defaults (`--bubble-*-fg: var(--foreground)`), so it fails three pairs. + expect(failures.map((f) => `${f.mode}:${f.pair}`)).toEqual([ + "light:foreground on background", + "light:bubble-user-fg on bubble-user-bg", + "light:bubble-assistant-fg on bubble-assistant-bg", + "dark:bubble-user-fg on bubble-user-bg", + ]) + for (const failure of failures) expect(failure.ratio).toBeLessThan(4.5) + }) + + it("match the state they produce and survive an export round trip", () => { + for (const preset of BUNDLED_PRESETS) { + const app = presetToApplication(preset) + const current = { + themeColor: app.themeColor, + customTheme: app.customTheme, + uiFontId: app.uiFont ?? "inter", + monoFontId: app.monoFont ?? "system-mono", + codeTheme: app.codeTheme ?? { + light: "github-light", + dark: "github-dark", + }, + } + expect(presetMatches(preset, current), preset.id).toBe(true) + // Every other bundled preset must NOT match this state, or the gallery + // would light two cards at once. + for (const other of BUNDLED_PRESETS) { + if (other.id === preset.id) continue + expect( + presetMatches(other, current), + `${other.id} vs ${preset.id}` + ).toBe(false) + } + // A fully specified preset exports back to itself. + if (preset.typography?.ui && preset.typography?.mono && preset.code) { + const { preset: exported, warnings } = snapshotToPreset( + { ...current, mode: app.mode ?? null }, + preset + ) + expect(warnings, preset.id).toEqual([]) + expect(exported, preset.id).toEqual(preset) + } + } + }) +}) + +describe("globals.css defaults the presets rely on", () => { + it("ship --font-mono as the same stack the system-mono font resolves to", () => { + // With no stored choice the inline script writes nothing, so this default + // is what code renders in. It must equal the "system-mono" catalog entry + // (and Tailwind's own default stack) or an upgrade would change code + // rendering for everyone. + expect(rootDefaults["font-mono"]).toBe(MONO_FALLBACK) + expect(resolveFontStack("system-mono", "", "mono")).toBe(MONO_FALLBACK) + }) + + it("ship message text at the stock text-sm size", () => { + expect(rootDefaults["chat-font-size"]).toBe( + `${DEFAULT_CHAT_FONT_SIZE_REM}rem` + ) + expect(rootDefaults["chat-line-height"]).toBe("calc(1.25 / 0.875)") + }) + + it("point every surface token back at a shadcn token by default", () => { + // A theme that never mentions the surface tokens must render exactly as + // it did before they existed: the defaults are references, not colours. + expect(rootDefaults["bubble-user-bg"]).toBe("var(--secondary)") + expect(rootDefaults["bubble-user-fg"]).toBe("var(--foreground)") + expect(rootDefaults["bubble-user-radius"]).toBe("var(--radius)") + expect(rootDefaults["bubble-assistant-bg"]).toBe("transparent") + expect(rootDefaults["bubble-assistant-radius"]).toBe("0px") + expect(rootDefaults["bubble-assistant-padding"]).toBe("0px") + expect(rootDefaults["composer-bg"]).toBe("transparent") + expect(rootDefaults["composer-radius"]).toBe("calc(var(--radius) * 1.4)") + expect(rootDefaults["status-bar-bg"]).toBe("var(--muted)") + expect(rootDefaults["status-bar-fg"]).toBe("var(--muted-foreground)") + expect(rootDefaults["status-bar-border"]).toBe("var(--border)") + }) +}) diff --git a/src/lib/appearance-presets-bundled.ts b/src/lib/appearance-presets-bundled.ts new file mode 100644 index 0000000000..75d86a6797 --- /dev/null +++ b/src/lib/appearance-presets-bundled.ts @@ -0,0 +1,639 @@ +// src/lib/appearance-presets-bundled.ts +// +// The presets codeg ships. Each one is a complete document in the shareable +// format, so exporting a bundled preset and importing it back is the identity, +// and each is the reference for what a preset can express: palette, fonts, +// density, bubble and composer shape, code colours. +// +// Names and descriptions are neutral. Several looks are inspired by the +// palettes and typography of terminal-first agent UIs and editors, but every +// value here is our own and nothing is copied from another product. +// +// `appearance-presets-bundled.test.ts` validates every preset against the +// schema and checks every text / surface pair for WCAG AA contrast in both +// modes, so a palette edit that breaks readability fails the build. + +import type { AppearancePreset } from "./appearance-preset" + +/** + * Ids of the shipped presets, as a literal union so the settings page can + * address their translated names (`presets.bundled..name`) with a typed + * message key. + */ +export const BUNDLED_PRESET_IDS = [ + "default", + "warm-terminal", + "ink", + "dense-ide", + "high-contrast", + "midnight", + "paper", +] as const + +export type BundledPresetId = (typeof BUNDLED_PRESET_IDS)[number] + +export const DEFAULT_PRESET_ID: BundledPresetId = "default" + +const defaultPreset: AppearancePreset = { + schemaVersion: 1, + id: DEFAULT_PRESET_ID, + name: "Default", + description: + "The stock codeg look: neutral palette, Inter, GitHub code colors.", + author: "codeg", + base: "neutral", + colors: {}, + code: { light: "github-light", dark: "github-dark" }, +} + +const warmTerminal: AppearancePreset = { + schemaVersion: 1, + id: "warm-terminal", + name: "Warm terminal", + description: + "Warm charcoal, amber accents and a monospace interface, the way a terminal-first agent feels. Light mode is warm paper.", + author: "codeg", + base: "stone", + mode: "dark", + colors: { + light: { + background: "oklch(0.975 0.012 85)", + foreground: "oklch(0.27 0.02 60)", + card: "oklch(0.99 0.008 85)", + "card-foreground": "oklch(0.27 0.02 60)", + popover: "oklch(0.99 0.008 85)", + "popover-foreground": "oklch(0.27 0.02 60)", + primary: "oklch(0.52 0.15 50)", + "primary-foreground": "oklch(0.985 0.01 85)", + secondary: "oklch(0.93 0.02 85)", + "secondary-foreground": "oklch(0.27 0.02 60)", + muted: "oklch(0.93 0.02 85)", + "muted-foreground": "oklch(0.48 0.03 60)", + accent: "oklch(0.9 0.04 75)", + "accent-foreground": "oklch(0.27 0.02 60)", + border: "oklch(0.86 0.02 80)", + input: "oklch(0.86 0.02 80)", + ring: "oklch(0.52 0.15 50)", + sidebar: "oklch(0.955 0.015 85)", + "sidebar-foreground": "oklch(0.27 0.02 60)", + "sidebar-primary": "oklch(0.52 0.15 50)", + "sidebar-primary-foreground": "oklch(0.985 0.01 85)", + "sidebar-accent": "oklch(0.9 0.025 85)", + "sidebar-accent-foreground": "oklch(0.27 0.02 60)", + "sidebar-border": "oklch(0.86 0.02 80)", + "sidebar-ring": "oklch(0.52 0.15 50)", + "bubble-user-bg": "oklch(0.92 0.03 80)", + "bubble-user-fg": "oklch(0.27 0.02 60)", + "bubble-user-border": "oklch(0.84 0.03 75)", + "status-bar-bg": "oklch(0.94 0.015 85)", + "status-bar-fg": "oklch(0.45 0.03 60)", + "status-bar-border": "oklch(0.86 0.02 80)", + "composer-bg": "oklch(0.99 0.008 85)", + "composer-border": "oklch(0.52 0.15 50 / 45%)", + }, + dark: { + background: "oklch(0.2 0.012 55)", + foreground: "oklch(0.93 0.02 85)", + card: "oklch(0.24 0.012 55)", + "card-foreground": "oklch(0.93 0.02 85)", + popover: "oklch(0.24 0.012 55)", + "popover-foreground": "oklch(0.93 0.02 85)", + primary: "oklch(0.76 0.14 60)", + "primary-foreground": "oklch(0.2 0.03 60)", + secondary: "oklch(0.28 0.014 55)", + "secondary-foreground": "oklch(0.93 0.02 85)", + muted: "oklch(0.28 0.014 55)", + "muted-foreground": "oklch(0.72 0.03 70)", + accent: "oklch(0.32 0.03 60)", + "accent-foreground": "oklch(0.95 0.02 85)", + border: "oklch(1 0 0 / 12%)", + input: "oklch(1 0 0 / 16%)", + ring: "oklch(0.76 0.14 60)", + sidebar: "oklch(0.17 0.012 55)", + "sidebar-foreground": "oklch(0.9 0.02 85)", + "sidebar-primary": "oklch(0.76 0.14 60)", + "sidebar-primary-foreground": "oklch(0.2 0.03 60)", + "sidebar-accent": "oklch(0.25 0.014 55)", + "sidebar-accent-foreground": "oklch(0.95 0.02 85)", + "sidebar-border": "oklch(1 0 0 / 10%)", + "sidebar-ring": "oklch(0.76 0.14 60)", + "bubble-user-bg": "oklch(0.26 0.02 60)", + "bubble-user-fg": "oklch(0.93 0.02 85)", + "bubble-user-border": "oklch(1 0 0 / 10%)", + "status-bar-bg": "oklch(0.17 0.012 55)", + "status-bar-fg": "oklch(0.72 0.03 70)", + "status-bar-border": "oklch(1 0 0 / 10%)", + "composer-bg": "oklch(0.17 0.012 55)", + "composer-border": "oklch(0.76 0.14 60 / 45%)", + }, + }, + shape: { + radius: "0.375rem", + userBubbleRadius: "0.375rem", + composerRadius: "0.5rem", + }, + density: { spacing: 0.95 }, + typography: { + ui: "jetbrains-mono", + mono: "jetbrains-mono", + chatFontSize: 0.8125, + chatLineHeight: 1.5, + }, + code: { light: "vitesse-light", dark: "vitesse-dark" }, +} + +const ink: AppearancePreset = { + schemaVersion: 1, + id: "ink", + name: "Ink", + description: + "Monochrome and square: black, white and a few grays, outlined bubbles, no accent color anywhere.", + author: "codeg", + base: "neutral", + colors: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.15 0 0)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.15 0 0)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.15 0 0)", + primary: "oklch(0.15 0 0)", + "primary-foreground": "oklch(1 0 0)", + secondary: "oklch(0.955 0 0)", + "secondary-foreground": "oklch(0.15 0 0)", + muted: "oklch(0.955 0 0)", + "muted-foreground": "oklch(0.5 0 0)", + accent: "oklch(0.93 0 0)", + "accent-foreground": "oklch(0.15 0 0)", + border: "oklch(0.85 0 0)", + input: "oklch(0.85 0 0)", + ring: "oklch(0.5 0 0)", + sidebar: "oklch(0.98 0 0)", + "sidebar-foreground": "oklch(0.15 0 0)", + "sidebar-primary": "oklch(0.15 0 0)", + "sidebar-primary-foreground": "oklch(1 0 0)", + "sidebar-accent": "oklch(0.93 0 0)", + "sidebar-accent-foreground": "oklch(0.15 0 0)", + "sidebar-border": "oklch(0.85 0 0)", + "sidebar-ring": "oklch(0.5 0 0)", + "bubble-user-bg": "transparent", + "bubble-user-fg": "oklch(0.15 0 0)", + "bubble-user-border": "oklch(0.8 0 0)", + "status-bar-bg": "oklch(0.98 0 0)", + "status-bar-fg": "oklch(0.45 0 0)", + "status-bar-border": "oklch(0.85 0 0)", + "composer-bg": "oklch(1 0 0)", + "composer-border": "oklch(0.6 0 0)", + }, + dark: { + background: "oklch(0.13 0 0)", + foreground: "oklch(0.96 0 0)", + card: "oklch(0.17 0 0)", + "card-foreground": "oklch(0.96 0 0)", + popover: "oklch(0.17 0 0)", + "popover-foreground": "oklch(0.96 0 0)", + primary: "oklch(0.96 0 0)", + "primary-foreground": "oklch(0.13 0 0)", + secondary: "oklch(0.22 0 0)", + "secondary-foreground": "oklch(0.96 0 0)", + muted: "oklch(0.22 0 0)", + "muted-foreground": "oklch(0.74 0 0)", + accent: "oklch(0.27 0 0)", + "accent-foreground": "oklch(0.96 0 0)", + border: "oklch(1 0 0 / 14%)", + input: "oklch(1 0 0 / 18%)", + ring: "oklch(0.7 0 0)", + sidebar: "oklch(0.11 0 0)", + "sidebar-foreground": "oklch(0.93 0 0)", + "sidebar-primary": "oklch(0.96 0 0)", + "sidebar-primary-foreground": "oklch(0.13 0 0)", + "sidebar-accent": "oklch(0.2 0 0)", + "sidebar-accent-foreground": "oklch(0.96 0 0)", + "sidebar-border": "oklch(1 0 0 / 12%)", + "sidebar-ring": "oklch(0.7 0 0)", + "bubble-user-bg": "transparent", + "bubble-user-fg": "oklch(0.96 0 0)", + "bubble-user-border": "oklch(1 0 0 / 22%)", + "status-bar-bg": "oklch(0.11 0 0)", + "status-bar-fg": "oklch(0.72 0 0)", + "status-bar-border": "oklch(1 0 0 / 12%)", + "composer-bg": "oklch(0.11 0 0)", + "composer-border": "oklch(1 0 0 / 28%)", + }, + }, + shape: { + radius: "0.125rem", + userBubbleRadius: "0.125rem", + composerRadius: "0.125rem", + }, + typography: { + ui: "geist", + mono: "geist-mono", + chatFontSize: 0.875, + chatLineHeight: 1.5, + }, + code: { light: "min-light", dark: "min-dark" }, +} + +const denseIde: AppearancePreset = { + schemaVersion: 1, + id: "dense-ide", + name: "Dense IDE", + description: + "Compact spacing, small text, blue accents and a colored status bar, like an editor with a chat panel.", + author: "codeg", + base: "slate", + colors: { + light: { + background: "oklch(0.985 0.003 250)", + foreground: "oklch(0.22 0.03 260)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.22 0.03 260)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.22 0.03 260)", + primary: "oklch(0.5 0.2 255)", + "primary-foreground": "oklch(0.985 0.003 250)", + secondary: "oklch(0.94 0.01 250)", + "secondary-foreground": "oklch(0.22 0.03 260)", + muted: "oklch(0.94 0.01 250)", + "muted-foreground": "oklch(0.48 0.03 255)", + accent: "oklch(0.91 0.02 250)", + "accent-foreground": "oklch(0.22 0.03 260)", + border: "oklch(0.87 0.01 250)", + input: "oklch(0.87 0.01 250)", + ring: "oklch(0.5 0.2 255)", + sidebar: "oklch(0.955 0.005 250)", + "sidebar-foreground": "oklch(0.22 0.03 260)", + "sidebar-primary": "oklch(0.5 0.2 255)", + "sidebar-primary-foreground": "oklch(0.985 0.003 250)", + "sidebar-accent": "oklch(0.9 0.015 250)", + "sidebar-accent-foreground": "oklch(0.22 0.03 260)", + "sidebar-border": "oklch(0.87 0.01 250)", + "sidebar-ring": "oklch(0.5 0.2 255)", + "bubble-user-bg": "oklch(0.93 0.02 250)", + "bubble-user-fg": "oklch(0.22 0.03 260)", + "bubble-user-border": "oklch(0.85 0.02 250)", + "status-bar-bg": "oklch(0.5 0.16 255)", + "status-bar-fg": "oklch(0.98 0.005 250)", + "status-bar-border": "oklch(0.5 0.16 255)", + "composer-bg": "oklch(1 0 0)", + "composer-border": "oklch(0.75 0.03 250)", + }, + dark: { + background: "oklch(0.18 0.02 260)", + foreground: "oklch(0.92 0.01 250)", + card: "oklch(0.21 0.02 260)", + "card-foreground": "oklch(0.92 0.01 250)", + popover: "oklch(0.21 0.02 260)", + "popover-foreground": "oklch(0.92 0.01 250)", + primary: "oklch(0.7 0.15 250)", + "primary-foreground": "oklch(0.15 0.02 260)", + secondary: "oklch(0.26 0.02 260)", + "secondary-foreground": "oklch(0.92 0.01 250)", + muted: "oklch(0.26 0.02 260)", + "muted-foreground": "oklch(0.72 0.02 250)", + accent: "oklch(0.3 0.03 255)", + "accent-foreground": "oklch(0.95 0.01 250)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 14%)", + ring: "oklch(0.7 0.15 250)", + sidebar: "oklch(0.15 0.02 260)", + "sidebar-foreground": "oklch(0.9 0.01 250)", + "sidebar-primary": "oklch(0.7 0.15 250)", + "sidebar-primary-foreground": "oklch(0.15 0.02 260)", + "sidebar-accent": "oklch(0.23 0.02 260)", + "sidebar-accent-foreground": "oklch(0.95 0.01 250)", + "sidebar-border": "oklch(1 0 0 / 8%)", + "sidebar-ring": "oklch(0.7 0.15 250)", + "bubble-user-bg": "oklch(0.25 0.03 255)", + "bubble-user-fg": "oklch(0.92 0.01 250)", + "bubble-user-border": "oklch(1 0 0 / 8%)", + "status-bar-bg": "oklch(0.5 0.16 255)", + "status-bar-fg": "oklch(0.98 0.005 250)", + "status-bar-border": "oklch(0.5 0.16 255)", + "composer-bg": "oklch(0.15 0.02 260)", + "composer-border": "oklch(1 0 0 / 18%)", + }, + }, + shape: { + radius: "0.25rem", + userBubbleRadius: "0.25rem", + composerRadius: "0.25rem", + }, + density: { spacing: 0.85 }, + typography: { + ui: "system-ui", + mono: "jetbrains-mono", + chatFontSize: 0.8125, + chatLineHeight: 1.4, + }, + code: { light: "light-plus", dark: "dark-plus" }, +} + +const highContrast: AppearancePreset = { + schemaVersion: 1, + id: "high-contrast", + name: "High contrast", + description: + "Pure black on white (and white on black), strong borders, larger text and high-contrast code colors.", + author: "codeg", + base: "neutral", + colors: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0 0 0)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0 0 0)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0 0 0)", + primary: "oklch(0 0 0)", + "primary-foreground": "oklch(1 0 0)", + secondary: "oklch(0.92 0 0)", + "secondary-foreground": "oklch(0 0 0)", + muted: "oklch(0.92 0 0)", + "muted-foreground": "oklch(0.32 0 0)", + accent: "oklch(0.88 0 0)", + "accent-foreground": "oklch(0 0 0)", + border: "oklch(0.4 0 0)", + input: "oklch(0.4 0 0)", + ring: "oklch(0 0 0)", + sidebar: "oklch(1 0 0)", + "sidebar-foreground": "oklch(0 0 0)", + "sidebar-primary": "oklch(0 0 0)", + "sidebar-primary-foreground": "oklch(1 0 0)", + "sidebar-accent": "oklch(0.9 0 0)", + "sidebar-accent-foreground": "oklch(0 0 0)", + "sidebar-border": "oklch(0.4 0 0)", + "sidebar-ring": "oklch(0 0 0)", + "bubble-user-bg": "oklch(0.95 0 0)", + "bubble-user-fg": "oklch(0 0 0)", + "bubble-user-border": "oklch(0.4 0 0)", + "status-bar-bg": "oklch(1 0 0)", + "status-bar-fg": "oklch(0.2 0 0)", + "status-bar-border": "oklch(0.4 0 0)", + "composer-bg": "oklch(1 0 0)", + "composer-border": "oklch(0.3 0 0)", + }, + dark: { + background: "oklch(0 0 0)", + foreground: "oklch(1 0 0)", + card: "oklch(0.1 0 0)", + "card-foreground": "oklch(1 0 0)", + popover: "oklch(0.1 0 0)", + "popover-foreground": "oklch(1 0 0)", + primary: "oklch(1 0 0)", + "primary-foreground": "oklch(0 0 0)", + secondary: "oklch(0.2 0 0)", + "secondary-foreground": "oklch(1 0 0)", + muted: "oklch(0.2 0 0)", + "muted-foreground": "oklch(0.88 0 0)", + accent: "oklch(0.3 0 0)", + "accent-foreground": "oklch(1 0 0)", + border: "oklch(0.75 0 0)", + input: "oklch(0.75 0 0)", + ring: "oklch(1 0 0)", + sidebar: "oklch(0 0 0)", + "sidebar-foreground": "oklch(1 0 0)", + "sidebar-primary": "oklch(1 0 0)", + "sidebar-primary-foreground": "oklch(0 0 0)", + "sidebar-accent": "oklch(0.25 0 0)", + "sidebar-accent-foreground": "oklch(1 0 0)", + "sidebar-border": "oklch(0.75 0 0)", + "sidebar-ring": "oklch(1 0 0)", + "bubble-user-bg": "oklch(0.15 0 0)", + "bubble-user-fg": "oklch(1 0 0)", + "bubble-user-border": "oklch(0.8 0 0)", + "status-bar-bg": "oklch(0 0 0)", + "status-bar-fg": "oklch(0.92 0 0)", + "status-bar-border": "oklch(0.75 0 0)", + "composer-bg": "oklch(0 0 0)", + "composer-border": "oklch(0.85 0 0)", + }, + }, + shape: { + radius: "0.125rem", + userBubbleRadius: "0.125rem", + composerRadius: "0.125rem", + }, + typography: { + chatFontSize: 0.9375, + chatLineHeight: 1.55, + }, + code: { + light: "github-light-high-contrast", + dark: "github-dark-high-contrast", + }, +} + +const midnight: AppearancePreset = { + schemaVersion: 1, + id: "midnight", + name: "Midnight", + description: + "Deep blue-violet surfaces, soft rounded bubbles and a lilac accent, the mood of a late-night chat client.", + author: "codeg", + base: "violet", + mode: "dark", + colors: { + light: { + background: "oklch(0.985 0.005 290)", + foreground: "oklch(0.22 0.04 290)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.22 0.04 290)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.22 0.04 290)", + primary: "oklch(0.5 0.2 300)", + "primary-foreground": "oklch(0.985 0.005 290)", + secondary: "oklch(0.94 0.015 290)", + "secondary-foreground": "oklch(0.22 0.04 290)", + muted: "oklch(0.94 0.015 290)", + "muted-foreground": "oklch(0.48 0.04 290)", + accent: "oklch(0.92 0.03 295)", + "accent-foreground": "oklch(0.22 0.04 290)", + border: "oklch(0.88 0.015 290)", + input: "oklch(0.88 0.015 290)", + ring: "oklch(0.5 0.2 300)", + sidebar: "oklch(0.965 0.01 290)", + "sidebar-foreground": "oklch(0.22 0.04 290)", + "sidebar-primary": "oklch(0.5 0.2 300)", + "sidebar-primary-foreground": "oklch(0.985 0.005 290)", + "sidebar-accent": "oklch(0.91 0.025 292)", + "sidebar-accent-foreground": "oklch(0.22 0.04 290)", + "sidebar-border": "oklch(0.88 0.015 290)", + "sidebar-ring": "oklch(0.5 0.2 300)", + "bubble-user-bg": "oklch(0.92 0.04 295)", + "bubble-user-fg": "oklch(0.22 0.04 290)", + "bubble-user-border": "transparent", + "status-bar-bg": "oklch(0.955 0.01 290)", + "status-bar-fg": "oklch(0.45 0.04 290)", + "status-bar-border": "oklch(0.88 0.015 290)", + "composer-bg": "oklch(1 0 0)", + "composer-border": "oklch(0.5 0.2 300 / 40%)", + }, + dark: { + background: "oklch(0.17 0.03 285)", + foreground: "oklch(0.94 0.01 285)", + card: "oklch(0.21 0.03 285)", + "card-foreground": "oklch(0.94 0.01 285)", + popover: "oklch(0.21 0.03 285)", + "popover-foreground": "oklch(0.94 0.01 285)", + primary: "oklch(0.74 0.16 300)", + "primary-foreground": "oklch(0.16 0.04 290)", + secondary: "oklch(0.26 0.035 285)", + "secondary-foreground": "oklch(0.94 0.01 285)", + muted: "oklch(0.26 0.035 285)", + "muted-foreground": "oklch(0.73 0.03 290)", + accent: "oklch(0.31 0.05 290)", + "accent-foreground": "oklch(0.96 0.01 285)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 14%)", + ring: "oklch(0.74 0.16 300)", + sidebar: "oklch(0.14 0.03 285)", + "sidebar-foreground": "oklch(0.92 0.01 285)", + "sidebar-primary": "oklch(0.74 0.16 300)", + "sidebar-primary-foreground": "oklch(0.16 0.04 290)", + "sidebar-accent": "oklch(0.22 0.035 285)", + "sidebar-accent-foreground": "oklch(0.96 0.01 285)", + "sidebar-border": "oklch(1 0 0 / 8%)", + "sidebar-ring": "oklch(0.74 0.16 300)", + "bubble-user-bg": "oklch(0.3 0.07 295)", + "bubble-user-fg": "oklch(0.96 0.01 285)", + "bubble-user-border": "transparent", + "status-bar-bg": "oklch(0.14 0.03 285)", + "status-bar-fg": "oklch(0.72 0.03 290)", + "status-bar-border": "oklch(1 0 0 / 8%)", + "composer-bg": "oklch(0.14 0.03 285)", + "composer-border": "oklch(0.74 0.16 300 / 40%)", + }, + }, + shape: { + radius: "0.75rem", + userBubbleRadius: "1rem", + composerRadius: "1rem", + }, + typography: { + ui: "inter", + mono: "geist-mono", + chatFontSize: 0.9375, + chatLineHeight: 1.6, + }, + code: { light: "one-light", dark: "one-dark-pro" }, +} + +const paper: AppearancePreset = { + schemaVersion: 1, + id: "paper", + name: "Paper", + description: + "A warm, light reading surface with roomy spacing and larger text. Dark mode is a dim dusk instead of black.", + author: "codeg", + base: "stone", + mode: "light", + colors: { + light: { + background: "oklch(0.965 0.012 85)", + foreground: "oklch(0.28 0.03 60)", + card: "oklch(0.985 0.008 85)", + "card-foreground": "oklch(0.28 0.03 60)", + popover: "oklch(0.985 0.008 85)", + "popover-foreground": "oklch(0.28 0.03 60)", + primary: "oklch(0.45 0.08 50)", + "primary-foreground": "oklch(0.985 0.008 85)", + secondary: "oklch(0.92 0.02 85)", + "secondary-foreground": "oklch(0.28 0.03 60)", + muted: "oklch(0.92 0.02 85)", + "muted-foreground": "oklch(0.47 0.03 60)", + accent: "oklch(0.89 0.03 80)", + "accent-foreground": "oklch(0.28 0.03 60)", + border: "oklch(0.85 0.02 80)", + input: "oklch(0.85 0.02 80)", + ring: "oklch(0.45 0.08 50)", + sidebar: "oklch(0.945 0.015 85)", + "sidebar-foreground": "oklch(0.28 0.03 60)", + "sidebar-primary": "oklch(0.45 0.08 50)", + "sidebar-primary-foreground": "oklch(0.985 0.008 85)", + "sidebar-accent": "oklch(0.89 0.025 85)", + "sidebar-accent-foreground": "oklch(0.28 0.03 60)", + "sidebar-border": "oklch(0.85 0.02 80)", + "sidebar-ring": "oklch(0.45 0.08 50)", + "bubble-user-bg": "oklch(0.985 0.008 85)", + "bubble-user-fg": "oklch(0.28 0.03 60)", + "bubble-user-border": "oklch(0.85 0.02 80)", + "status-bar-bg": "oklch(0.945 0.015 85)", + "status-bar-fg": "oklch(0.45 0.03 60)", + "status-bar-border": "oklch(0.85 0.02 80)", + "composer-bg": "oklch(0.985 0.008 85)", + "composer-border": "oklch(0.8 0.025 75)", + }, + dark: { + background: "oklch(0.22 0.015 60)", + foreground: "oklch(0.92 0.02 85)", + card: "oklch(0.26 0.015 60)", + "card-foreground": "oklch(0.92 0.02 85)", + popover: "oklch(0.26 0.015 60)", + "popover-foreground": "oklch(0.92 0.02 85)", + primary: "oklch(0.8 0.1 70)", + "primary-foreground": "oklch(0.22 0.03 60)", + secondary: "oklch(0.3 0.018 60)", + "secondary-foreground": "oklch(0.92 0.02 85)", + muted: "oklch(0.3 0.018 60)", + "muted-foreground": "oklch(0.74 0.03 75)", + accent: "oklch(0.34 0.025 65)", + "accent-foreground": "oklch(0.95 0.02 85)", + border: "oklch(1 0 0 / 12%)", + input: "oklch(1 0 0 / 16%)", + ring: "oklch(0.8 0.1 70)", + sidebar: "oklch(0.19 0.015 60)", + "sidebar-foreground": "oklch(0.9 0.02 85)", + "sidebar-primary": "oklch(0.8 0.1 70)", + "sidebar-primary-foreground": "oklch(0.22 0.03 60)", + "sidebar-accent": "oklch(0.27 0.018 60)", + "sidebar-accent-foreground": "oklch(0.95 0.02 85)", + "sidebar-border": "oklch(1 0 0 / 10%)", + "sidebar-ring": "oklch(0.8 0.1 70)", + "bubble-user-bg": "oklch(0.29 0.02 60)", + "bubble-user-fg": "oklch(0.93 0.02 85)", + "bubble-user-border": "oklch(1 0 0 / 10%)", + "status-bar-bg": "oklch(0.19 0.015 60)", + "status-bar-fg": "oklch(0.72 0.03 75)", + "status-bar-border": "oklch(1 0 0 / 10%)", + "composer-bg": "oklch(0.19 0.015 60)", + "composer-border": "oklch(1 0 0 / 22%)", + }, + }, + shape: { + radius: "0.5rem", + userBubbleRadius: "0.75rem", + composerRadius: "0.75rem", + }, + density: { spacing: 1.05 }, + typography: { + ui: "inter", + mono: "jetbrains-mono", + chatFontSize: 0.9375, + chatLineHeight: 1.65, + }, + code: { light: "rose-pine-dawn", dark: "rose-pine-moon" }, +} + +export const BUNDLED_PRESETS: readonly AppearancePreset[] = [ + defaultPreset, + warmTerminal, + ink, + denseIde, + highContrast, + midnight, + paper, +] + +export const BUNDLED_PRESET_BY_ID: Record = + Object.fromEntries(BUNDLED_PRESETS.map((preset) => [preset.id, preset])) + +const BUNDLED_ID_SET = new Set(BUNDLED_PRESET_IDS) + +export function isBundledPresetId( + id: string | null | undefined +): id is BundledPresetId { + return !!id && BUNDLED_ID_SET.has(id) +} diff --git a/src/lib/appearance-script.test.ts b/src/lib/appearance-script.test.ts index 9a4f3cf1b8..0ae9c43703 100644 --- a/src/lib/appearance-script.test.ts +++ b/src/lib/appearance-script.test.ts @@ -6,10 +6,14 @@ import { STORAGE_KEY_CUSTOM_STYLE_SUSPENDED, STORAGE_KEY_CUSTOM_THEME, STORAGE_KEY_CUSTOM_THEME_ENABLED, + STORAGE_KEY_MONO_FONT, + STORAGE_KEY_MONO_FONT_STACK, STORAGE_KEY_THEME_COLOR, STORAGE_KEY_ZOOM_LEVEL, } from "./appearance-script" -import { CUSTOM_CSS_ELEMENT_ID } from "./custom-style" +import { CUSTOM_CSS_ELEMENT_ID, CUSTOM_THEME_TOKENS } from "./custom-style" +import { presetToApplication } from "./appearance-preset" +import { BUNDLED_PRESET_BY_ID } from "./appearance-presets-bundled" /** * 这段脚本跑在第一帧渲染之前、任何模块加载之前,是整个外观体系里最脆弱的一环: @@ -146,6 +150,90 @@ describe("APPEARANCE_INIT_SCRIPT — custom theme tokens", () => { }) }) +describe("APPEARANCE_INIT_SCRIPT — appearance presets", () => { + it("carries every custom-theme token, so a preset's surface and layout tokens paint before first paint", () => { + // The token list is inlined into the script at build time. Pin the + // inlined copy to the source list the same way the zoom rungs are pinned: + // a token the provider applies but the script does not would flash from + // the default to the preset value on every launch. + const listed = JSON.parse( + /var TOKENS = (\[[^\]]*\])/.exec(APPEARANCE_INIT_SCRIPT)![1] + ) + expect(listed).toEqual([...CUSTOM_THEME_TOKENS]) + }) + + it("applies a bundled preset's tokens exactly as the provider stores them", () => { + // What applyPreset writes to storage is what the script reads: the fanned + // out light/dark map. Everything a preset can set through tokens (colours, + // surfaces, density, message text, bubble shape) lands inline on + // in the mode the window resolves to. + const application = presetToApplication(BUNDLED_PRESET_BY_ID["dense-ide"]) + localStorage.setItem("theme", "dark") + localStorage.setItem(STORAGE_KEY_THEME_COLOR, application.themeColor) + localStorage.setItem( + STORAGE_KEY_CUSTOM_THEME, + JSON.stringify(application.customTheme) + ) + + runInitScript() + + const style = document.documentElement.style + expect(document.documentElement.getAttribute("data-theme")).toBe("slate") + expect(style.getPropertyValue("--primary")).toBe( + application.customTheme.dark.primary + ) + expect(style.getPropertyValue("--status-bar-bg")).toBe( + application.customTheme.dark["status-bar-bg"] + ) + expect(style.getPropertyValue("--spacing")).toBe("0.2125rem") + expect(style.getPropertyValue("--chat-font-size")).toBe("0.8125rem") + expect(style.getPropertyValue("--bubble-user-radius")).toBe("0.25rem") + }) + + it("applies the stored code font stack to --font-mono like the interface font", () => { + localStorage.setItem(STORAGE_KEY_MONO_FONT, "jetbrains-mono") + localStorage.setItem( + STORAGE_KEY_MONO_FONT_STACK, + '"JetBrains Mono Variable", ui-monospace, monospace' + ) + + runInitScript() + + expect(document.documentElement.style.getPropertyValue("--font-mono")).toBe( + '"JetBrains Mono Variable", ui-monospace, monospace' + ) + }) + + it("leaves --font-mono to the stylesheet default without an explicit choice", () => { + // A cached stack without an id is not a preference (see the ui font + // rule); the :root default paints the stock system monospace. + localStorage.setItem( + STORAGE_KEY_MONO_FONT_STACK, + '"JetBrains Mono Variable", ui-monospace, monospace' + ) + + runInitScript() + + expect(document.documentElement.style.getPropertyValue("--font-mono")).toBe( + "" + ) + }) + + it("refuses a code font stack that could break out of the declaration", () => { + localStorage.setItem(STORAGE_KEY_MONO_FONT, "custom") + localStorage.setItem( + STORAGE_KEY_MONO_FONT_STACK, + "x; } body { display:none" + ) + + runInitScript() + + expect(document.documentElement.style.getPropertyValue("--font-mono")).toBe( + "" + ) + }) +}) + describe("APPEARANCE_INIT_SCRIPT — custom CSS", () => { it("injects the stored CSS at the end of when enabled", () => { localStorage.setItem(STORAGE_KEY_CUSTOM_CSS_ENABLED, "1") diff --git a/src/lib/appearance-script.ts b/src/lib/appearance-script.ts index e47c8c7c2a..d633d03465 100644 --- a/src/lib/appearance-script.ts +++ b/src/lib/appearance-script.ts @@ -34,6 +34,22 @@ export const STORAGE_KEY_TERMINAL_FONT = "codeg-terminal-font" export const STORAGE_KEY_TERMINAL_FONT_CUSTOM = "codeg-terminal-font-custom" export const STORAGE_KEY_TERMINAL_FONT_SIZE = "codeg-terminal-font-size" export const STORAGE_KEY_TERMINAL_LIGATURES = "codeg-terminal-ligatures" +// Monospace font for code in messages (drives --font-mono: Streamdown code +// blocks, inline code, `font-mono` utilities). Same three keys and the same +// pre-paint treatment as the interface font: the resolved stack is cached so +// the inline script can apply it without the font catalog. +export const STORAGE_KEY_MONO_FONT = "codeg-mono-font" +export const STORAGE_KEY_MONO_FONT_CUSTOM = "codeg-mono-font-custom" +export const STORAGE_KEY_MONO_FONT_STACK = "codeg-mono-font-stack" + +// Appearance presets. Applying one writes the same theme-color / custom-theme +// / font keys a hand edit would, so nothing at boot reads the preset itself. +// CODE_THEME is `{ light, dark }` Shiki theme ids for code blocks in messages +// (applied after hydration; highlighting is async anyway). APPEARANCE_PRESET +// keeps the last applied preset document so the settings page can tell +// "Warm terminal" from "Warm terminal (modified)" and name an imported one. +export const STORAGE_KEY_CODE_THEME = "codeg-code-theme" +export const STORAGE_KEY_APPEARANCE_PRESET = "codeg-appearance-preset" // Workspace 背景图片。图片本身存磁盘(~/.codeg/backgrounds/),localStorage 只存 // 展示配置。仅 enabled 与 panel-opacity 需要预水合(它们作用于首帧就存在的结构性 @@ -114,6 +130,15 @@ const SCRIPT = ` document.documentElement.style.setProperty("--font-sans", uiFontStack); } + // Code font for messages: same rule as the interface font, writing + // --font-mono. No stored id means the :root default (the system monospace + // stack Tailwind ships), so an upgrade paints code exactly as before. + var monoFontId = localStorage.getItem("${STORAGE_KEY_MONO_FONT}"); + var monoFontStack = localStorage.getItem("${STORAGE_KEY_MONO_FONT_STACK}"); + if (monoFontId && monoFontStack && monoFontStack.length < 512 && !/[;{}<>]/.test(monoFontStack)) { + document.documentElement.style.setProperty("--font-mono", monoFontStack); + } + // Workspace 背景:预水合仅处理首帧就存在的结构性表面。启用时给 打 // data-workspace-bg 属性并预置 --ws-surface-alpha,避免面板 opaque→translucent // 跳变。图片本身异步从磁盘读,不在此处理。 diff --git a/src/lib/code-themes.test.ts b/src/lib/code-themes.test.ts new file mode 100644 index 0000000000..7166d3ab92 --- /dev/null +++ b/src/lib/code-themes.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest" +import { bundledThemesInfo } from "shiki" + +import { + CODE_THEMES, + DEFAULT_CODE_THEME, + codeThemeLabel, + codeThemeTuple, + isCodeThemeId, + parseStoredCodeTheme, + sanitizeCodeTheme, +} from "./code-themes" + +describe("CODE_THEMES", () => { + it("is exactly Shiki's bundled theme list, with the same light/dark type", () => { + // The allowlist is hand-written so the settings page and the preset + // validator never import Shiki's engine. Pinning it to what the installed + // Shiki actually bundles means a version bump that renames or drops a + // theme fails here instead of leaving a preset pointing at nothing. + const ours = CODE_THEMES.map((t) => `${t.id}:${t.type}`).sort() + const shiki = bundledThemesInfo.map((t) => `${t.id}:${t.type}`).sort() + expect(ours).toEqual(shiki) + }) + + it("has unique ids and includes the defaults", () => { + expect(new Set(CODE_THEMES.map((t) => t.id)).size).toBe(CODE_THEMES.length) + expect(isCodeThemeId(DEFAULT_CODE_THEME.light)).toBe(true) + expect(isCodeThemeId(DEFAULT_CODE_THEME.dark)).toBe(true) + expect(isCodeThemeId("github-darker-than-dark")).toBe(false) + expect(isCodeThemeId(42)).toBe(false) + }) +}) + +describe("sanitizeCodeTheme / parseStoredCodeTheme", () => { + it("keeps known halves and falls back per half", () => { + expect( + sanitizeCodeTheme({ light: "min-light", dark: "not-a-theme" }) + ).toEqual({ light: "min-light", dark: DEFAULT_CODE_THEME.dark }) + expect(parseStoredCodeTheme('{"light":"one-light","dark":"nord"}')).toEqual( + { + light: "one-light", + dark: "nord", + } + ) + }) + + it("never throws on garbage", () => { + expect(parseStoredCodeTheme("{oops")).toEqual(DEFAULT_CODE_THEME) + expect(parseStoredCodeTheme(null)).toEqual(DEFAULT_CODE_THEME) + expect(sanitizeCodeTheme("nord")).toEqual(DEFAULT_CODE_THEME) + }) + + it("orders the tuple light-first for Streamdown", () => { + expect(codeThemeTuple({ light: "one-light", dark: "nord" })).toEqual([ + "one-light", + "nord", + ]) + }) +}) + +describe("codeThemeLabel", () => { + it("restores casing without translating proper nouns", () => { + expect(codeThemeLabel("github-dark-high-contrast")).toBe( + "GitHub Dark High Contrast" + ) + expect(codeThemeLabel("catppuccin-mocha")).toBe("Catppuccin Mocha") + expect(codeThemeLabel("synthwave-84")).toBe("Synthwave 84") + }) +}) diff --git a/src/lib/code-themes.ts b/src/lib/code-themes.ts new file mode 100644 index 0000000000..2e8d39feb7 --- /dev/null +++ b/src/lib/code-themes.ts @@ -0,0 +1,144 @@ +// src/lib/code-themes.ts +// +// The syntax-colour themes a preset may name for code blocks in messages. +// Every entry is a theme Shiki already bundles (the highlighter loads it on +// demand), so picking one costs no download and a preset can never point at +// code that is not in the app. `code-themes.test.ts` pins this list to +// Shiki's `bundledThemesInfo` so an upgrade that adds or drops a theme fails +// loudly instead of leaving a stale id here. + +import type { BundledTheme } from "shiki" + +export type CodeThemeType = "light" | "dark" + +export type CodeThemeDef = { id: BundledTheme; type: CodeThemeType } + +const light = (id: BundledTheme): CodeThemeDef => ({ id, type: "light" }) +const dark = (id: BundledTheme): CodeThemeDef => ({ id, type: "dark" }) + +export const CODE_THEMES: readonly CodeThemeDef[] = [ + dark("andromeeda"), + dark("aurora-x"), + dark("ayu-dark"), + light("ayu-light"), + dark("ayu-mirage"), + dark("catppuccin-frappe"), + light("catppuccin-latte"), + dark("catppuccin-macchiato"), + dark("catppuccin-mocha"), + dark("dark-plus"), + dark("dracula"), + dark("dracula-soft"), + dark("everforest-dark"), + light("everforest-light"), + dark("github-dark"), + dark("github-dark-default"), + dark("github-dark-dimmed"), + dark("github-dark-high-contrast"), + light("github-light"), + light("github-light-default"), + light("github-light-high-contrast"), + dark("gruvbox-dark-hard"), + dark("gruvbox-dark-medium"), + dark("gruvbox-dark-soft"), + light("gruvbox-light-hard"), + light("gruvbox-light-medium"), + light("gruvbox-light-soft"), + dark("horizon"), + dark("houston"), + dark("kanagawa-dragon"), + light("kanagawa-lotus"), + dark("kanagawa-wave"), + dark("laserwave"), + light("light-plus"), + dark("material-theme"), + dark("material-theme-darker"), + light("material-theme-lighter"), + dark("material-theme-ocean"), + dark("material-theme-palenight"), + dark("min-dark"), + light("min-light"), + dark("monokai"), + dark("night-owl"), + light("night-owl-light"), + dark("nord"), + dark("one-dark-pro"), + light("one-light"), + dark("plastic"), + dark("poimandres"), + dark("red"), + dark("rose-pine"), + light("rose-pine-dawn"), + dark("rose-pine-moon"), + dark("slack-dark"), + light("slack-ochin"), + light("snazzy-light"), + dark("solarized-dark"), + light("solarized-light"), + dark("synthwave-84"), + dark("tokyo-night"), + dark("vesper"), + dark("vitesse-black"), + dark("vitesse-dark"), + light("vitesse-light"), +] + +export const CODE_THEME_BY_ID: Record = + Object.fromEntries(CODE_THEMES.map((t) => [t.id, t])) + +/** One theme per mode; Streamdown switches between them on the `.dark` class. */ +export type CodeThemePair = { light: BundledTheme; dark: BundledTheme } + +/** Streamdown's own defaults, so an unset preference renders as it always has. */ +export const DEFAULT_CODE_THEME: CodeThemePair = { + light: "github-light", + dark: "github-dark", +} + +export function isCodeThemeId(id: unknown): id is BundledTheme { + return typeof id === "string" && id in CODE_THEME_BY_ID +} + +/** + * Display name for a theme id. Theme names are proper nouns (Dracula, Nord, + * Catppuccin Mocha), so they are not translated; this only fixes the casing + * the ids lose. + */ +export function codeThemeLabel(id: string): string { + return id + .split("-") + .map((word) => { + if (word === "github") return "GitHub" + if (/^\d/.test(word)) return word + return word.charAt(0).toUpperCase() + word.slice(1) + }) + .join(" ") +} + +/** Storage / preset value -> a pair of known ids, unknown halves falling back. */ +export function sanitizeCodeTheme(input: unknown): CodeThemePair { + if (!input || typeof input !== "object") return { ...DEFAULT_CODE_THEME } + const record = input as Record + return { + light: isCodeThemeId(record.light) + ? record.light + : DEFAULT_CODE_THEME.light, + dark: isCodeThemeId(record.dark) ? record.dark : DEFAULT_CODE_THEME.dark, + } +} + +export function parseStoredCodeTheme(raw: string | null): CodeThemePair { + if (!raw) return { ...DEFAULT_CODE_THEME } + try { + return sanitizeCodeTheme(JSON.parse(raw)) + } catch { + return { ...DEFAULT_CODE_THEME } + } +} + +/** The `[light, dark]` tuple Streamdown's `shikiTheme` prop expects. */ +export function codeThemeTuple( + pair: CodeThemePair +): [BundledTheme, BundledTheme] { + return [pair.light, pair.dark] +} diff --git a/src/lib/color-contrast.test.ts b/src/lib/color-contrast.test.ts new file mode 100644 index 0000000000..796b7f521c --- /dev/null +++ b/src/lib/color-contrast.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest" + +import { + compositeOver, + contrastRatio, + parseCssColor, + relativeLuminance, + resolveCssColor, +} from "./color-contrast" + +const white = parseCssColor("#ffffff")! +const black = parseCssColor("#000000")! + +describe("parseCssColor", () => { + it("reads every hex form", () => { + expect(parseCssColor("#fff")).toEqual(white) + expect(parseCssColor("#FFFFFF")).toEqual(white) + expect(parseCssColor("#0000")?.a).toBe(0) + expect(parseCssColor("#00000080")?.a).toBeCloseTo(128 / 255, 5) + expect(parseCssColor("#12345")).toBeNull() + expect(parseCssColor("#gggggg")).toBeNull() + }) + + it("reads rgb() and hsl() in the modern and legacy forms", () => { + expect(parseCssColor("rgb(255 255 255)")).toEqual(white) + expect(parseCssColor("rgba(255, 255, 255, 1)")).toEqual(white) + expect(parseCssColor("rgb(100% 100% 100% / 50%)")?.a).toBe(0.5) + expect(parseCssColor("hsl(0 0% 100%)")).toEqual(white) + expect(parseCssColor("hsl(0, 0%, 0%, 0.25)")?.a).toBe(0.25) + // Pure red in hsl is pure red in rgb. + const red = parseCssColor("hsl(0 100% 50%)")! + expect(red.r).toBeCloseTo(1, 5) + expect(red.g).toBeCloseTo(0, 5) + expect(red.b).toBeCloseTo(0, 5) + }) + + it("converts oklch() and oklab() to sRGB", () => { + // oklch(1 0 0) is white, oklch(0 0 0) is black. + const w = parseCssColor("oklch(1 0 0)")! + expect(w.r).toBeCloseTo(1, 3) + expect(w.g).toBeCloseTo(1, 3) + expect(w.b).toBeCloseTo(1, 3) + const b = parseCssColor("oklch(0 0 0)")! + expect(b.r).toBeCloseTo(0, 3) + expect(b.g).toBeCloseTo(0, 3) + expect(b.b).toBeCloseTo(0, 3) + // A mid grey: luminance is L cubed for an achromatic colour. + const grey = parseCssColor("oklch(0.5 0 0)")! + expect(relativeLuminance(grey)).toBeCloseTo(0.125, 2) + // Percent lightness and an explicit hue unit. + expect(parseCssColor("oklch(50% 0 90deg)")?.r).toBeCloseTo(grey.r, 5) + // Alpha survives the slash syntax. + expect(parseCssColor("oklch(1 0 0 / 10%)")?.a).toBeCloseTo(0.1, 5) + // oklab agrees with oklch at zero chroma. + expect(parseCssColor("oklab(0.5 0 0)")?.r).toBeCloseTo(grey.r, 5) + }) + + it("keeps a chromatic oklch inside the sRGB cube", () => { + // shadcn's own violet primary is in gamut; a wildly saturated one is not, + // and must come back clamped rather than negative. + const violet = parseCssColor("oklch(0.606 0.25 292.717)")! + for (const c of [violet.r, violet.g, violet.b]) { + expect(c).toBeGreaterThanOrEqual(0) + expect(c).toBeLessThanOrEqual(1) + } + const wild = parseCssColor("oklch(0.9 0.4 150)")! + for (const c of [wild.r, wild.g, wild.b]) { + expect(c).toBeGreaterThanOrEqual(0) + expect(c).toBeLessThanOrEqual(1) + } + }) + + it("refuses what it does not understand instead of guessing", () => { + expect(parseCssColor("plum")).toBeNull() + expect(parseCssColor("color-mix(in oklab, #fff 40%, #000)")).toBeNull() + expect(parseCssColor("var(--primary)")).toBeNull() + expect(parseCssColor("oklch(0.5 0)")).toBeNull() + expect(parseCssColor("rgb(1 2 3 4 5)")).toBeNull() + expect(parseCssColor("")).toBeNull() + expect(parseCssColor("url(https://example.com/x.png)")).toBeNull() + }) +}) + +describe("resolveCssColor", () => { + const vars: Record = { + background: "#ffffff", + "bubble-user-bg": "var(--secondary)", + secondary: "var(--muted)", + muted: "oklch(0.97 0 0)", + loop: "var(--loop)", + } + const lookup = (name: string) => vars[name] + + it("follows var() chains to the literal at the end", () => { + const resolved = resolveCssColor("var(--bubble-user-bg)", lookup)! + expect(relativeLuminance(resolved)).toBeCloseTo(0.97 ** 3, 2) + }) + + it("uses the fallback when a name is unknown, and null when there is none", () => { + expect(resolveCssColor("var(--nope, #000000)", lookup)).toEqual(black) + expect(resolveCssColor("var(--nope)", lookup)).toBeNull() + }) + + it("does not spin on a cycle", () => { + expect(resolveCssColor("var(--loop)", lookup)).toBeNull() + }) +}) + +describe("contrastRatio", () => { + it("is 21:1 for black on white and 1:1 for a colour on itself", () => { + expect(contrastRatio(black, white)).toBeCloseTo(21, 5) + expect(contrastRatio(white, black)).toBeCloseTo(21, 5) + expect(contrastRatio(white, white)).toBeCloseTo(1, 5) + }) + + it("matches the published value for a known pair", () => { + // #767676 on white is the classic 4.54:1 AA boundary example. + const grey = parseCssColor("#767676")! + expect(contrastRatio(grey, white)).toBeCloseTo(4.54, 1) + }) + + it("composites translucent text and surfaces before comparing", () => { + const halfBlack = parseCssColor("rgb(0 0 0 / 50%)")! + // Half-black text on white reads as a mid grey, not as black. + expect(contrastRatio(halfBlack, white)).toBeLessThan(21) + expect(contrastRatio(halfBlack, white)).toBeGreaterThan(1) + // A transparent surface is judged against the base it sits on. + const transparent = parseCssColor("transparent")! + expect(contrastRatio(black, transparent, white)).toBeCloseTo(21, 5) + expect(contrastRatio(white, transparent, black)).toBeCloseTo(21, 5) + expect(compositeOver(transparent, white)).toEqual(white) + }) +}) diff --git a/src/lib/color-contrast.ts b/src/lib/color-contrast.ts new file mode 100644 index 0000000000..8028562575 --- /dev/null +++ b/src/lib/color-contrast.ts @@ -0,0 +1,318 @@ +// src/lib/color-contrast.ts +// +// Dependency-free colour math for the appearance presets: parse the CSS colour +// syntaxes the theme tokens actually use (hex, rgb(), hsl(), oklch(), oklab() +// and the transparent keyword) into linear sRGB, resolve `var()` references, +// and compute WCAG 2 contrast ratios. +// +// This is deliberately NOT a general CSS colour parser. `color-mix()` and the +// relative colour syntax are unsupported on purpose: a preset that wants a +// contrast guarantee has to spell its text / surface pairs out as literal +// colours, which is also what makes them readable in the JSON. + +/** Linear-light sRGB, every channel 0..1 (out-of-gamut input is clamped). */ +export type LinearRgba = { r: number; g: number; b: number; a: number } + +const KEYWORDS: Record = { + transparent: { r: 0, g: 0, b: 0, a: 0 }, + white: { r: 1, g: 1, b: 1, a: 1 }, + black: { r: 0, g: 0, b: 0, a: 1 }, +} + +function clamp01(n: number): number { + return n < 0 ? 0 : n > 1 ? 1 : n +} + +/** sRGB transfer function, gamma-encoded 0..1 to linear 0..1. */ +function toLinear(channel: number): number { + const c = clamp01(channel) + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 +} + +function parseNumber(token: string): number | null { + if (!/^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(token)) return null + const n = Number(token) + return Number.isFinite(n) ? n : null +} + +/** `50%` -> 0.5, `0.5` -> 0.5, `none` -> 0; null when it is neither. */ +function parseAlpha(token: string | undefined): number | null { + if (token === undefined) return 1 + if (token === "none") return 0 + if (token.endsWith("%")) { + const n = parseNumber(token.slice(0, -1)) + return n === null ? null : clamp01(n / 100) + } + const n = parseNumber(token) + return n === null ? null : clamp01(n) +} + +/** + * Split a functional colour's arguments into positional channels plus the + * optional alpha. Accepts both the modern space-separated form with `/` and + * the legacy comma form (`rgba(1, 2, 3, 0.5)`). + */ +function splitArgs( + body: string +): { channels: string[]; alpha: string | undefined } | null { + const trimmed = body.trim() + if (!trimmed) return null + let channelsPart = trimmed + let alpha: string | undefined + const slash = trimmed.indexOf("/") + if (slash >= 0) { + channelsPart = trimmed.slice(0, slash) + alpha = trimmed.slice(slash + 1).trim() + if (!alpha || alpha.includes("/")) return null + } + const parts = channelsPart + .split(/[\s,]+/) + .map((p) => p.trim()) + .filter(Boolean) + if (alpha === undefined && parts.length === 4) { + // Legacy `rgba(r, g, b, a)` / `hsla(h, s, l, a)`. + alpha = parts.pop() + } + if (parts.length !== 3) return null + return { channels: parts, alpha } +} + +function parseHex(hex: string): LinearRgba | null { + const digits = hex.slice(1) + if (!/^[0-9a-f]+$/i.test(digits)) return null + let r: number, g: number, b: number, a: number + if (digits.length === 3 || digits.length === 4) { + const [rr, gg, bb, aa] = digits.split("").map((d) => parseInt(d + d, 16)) + r = rr + g = gg + b = bb + a = digits.length === 4 ? aa : 255 + } else if (digits.length === 6 || digits.length === 8) { + r = parseInt(digits.slice(0, 2), 16) + g = parseInt(digits.slice(2, 4), 16) + b = parseInt(digits.slice(4, 6), 16) + a = digits.length === 8 ? parseInt(digits.slice(6, 8), 16) : 255 + } else { + return null + } + return { + r: toLinear(r / 255), + g: toLinear(g / 255), + b: toLinear(b / 255), + a: a / 255, + } +} + +/** `255` or `100%` -> gamma-encoded 0..1. */ +function parseRgbChannel(token: string): number | null { + if (token === "none") return 0 + if (token.endsWith("%")) { + const n = parseNumber(token.slice(0, -1)) + return n === null ? null : clamp01(n / 100) + } + const n = parseNumber(token) + return n === null ? null : clamp01(n / 255) +} + +function parseRgb(body: string): LinearRgba | null { + const args = splitArgs(body) + if (!args) return null + const channels = args.channels.map(parseRgbChannel) + const alpha = parseAlpha(args.alpha) + if (channels.some((c) => c === null) || alpha === null) return null + const [r, g, b] = channels as number[] + return { r: toLinear(r), g: toLinear(g), b: toLinear(b), a: alpha } +} + +function parseHue(token: string): number | null { + if (token === "none") return 0 + const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(deg|grad|rad|turn)?$/i.exec(token) + if (!match) return null + const n = Number(match[1]) + switch ((match[2] ?? "deg").toLowerCase()) { + case "grad": + return n * 0.9 + case "rad": + return (n * 180) / Math.PI + case "turn": + return n * 360 + default: + return n + } +} + +function parsePercentOrUnit(token: string, scale: number): number | null { + if (token === "none") return 0 + if (token.endsWith("%")) { + const n = parseNumber(token.slice(0, -1)) + return n === null ? null : (n / 100) * scale + } + return parseNumber(token) +} + +function hslToLinear(h: number, s: number, l: number, a: number): LinearRgba { + const hue = (((h % 360) + 360) % 360) / 30 + const chroma = (1 - Math.abs(2 * l - 1)) * s + const x = chroma * (1 - Math.abs((hue % 2) - 1)) + let r = 0 + let g = 0 + let b = 0 + if (hue < 1) [r, g, b] = [chroma, x, 0] + else if (hue < 2) [r, g, b] = [x, chroma, 0] + else if (hue < 3) [r, g, b] = [0, chroma, x] + else if (hue < 4) [r, g, b] = [0, x, chroma] + else if (hue < 5) [r, g, b] = [x, 0, chroma] + else [r, g, b] = [chroma, 0, x] + const m = l - chroma / 2 + return { + r: toLinear(r + m), + g: toLinear(g + m), + b: toLinear(b + m), + a, + } +} + +function parseHsl(body: string): LinearRgba | null { + const args = splitArgs(body) + if (!args) return null + const h = parseHue(args.channels[0]) + const s = parsePercentOrUnit(args.channels[1], 1) + const l = parsePercentOrUnit(args.channels[2], 1) + const alpha = parseAlpha(args.alpha) + if (h === null || s === null || l === null || alpha === null) return null + return hslToLinear(h, clamp01(s), clamp01(l), alpha) +} + +/** OKLab -> linear sRGB (Björn Ottosson's reference matrices). */ +function oklabToLinear(L: number, a: number, b: number, alpha: number) { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b + const m_ = L - 0.1055613458 * a - 0.0638541728 * b + const s_ = L - 0.0894841775 * a - 1.291485548 * b + const l = l_ ** 3 + const m = m_ ** 3 + const s = s_ ** 3 + return { + r: clamp01(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + g: clamp01(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + b: clamp01(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + a: alpha, + } +} + +function parseOklch(body: string): LinearRgba | null { + const args = splitArgs(body) + if (!args) return null + const L = parsePercentOrUnit(args.channels[0], 1) + // The `%` reference for chroma is 0.4 per the spec. + const C = parsePercentOrUnit(args.channels[1], 0.4) + const H = parseHue(args.channels[2]) + const alpha = parseAlpha(args.alpha) + if (L === null || C === null || H === null || alpha === null) return null + const rad = (H * Math.PI) / 180 + return oklabToLinear( + clamp01(L), + Math.max(0, C) * Math.cos(rad), + Math.max(0, C) * Math.sin(rad), + alpha + ) +} + +function parseOklab(body: string): LinearRgba | null { + const args = splitArgs(body) + if (!args) return null + const L = parsePercentOrUnit(args.channels[0], 1) + // `%` reference for a / b is 0.4 per the spec. + const a = parsePercentOrUnit(args.channels[1], 0.4) + const b = parsePercentOrUnit(args.channels[2], 0.4) + const alpha = parseAlpha(args.alpha) + if (L === null || a === null || b === null || alpha === null) return null + return oklabToLinear(clamp01(L), a, b, alpha) +} + +/** + * Parse a literal CSS colour. Returns null for anything it does not + * understand, including `var()` (see {@link resolveCssColor}) and + * `color-mix()`. + */ +export function parseCssColor(value: string): LinearRgba | null { + const v = value.trim().toLowerCase() + if (!v) return null + if (v in KEYWORDS) return KEYWORDS[v] + if (v.startsWith("#")) return parseHex(v) + const fn = /^([a-z]+)\((.*)\)$/s.exec(v) + if (!fn) return null + const body = fn[2] + switch (fn[1]) { + case "rgb": + case "rgba": + return parseRgb(body) + case "hsl": + case "hsla": + return parseHsl(body) + case "oklch": + return parseOklch(body) + case "oklab": + return parseOklab(body) + default: + return null + } +} + +/** + * Resolve `var(--name)` chains (with an optional fallback) through `lookup` + * and parse the literal at the end. Cycles and unknown names resolve to null + * rather than throwing, so a test can report the token that failed. + */ +export function resolveCssColor( + value: string, + lookup: (name: string) => string | undefined, + depth = 0 +): LinearRgba | null { + const v = value.trim() + const ref = /^var\(\s*(--[a-zA-Z0-9-]+)\s*(?:,\s*(.*))?\)$/s.exec(v) + if (!ref) return parseCssColor(v) + if (depth > 16) return null + const next = lookup(ref[1].slice(2)) + if (next !== undefined) return resolveCssColor(next, lookup, depth + 1) + return ref[2] !== undefined + ? resolveCssColor(ref[2], lookup, depth + 1) + : null +} + +/** Source-over compositing in linear light; the backdrop is taken as opaque. */ +export function compositeOver( + top: LinearRgba, + backdrop: LinearRgba +): LinearRgba { + const a = clamp01(top.a) + return { + r: top.r * a + backdrop.r * (1 - a), + g: top.g * a + backdrop.g * (1 - a), + b: top.b * a + backdrop.b * (1 - a), + a: 1, + } +} + +/** WCAG 2 relative luminance of an opaque colour. */ +export function relativeLuminance(color: LinearRgba): number { + return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b +} + +/** + * WCAG 2 contrast ratio between text and its surface. A translucent text + * colour is composited over the surface first, and a translucent surface over + * `base` (the page background) so `transparent` bubbles are judged against + * what actually shows through. + */ +export function contrastRatio( + text: LinearRgba, + surface: LinearRgba, + base: LinearRgba = KEYWORDS.white +): number { + const bg = surface.a < 1 ? compositeOver(surface, base) : surface + const fg = text.a < 1 ? compositeOver(text, bg) : text + const l1 = relativeLuminance(fg) + const l2 = relativeLuminance(bg) + const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1] + return (hi + 0.05) / (lo + 0.05) +} diff --git a/src/lib/custom-style.test.ts b/src/lib/custom-style.test.ts index d05b73d26e..7b7fbeee21 100644 --- a/src/lib/custom-style.test.ts +++ b/src/lib/custom-style.test.ts @@ -3,8 +3,12 @@ import { ADVANCED_THEME_TOKENS, BASIC_THEME_TOKENS, CUSTOM_THEME_TOKENS, + LAYOUT_THEME_TOKENS, MAX_CUSTOM_CSS_BYTES, + SHADCN_THEME_TOKENS, + SURFACE_COLOR_TOKENS, applyTokenOverrides, + isColorToken, isValidTokenValue, parseStoredCustomTheme, parseThemeRegistryItem, @@ -18,12 +22,36 @@ describe("token whitelist", () => { it("covers exactly shadcn's 31 semantic colors plus radius", () => { // 与 https://ui.shadcn.com/docs/theming 的 Theme Tokens 表格对齐。多一个少一个 // 都意味着我们和 shadcn 的 cssVars 形状分叉了,粘贴互通就会有洞。 - expect(CUSTOM_THEME_TOKENS).toHaveLength(32) - expect(new Set(CUSTOM_THEME_TOKENS).size).toBe(32) - expect(CUSTOM_THEME_TOKENS).toContain("radius") + expect(SHADCN_THEME_TOKENS).toHaveLength(32) + expect(new Set(SHADCN_THEME_TOKENS).size).toBe(32) + expect(SHADCN_THEME_TOKENS).toContain("radius") expect(BASIC_THEME_TOKENS).toHaveLength(8) expect(ADVANCED_THEME_TOKENS).toHaveLength(24) }) + + it("appends codeg's surface and layout tokens after the shadcn set", () => { + // The shadcn slice stays a prefix, so a pasted shadcn theme and an + // exported one keep lining up key for key; everything codeg-specific + // comes after it. + expect(CUSTOM_THEME_TOKENS.slice(0, SHADCN_THEME_TOKENS.length)).toEqual([ + ...SHADCN_THEME_TOKENS, + ]) + expect(CUSTOM_THEME_TOKENS).toHaveLength( + SHADCN_THEME_TOKENS.length + + SURFACE_COLOR_TOKENS.length + + LAYOUT_THEME_TOKENS.length + ) + expect(new Set(CUSTOM_THEME_TOKENS).size).toBe(CUSTOM_THEME_TOKENS.length) + }) + + it("only offers a swatch for colour tokens", () => { + for (const token of SURFACE_COLOR_TOKENS) + expect(isColorToken(token)).toBe(true) + for (const token of LAYOUT_THEME_TOKENS) + expect(isColorToken(token)).toBe(false) + expect(isColorToken("radius")).toBe(false) + expect(isColorToken("primary")).toBe(true) + }) }) describe("isValidTokenValue", () => { diff --git a/src/lib/custom-style.ts b/src/lib/custom-style.ts index 5420d23b5f..ce66cf3912 100644 --- a/src/lib/custom-style.ts +++ b/src/lib/custom-style.ts @@ -67,22 +67,72 @@ export const ADVANCED_THEME_TOKENS = [ "sidebar-ring", ] as const -export const CUSTOM_THEME_TOKENS = [ +/** + * The 31 shadcn semantic colours plus `radius`: the slice of the map that is + * byte-compatible with shadcn's `cssVars`, so a pasted shadcn theme lands on + * exactly these keys. + */ +export const SHADCN_THEME_TOKENS = [ ...BASIC_THEME_TOKENS, ...ADVANCED_THEME_TOKENS, ] as const +/** + * codeg's own surfaces, routed through tokens so an appearance preset can + * restyle them. These get a swatch in the editor like the shadcn colours. The + * defaults live in globals.css `:root` and point back at the shadcn tokens + * (the user bubble is `--secondary`, the status bar is `--muted`, ...), so a + * theme that never mentions them renders exactly as it did before they + * existed. + */ +export const SURFACE_COLOR_TOKENS = [ + "bubble-user-bg", + "bubble-user-fg", + "bubble-user-border", + "bubble-assistant-bg", + "bubble-assistant-fg", + "bubble-assistant-border", + "composer-bg", + "composer-border", + "status-bar-bg", + "status-bar-fg", + "status-bar-border", +] as const + +/** + * Lengths and ratios rather than colours: no swatch, set through the preset + * controls. `spacing` is Tailwind's `--spacing` unit (every `p-*` / `gap-*` / + * `h-*` utility is a multiple of it), which is what makes one value a density + * knob for the whole interface. + */ +export const LAYOUT_THEME_TOKENS = [ + "spacing", + "chat-font-size", + "chat-line-height", + "bubble-user-radius", + "bubble-assistant-radius", + "bubble-assistant-padding", + "composer-radius", +] as const + +export const CUSTOM_THEME_TOKENS = [ + ...SHADCN_THEME_TOKENS, + ...SURFACE_COLOR_TOKENS, + ...LAYOUT_THEME_TOKENS, +] as const + export type CustomThemeToken = (typeof CUSTOM_THEME_TOKENS)[number] const TOKEN_SET = new Set(CUSTOM_THEME_TOKENS) +const LAYOUT_TOKEN_SET = new Set(LAYOUT_THEME_TOKENS) export function isCustomThemeToken(name: unknown): name is CustomThemeToken { return typeof name === "string" && TOKEN_SET.has(name) } -/** `radius` 之外的都是颜色,UI 用它决定渲染色板还是滑块。 */ +/** 颜色 token 渲染色板;`radius` 与布局类 token(长度 / 比例)走滑块或选择器。 */ export function isColorToken(token: string): boolean { - return token !== "radius" + return token !== "radius" && !LAYOUT_TOKEN_SET.has(token) } // ─── 取值校验 ─── diff --git a/src/lib/font-presets.ts b/src/lib/font-presets.ts index e013780825..1895ad6608 100644 --- a/src/lib/font-presets.ts +++ b/src/lib/font-presets.ts @@ -107,6 +107,13 @@ export const MONO_FONTS: readonly FontDef[] = FONTS.filter( export const DEFAULT_UI_FONT_ID = "inter" export const DEFAULT_EDITOR_FONT_ID = "system-mono" export const DEFAULT_TERMINAL_FONT_ID = "system-mono" +/** + * Code in messages (--font-mono). Must resolve to the `:root --font-mono` stack + * in globals.css, which is also Tailwind's default monospace stack: with no + * stored choice the inline script writes nothing and the CSS default paints + * code exactly as it did before the preference existed. + */ +export const DEFAULT_MONO_FONT_ID = "system-mono" /** 编辑器 / 终端基础字号(px)。最终字号 = base × zoom% / 100,与现有缩放叠加。 */ export const FONT_SIZES = [10, 11, 12, 13, 14, 15, 16, 18, 20] as const diff --git a/src/lib/theme-presets.test.ts b/src/lib/theme-presets.test.ts index 716ee5c835..5d292efaf0 100644 --- a/src/lib/theme-presets.test.ts +++ b/src/lib/theme-presets.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest" import { APPEARANCE_INIT_SCRIPT } from "./appearance-script" -import { DEFAULT_ZOOM_LEVEL, ZOOM_LEVELS, stepZoom } from "./theme-presets" +import { + DEFAULT_ZOOM_LEVEL, + THEME_COLORS, + ZOOM_LEVELS, + stepZoom, +} from "./theme-presets" describe("stepZoom", () => { it("walks the Settings rungs and stops at the ends", () => { @@ -28,4 +33,14 @@ describe("pre-paint zoom whitelist", () => { expect(listed).toEqual([...ZOOM_LEVELS]) }) + + // Same shape for the palette ids: a base palette a preset can name but the + // script does not know would paint neutral until hydration on every launch. + it("keeps VALID_COLORS in step with THEME_COLORS", () => { + const listed = JSON.parse( + /var VALID_COLORS = (\[[^\]]*\])/.exec(APPEARANCE_INIT_SCRIPT)![1] + ) + + expect(listed).toEqual([...THEME_COLORS]) + }) }) From 26732cd9d90d34396e0e07386a7a2d7f60c7d4a3 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:35:27 -0700 Subject: [PATCH 2/2] feat(appearance): a gallery of shared presets, checked by digest Settings > Appearance gets a Preset gallery: presets other people publish, listed in one static index.json in a public repository, with search, a card per listing drawn from the preset's own colours, Preview (apply, then revert to the exact previous look), Install, Update and Remove. Nothing is polled; the index loads when the section opens without a fresh saved copy, and on Refresh, and the section says when it was last refreshed. The index is fetched host-side through two stateless commands shaped like the wallpaper market's (`_core` + Axum handler), with a shape-based URL policy instead of a host allowlist because the source is a setting: https only, no credentials, no IP-literal or loopback host, every redirect hop re-checked. The index is capped at 256 KiB and a preset file at 32 KiB, both on the declared length and on the running total, and a preset file is refused unless its SHA-256 equals the digest its listing carries. The frontend validates the index strictly, keeps only same-origin listings, compares the digest again, runs every file through parseAppearancePreset before anything is applied or stored, and refuses a file whose id is not the listing's. Installed presets are plain preset documents in localStorage with their source URL and digest, so "update available" is a digest comparison and Remove deletes only the local copy. The provider gains a preview: startPresetPreview records what a preset replaces (fonts and code colours only when the preset sets them), applies it like applyPreset does, and endPresetPreview keeps it or restores the record; a window that goes away mid-preview restores it too, storage included. The default index URL points at a seed repository holding the seven bundled looks, standing in until a maintainer-owned one exists. --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/preset_gallery.rs | 56 ++ src-tauri/src/lib.rs | 6 +- src-tauri/src/preset_gallery.rs | 433 ++++++++ src-tauri/src/web/handlers/mod.rs | 1 + src-tauri/src/web/handlers/preset_gallery.rs | 25 + src-tauri/src/web/router.rs | 9 + src/components/appearance-provider.test.tsx | 173 ++++ src/components/appearance-provider.tsx | 120 +++ ...appearance-preset-gallery-section.test.tsx | 614 ++++++++++++ .../appearance-preset-gallery-section.tsx | 925 ++++++++++++++++++ .../settings/appearance-settings.tsx | 4 + src/hooks/use-appearance.ts | 6 + src/i18n/messages/ar.json | 52 + src/i18n/messages/de.json | 52 + src/i18n/messages/en.json | 52 + src/i18n/messages/es.json | 52 + src/i18n/messages/fr.json | 52 + src/i18n/messages/ja.json | 52 + src/i18n/messages/ko.json | 52 + src/i18n/messages/pt.json | 52 + src/i18n/messages/zh-CN.json | 52 + src/i18n/messages/zh-TW.json | 52 + src/lib/appearance-preset-gallery.test.ts | 616 ++++++++++++ src/lib/appearance-preset-gallery.ts | 901 +++++++++++++++++ src/lib/appearance-preset.ts | 10 + src/lib/appearance-script.ts | 12 + 27 files changed, 4431 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/commands/preset_gallery.rs create mode 100644 src-tauri/src/preset_gallery.rs create mode 100644 src-tauri/src/web/handlers/preset_gallery.rs create mode 100644 src/components/settings/appearance-preset-gallery-section.test.tsx create mode 100644 src/components/settings/appearance-preset-gallery-section.tsx create mode 100644 src/lib/appearance-preset-gallery.test.ts create mode 100644 src/lib/appearance-preset-gallery.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6b6e316fc7..735e3165ec 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -28,6 +28,7 @@ pub mod open_in; #[cfg(feature = "tauri-runtime")] pub mod notification; pub mod pet; +pub mod preset_gallery; pub mod project_boot; pub mod question; pub mod quick_messages; diff --git a/src-tauri/src/commands/preset_gallery.rs b/src-tauri/src/commands/preset_gallery.rs new file mode 100644 index 0000000000..f883382776 --- /dev/null +++ b/src-tauri/src/commands/preset_gallery.rs @@ -0,0 +1,56 @@ +//! Tauri + Axum command surface for the appearance preset gallery. +//! +//! Thin double-mode wrappers over `crate::preset_gallery`, which owns the URL +//! policy, the byte caps and the digest check. Stateless (no DB / `AppState`), +//! like the `background_market_*` trio in `commands::background`. + +use crate::app_error::AppCommandError; +use crate::preset_gallery::{self, GalleryDocument}; + +// ─── core ops ─────────────────────────────────────────────────────────── + +pub async fn preset_gallery_fetch_index_core( + url: String, +) -> Result { + preset_gallery::fetch_index(&url).await +} + +pub async fn preset_gallery_fetch_preset_core( + url: String, + sha256: String, +) -> Result { + preset_gallery::fetch_preset(&url, &sha256).await +} + +// ─── web-handler param structs ────────────────────────────────────────── + +/// Web-mode JSON bodies for the `preset_gallery_*` commands. The Tauri commands +/// take flat scalars (auto snake_case-translated on the way in); the Axum +/// handlers need named structs to deserialize the same camelCase payload. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PresetGalleryIndexParams { + pub url: String, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PresetGalleryPresetParams { + pub url: String, + pub sha256: String, +} + +// ─── tauri command wrappers ───────────────────────────────────────────── + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn preset_gallery_fetch_index(url: String) -> Result { + preset_gallery_fetch_index_core(url).await +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn preset_gallery_fetch_preset( + url: String, + sha256: String, +) -> Result { + preset_gallery_fetch_preset_core(url, sha256).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2b692404f5..dea96e2faf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,6 +37,7 @@ pub mod pet_state_mapper; pub mod pets; #[cfg(feature = "tauri-runtime")] pub mod preferences; +pub mod preset_gallery; pub mod process; pub mod supervise; mod terminal; @@ -71,7 +72,8 @@ mod tauri_app { experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, folder_links, office_tools as office_tools_commands, open_in, folders, logging as logging_commands, mcp as mcp_commands, - model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, + model_provider as model_provider_commands, notification, pet as pet_commands, + preset_gallery as preset_gallery_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, remote_proxy as remote_proxy_commands, remote_workspace as remote_workspace_commands, science as science_commands, @@ -1366,6 +1368,8 @@ mod tauri_app { background_commands::background_market_search, background_commands::background_market_asset, background_commands::background_market_download, + preset_gallery_commands::preset_gallery_fetch_index, + preset_gallery_commands::preset_gallery_fetch_preset, app_update_commands::app_update_state, app_update_commands::perform_app_update, app_update_commands::restart_app, diff --git a/src-tauri/src/preset_gallery.rs b/src-tauri/src/preset_gallery.rs new file mode 100644 index 0000000000..d10ce479db --- /dev/null +++ b/src-tauri/src/preset_gallery.rs @@ -0,0 +1,433 @@ +//! Appearance preset gallery: a static JSON index in a public repository plus +//! the preset files it lists, fetched host-side the way the wallpaper market +//! (`crate::backgrounds::marketplace`) and the pet marketplace proxy their +//! listings. Two operations: +//! +//! - `fetch_index(url)` — the index document, capped at [`MAX_INDEX_BYTES`]. +//! - `fetch_preset(url, sha256)` — one preset file, capped at +//! [`MAX_PRESET_BYTES`] and refused unless the SHA-256 of the bytes equals the +//! digest the index listed for it, so a file that changed after it was +//! indexed, or a host serving something else under that path, never reaches +//! the frontend. +//! +//! Unlike the two marketplaces there is no fixed host: the index URL is a user +//! setting (the default points at a public GitHub repository), so the policy is +//! shape-based rather than an allowlist. https only, no embedded credentials, no +//! IP-literal or loopback hosts (in server mode this proxy runs on a machine +//! whose private network the browser must not be able to read through it), and +//! every redirect hop is held to the same rule. The bytes come back as text for +//! the frontend's strict validator (`src/lib/appearance-preset.ts`); nothing +//! here interprets them, and no error echoes a response body. + +use std::collections::BTreeMap; +use std::sync::LazyLock; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::app_error::AppCommandError; + +/// Index cap. A few hundred listings with swatches fit in a fraction of this. +pub const MAX_INDEX_BYTES: u64 = 256 * 1024; +/// Preset file cap. Deliberately equals `MAX_PRESET_BYTES` in +/// `src/lib/appearance-preset.ts`, so the transport cap and the validator's own +/// size check agree on one ceiling. +pub const MAX_PRESET_BYTES: u64 = 32 * 1024; +const GALLERY_USER_AGENT: &str = "codeg-preset-gallery/1.0"; +/// Deadline for one fetch; both documents are at most a few hundred KiB. +const FETCH_TIMEOUT: Duration = Duration::from_secs(20); +/// Redirect hops allowed, each re-checked against the URL policy. +const MAX_REDIRECT_HOPS: usize = 5; + +static GALLERY_HTTP_CLIENT: LazyLock> = LazyLock::new(|| { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(8)) + // Per-read: a stalled transfer fails fast, a slow one may finish + // inside `FETCH_TIMEOUT`. + .read_timeout(Duration::from_secs(15)) + .redirect(gallery_redirect_policy()) + .user_agent(GALLERY_USER_AGENT) + .build() + .map_err(|e| format!("failed to initialize preset gallery HTTP client: {e}")) +}); + +fn client() -> Result<&'static reqwest::Client, AppCommandError> { + GALLERY_HTTP_CLIENT + .as_ref() + .map_err(|err| AppCommandError::network(err.clone())) +} + +// ─── URL policy ────────────────────────────────────────────────────────── + +/// Whether a host is one this proxy must never be steered onto: an IP literal +/// (v4, or a bracketed v6) or a loopback name. The URL parser has already +/// normalized shorthand forms such as `0x7f.1`, so the address parse sees them. +pub(crate) fn is_ip_or_local_host(host: &str) -> bool { + let bare = host.trim_start_matches('[').trim_end_matches(']'); + bare.parse::().is_ok() + || host.eq_ignore_ascii_case("localhost") + || host.to_ascii_lowercase().ends_with(".localhost") +} + +/// Whether a URL, first hop or redirect target, is inside the policy. +/// +/// Validating only the URL we dial would cover the first hop; under the default +/// redirect policy the client would then follow a `Location` anywhere, and since +/// the response body is handed back to the caller, a redirect onto a private +/// address would turn this proxy into a reader of whatever the host can reach. +pub(crate) fn is_allowed_gallery_hop(url: &reqwest::Url) -> bool { + url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && url.host_str().is_some_and(|host| !is_ip_or_local_host(host)) +} + +fn gallery_redirect_policy() -> reqwest::redirect::Policy { + reqwest::redirect::Policy::custom(|attempt| { + if !is_allowed_gallery_hop(attempt.url()) { + let refused = format!("refused a redirect to {}", attempt.url()); + return attempt.error(refused); + } + if attempt.previous().len() > MAX_REDIRECT_HOPS { + return attempt.error(format!("more than {MAX_REDIRECT_HOPS} redirects")); + } + attempt.follow() + }) +} + +/// Parse a gallery URL under the policy, with a reason for each refusal. +pub(crate) fn parse_gallery_https_url(raw: &str) -> Result { + let url = reqwest::Url::parse(raw.trim()) + .map_err(|_| AppCommandError::invalid_input("Gallery URL must be a valid https URL."))?; + if url.scheme() != "https" { + return Err(AppCommandError::invalid_input("Gallery URL must use https.")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(AppCommandError::invalid_input( + "Gallery URL must not embed credentials.", + )); + } + let host = url + .host_str() + .ok_or_else(|| AppCommandError::invalid_input("Gallery URL must name a host."))?; + if is_ip_or_local_host(host) { + return Err(AppCommandError::invalid_input( + "Gallery URL must name a public host, not an IP address or localhost.", + )); + } + Ok(url) +} + +// ─── Wire type ─────────────────────────────────────────────────────────── + +/// A fetched document: the text for the frontend's validator, the SHA-256 of +/// the bytes it was decoded from, and their count. camelCase on the wire, +/// mirrored by `GalleryDocument` in `src/lib/appearance-preset-gallery.ts`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GalleryDocument { + pub text: String, + pub sha256: String, + pub bytes: u64, +} + +// ─── Digest ────────────────────────────────────────────────────────────── + +/// Lowercase hex SHA-256 of `bytes`. +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +/// A listed digest normalized to lowercase hex, or a refusal: 64 hex digits and +/// nothing else, checked before any network is touched. +pub(crate) fn normalize_sha256(listed: &str) -> Result { + let lower = listed.trim().to_ascii_lowercase(); + if lower.len() != 64 || !lower.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(AppCommandError::invalid_input( + "Listed digest must be 64 hex characters of SHA-256.", + )); + } + Ok(lower) +} + +/// The digest of `bytes` when it equals the listed one; otherwise a refusal that +/// names both digests and nothing of the content. +pub(crate) fn verify_sha256(bytes: &[u8], listed: &str) -> Result { + let expected = normalize_sha256(listed)?; + let actual = sha256_hex(bytes); + if actual != expected { + return Err(AppCommandError::configuration_invalid(format!( + "Preset file does not match its listed digest (listed {expected}, got {actual})." + )) + .with_i18n("presetGallery.errors.hashMismatch", BTreeMap::new())); + } + Ok(actual) +} + +// ─── Caps ──────────────────────────────────────────────────────────────── + +fn too_large(cap: u64, what: &str) -> AppCommandError { + let cap_kib = cap / 1024; + AppCommandError::network(format!("{what} exceeds the {cap_kib} KiB cap.")) + .with_i18n( + "presetGallery.errors.tooLarge", + BTreeMap::from([("limit".to_string(), cap_kib.to_string())]), + ) +} + +/// Refuse a declared Content-Length past the cap before reading anything. +pub(crate) fn check_declared_length( + declared: Option, + cap: u64, + what: &str, +) -> Result<(), AppCommandError> { + match declared { + Some(len) if len > cap => Err(too_large(cap, what)), + _ => Ok(()), + } +} + +/// Append a chunk unless the running total would pass the cap, so a lying +/// Content-Length or a chunked stream cannot balloon memory. +pub(crate) fn push_capped( + buf: &mut Vec, + chunk: &[u8], + cap: u64, + what: &str, +) -> Result<(), AppCommandError> { + if buf.len() as u64 + chunk.len() as u64 > cap { + return Err(too_large(cap, what)); + } + buf.extend_from_slice(chunk); + Ok(()) +} + +async fn read_capped( + mut resp: reqwest::Response, + cap: u64, + what: &str, +) -> Result, AppCommandError> { + check_declared_length(resp.content_length(), cap, what)?; + let mut buf: Vec = Vec::new(); + while let Some(chunk) = resp + .chunk() + .await + .map_err(|e| AppCommandError::network(format!("{what} failed mid-transfer: {e}")))? + { + push_capped(&mut buf, &chunk, cap, what)?; + } + Ok(buf) +} + +// ─── Fetch ─────────────────────────────────────────────────────────────── + +/// GET an already policy-checked URL with a byte cap and a deadline. A non-2xx +/// status is a refusal that names the status and nothing else. +async fn fetch_capped( + url: &reqwest::Url, + cap: u64, + what: &str, +) -> Result, AppCommandError> { + let resp = client()? + .get(url.clone()) + .timeout(FETCH_TIMEOUT) + .send() + .await + .map_err(|e| AppCommandError::network(format!("{what} fetch failed: {e}")))?; + if !resp.status().is_success() { + return Err(AppCommandError::network(format!( + "{what} returned HTTP {}", + resp.status() + ))); + } + read_capped(resp, cap, what).await +} + +fn document_from_bytes(bytes: Vec, what: &str) -> Result { + let len = bytes.len() as u64; + let sha256 = sha256_hex(&bytes); + let text = String::from_utf8(bytes).map_err(|_| { + AppCommandError::configuration_invalid(format!("{what} is not valid UTF-8.")) + })?; + Ok(GalleryDocument { + text, + sha256, + bytes: len, + }) +} + +/// The gallery index at `url`, as text plus its digest. +pub async fn fetch_index(url: &str) -> Result { + let url = parse_gallery_https_url(url)?; + let bytes = fetch_capped(&url, MAX_INDEX_BYTES, "Preset gallery index").await?; + document_from_bytes(bytes, "Preset gallery index") +} + +/// One preset file, only if its bytes hash to `listed_sha256`. +pub async fn fetch_preset( + url: &str, + listed_sha256: &str, +) -> Result { + // Both refusals happen before any network is touched. + let expected = normalize_sha256(listed_sha256)?; + let url = parse_gallery_https_url(url)?; + let bytes = fetch_capped(&url, MAX_PRESET_BYTES, "Preset file").await?; + verify_sha256(&bytes, &expected)?; + document_from_bytes(bytes, "Preset file") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_policy_accepts_public_https_only() { + assert!( + parse_gallery_https_url("https://raw.githubusercontent.com/o/r/main/index.json") + .is_ok() + ); + assert!(parse_gallery_https_url(" https://example.com/index.json ").is_ok()); + assert!(parse_gallery_https_url("http://example.com/index.json").is_err()); + assert!(parse_gallery_https_url("file:///etc/passwd").is_err()); + assert!(parse_gallery_https_url("https://user:pw@example.com/index.json").is_err()); + assert!(parse_gallery_https_url("https://127.0.0.1/index.json").is_err()); + assert!(parse_gallery_https_url("https://[::1]/index.json").is_err()); + assert!(parse_gallery_https_url("https://localhost/index.json").is_err()); + assert!(parse_gallery_https_url("https://gallery.localhost/index.json").is_err()); + assert!(parse_gallery_https_url("https://169.254.169.254/latest/meta-data/").is_err()); + assert!(parse_gallery_https_url("not a url").is_err()); + } + + #[test] + fn redirect_hops_are_held_to_the_same_policy() { + let allowed = |raw: &str| is_allowed_gallery_hop(&reqwest::Url::parse(raw).unwrap()); + assert!(allowed("https://cdn.example.com/presets/x.json")); + assert!(!allowed("http://cdn.example.com/presets/x.json")); + assert!(!allowed("https://10.0.0.8/x.json")); + assert!(!allowed("https://localhost:8443/x.json")); + assert!(!allowed("https://u:p@cdn.example.com/x.json")); + } + + #[test] + fn sha256_matches_known_vectors() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn listed_digest_must_be_well_formed_and_match() { + let body = br#"{"schemaVersion":1}"#; + let digest = sha256_hex(body); + assert_eq!(digest.len(), 64); + assert_eq!(verify_sha256(body, &digest).unwrap(), digest); + // Case and surrounding whitespace in the listing are tolerated. + let shouted = format!(" {} ", digest.to_ascii_uppercase()); + assert_eq!(verify_sha256(body, &shouted).unwrap(), digest); + // Any other digest, or a malformed one, is a refusal. + assert!(verify_sha256(body, &sha256_hex(b"something else")).is_err()); + assert!(normalize_sha256("abc").is_err()); + assert!(normalize_sha256(&"g".repeat(64)).is_err()); + assert!(normalize_sha256(&"a".repeat(63)).is_err()); + } + + #[test] + fn caps_apply_to_declared_length_and_running_total() { + assert!(check_declared_length(None, 8, "x").is_ok()); + assert!(check_declared_length(Some(8), 8, "x").is_ok()); + assert!(check_declared_length(Some(9), 8, "x").is_err()); + + let mut buf = Vec::new(); + assert!(push_capped(&mut buf, &[0u8; 5], 8, "x").is_ok()); + assert!(push_capped(&mut buf, &[0u8; 3], 8, "x").is_ok()); + assert_eq!(buf.len(), 8); + assert!(push_capped(&mut buf, &[0u8; 1], 8, "x").is_err()); + assert_eq!(buf.len(), 8, "a refused chunk is not appended"); + } + + #[tokio::test] + async fn refusals_happen_before_any_network() { + // Nothing listens on these; a refusal here is the policy, not a timeout. + assert!(fetch_index("http://127.0.0.1:9/index.json").await.is_err()); + assert!(fetch_preset("https://127.0.0.1:9/x.json", &"a".repeat(64)) + .await + .is_err()); + assert!(fetch_preset("https://example.com/x.json", "not-a-digest") + .await + .is_err()); + } + + #[tokio::test] + async fn fetch_enforces_caps_and_reports_the_digest() { + use axum::routing::get; + + const SMALL: &[u8] = + br#"{"schemaVersion":1,"id":"x","name":"X","base":"neutral","colors":{}}"#; + + let app = axum::Router::new() + // Declares an oversized length up front: rejected without reading + // the body at all. + .route( + "/declared.json", + get(|| async { vec![b' '; (MAX_PRESET_BYTES * 2) as usize] }), + ) + // Chunked with no Content-Length, so only the running total can + // stop it; the case a length check alone would miss. + .route( + "/chunked.json", + get(|| async { + let chunks = futures_util::stream::iter((0..8).map(|_| { + Ok::<_, std::io::Error>(vec![b' '; (MAX_PRESET_BYTES / 4) as usize]) + })); + axum::response::Response::builder() + .body(axum::body::Body::from_stream(chunks)) + .unwrap() + }), + ) + .route("/small.json", get(|| async { SMALL })) + .route( + "/missing.json", + get(|| async { axum::http::StatusCode::NOT_FOUND }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + let url = |path: &str| reqwest::Url::parse(&format!("http://{addr}{path}")).unwrap(); + + assert!(fetch_capped(&url("/declared.json"), MAX_PRESET_BYTES, "x") + .await + .is_err()); + assert!(fetch_capped(&url("/chunked.json"), MAX_PRESET_BYTES, "x") + .await + .is_err()); + assert!(fetch_capped(&url("/missing.json"), MAX_PRESET_BYTES, "x") + .await + .is_err()); + + let bytes = fetch_capped(&url("/small.json"), MAX_PRESET_BYTES, "x") + .await + .expect("within the cap"); + assert_eq!(bytes, SMALL); + let doc = document_from_bytes(bytes.clone(), "x").unwrap(); + assert_eq!(doc.text, String::from_utf8_lossy(SMALL)); + assert_eq!(doc.sha256, sha256_hex(SMALL)); + assert_eq!(doc.bytes, SMALL.len() as u64); + // The pinned digest is what lets the file through; any other refuses it. + assert!(verify_sha256(&bytes, &doc.sha256).is_ok()); + assert!(verify_sha256(&bytes, &sha256_hex(b"tampered")).is_err()); + + server.abort(); + } +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 6a38e353f5..21446d434e 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -26,6 +26,7 @@ pub mod model_provider; pub mod office_tools; pub mod office_watch_proxy; pub mod pet; +pub mod preset_gallery; pub mod project_boot; pub mod question; pub mod quick_messages; diff --git a/src-tauri/src/web/handlers/preset_gallery.rs b/src-tauri/src/web/handlers/preset_gallery.rs new file mode 100644 index 0000000000..94daddc1c2 --- /dev/null +++ b/src-tauri/src/web/handlers/preset_gallery.rs @@ -0,0 +1,25 @@ +//! Axum handlers mirroring `commands::preset_gallery`. Both are stateless +//! (proxied fetch only), so neither takes `Extension>`. + +use axum::Json; + +use crate::app_error::AppCommandError; +use crate::commands::preset_gallery as preset_gallery_commands; +use crate::commands::preset_gallery::{PresetGalleryIndexParams, PresetGalleryPresetParams}; +use crate::preset_gallery::GalleryDocument; + +pub async fn preset_gallery_fetch_index( + Json(params): Json, +) -> Result, AppCommandError> { + preset_gallery_commands::preset_gallery_fetch_index_core(params.url) + .await + .map(Json) +} + +pub async fn preset_gallery_fetch_preset( + Json(params): Json, +) -> Result, AppCommandError> { + preset_gallery_commands::preset_gallery_fetch_preset_core(params.url, params.sha256) + .await + .map(Json) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index aaed65db10..d87177ccf9 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1602,6 +1602,15 @@ pub fn build_router( "/background_market_download", post(handlers::background::background_market_download), ) + // ─── Preset gallery ─── + .route( + "/preset_gallery_fetch_index", + post(handlers::preset_gallery::preset_gallery_fetch_index), + ) + .route( + "/preset_gallery_fetch_preset", + post(handlers::preset_gallery::preset_gallery_fetch_preset), + ) // ─── Pet ─── .route("/pet_list", post(handlers::pet::pet_list)) .route("/pet_get", post(handlers::pet::pet_get)) diff --git a/src/components/appearance-provider.test.tsx b/src/components/appearance-provider.test.tsx index 83f1551be1..d1b76abd7d 100644 --- a/src/components/appearance-provider.test.tsx +++ b/src/components/appearance-provider.test.tsx @@ -477,3 +477,176 @@ describe("window zoom keys", () => { expect(currentZoomPx()).toBe("16px") }) }) + +describe("preset preview", () => { + const warmTerminal = BUNDLED_PRESET_BY_ID["warm-terminal"] + const ink = BUNDLED_PRESET_BY_ID["ink"] + const stock = BUNDLED_PRESET_BY_ID["default"] + + function PreviewProbe() { + const { + applyPreset, + appliedPreset, + presetPreview, + startPresetPreview, + endPresetPreview, + codeTheme, + } = useAppearancePresets() + const { monoFont } = useMonoFont() + return ( + <> + + + + + + {presetPreview?.id ?? "none"} + {appliedPreset?.id ?? "none"} + + {codeTheme.light}/{codeTheme.dark} + + {monoFont.id} + + ) + } + + function rootVar(name: string) { + return document.documentElement.style.getPropertyValue(name) + } + + function storageSnapshot() { + return { + theme: localStorage.getItem(STORAGE_KEY_THEME_COLOR), + custom: localStorage.getItem(STORAGE_KEY_CUSTOM_THEME), + enabled: localStorage.getItem(STORAGE_KEY_CUSTOM_THEME_ENABLED), + code: localStorage.getItem(STORAGE_KEY_CODE_THEME), + mono: localStorage.getItem(STORAGE_KEY_MONO_FONT), + ui: localStorage.getItem(STORAGE_KEY_UI_FONT), + preset: localStorage.getItem(STORAGE_KEY_APPEARANCE_PRESET), + } + } + + beforeEach(() => { + localStorage.clear() + document.documentElement.removeAttribute("data-theme") + }) + + it("restores the look from before the preview exactly, fonts and code colours included", () => { + render( + + + + ) + fireEvent.click(screen.getByText("apply-ink")) + act(() => { + vi.advanceTimersByTime(500) + }) + const inkPlan = presetToApplication(ink) + const before = storageSnapshot() + + fireEvent.click(screen.getByText("preview-warm")) + expect(screen.getByTestId("previewing").textContent).toBe("warm-terminal") + expect(screen.getByTestId("applied").textContent).toBe("warm-terminal") + expect(document.documentElement.getAttribute("data-theme")).toBe("stone") + expect(screen.getByTestId("mono").textContent).toBe("jetbrains-mono") + expect(screen.getByTestId("code").textContent).toBe( + "vitesse-light/vitesse-dark" + ) + // Let the preview's custom theme reach storage, so the revert has to + // undo storage as well as the DOM. + act(() => { + vi.advanceTimersByTime(500) + }) + expect(JSON.parse(localStorage.getItem(STORAGE_KEY_CUSTOM_THEME)!)).toEqual( + presetToApplication(warmTerminal).customTheme + ) + + fireEvent.click(screen.getByText("revert")) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(screen.getByTestId("previewing").textContent).toBe("none") + expect(screen.getByTestId("applied").textContent).toBe("ink") + expect(document.documentElement.getAttribute("data-theme")).toBe( + inkPlan.themeColor + ) + expect(rootVar("--primary")).toBe(inkPlan.customTheme.light.primary) + expect(screen.getByTestId("code").textContent).toBe( + `${ink.code!.light}/${ink.code!.dark}` + ) + expect(screen.getByTestId("mono").textContent).toBe( + inkPlan.monoFont ?? "jetbrains-mono" + ) + expect(storageSnapshot()).toEqual(before) + }) + + it("keeps a preview as the applied look", () => { + render( + + + + ) + fireEvent.click(screen.getByText("preview-warm")) + fireEvent.click(screen.getByText("keep")) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(screen.getByTestId("previewing").textContent).toBe("none") + expect(screen.getByTestId("applied").textContent).toBe("warm-terminal") + expect(document.documentElement.getAttribute("data-theme")).toBe("stone") + expect( + JSON.parse(localStorage.getItem(STORAGE_KEY_APPEARANCE_PRESET)!).id + ).toBe("warm-terminal") + // A revert after keeping has nothing to revert to. + fireEvent.click(screen.getByText("revert")) + expect(screen.getByTestId("applied").textContent).toBe("warm-terminal") + }) + + it("does not write fonts back for a preset that never touched them", () => { + render( + + + + ) + expect(localStorage.getItem(STORAGE_KEY_MONO_FONT)).toBeNull() + // The stock preset sets code colours but no fonts. + fireEvent.click(screen.getByText("preview-stock")) + fireEvent.click(screen.getByText("revert")) + expect(localStorage.getItem(STORAGE_KEY_MONO_FONT)).toBeNull() + expect(localStorage.getItem(STORAGE_KEY_UI_FONT)).toBeNull() + expect(localStorage.getItem(STORAGE_KEY_APPEARANCE_PRESET)).toBeNull() + expect(screen.getByTestId("applied").textContent).toBe("none") + }) + + it("a window that goes away mid-preview restores the look, storage included", () => { + render( + + + + ) + fireEvent.click(screen.getByText("apply-ink")) + act(() => { + vi.advanceTimersByTime(500) + }) + const before = storageSnapshot() + + fireEvent.click(screen.getByText("preview-warm")) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(localStorage.getItem(STORAGE_KEY_THEME_COLOR)).toBe("stone") + + // No timers advance after this: what is in storage is what the next + // window will read. + act(() => { + window.dispatchEvent(new Event("pagehide")) + }) + expect(storageSnapshot()).toEqual(before) + expect(document.documentElement.getAttribute("data-theme")).toBe( + presetToApplication(ink).themeColor + ) + }) +}) diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index e544745052..c1a4319587 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -129,6 +129,22 @@ function syncAppearanceMode(mode: string) { export type FontSelection = { id: string; custom: string } +/** + * What a preset preview replaces. Fonts and code colours are recorded only + * when the previewed preset sets them, since applyPreset leaves them alone + * otherwise and restoring an untouched setting would write it to storage for + * the first time. + */ +type AppearanceRestorePoint = { + themeColor: ThemeColor + customTheme: CustomTheme + customThemeEnabled: boolean + appliedPreset: AppearancePreset | null + uiFont?: FontSelection + monoFont?: FontSelection + codeTheme?: CodeThemePair +} + type AppearanceContextValue = { themeColor: ThemeColor setThemeColor: (color: ThemeColor) => void @@ -177,6 +193,16 @@ type AppearanceContextValue = { * them. The light / dark mode is a window setting applied by the caller. */ applyPreset: (preset: AppearancePreset) => void + /** + * The preset being previewed from the gallery, or null. A preview applies a + * preset exactly as applyPreset does after recording what it replaces; + * ending it either keeps the result or restores that record, so the look + * from before the preview comes back exactly, fonts and code colours + * included. A window that goes away mid-preview restores it too. + */ + presetPreview: AppearancePreset | null + startPresetPreview: (preset: AppearancePreset) => void + endPresetPreview: (keep: boolean) => void /** Write one token to BOTH modes (layout tokens are mode-independent); null clears it. */ setSharedThemeToken: (token: CustomThemeToken, value: string | null) => void /** Workspace 背景图片总开关。关闭时不加载图片、不触发任何表面半透明。 */ @@ -484,6 +510,11 @@ export function AppearanceProvider({ useState(() => parseStoredAppearancePreset(readStored(STORAGE_KEY_APPEARANCE_PRESET)) ) + const [presetPreview, setPresetPreviewState] = + useState(null) + // The look a preview replaced. A ref, not state: it is read on the way out + // of the window too, where a state update would never be committed. + const presetPreviewRestoreRef = useRef(null) // Workspace 背景图片配置(图片 URL 异步加载,初始 null)。 const [workspaceBgEnabled, setWorkspaceBgEnabledState] = useState( @@ -752,6 +783,92 @@ export function AppearanceProvider({ [setThemeColor, setCustomThemeEnabled, setUiFont, setMonoFont, setCodeTheme] ) + // The inverse of applyPreset for one recorded point. Every setter here + // persists on its own except the custom theme, which is debounced; `flush` + // writes it immediately for the pagehide path, where no effect will run. + const restoreAppearance = useCallback( + (point: AppearanceRestorePoint, flush: boolean) => { + setThemeColor(point.themeColor) + setCustomThemeState(point.customTheme) + if (flush) { + persist(STORAGE_KEY_CUSTOM_THEME, JSON.stringify(point.customTheme)) + } + setCustomThemeEnabled(point.customThemeEnabled) + if (point.uiFont) setUiFont(point.uiFont.id, point.uiFont.custom) + if (point.monoFont) setMonoFont(point.monoFont.id, point.monoFont.custom) + if (point.codeTheme) setCodeTheme(point.codeTheme) + setAppliedPresetState(point.appliedPreset) + if (point.appliedPreset) { + persist( + STORAGE_KEY_APPEARANCE_PRESET, + serializeAppearancePreset(point.appliedPreset) + ) + } else { + try { + localStorage.removeItem(STORAGE_KEY_APPEARANCE_PRESET) + } catch { + // localStorage unavailable + } + } + }, + [setThemeColor, setCustomThemeEnabled, setUiFont, setMonoFont, setCodeTheme] + ) + + const startPresetPreview = useCallback( + (preset: AppearancePreset) => { + // A second preview keeps the first record, so what comes back is the + // look from before any preview. A setting the first preset left alone + // is still the original now, so it can be recorded late. + const point = presetPreviewRestoreRef.current ?? { + themeColor, + customTheme, + customThemeEnabled, + appliedPreset, + } + if (!point.uiFont && preset.typography?.ui) point.uiFont = uiFont + if (!point.monoFont && preset.typography?.mono) point.monoFont = monoFont + if (!point.codeTheme && preset.code) point.codeTheme = codeTheme + presetPreviewRestoreRef.current = point + setPresetPreviewState(preset) + applyPreset(preset) + }, + [ + themeColor, + customTheme, + customThemeEnabled, + appliedPreset, + uiFont, + monoFont, + codeTheme, + applyPreset, + ] + ) + + const endPresetPreview = useCallback( + (keep: boolean) => { + const point = presetPreviewRestoreRef.current + presetPreviewRestoreRef.current = null + setPresetPreviewState(null) + if (!keep && point) restoreAppearance(point, false) + }, + [restoreAppearance] + ) + + // A window that goes away mid-preview (closed, reloaded) must not leave the + // preview behind as if it had been kept. Registered after the debounced + // persisters' own pagehide flush, so this write is the last word. + useEffect(() => { + if (!presetPreview) return + const revert = () => { + const point = presetPreviewRestoreRef.current + if (!point) return + presetPreviewRestoreRef.current = null + restoreAppearance(point, true) + } + window.addEventListener("pagehide", revert) + return () => window.removeEventListener("pagehide", revert) + }, [presetPreview, restoreAppearance]) + // 传进来的必须是 sanitizeCustomCss 处理过的文本:存的即是注入的,预水合脚本 // 才能原样使用而不必在 inline 脚本里重跑一遍 CSSOM 校验。 const setCustomCss = useCallback((css: string) => { @@ -1230,6 +1347,9 @@ export function AppearanceProvider({ setCodeTheme, appliedPreset, applyPreset, + presetPreview, + startPresetPreview, + endPresetPreview, setSharedThemeToken, showWelcomeQuickActions, setShowWelcomeQuickActions, diff --git a/src/components/settings/appearance-preset-gallery-section.test.tsx b/src/components/settings/appearance-preset-gallery-section.test.tsx new file mode 100644 index 0000000000..4151bbfc02 --- /dev/null +++ b/src/components/settings/appearance-preset-gallery-section.test.tsx @@ -0,0 +1,614 @@ +import { createHash } from "node:crypto" +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const toastMock = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})) +vi.mock("sonner", () => ({ toast: toastMock })) + +const themeMock = vi.hoisted(() => ({ + theme: "light" as string | undefined, + setTheme: vi.fn(), +})) +vi.mock("next-themes", () => ({ + useTheme: () => ({ theme: themeMock.theme, setTheme: themeMock.setTheme }), +})) + +// Only the transport is stubbed. The index validator, the preset validator, +// the digest comparison and the storage all run for real. +const callMock = vi.hoisted(() => vi.fn()) +vi.mock("@/lib/transport", () => ({ + getTransport: () => ({ call: callMock }), +})) + +import { AppearancePresetGallerySection } from "./appearance-preset-gallery-section" +import { AppearanceProvider } from "@/components/appearance-provider" +import enMessages from "@/i18n/messages/en.json" +import { + serializeAppearancePreset, + type AppearancePreset, +} from "@/lib/appearance-preset" +import { + DEFAULT_PRESET_GALLERY_INDEX_URL, + MAX_GALLERY_INDEX_BYTES, + type GalleryDocument, +} from "@/lib/appearance-preset-gallery" +import { + STORAGE_KEY_APPEARANCE_PRESET, + STORAGE_KEY_CUSTOM_THEME, + STORAGE_KEY_PRESET_GALLERY_CACHE, + STORAGE_KEY_PRESET_GALLERY_INDEX_URL, + STORAGE_KEY_PRESET_GALLERY_INSTALLED, + STORAGE_KEY_THEME_COLOR, +} from "@/lib/appearance-script" + +const INDEX_URL = DEFAULT_PRESET_GALLERY_INDEX_URL + +function sha256(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex") +} + +function doc(text: string): GalleryDocument { + return { text, sha256: sha256(text), bytes: Buffer.byteLength(text) } +} + +const sharedLook: AppearancePreset = { + schemaVersion: 1, + id: "shared-look", + name: "Shared look", + description: "Came in from the gallery.", + author: "someone", + base: "rose", + mode: "dark", + colors: { light: { primary: "#336699" }, dark: { primary: "#99ccff" } }, + density: { spacing: 0.92 }, +} +const sharedText = serializeAppearancePreset(sharedLook) +const sharedUrl = new URL("presets/shared-look.codeg-preset.json", INDEX_URL) + .href + +const quietInk: AppearancePreset = { + schemaVersion: 1, + id: "quiet-ink", + name: "Quiet ink", + author: "someone else", + base: "zinc", + colors: { light: { primary: "#222222" } }, +} +const quietText = serializeAppearancePreset(quietInk) +const quietUrl = new URL("presets/quiet-ink.codeg-preset.json", INDEX_URL).href + +function listing( + preset: AppearancePreset, + text: string, + overrides: Record = {} +) { + return { + id: preset.id, + name: preset.name, + description: preset.description, + author: preset.author, + base: preset.base, + mode: preset.mode, + url: `presets/${preset.id}.codeg-preset.json`, + sha256: sha256(text), + swatches: { light: ["#ffffff", "#336699"], dark: ["#000000", "#99ccff"] }, + ...overrides, + } +} + +const twoListings = JSON.stringify({ + schemaVersion: 1, + presets: [ + listing(sharedLook, sharedText), + listing(quietInk, quietText, { swatches: undefined }), + ], +}) + +/** + * A fake backend with the real backend's contract: it hands back the index, + * and a preset file only when its bytes hash to the digest it was asked for. + */ +function serveGallery( + index: string, + files: Record = { + [sharedUrl]: sharedText, + [quietUrl]: quietText, + } +) { + callMock.mockImplementation( + (command: string, args: { url: string; sha256?: string }) => { + if (command === "preset_gallery_fetch_index") { + return Promise.resolve(doc(index)) + } + if (command === "preset_gallery_fetch_preset") { + const text = files[args.url] + if (text === undefined) { + return Promise.reject(new Error("Preset file returned HTTP 404")) + } + if (sha256(text) !== args.sha256) { + return Promise.reject({ + code: "configuration_invalid", + message: "Preset file does not match its listed digest.", + i18n_key: "presetGallery.errors.hashMismatch", + }) + } + return Promise.resolve(doc(text)) + } + return Promise.reject(new Error(`unexpected command ${command}`)) + } + ) +} + +function renderSection() { + return render( + + + + + + ) +} + +function rootVar(name: string) { + return document.documentElement.style.getPropertyValue(name) +} + +function installedList(): unknown[] { + return JSON.parse( + localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_INSTALLED) ?? "[]" + ) +} + +function seedInstalled(preset: AppearancePreset, text: string, url: string) { + localStorage.setItem( + STORAGE_KEY_PRESET_GALLERY_INSTALLED, + JSON.stringify([ + { + preset, + url, + indexUrl: INDEX_URL, + sha256: sha256(text), + installedAt: "2026-09-01T00:00:00.000Z", + }, + ]) + ) +} + +function seedCache(text: string, fetchedAt = new Date().toISOString()) { + localStorage.setItem( + STORAGE_KEY_PRESET_GALLERY_CACHE, + JSON.stringify({ indexUrl: INDEX_URL, fetchedAt, text }) + ) +} + +beforeEach(() => { + localStorage.clear() + document.documentElement.removeAttribute("style") + document.documentElement.removeAttribute("data-theme") + document.documentElement.classList.remove("dark") + callMock.mockReset() + toastMock.success.mockReset() + toastMock.error.mockReset() + themeMock.theme = "light" + themeMock.setTheme.mockReset() +}) + +afterEach(() => { + document.documentElement.removeAttribute("style") +}) + +describe("AppearancePresetGallerySection", () => { + it("loads the index when there is no saved copy and draws each card from its listing", async () => { + serveGallery(twoListings) + renderSection() + + const card = await screen.findByRole("group", { name: "Shared look" }) + expect(callMock).toHaveBeenCalledWith("preset_gallery_fetch_index", { + url: INDEX_URL, + }) + // The strip is the listing's own colours, no image involved. + const strip = Array.from(card.querySelectorAll("span[style]")).map( + (el) => (el as HTMLElement).style.backgroundColor + ) + expect(strip).toEqual(["rgb(255, 255, 255)", "rgb(51, 102, 153)"]) + expect(within(card).getByText("by someone · Dark")).toBeInTheDocument() + expect( + within(card).getByText("Came in from the gallery.") + ).toBeInTheDocument() + expect(within(card).getByRole("button", { name: "Install" })).toBeEnabled() + expect(within(card).getByRole("button", { name: "Preview" })).toBeEnabled() + // A listing without swatches still gets a strip, from its base palette. + const quiet = screen.getByRole("group", { name: "Quiet ink" }) + expect(quiet.querySelectorAll("span[style]")).toHaveLength(4) + expect(screen.getByText(/^Last refreshed /)).toBeInTheDocument() + // The index became the saved copy. + expect( + JSON.parse(localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_CACHE)!) + ).toMatchObject({ indexUrl: INDEX_URL, text: twoListings }) + }) + + it("narrows the cards by search", async () => { + serveGallery(twoListings) + renderSection() + await screen.findByRole("group", { name: "Shared look" }) + + fireEvent.change(screen.getByRole("textbox", { name: "Search presets…" }), { + target: { value: "QUIET" }, + }) + expect(screen.queryByRole("group", { name: "Shared look" })).toBeNull() + expect(screen.getByRole("group", { name: "Quiet ink" })).toBeInTheDocument() + + fireEvent.change(screen.getByRole("textbox", { name: "Search presets…" }), { + target: { value: "nothing like this" }, + }) + expect(screen.getByText("No presets matched.")).toBeInTheDocument() + }) + + it("installs a verified file: stored with its source and digest, then applied", async () => { + serveGallery(twoListings) + renderSection() + const card = await screen.findByRole("group", { name: "Shared look" }) + + fireEvent.click(within(card).getByRole("button", { name: "Install" })) + await waitFor(() => + expect(toastMock.success).toHaveBeenCalledWith("Installed: Shared look") + ) + + // The backend was asked for exactly the listed digest. + expect(callMock).toHaveBeenCalledWith("preset_gallery_fetch_preset", { + url: sharedUrl, + sha256: sha256(sharedText), + }) + const stored = installedList() + expect(stored).toHaveLength(1) + expect(stored[0]).toMatchObject({ + url: sharedUrl, + indexUrl: INDEX_URL, + sha256: sha256(sharedText), + preset: sharedLook, + }) + // Applied, mode included. The token write is an effect, so wait for it. + await waitFor(() => expect(rootVar("--primary")).toBe("#336699")) + expect(document.documentElement.getAttribute("data-theme")).toBe("rose") + expect(rootVar("--spacing")).toBe("0.23rem") + expect(themeMock.setTheme).toHaveBeenCalledWith("dark") + expect( + JSON.parse(localStorage.getItem(STORAGE_KEY_APPEARANCE_PRESET)!).id + ).toBe("shared-look") + // The card now says so. + expect(within(card).getByText("Installed")).toBeInTheDocument() + expect(within(card).queryByRole("button", { name: "Install" })).toBeNull() + expect(within(card).getByRole("button", { name: "Apply" })).toBeEnabled() + expect(within(card).getByRole("button", { name: "Remove" })).toBeEnabled() + }) + + it("flags an installed preset whose listing changed, and update replaces the copy", async () => { + const oldLook: AppearancePreset = { + ...sharedLook, + colors: { light: { primary: "#000000" }, dark: {} }, + } + const oldText = serializeAppearancePreset(oldLook) + seedInstalled(oldLook, oldText, sharedUrl) + // It is also the current look, so the update must reach the screen. + localStorage.setItem(STORAGE_KEY_APPEARANCE_PRESET, oldText) + localStorage.setItem( + STORAGE_KEY_CUSTOM_THEME, + JSON.stringify({ light: { primary: "#000000" }, dark: {} }) + ) + document.documentElement.setAttribute("data-theme", "rose") + serveGallery(twoListings) + renderSection() + + const card = await screen.findByRole("group", { name: "Shared look" }) + expect(within(card).getByText("Update available")).toBeInTheDocument() + expect(rootVar("--primary")).toBe("#000000") + + fireEvent.click(within(card).getByRole("button", { name: "Update" })) + await waitFor(() => + expect(toastMock.success).toHaveBeenCalledWith("Updated: Shared look") + ) + const stored = installedList() + expect(stored).toHaveLength(1) + expect(stored[0]).toMatchObject({ + sha256: sha256(sharedText), + preset: sharedLook, + }) + await waitFor(() => expect(rootVar("--primary")).toBe("#336699")) + expect(within(card).queryByText("Update available")).toBeNull() + expect(within(card).getByText("Installed")).toBeInTheDocument() + }) + + it("does not re-apply an update to a preset that is not the current look", async () => { + const oldLook: AppearancePreset = { + ...sharedLook, + colors: { light: { primary: "#000000" }, dark: {} }, + } + seedInstalled(oldLook, serializeAppearancePreset(oldLook), sharedUrl) + document.documentElement.setAttribute("data-theme", "green") + localStorage.setItem( + STORAGE_KEY_CUSTOM_THEME, + JSON.stringify({ light: { primary: "#123456" }, dark: {} }) + ) + serveGallery(twoListings) + renderSection() + const card = await screen.findByRole("group", { name: "Shared look" }) + + fireEvent.click(within(card).getByRole("button", { name: "Update" })) + await waitFor(() => + expect(toastMock.success).toHaveBeenCalledWith("Updated: Shared look") + ) + expect(installedList()[0]).toMatchObject({ sha256: sha256(sharedText) }) + expect(document.documentElement.getAttribute("data-theme")).toBe("green") + expect(rootVar("--primary")).toBe("#123456") + }) + + it("removes only the local copy and leaves the look alone", async () => { + seedInstalled(sharedLook, sharedText, sharedUrl) + document.documentElement.setAttribute("data-theme", "rose") + localStorage.setItem( + STORAGE_KEY_CUSTOM_THEME, + JSON.stringify({ light: { primary: "#336699" }, dark: {} }) + ) + serveGallery(twoListings) + renderSection() + const card = await screen.findByRole("group", { name: "Shared look" }) + expect(within(card).getByText("Installed")).toBeInTheDocument() + + fireEvent.click(within(card).getByRole("button", { name: "Remove" })) + expect(toastMock.success).toHaveBeenCalledWith("Removed: Shared look") + expect(installedList()).toEqual([]) + expect(document.documentElement.getAttribute("data-theme")).toBe("rose") + expect(rootVar("--primary")).toBe("#336699") + expect(within(card).getByRole("button", { name: "Install" })).toBeEnabled() + expect(within(card).queryByText("Installed")).toBeNull() + }) + + it("previews a listed preset and revert restores the previous look exactly", async () => { + document.documentElement.setAttribute("data-theme", "green") + localStorage.setItem(STORAGE_KEY_THEME_COLOR, "green") + const before = { light: { primary: "#111111", radius: "1rem" }, dark: {} } + localStorage.setItem(STORAGE_KEY_CUSTOM_THEME, JSON.stringify(before)) + serveGallery(twoListings) + renderSection() + await waitFor(() => expect(rootVar("--primary")).toBe("#111111")) + const card = await screen.findByRole("group", { name: "Shared look" }) + const quiet = screen.getByRole("group", { name: "Quiet ink" }) + + fireEvent.click(within(card).getByRole("button", { name: "Preview" })) + await screen.findByText("Previewing Shared look") + + // The preview is the real thing, applied like an install would be. + expect(document.documentElement.getAttribute("data-theme")).toBe("rose") + expect(rootVar("--primary")).toBe("#336699") + expect(rootVar("--spacing")).toBe("0.23rem") + expect(rootVar("--radius")).toBe("") + expect(themeMock.setTheme).toHaveBeenCalledWith("dark") + expect( + JSON.parse(localStorage.getItem(STORAGE_KEY_APPEARANCE_PRESET)!).id + ).toBe("shared-look") + // But it is not an install, and nothing else can be started meanwhile. + expect( + localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_INSTALLED) + ).toBeNull() + expect( + within(quiet).getByRole("button", { name: "Install" }) + ).toBeDisabled() + + fireEvent.click(screen.getByRole("button", { name: "Revert" })) + + expect(screen.queryByText("Previewing Shared look")).toBeNull() + expect(document.documentElement.getAttribute("data-theme")).toBe("green") + expect(rootVar("--primary")).toBe("#111111") + expect(rootVar("--radius")).toBe("1rem") + expect(rootVar("--spacing")).toBe("") + expect(themeMock.setTheme).toHaveBeenLastCalledWith("light") + expect(localStorage.getItem(STORAGE_KEY_THEME_COLOR)).toBe("green") + expect(localStorage.getItem(STORAGE_KEY_APPEARANCE_PRESET)).toBeNull() + expect( + localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_INSTALLED) + ).toBeNull() + await waitFor(() => + expect( + JSON.parse(localStorage.getItem(STORAGE_KEY_CUSTOM_THEME)!) + ).toEqual(before) + ) + expect(within(quiet).getByRole("button", { name: "Install" })).toBeEnabled() + }) + + it("keeps a preview by installing it, with the look left in place", async () => { + serveGallery(twoListings) + renderSection() + const card = await screen.findByRole("group", { name: "Shared look" }) + + fireEvent.click(within(card).getByRole("button", { name: "Preview" })) + await screen.findByText("Previewing Shared look") + fireEvent.click(screen.getByRole("button", { name: "Keep and install" })) + + expect(screen.queryByText("Previewing Shared look")).toBeNull() + expect(toastMock.success).toHaveBeenCalledWith("Installed: Shared look") + expect(installedList()[0]).toMatchObject({ + sha256: sha256(sharedText), + preset: sharedLook, + }) + expect(document.documentElement.getAttribute("data-theme")).toBe("rose") + expect(rootVar("--primary")).toBe("#336699") + expect(within(card).getByText("Installed")).toBeInTheDocument() + }) + + it("opens from a fresh saved copy without fetching, and keeps it when a refresh fails", async () => { + seedCache(twoListings) + callMock.mockRejectedValue( + new Error("Preset gallery index fetch failed: offline") + ) + renderSection() + + expect( + screen.getByRole("group", { name: "Shared look" }) + ).toBeInTheDocument() + expect(callMock).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole("button", { name: "Refresh" })) + const alert = await screen.findByRole("alert") + expect( + within(alert).getByText("Could not load the gallery.") + ).toBeInTheDocument() + expect(within(alert).getByText(/offline/)).toBeInTheDocument() + expect( + within(alert).getByText("Showing the saved copy.") + ).toBeInTheDocument() + expect( + screen.getByRole("group", { name: "Shared look" }) + ).toBeInTheDocument() + // The failed refresh did not touch the saved copy. + expect( + JSON.parse(localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_CACHE)!).text + ).toBe(twoListings) + }) + + it("refreshes a saved copy that is a day old when it opens", async () => { + seedCache(twoListings, "2026-09-01T00:00:00.000Z") + serveGallery(twoListings) + renderSection() + expect( + screen.getByRole("group", { name: "Shared look" }) + ).toBeInTheDocument() + await waitFor(() => + expect(callMock).toHaveBeenCalledWith("preset_gallery_fetch_index", { + url: INDEX_URL, + }) + ) + }) + + it("refuses an index over the size cap and says so", async () => { + callMock.mockResolvedValue(doc("x".repeat(MAX_GALLERY_INDEX_BYTES + 1))) + renderSection() + const alert = await screen.findByRole("alert") + expect( + within(alert).getByText("This gallery index cannot be used.") + ).toBeInTheDocument() + expect( + within(alert).getByText("The file is too large (limit 256 KB)") + ).toBeInTheDocument() + expect(localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_CACHE)).toBeNull() + }) + + it("refuses a file the backend reports as not matching its listing, changing nothing", async () => { + // The listing promises one digest; the file on the server hashes to another. + serveGallery(twoListings, { [sharedUrl]: `${sharedText}\n` }) + renderSection() + const card = await screen.findByRole("group", { name: "Shared look" }) + + fireEvent.click(within(card).getByRole("button", { name: "Install" })) + const dialog = await screen.findByRole("dialog") + expect( + within(dialog).getByText("This preset cannot be installed") + ).toBeInTheDocument() + expect( + within(dialog).getByText( + "The file does not match the fingerprint in its listing" + ) + ).toBeInTheDocument() + expect( + localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_INSTALLED) + ).toBeNull() + expect(document.documentElement.getAttribute("data-theme")).toBeNull() + expect(rootVar("--primary")).toBe("") + expect(toastMock.success).not.toHaveBeenCalled() + }) + + it("refuses bytes returned under a digest other than the listed one", async () => { + // A backend that returned the file but reported a different digest is + // caught by the second comparison here. + callMock.mockImplementation((command: string) => + Promise.resolve( + command === "preset_gallery_fetch_index" + ? doc(twoListings) + : { ...doc(sharedText), sha256: sha256("something else") } + ) + ) + renderSection() + const card = await screen.findByRole("group", { name: "Shared look" }) + + fireEvent.click(within(card).getByRole("button", { name: "Install" })) + const dialog = await screen.findByRole("dialog") + expect( + within(dialog).getByText( + "The file does not match the fingerprint in its listing" + ) + ).toBeInTheDocument() + expect( + localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_INSTALLED) + ).toBeNull() + expect(rootVar("--primary")).toBe("") + }) + + it("refuses a file that is not a valid preset, naming the field", async () => { + const withScript = JSON.stringify({ ...sharedLook, script: "alert(1)" }) + const index = JSON.stringify({ + schemaVersion: 1, + presets: [listing(sharedLook, withScript)], + }) + serveGallery(index, { [sharedUrl]: withScript }) + renderSection() + const card = await screen.findByRole("group", { name: "Shared look" }) + + fireEvent.click(within(card).getByRole("button", { name: "Install" })) + const dialog = await screen.findByRole("dialog") + expect( + within(dialog).getByText("The file is not a valid preset") + ).toBeInTheDocument() + expect( + within(dialog) + .getAllByRole("listitem") + .map((li) => li.textContent) + ).toEqual(["script: Unknown field"]) + expect( + localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_INSTALLED) + ).toBeNull() + expect(rootVar("--primary")).toBe("") + }) + + it("accepts only an https public source, and loads the new one", async () => { + serveGallery(twoListings) + renderSection() + await screen.findByRole("group", { name: "Shared look" }) + callMock.mockClear() + + fireEvent.click(screen.getByRole("button", { name: "Source" })) + const input = screen.getByLabelText("Default source") + fireEvent.change(input, { + target: { value: "http://example.com/index.json" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Use this source" })) + expect( + screen.getByText("Enter an https link to a public site.") + ).toBeInTheDocument() + expect(callMock).not.toHaveBeenCalled() + expect( + localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_INDEX_URL) + ).toBeNull() + + fireEvent.change(input, { + target: { value: "https://example.com/gallery/index.json" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Use this source" })) + await waitFor(() => + expect(callMock).toHaveBeenCalledWith("preset_gallery_fetch_index", { + url: "https://example.com/gallery/index.json", + }) + ) + expect(localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_INDEX_URL)).toBe( + "https://example.com/gallery/index.json" + ) + }) +}) diff --git a/src/components/settings/appearance-preset-gallery-section.tsx b/src/components/settings/appearance-preset-gallery-section.tsx new file mode 100644 index 0000000000..bf565c3623 --- /dev/null +++ b/src/components/settings/appearance-preset-gallery-section.tsx @@ -0,0 +1,925 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useLocale, useTranslations } from "next-intl" +import { useTheme } from "next-themes" +import { toast } from "sonner" +import { + Check, + Download, + Eye, + Loader2, + RefreshCw, + Settings2, + Store, + Trash2, +} from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { useAppearancePresets, useCustomStyle } from "@/hooks/use-appearance" +import { + toLocalizedErrorMessage, + type AppErrorTranslator, +} from "@/lib/app-error" +import { + MAX_PRESET_BYTES, + type AppearancePreset, + type PresetIssue, +} from "@/lib/appearance-preset" +import { + DEFAULT_PRESET_GALLERY_INDEX_URL, + MAX_GALLERY_INDEX_BYTES, + downloadGalleryPreset, + filterGalleryEntries, + galleryEntryStatus, + galleryEntrySwatches, + installedFromDownload, + isGalleryCacheStale, + normalizeGalleryIndexUrl, + parsePresetGalleryIndex, + presetSwatches, + readGalleryCache, + readGalleryIndexUrl, + readInstalledGalleryPresets, + refreshGalleryIndex, + removeInstalledGalleryPreset, + upsertInstalledGalleryPreset, + writeGalleryIndexUrl, + writeInstalledGalleryPresets, + type GalleryEntry, + type GalleryIndex, + type GalleryIssue, + type InstalledGalleryPreset, +} from "@/lib/appearance-preset-gallery" +import { STORAGE_KEY_PRESET_GALLERY_INSTALLED } from "@/lib/appearance-script" +import { cn } from "@/lib/utils" + +type GalleryState = { + index: GalleryIndex | null + skipped: GalleryIssue[] + /** ISO timestamp of the fetch the index on screen came from. */ + fetchedAt: string | null + /** The index on screen is the saved copy; no fetch has succeeded yet. */ + fromCache: boolean +} + +type LoadError = + | { kind: "index"; issues: GalleryIssue[] } + | { kind: "transport"; error: unknown } + +type FileError = { + name: string + code: "hash-mismatch" | "id-mismatch" | "invalid-preset" | "transport" + issues?: PresetIssue[] + error?: unknown +} + +type Preview = { + entry: GalleryEntry + preset: AppearancePreset + sha256: string + /** The next-themes setting before the preview, restored on revert. */ + previousTheme: string | undefined +} + +const EMPTY_GALLERY: GalleryState = { + index: null, + skipped: [], + fetchedAt: null, + fromCache: false, +} + +/** The saved copy for `indexUrl`, parsed; empty when there is none or it no longer validates. */ +function readSavedGallery(indexUrl: string): GalleryState { + const cache = readGalleryCache(indexUrl) + if (!cache) return EMPTY_GALLERY + const parsed = parsePresetGalleryIndex(cache.text, indexUrl) + if (!parsed.ok) return EMPTY_GALLERY + return { + index: parsed.index, + skipped: parsed.skipped, + fetchedAt: cache.fetchedAt, + fromCache: true, + } +} + +function syncAppearanceMode(mode: string) { + if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) + return + import("@/lib/tauri").then((t) => + t.updateAppearanceMode(mode).catch(() => {}) + ) +} + +function SwatchStrip({ colors }: { colors: string[] }) { + return ( + + {colors.map((color, index) => ( + + ))} + + ) +} + +export function AppearancePresetGallerySection() { + const t = useTranslations("AppearanceSettings") + const locale = useLocale() + const { theme, setTheme } = useTheme() + const { + appliedPreset, + applyPreset, + presetPreview, + startPresetPreview, + endPresetPreview, + } = useAppearancePresets() + const { isDarkMode } = useCustomStyle() + + const [indexUrl, setIndexUrl] = useState(() => readGalleryIndexUrl()) + const [gallery, setGallery] = useState(() => + readSavedGallery(readGalleryIndexUrl()) + ) + const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState(null) + const [search, setSearch] = useState("") + const [sourceOpen, setSourceOpen] = useState(false) + const [sourceDraft, setSourceDraft] = useState(indexUrl) + const [sourceInvalid, setSourceInvalid] = useState(false) + const [fileError, setFileError] = useState(null) + const [preview, setPreview] = useState(null) + const [busyId, setBusyId] = useState(null) + // The single-flight gate lives in a ref: disabling the other cards waits for + // a re-render, and two clicks in one frame would both see the old null. + const busyRef = useRef(null) + // Generation guard: a slow refresh that lands after a newer one loses. + const requestSeq = useRef(0) + + const [installed, setInstalled] = useState(() => + readInstalledGalleryPresets() + ) + const installedRef = useRef(installed) + useEffect(() => { + installedRef.current = installed + }, [installed]) + const installedById = useMemo( + () => new Map(installed.map((item) => [item.preset.id, item])), + [installed] + ) + + const updateInstalled = useCallback((next: InstalledGalleryPreset[]) => { + setInstalled(next) + writeInstalledGalleryPresets(next) + }, []) + + // The library is shared between windows through storage, like every other + // appearance setting. + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY_PRESET_GALLERY_INSTALLED) { + setInstalled(readInstalledGalleryPresets()) + } + } + window.addEventListener("storage", onStorage) + return () => window.removeEventListener("storage", onStorage) + }, []) + + // ─── Index ─── + + const refresh = useCallback(async (url: string) => { + const seq = ++requestSeq.current + setLoading(true) + setLoadError(null) + try { + const result = await refreshGalleryIndex(url) + if (seq !== requestSeq.current) return + if (result.ok) { + setGallery({ + index: result.index, + skipped: result.skipped, + fetchedAt: result.fetchedAt, + fromCache: false, + }) + } else { + setLoadError({ kind: "index", issues: result.issues }) + } + } catch (error) { + if (seq !== requestSeq.current) return + setLoadError({ kind: "transport", error }) + } finally { + if (seq === requestSeq.current) setLoading(false) + } + }, []) + + // One load when the section opens, and only when there is no saved copy or + // it is a day old. No timer and no polling: after this, Refresh is the user's. + useEffect(() => { + if ( + gallery.index && + gallery.fetchedAt && + !isGalleryCacheStale(gallery.fetchedAt) + ) { + return + } + void refresh(indexUrl) + // Mount-only by design; later refreshes are explicit. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const onSaveSource = useCallback(() => { + const next = normalizeGalleryIndexUrl(sourceDraft) + if (!next) { + setSourceInvalid(true) + return + } + setSourceInvalid(false) + writeGalleryIndexUrl(next) + setIndexUrl(next) + setSourceDraft(next) + setGallery(readSavedGallery(next)) + setSourceOpen(false) + void refresh(next) + }, [sourceDraft, refresh]) + + const onResetSource = useCallback(() => { + const next = DEFAULT_PRESET_GALLERY_INDEX_URL + setSourceInvalid(false) + writeGalleryIndexUrl(next) + setIndexUrl(next) + setSourceDraft(next) + setGallery(readSavedGallery(next)) + setSourceOpen(false) + void refresh(next) + }, [refresh]) + + // ─── Apply / install / update / remove / preview ─── + + const applyWithMode = useCallback( + (preset: AppearancePreset) => { + applyPreset(preset) + if (preset.mode) { + setTheme(preset.mode) + syncAppearanceMode(preset.mode) + } + }, + [applyPreset, setTheme] + ) + + /** Download through the backend, verified and validated, or a file error. */ + const download = useCallback(async (entry: GalleryEntry) => { + try { + const result = await downloadGalleryPreset(entry) + if (result.ok) return result + setFileError({ + name: entry.name, + code: result.code, + issues: result.code === "invalid-preset" ? result.issues : undefined, + }) + return null + } catch (error) { + setFileError({ name: entry.name, code: "transport", error }) + return null + } + }, []) + + const withBusy = useCallback( + async (id: string, work: () => Promise) => { + if (busyRef.current !== null) return + busyRef.current = id + setBusyId(id) + try { + await work() + } finally { + busyRef.current = null + setBusyId(null) + } + }, + [] + ) + + const onInstall = useCallback( + (entry: GalleryEntry) => + withBusy(entry.id, async () => { + const result = await download(entry) + if (!result) return + updateInstalled( + upsertInstalledGalleryPreset( + installedRef.current, + installedFromDownload(entry, result, indexUrl) + ) + ) + applyWithMode(result.preset) + toast.success(t("presetGallery.toasts.installed", { name: entry.name })) + }), + [withBusy, download, updateInstalled, indexUrl, applyWithMode, t] + ) + + const onUpdate = useCallback( + (entry: GalleryEntry) => + withBusy(entry.id, async () => { + const result = await download(entry) + if (!result) return + updateInstalled( + upsertInstalledGalleryPreset( + installedRef.current, + installedFromDownload(entry, result, indexUrl) + ) + ) + // Re-applied only when this preset is the current look, so an update + // never changes a look the user has since switched away from. + if (appliedPreset?.id === entry.id) applyWithMode(result.preset) + toast.success(t("presetGallery.toasts.updated", { name: entry.name })) + }), + [ + withBusy, + download, + updateInstalled, + indexUrl, + appliedPreset?.id, + applyWithMode, + t, + ] + ) + + const onRemove = useCallback( + (item: InstalledGalleryPreset) => { + updateInstalled( + removeInstalledGalleryPreset(installedRef.current, item.preset.id) + ) + toast.success( + t("presetGallery.toasts.removed", { name: item.preset.name }) + ) + }, + [updateInstalled, t] + ) + + const onApplyInstalled = useCallback( + (item: InstalledGalleryPreset) => { + applyWithMode(item.preset) + toast.success(t("presets.toasts.applied", { name: item.preset.name })) + }, + [applyWithMode, t] + ) + + const onPreview = useCallback( + (entry: GalleryEntry) => + withBusy(entry.id, async () => { + // An installed copy at the listed digest needs no download. + const local = installedRef.current.find( + (item) => item.preset.id === entry.id + ) + const verified = + local && local.sha256 === entry.sha256 + ? { preset: local.preset, sha256: local.sha256 } + : await download(entry) + if (!verified) return + setPreview({ + entry, + preset: verified.preset, + sha256: verified.sha256, + previousTheme: theme, + }) + startPresetPreview(verified.preset) + if (verified.preset.mode) { + setTheme(verified.preset.mode) + syncAppearanceMode(verified.preset.mode) + } + }), + [withBusy, download, theme, startPresetPreview, setTheme] + ) + + const onRevertPreview = useCallback(() => { + if (!preview) return + endPresetPreview(false) + if (preview.preset.mode && preview.previousTheme) { + setTheme(preview.previousTheme) + syncAppearanceMode(preview.previousTheme) + } + setPreview(null) + }, [preview, endPresetPreview, setTheme]) + + const onKeepPreview = useCallback(() => { + if (!preview) return + const { entry, preset, sha256 } = preview + updateInstalled( + upsertInstalledGalleryPreset( + installedRef.current, + installedFromDownload(entry, { preset, sha256 }, indexUrl) + ) + ) + endPresetPreview(true) + setPreview(null) + toast.success(t("presetGallery.toasts.installed", { name: entry.name })) + }, [preview, updateInstalled, indexUrl, endPresetPreview, t]) + + // Leaving the page mid-preview reverts it; a preview is never kept by + // accident. The latest revert is read through a ref so the cleanup does not + // re-register on every render. + const revertRef = useRef(onRevertPreview) + useEffect(() => { + revertRef.current = onRevertPreview + }, [onRevertPreview]) + useEffect(() => () => revertRef.current(), []) + + // ─── Derived ─── + + const entries = useMemo( + () => filterGalleryEntries(gallery.index?.presets ?? [], search), + [gallery.index, search] + ) + const listedIds = useMemo( + () => new Set((gallery.index?.presets ?? []).map((entry) => entry.id)), + [gallery.index] + ) + // Installed presets the current index no longer lists still belong to the + // user; they stay applicable and removable. + const unlisted = useMemo( + () => + filterGalleryEntries( + installed + .filter((item) => !listedIds.has(item.preset.id)) + .map((item) => ({ + id: item.preset.id, + name: item.preset.name, + description: item.preset.description, + author: item.preset.author, + version: item.preset.version, + url: item.url, + sha256: item.sha256, + })), + search + ), + [installed, listedIds, search] + ) + + const lastRefreshed = useMemo(() => { + if (!gallery.fetchedAt) return null + const at = new Date(gallery.fetchedAt) + if (Number.isNaN(at.getTime())) return null + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(at) + }, [gallery.fetchedAt, locale]) + + const tError = t as unknown as AppErrorTranslator + const describeIndexIssue = (issue: GalleryIssue): string => { + const message = + issue.code === "too-large" + ? t("presetGallery.errors.tooLarge", { + limit: Math.round(MAX_GALLERY_INDEX_BYTES / 1024), + }) + : issue.code === "invalid-json" + ? t("presetGallery.errors.invalidJson") + : issue.code === "unsupported-version" + ? t("presetGallery.errors.unsupportedVersion") + : issue.code === "too-many" + ? t("presetGallery.errors.tooMany") + : t("presetGallery.errors.malformed") + return issue.path ? `${issue.path}: ${message}` : message + } + const fileErrorMessage = (error: FileError): string => { + switch (error.code) { + case "hash-mismatch": + return t("presetGallery.errors.hashMismatch") + case "id-mismatch": + return t("presetGallery.errors.idMismatch") + case "invalid-preset": + return t("presetGallery.errors.invalidPreset") + case "transport": + return toLocalizedErrorMessage(error.error, tError) + } + } + + const actionsLocked = busyId !== null || preview !== null + const isDefaultSource = indexUrl === DEFAULT_PRESET_GALLERY_INDEX_URL + const hasIndex = gallery.index !== null + const modeLabel = (mode: GalleryEntry["mode"]) => + mode ? t(`presets.modes.${mode}`) : null + + const renderCard = ( + entry: GalleryEntry, + local: InstalledGalleryPreset | undefined, + listed: boolean + ) => { + const status = listed ? galleryEntryStatus(entry, local) : "installed" + const busy = busyId === entry.id + const current = appliedPreset?.id === entry.id && presetPreview === null + const swatches = local + ? presetSwatches(local.preset, isDarkMode) + : galleryEntrySwatches(entry, isDarkMode) + return ( +
+ +
+ {entry.name} + {entry.version && ( + + {entry.version} + + )} + {status === "update" && ( + + {t("presetGallery.updateAvailable")} + + )} + {status === "installed" && ( + + + {t("presetGallery.installed")} + + )} + {!listed && ( + + {t("presetGallery.notListed")} + + )} +
+ + {[ + entry.author + ? t("presetGallery.byAuthor", { author: entry.author }) + : null, + modeLabel(entry.mode), + ] + .filter(Boolean) + .join(" · ")} + + {entry.description && ( + + {entry.description} + + )} +
+ {listed && ( + + )} + {status === "available" ? ( + + ) : ( + <> + {local && ( + + )} + {status === "update" && ( + + )} + {local && ( + + )} + + )} +
+
+ ) + } + + return ( +
+
+ +

+ {t("presetGallery.sectionTitle")} +

+
+ +

+ {t("presetGallery.sectionDescription")} +

+ + {/* ===== Toolbar ===== */} +
+ setSearch(e.target.value)} + placeholder={t("presetGallery.searchPlaceholder")} + aria-label={t("presetGallery.searchPlaceholder")} + className="h-8 w-56" + /> + + + + {lastRefreshed + ? t("presetGallery.lastRefreshed", { time: lastRefreshed }) + : t("presetGallery.notLoaded")} + +
+ + {sourceOpen && ( +
+ +
+ { + setSourceDraft(e.target.value) + setSourceInvalid(false) + }} + aria-invalid={sourceInvalid || undefined} + className="h-8 min-w-64 flex-1 font-mono text-xs" + spellCheck={false} + /> + + {!isDefaultSource && ( + + )} +
+

+ {sourceInvalid + ? t("presetGallery.sourceInvalid") + : t("presetGallery.sourceHint")} +

+
+ )} + + {/* ===== Preview bar ===== */} + {preview && ( +
+ +
+

+ {t("presetGallery.previewing", { name: preview.entry.name })} +

+

+ {t("presetGallery.previewHint")} +

+
+ + +
+ )} + + {/* ===== Load error (the saved copy, if any, stays on screen) ===== */} + {loadError && ( +
+

+ {loadError.kind === "index" + ? t("presetGallery.indexInvalid") + : t("presetGallery.loadFailed")} +

+ {loadError.kind === "index" ? ( +
    + {loadError.issues.map((issue, index) => ( +
  • + {describeIndexIssue(issue)} +
  • + ))} +
+ ) : ( +

+ {toLocalizedErrorMessage(loadError.error, tError)} +

+ )} + {hasIndex && ( +

+ {t("presetGallery.savedCopy")} +

+ )} + +
+ )} + + {gallery.skipped.length > 0 && ( +

`${issue.path}: ${issue.code}`) + .join("\n")} + > + {t("presetGallery.skipped", { count: gallery.skipped.length })} +

+ )} + + {/* ===== Cards ===== */} + {!hasIndex && loading ? ( +
+ +
+ ) : hasIndex && entries.length === 0 && unlisted.length === 0 ? ( +

+ {gallery.index?.presets.length === 0 + ? t("presetGallery.emptyIndex") + : t("presetGallery.empty")} +

+ ) : ( +
+ {entries.map((entry) => + renderCard(entry, installedById.get(entry.id), true) + )} + {unlisted.map((entry) => + renderCard(entry, installedById.get(entry.id), false) + )} +
+ )} + + {/* ===== File error ===== */} + { + if (!open) setFileError(null) + }} + > + + + {t("presetGallery.fileErrorTitle")} + + {t("presetGallery.fileErrorDescription")} + + + {fileError && ( +
+

{fileError.name}

+

+ {fileErrorMessage(fileError)} +

+ {fileError.issues && fileError.issues.length > 0 && ( +
    + {fileError.issues.map((issue, index) => ( +
  • + {issue.path ? `${issue.path}: ` : ""} + {t(`presets.errors.${issue.code}`, { + limit: Math.round(MAX_PRESET_BYTES / 1024), + })} + {issue.detail ? ` (${issue.detail})` : ""} +
  • + ))} +
+ )} +
+ )} + + + +
+
+
+ ) +} diff --git a/src/components/settings/appearance-settings.tsx b/src/components/settings/appearance-settings.tsx index 80449db6d8..166b5c206f 100644 --- a/src/components/settings/appearance-settings.tsx +++ b/src/components/settings/appearance-settings.tsx @@ -31,6 +31,7 @@ import { FontSettingsSection } from "./font-settings-section" import { WorkspaceBackgroundSection } from "./workspace-background-section" import { CustomStyleSection } from "./custom-style-section" import { AppearancePresetsSection } from "./appearance-presets-section" +import { AppearancePresetGallerySection } from "./appearance-preset-gallery-section" type ThemeMode = "system" | "light" | "dark" @@ -169,6 +170,9 @@ export function AppearanceSettings() { {/* ===== Presets (whole looks, import / export) ===== */} + {/* ===== Preset gallery (shared looks from a public index) ===== */} + + {/* ===== Custom style (token overrides + free-form CSS) ===== */} diff --git a/src/hooks/use-appearance.ts b/src/hooks/use-appearance.ts index 9a4d2f5668..b4532af904 100644 --- a/src/hooks/use-appearance.ts +++ b/src/hooks/use-appearance.ts @@ -133,6 +133,9 @@ export function useAppearancePresets() { setCodeTheme, appliedPreset, applyPreset, + presetPreview, + startPresetPreview, + endPresetPreview, setSharedThemeToken, } = useAppearance() return { @@ -145,6 +148,9 @@ export function useAppearancePresets() { setCodeTheme, appliedPreset, applyPreset, + presetPreview, + startPresetPreview, + endPresetPreview, setSharedThemeToken, } } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 01bc4d956d..50eee4cb86 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -258,6 +258,58 @@ } } }, + "presetGallery": { + "sectionTitle": "معرض الإعدادات المسبقة", + "sectionDescription": "مظاهر يشاركها آخرون، مدرجة في فهرس عام. يُطابَق كل ملف مع البصمة المسجّلة له في الفهرس ويُتحقّق من أنه لا يحوي سوى الألوان والخطوط والتباعد قبل تطبيق أي شيء أو حفظه. لا يُحمَّل شيء في الخلفية: استخدم «تحديث» للبحث عن إعدادات مسبقة جديدة.", + "searchPlaceholder": "ابحث في الإعدادات المسبقة…", + "refresh": "تحديث", + "lastRefreshed": "آخر تحديث: {time}", + "notLoaded": "لم يُحمَّل بعد", + "savedCopy": "تُعرض النسخة المحفوظة.", + "skipped": "{count, plural, one {تم تجاوز عنصر واحد لأنه غير صالح.} other {تم تجاوز # عناصر لأنها غير صالحة.}}", + "loadFailed": "تعذّر تحميل المعرض.", + "indexInvalid": "لا يمكن استخدام فهرس هذا المعرض.", + "retry": "إعادة المحاولة", + "empty": "لا توجد إعدادات مسبقة مطابقة.", + "emptyIndex": "لا يضم هذا المعرض أي إعدادات مسبقة بعد.", + "source": "المصدر", + "sourceDefault": "المصدر الافتراضي", + "sourceCustom": "مصدر مخصّص", + "sourceHint": "رابط https إلى ملف index.json. يجب أن تكون ملفات الإعدادات المسبقة على نفس الموقع الذي يوجد فيه الفهرس.", + "sourceInvalid": "أدخل رابط https إلى موقع عام.", + "sourceSave": "استخدام هذا المصدر", + "sourceReset": "العودة إلى الافتراضي", + "byAuthor": "بواسطة {author}", + "installed": "مثبَّت", + "updateAvailable": "يتوفر تحديث", + "notListed": "لم يعد مدرجًا", + "preview": "معاينة", + "install": "تثبيت", + "apply": "تطبيق", + "update": "تثبيت التحديث", + "remove": "إزالة", + "previewing": "معاينة: {name}", + "previewHint": "«التراجع» يعيد المظهر السابق، وكذلك مغادرة هذه الصفحة.", + "keep": "الاحتفاظ والتثبيت", + "revert": "تراجع", + "fileErrorTitle": "لا يمكن تثبيت هذا الإعداد المسبق", + "fileErrorDescription": "لم يتغيّر شيء.", + "toasts": { + "installed": "تم التثبيت: {name}", + "updated": "تم التحديث: {name}", + "removed": "تمت الإزالة: {name}" + }, + "errors": { + "tooLarge": "الملف كبير جدًا (الحد {limit} كيلوبايت)", + "invalidJson": "ليس JSON صالحًا", + "unsupportedVersion": "كُتب لإصدار مختلف من تنسيق المعرض", + "malformed": "حقل مفقود أو غير متوقع", + "tooMany": "يدرج عددًا كبيرًا جدًا من الإعدادات المسبقة", + "hashMismatch": "الملف لا يطابق البصمة المسجّلة له في الفهرس", + "idMismatch": "معرّف الملف لا يطابق مدخله في الفهرس", + "invalidPreset": "الملف ليس إعدادًا مسبقًا صالحًا" + } + }, "zoomLevel": { "sectionTitle": "تكبير النافذة", "sectionDescription": "تكبير أو تصغير الواجهة بالكامل. يتم تطبيقه فوراً ويُحفظ لكل جهاز على حدة. ⌘/Ctrl + و - ينتقلان بين نفس مستويات التكبير في هذه القائمة (⌘/Ctrl 0 يعيد الضبط إلى 100%).", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 8619993d27..684521294e 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -258,6 +258,58 @@ } } }, + "presetGallery": { + "sectionTitle": "Voreinstellungs-Galerie", + "sectionDescription": "Von anderen geteilte Looks aus einem öffentlichen Index. Jede Datei wird mit dem im Index hinterlegten Fingerabdruck abgeglichen und darauf geprüft, dass sie nur Farben, Schriften und Abstände enthält, bevor irgendetwas angewendet oder behalten wird. Im Hintergrund wird nichts geladen: Mit „Aktualisieren“ suchst du nach neuen Voreinstellungen.", + "searchPlaceholder": "Voreinstellungen suchen…", + "refresh": "Aktualisieren", + "lastRefreshed": "Zuletzt aktualisiert: {time}", + "notLoaded": "Noch nicht geladen", + "savedCopy": "Angezeigt wird die gespeicherte Kopie.", + "skipped": "{count, plural, one {# Eintrag wurde übersprungen, weil er ungültig ist.} other {# Einträge wurden übersprungen, weil sie ungültig sind.}}", + "loadFailed": "Die Galerie konnte nicht geladen werden.", + "indexInvalid": "Dieser Galerie-Index lässt sich nicht verwenden.", + "retry": "Erneut versuchen", + "empty": "Keine passenden Voreinstellungen gefunden.", + "emptyIndex": "Diese Galerie führt noch keine Voreinstellungen.", + "source": "Quelle", + "sourceDefault": "Standardquelle", + "sourceCustom": "Eigene Quelle", + "sourceHint": "Ein https-Link zu einer index.json. Die Voreinstellungsdateien müssen auf derselben Website liegen wie der Index.", + "sourceInvalid": "Gib einen https-Link zu einer öffentlichen Website ein.", + "sourceSave": "Diese Quelle verwenden", + "sourceReset": "Zurück zum Standard", + "byAuthor": "von {author}", + "installed": "Installiert", + "updateAvailable": "Update verfügbar", + "notListed": "Nicht mehr im Index", + "preview": "Vorschau", + "install": "Installieren", + "apply": "Anwenden", + "update": "Update", + "remove": "Entfernen", + "previewing": "Vorschau: {name}", + "previewHint": "„Zurücksetzen“ bringt den vorherigen Look zurück, ebenso das Verlassen dieser Seite.", + "keep": "Behalten und installieren", + "revert": "Zurücksetzen", + "fileErrorTitle": "Diese Voreinstellung lässt sich nicht installieren", + "fileErrorDescription": "Es wurde nichts geändert.", + "toasts": { + "installed": "Installiert: {name}", + "updated": "Aktualisiert: {name}", + "removed": "Entfernt: {name}" + }, + "errors": { + "tooLarge": "Datei ist zu groß (Limit {limit} KB)", + "invalidJson": "Kein gültiges JSON", + "unsupportedVersion": "Für eine andere Version des Galerie-Formats geschrieben", + "malformed": "Feld fehlt oder ist unerwartet", + "tooMany": "Führt zu viele Voreinstellungen", + "hashMismatch": "Die Datei stimmt nicht mit dem Fingerabdruck im Index überein", + "idMismatch": "Die id der Datei stimmt nicht mit dem Eintrag im Index überein", + "invalidPreset": "Die Datei ist keine gültige Voreinstellung" + } + }, "zoomLevel": { "sectionTitle": "Fensterzoom", "sectionDescription": "Skaliert die gesamte Oberfläche. Wird sofort übernommen und pro Gerät gespeichert. ⌘/Ctrl + und - durchlaufen dieselben Zoomstufen wie dieses Menü (⌘/Ctrl 0 setzt auf 100% zurück).", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b853f80540..0591bb5954 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -258,6 +258,58 @@ } } }, + "presetGallery": { + "sectionTitle": "Preset gallery", + "sectionDescription": "Looks shared by other people, listed in a public index. Every file is checked against the fingerprint in its listing and validated as colors, fonts and spacing only before anything is applied or kept. Nothing loads in the background: use Refresh to look for new presets.", + "searchPlaceholder": "Search presets…", + "refresh": "Refresh", + "lastRefreshed": "Last refreshed {time}", + "notLoaded": "Not loaded yet", + "savedCopy": "Showing the saved copy.", + "skipped": "{count, plural, one {# listing was skipped because it is invalid.} other {# listings were skipped because they are invalid.}}", + "loadFailed": "Could not load the gallery.", + "indexInvalid": "This gallery index cannot be used.", + "retry": "Retry", + "empty": "No presets matched.", + "emptyIndex": "This gallery lists no presets yet.", + "source": "Source", + "sourceDefault": "Default source", + "sourceCustom": "Custom source", + "sourceHint": "An https link to an index.json. Preset files must be on the same site as the index.", + "sourceInvalid": "Enter an https link to a public site.", + "sourceSave": "Use this source", + "sourceReset": "Back to default", + "byAuthor": "by {author}", + "installed": "Installed", + "updateAvailable": "Update available", + "notListed": "No longer listed", + "preview": "Preview", + "install": "Install", + "apply": "Apply", + "update": "Update", + "remove": "Remove", + "previewing": "Previewing {name}", + "previewHint": "Revert brings back the look you had, and so does leaving this page.", + "keep": "Keep and install", + "revert": "Revert", + "fileErrorTitle": "This preset cannot be installed", + "fileErrorDescription": "Nothing was changed.", + "toasts": { + "installed": "Installed: {name}", + "updated": "Updated: {name}", + "removed": "Removed: {name}" + }, + "errors": { + "tooLarge": "The file is too large (limit {limit} KB)", + "invalidJson": "Not valid JSON", + "unsupportedVersion": "Written for a different version of the gallery format", + "malformed": "Missing or unexpected field", + "tooMany": "Lists too many presets", + "hashMismatch": "The file does not match the fingerprint in its listing", + "idMismatch": "The file's id does not match its listing", + "invalidPreset": "The file is not a valid preset" + } + }, "zoomLevel": { "sectionTitle": "Window zoom", "sectionDescription": "Scale the entire interface. Applies immediately and persists per device. ⌘/Ctrl + and - move through the same zoom levels as this menu (⌘/Ctrl 0 resets to 100%).", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index a0da79cea7..90d476e0ea 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -258,6 +258,58 @@ } } }, + "presetGallery": { + "sectionTitle": "Galería de preajustes", + "sectionDescription": "Aspectos compartidos por otras personas, listados en un índice público. Cada archivo se comprueba contra la huella indicada en el índice y se valida como solo colores, fuentes y espaciado antes de aplicar o guardar nada. No se carga nada en segundo plano: usa Actualizar para buscar preajustes nuevos.", + "searchPlaceholder": "Buscar preajustes…", + "refresh": "Actualizar", + "lastRefreshed": "Última actualización: {time}", + "notLoaded": "Aún no se ha cargado", + "savedCopy": "Se muestra la copia guardada.", + "skipped": "{count, plural, one {Se omitió # entrada porque no es válida.} other {Se omitieron # entradas porque no son válidas.}}", + "loadFailed": "No se pudo cargar la galería.", + "indexInvalid": "Este índice de galería no se puede usar.", + "retry": "Reintentar", + "empty": "No se encontraron preajustes coincidentes.", + "emptyIndex": "Esta galería aún no lista ningún preajuste.", + "source": "Origen", + "sourceDefault": "Origen predeterminado", + "sourceCustom": "Origen personalizado", + "sourceHint": "Un enlace https a un index.json. Los archivos de preajuste deben estar en el mismo sitio que el índice.", + "sourceInvalid": "Introduce un enlace https a un sitio público.", + "sourceSave": "Usar este origen", + "sourceReset": "Volver al predeterminado", + "byAuthor": "por {author}", + "installed": "Instalado", + "updateAvailable": "Actualización disponible", + "notListed": "Ya no está en el índice", + "preview": "Vista previa", + "install": "Instalar", + "apply": "Aplicar", + "update": "Instalar actualización", + "remove": "Quitar", + "previewing": "Vista previa: {name}", + "previewHint": "Revertir devuelve el aspecto anterior, igual que salir de esta página.", + "keep": "Conservar e instalar", + "revert": "Revertir", + "fileErrorTitle": "Este preajuste no se puede instalar", + "fileErrorDescription": "No se cambió nada.", + "toasts": { + "installed": "Instalado: {name}", + "updated": "Actualizado: {name}", + "removed": "Quitado: {name}" + }, + "errors": { + "tooLarge": "El archivo es demasiado grande (límite {limit} KB)", + "invalidJson": "No es JSON válido", + "unsupportedVersion": "Escrito para otra versión del formato de galería", + "malformed": "Falta un campo o no es el esperado", + "tooMany": "Lista demasiados preajustes", + "hashMismatch": "El archivo no coincide con la huella indicada en el índice", + "idMismatch": "El id del archivo no coincide con su entrada en el índice", + "invalidPreset": "El archivo no es un preajuste válido" + } + }, "zoomLevel": { "sectionTitle": "Zoom de ventana", "sectionDescription": "Escala toda la interfaz. Se aplica al instante y se guarda por dispositivo. ⌘/Ctrl + y - recorren los mismos niveles de zoom que este menú (⌘/Ctrl 0 vuelve al 100%).", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 3c5ec74883..66ad7e867c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -258,6 +258,58 @@ } } }, + "presetGallery": { + "sectionTitle": "Galerie de préréglages", + "sectionDescription": "Des apparences partagées par d'autres, répertoriées dans un index public. Chaque fichier est comparé à l'empreinte indiquée dans l'index, puis vérifié comme ne contenant que des couleurs, des polices et des espacements, avant d'être appliqué ou conservé. Rien ne se charge en arrière-plan : utilisez Actualiser pour chercher de nouveaux préréglages.", + "searchPlaceholder": "Rechercher des préréglages…", + "refresh": "Actualiser", + "lastRefreshed": "Dernière actualisation : {time}", + "notLoaded": "Pas encore chargé", + "savedCopy": "Affichage de la copie enregistrée.", + "skipped": "{count, plural, one {# entrée a été ignorée car elle est invalide.} other {# entrées ont été ignorées car elles sont invalides.}}", + "loadFailed": "Impossible de charger la galerie.", + "indexInvalid": "Cet index de galerie est inutilisable.", + "retry": "Réessayer", + "empty": "Aucun préréglage correspondant.", + "emptyIndex": "Cette galerie ne répertorie encore aucun préréglage.", + "source": "Source", + "sourceDefault": "Source par défaut", + "sourceCustom": "Source personnalisée", + "sourceHint": "Un lien https vers un fichier index.json. Les fichiers de préréglage doivent se trouver sur le même site que l'index.", + "sourceInvalid": "Saisissez un lien https vers un site public.", + "sourceSave": "Utiliser cette source", + "sourceReset": "Revenir à la source par défaut", + "byAuthor": "par {author}", + "installed": "Installé", + "updateAvailable": "Mise à jour disponible", + "notListed": "Plus répertorié", + "preview": "Aperçu", + "install": "Installer", + "apply": "Appliquer", + "update": "Mettre à jour", + "remove": "Retirer", + "previewing": "Aperçu : {name}", + "previewHint": "Rétablir ramène l'apparence précédente, tout comme quitter cette page.", + "keep": "Conserver et installer", + "revert": "Rétablir", + "fileErrorTitle": "Ce préréglage ne peut pas être installé", + "fileErrorDescription": "Rien n'a été modifié.", + "toasts": { + "installed": "Installé : {name}", + "updated": "Mis à jour : {name}", + "removed": "Retiré : {name}" + }, + "errors": { + "tooLarge": "Fichier trop volumineux (limite {limit} Ko)", + "invalidJson": "JSON invalide", + "unsupportedVersion": "Écrit pour une autre version du format de galerie", + "malformed": "Champ manquant ou inattendu", + "tooMany": "Répertorie trop de préréglages", + "hashMismatch": "Le fichier ne correspond pas à l'empreinte indiquée dans l'index", + "idMismatch": "L'id du fichier ne correspond pas à son entrée dans l'index", + "invalidPreset": "Le fichier n'est pas un préréglage valide" + } + }, "zoomLevel": { "sectionTitle": "Zoom de la fenêtre", "sectionDescription": "Met à l'échelle toute l'interface. S'applique immédiatement et est enregistré par appareil. ⌘/Ctrl + et - parcourent les mêmes niveaux de zoom que ce menu (⌘/Ctrl 0 rétablit 100%).", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 69995b890b..71a2d57741 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -258,6 +258,58 @@ } } }, + "presetGallery": { + "sectionTitle": "プリセットギャラリー", + "sectionDescription": "ほかの人が共有した外観を、公開インデックスから一覧します。各ファイルはインデックスに記された指紋と照合し、色・フォント・余白だけを含むことを検証したうえで、初めて適用・保存されます。バックグラウンドでは読み込みません。新しいプリセットを探すには「更新」を押してください。", + "searchPlaceholder": "プリセットを検索…", + "refresh": "更新", + "lastRefreshed": "最終更新:{time}", + "notLoaded": "まだ読み込んでいません", + "savedCopy": "保存済みのコピーを表示しています。", + "skipped": "{count, plural, other {無効な項目 # 件をスキップしました。}}", + "loadFailed": "ギャラリーを読み込めませんでした。", + "indexInvalid": "このギャラリーのインデックスは使用できません。", + "retry": "再試行", + "empty": "一致するプリセットがありません。", + "emptyIndex": "このギャラリーにはまだプリセットがありません。", + "source": "取得元", + "sourceDefault": "既定の取得元", + "sourceCustom": "カスタムの取得元", + "sourceHint": "index.json への https リンク。プリセットのファイルはインデックスと同じサイトに置く必要があります。", + "sourceInvalid": "公開サイトへの https リンクを入力してください。", + "sourceSave": "この取得元を使う", + "sourceReset": "既定に戻す", + "byAuthor": "作者:{author}", + "installed": "インストール済み", + "updateAvailable": "アップデートあり", + "notListed": "インデックスから削除済み", + "preview": "プレビュー", + "install": "インストール", + "apply": "適用", + "update": "アップデート", + "remove": "削除", + "previewing": "プレビュー中:{name}", + "previewHint": "「元に戻す」を押すか、このページを離れると、以前の外観に戻ります。", + "keep": "このまま使ってインストール", + "revert": "元に戻す", + "fileErrorTitle": "このプリセットはインストールできません", + "fileErrorDescription": "何も変更していません。", + "toasts": { + "installed": "インストールしました:{name}", + "updated": "アップデートしました:{name}", + "removed": "削除しました:{name}" + }, + "errors": { + "tooLarge": "ファイルが大きすぎます(上限 {limit} KB)", + "invalidJson": "有効な JSON ではありません", + "unsupportedVersion": "別バージョンのギャラリー形式で書かれています", + "malformed": "フィールドが不足しているか、想定外です", + "tooMany": "プリセットの数が上限を超えています", + "hashMismatch": "ファイルがインデックスの指紋と一致しません", + "idMismatch": "ファイルの id がインデックスの項目と一致しません", + "invalidPreset": "ファイルは有効なプリセットではありません" + } + }, "zoomLevel": { "sectionTitle": "ウィンドウズーム", "sectionDescription": "インターフェイス全体を拡大・縮小します。すぐに反映され、デバイスごとに保存されます。⌘/Ctrl + と - で、このメニューと同じ段階で拡大縮小します(⌘/Ctrl 0 で 100% に戻します)。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 3af8708866..5ffe1d5caa 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -258,6 +258,58 @@ } } }, + "presetGallery": { + "sectionTitle": "프리셋 갤러리", + "sectionDescription": "다른 사람이 공유한 모습을 공개 색인에서 살펴봅니다. 모든 파일은 색인에 기록된 지문과 대조하고, 색상·글꼴·간격만 담고 있는지 검증한 뒤에야 적용되거나 저장됩니다. 백그라운드에서 불러오지 않으니 새 프리셋을 찾으려면 새로고침을 누르세요.", + "searchPlaceholder": "프리셋 검색…", + "refresh": "새로고침", + "lastRefreshed": "마지막 새로고침: {time}", + "notLoaded": "아직 불러오지 않았습니다", + "savedCopy": "저장된 사본을 표시하고 있습니다.", + "skipped": "{count, plural, other {잘못된 항목 #개를 건너뛰었습니다.}}", + "loadFailed": "갤러리를 불러오지 못했습니다.", + "indexInvalid": "이 갤러리 색인은 사용할 수 없습니다.", + "retry": "재시도", + "empty": "일치하는 프리셋이 없습니다.", + "emptyIndex": "이 갤러리에는 아직 프리셋이 없습니다.", + "source": "출처", + "sourceDefault": "기본 출처", + "sourceCustom": "사용자 지정 출처", + "sourceHint": "index.json을 가리키는 https 링크입니다. 프리셋 파일은 색인과 같은 사이트에 있어야 합니다.", + "sourceInvalid": "공개 사이트의 https 링크를 입력하세요.", + "sourceSave": "이 출처 사용", + "sourceReset": "기본값으로", + "byAuthor": "작성자: {author}", + "installed": "설치됨", + "updateAvailable": "업데이트 있음", + "notListed": "더 이상 색인에 없음", + "preview": "미리보기", + "install": "설치", + "apply": "적용", + "update": "업데이트", + "remove": "제거", + "previewing": "미리보는 중: {name}", + "previewHint": "되돌리기를 누르거나 이 페이지를 떠나면 이전 모습으로 돌아갑니다.", + "keep": "유지하고 설치", + "revert": "되돌리기", + "fileErrorTitle": "이 프리셋은 설치할 수 없습니다", + "fileErrorDescription": "아무것도 바뀌지 않았습니다.", + "toasts": { + "installed": "설치했습니다: {name}", + "updated": "업데이트했습니다: {name}", + "removed": "제거했습니다: {name}" + }, + "errors": { + "tooLarge": "파일이 너무 큽니다 (최대 {limit} KB)", + "invalidJson": "유효한 JSON이 아닙니다", + "unsupportedVersion": "다른 버전의 갤러리 형식으로 작성되었습니다", + "malformed": "필드가 없거나 예상과 다릅니다", + "tooMany": "프리셋이 너무 많이 나열되어 있습니다", + "hashMismatch": "파일이 색인에 기록된 지문과 일치하지 않습니다", + "idMismatch": "파일의 id가 색인 항목과 일치하지 않습니다", + "invalidPreset": "파일이 유효한 프리셋이 아닙니다" + } + }, "zoomLevel": { "sectionTitle": "창 확대/축소", "sectionDescription": "전체 인터페이스를 확대하거나 축소합니다. 즉시 적용되며 장치별로 저장됩니다. ⌘/Ctrl +와 -는 이 메뉴와 같은 단계로 확대/축소합니다(⌘/Ctrl 0은 100%로 되돌립니다).", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index a9d47693c6..90c9b2fa4b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -258,6 +258,58 @@ } } }, + "presetGallery": { + "sectionTitle": "Galeria de predefinições", + "sectionDescription": "Aspetos partilhados por outras pessoas, listados num índice público. Cada ficheiro é comparado com a impressão digital indicada no índice e validado como contendo apenas cores, tipos de letra e espaçamento antes de algo ser aplicado ou guardado. Nada é carregado em segundo plano: use Atualizar para procurar predefinições novas.", + "searchPlaceholder": "Pesquisar predefinições…", + "refresh": "Atualizar", + "lastRefreshed": "Última atualização: {time}", + "notLoaded": "Ainda não carregado", + "savedCopy": "A mostrar a cópia guardada.", + "skipped": "{count, plural, one {# entrada foi ignorada por ser inválida.} other {# entradas foram ignoradas por serem inválidas.}}", + "loadFailed": "Não foi possível carregar a galeria.", + "indexInvalid": "Este índice de galeria não pode ser usado.", + "retry": "Tentar novamente", + "empty": "Nenhuma predefinição correspondente.", + "emptyIndex": "Esta galeria ainda não lista predefinições.", + "source": "Origem", + "sourceDefault": "Origem padrão", + "sourceCustom": "Origem personalizada", + "sourceHint": "Uma ligação https para um index.json. Os ficheiros de predefinição têm de estar no mesmo site que o índice.", + "sourceInvalid": "Introduza uma ligação https para um site público.", + "sourceSave": "Usar esta origem", + "sourceReset": "Voltar à padrão", + "byAuthor": "por {author}", + "installed": "Instalada", + "updateAvailable": "Atualização disponível", + "notListed": "Já não está no índice", + "preview": "Pré-visualizar", + "install": "Instalar", + "apply": "Aplicar", + "update": "Instalar atualização", + "remove": "Remover", + "previewing": "A pré-visualizar: {name}", + "previewHint": "Reverter devolve o aspeto anterior, tal como sair desta página.", + "keep": "Manter e instalar", + "revert": "Reverter", + "fileErrorTitle": "Esta predefinição não pode ser instalada", + "fileErrorDescription": "Nada foi alterado.", + "toasts": { + "installed": "Instalada: {name}", + "updated": "Atualizada: {name}", + "removed": "Removida: {name}" + }, + "errors": { + "tooLarge": "O ficheiro é demasiado grande (limite de {limit} KB)", + "invalidJson": "Não é JSON válido", + "unsupportedVersion": "Escrito para outra versão do formato da galeria", + "malformed": "Campo em falta ou inesperado", + "tooMany": "Lista predefinições a mais", + "hashMismatch": "O ficheiro não corresponde à impressão digital indicada no índice", + "idMismatch": "O id do ficheiro não corresponde à sua entrada no índice", + "invalidPreset": "O ficheiro não é uma predefinição válida" + } + }, "zoomLevel": { "sectionTitle": "Zoom da janela", "sectionDescription": "Dimensiona toda a interface. Aplica imediatamente e é salvo por dispositivo. ⌘/Ctrl + e - percorrem os mesmos níveis de zoom deste menu (⌘/Ctrl 0 volta para 100%).", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f7bcddf864..d84ac2a987 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -258,6 +258,58 @@ } } }, + "presetGallery": { + "sectionTitle": "预设市场", + "sectionDescription": "其他人分享的外观,收录在一份公开索引里。每个文件都会先核对索引中记录的指纹,再按「只含颜色、字体与间距」的规则校验,通过后才会应用或保存。不会在后台自动加载:点「刷新」查看新预设。", + "searchPlaceholder": "搜索预设…", + "refresh": "刷新", + "lastRefreshed": "上次刷新:{time}", + "notLoaded": "尚未加载", + "savedCopy": "当前显示的是已保存的副本。", + "skipped": "{count, plural, other {已跳过 # 条无效条目。}}", + "loadFailed": "预设市场加载失败。", + "indexInvalid": "这份预设索引无法使用。", + "retry": "重试", + "empty": "没有找到匹配的预设。", + "emptyIndex": "这个市场还没有收录任何预设。", + "source": "来源", + "sourceDefault": "默认来源", + "sourceCustom": "自定义来源", + "sourceHint": "一个指向 index.json 的 https 链接。预设文件必须与索引位于同一站点。", + "sourceInvalid": "请输入指向公开站点的 https 链接。", + "sourceSave": "使用此来源", + "sourceReset": "恢复默认", + "byAuthor": "作者:{author}", + "installed": "已安装", + "updateAvailable": "有更新", + "notListed": "已不在索引中", + "preview": "预览", + "install": "安装", + "apply": "应用", + "update": "更新", + "remove": "移除", + "previewing": "正在预览:{name}", + "previewHint": "点「还原」即可回到之前的外观,离开此页面也会自动还原。", + "keep": "保留并安装", + "revert": "还原", + "fileErrorTitle": "无法安装这个预设", + "fileErrorDescription": "没有做任何改动。", + "toasts": { + "installed": "已安装:{name}", + "updated": "已更新:{name}", + "removed": "已移除:{name}" + }, + "errors": { + "tooLarge": "文件过大(上限 {limit} KB)", + "invalidJson": "不是有效的 JSON", + "unsupportedVersion": "由不同版本的市场格式写成", + "malformed": "字段缺失或不符合预期", + "tooMany": "收录的预设数量超出上限", + "hashMismatch": "文件与索引中记录的指纹不一致", + "idMismatch": "文件的 id 与索引条目不一致", + "invalidPreset": "文件不是有效的预设" + } + }, "zoomLevel": { "sectionTitle": "窗口缩放", "sectionDescription": "整体放大或缩小界面,立即生效,按设备分别保存。⌘/Ctrl + 和 - 按与此菜单相同的档位调节(⌘/Ctrl 0 恢复为 100%)。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 4cd262b18d..67cdcb9a61 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -258,6 +258,58 @@ } } }, + "presetGallery": { + "sectionTitle": "預設市集", + "sectionDescription": "其他人分享的外觀,收錄在一份公開索引中。每個檔案都會先核對索引記錄的指紋,再依「只含顏色、字型與間距」的規則驗證,通過後才會套用或保存。不會在背景自動載入:點「重新整理」查看新預設。", + "searchPlaceholder": "搜尋預設…", + "refresh": "重新整理", + "lastRefreshed": "上次更新:{time}", + "notLoaded": "尚未載入", + "savedCopy": "目前顯示的是已儲存的副本。", + "skipped": "{count, plural, other {已略過 # 筆無效項目。}}", + "loadFailed": "預設市集載入失敗。", + "indexInvalid": "這份預設索引無法使用。", + "retry": "重試", + "empty": "沒有找到符合的預設。", + "emptyIndex": "這個市集還沒有收錄任何預設。", + "source": "來源", + "sourceDefault": "標準來源", + "sourceCustom": "自訂來源", + "sourceHint": "一個指向 index.json 的 https 連結。預設檔案必須與索引位於同一個網站。", + "sourceInvalid": "請輸入指向公開網站的 https 連結。", + "sourceSave": "使用此來源", + "sourceReset": "恢復標準來源", + "byAuthor": "作者:{author}", + "installed": "已安裝", + "updateAvailable": "有更新", + "notListed": "已不在索引中", + "preview": "預覽", + "install": "安裝", + "apply": "套用", + "update": "更新", + "remove": "移除", + "previewing": "正在預覽:{name}", + "previewHint": "點「還原」即可回到先前的外觀,離開此頁面也會自動還原。", + "keep": "保留並安裝", + "revert": "還原", + "fileErrorTitle": "無法安裝這個預設", + "fileErrorDescription": "沒有做任何改動。", + "toasts": { + "installed": "已安裝:{name}", + "updated": "已更新:{name}", + "removed": "已移除:{name}" + }, + "errors": { + "tooLarge": "檔案過大(上限 {limit} KB)", + "invalidJson": "不是有效的 JSON", + "unsupportedVersion": "由不同版本的市集格式寫成", + "malformed": "欄位缺失或不符合預期", + "tooMany": "收錄的預設數量超出上限", + "hashMismatch": "檔案與索引記錄的指紋不一致", + "idMismatch": "檔案的 id 與索引項目不一致", + "invalidPreset": "檔案不是有效的預設" + } + }, "zoomLevel": { "sectionTitle": "視窗縮放", "sectionDescription": "整體放大或縮小介面,立即生效,依裝置分別儲存。⌘/Ctrl + 和 - 依與此選單相同的檔位調節(⌘/Ctrl 0 恢復為 100%)。", diff --git a/src/lib/appearance-preset-gallery.test.ts b/src/lib/appearance-preset-gallery.test.ts new file mode 100644 index 0000000000..d8fde3d5c8 --- /dev/null +++ b/src/lib/appearance-preset-gallery.test.ts @@ -0,0 +1,616 @@ +import { createHash } from "node:crypto" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const callMock = vi.fn() + +vi.mock("@/lib/transport", () => ({ + getTransport: () => ({ call: callMock }), +})) + +import { + DEFAULT_PRESET_GALLERY_INDEX_URL, + GALLERY_CACHE_STALE_MS, + MAX_GALLERY_ENTRIES, + MAX_GALLERY_INDEX_BYTES, + downloadGalleryPreset, + filterGalleryEntries, + galleryEntryStatus, + galleryEntrySwatches, + installedFromDownload, + isGalleryCacheStale, + normalizeGalleryIndexUrl, + parseGalleryCache, + parseInstalledGalleryPresets, + parsePresetGalleryIndex, + presetSwatches, + readGalleryCache, + readGalleryIndexUrl, + refreshGalleryIndex, + removeInstalledGalleryPreset, + resolveGalleryFileUrl, + serializeInstalledGalleryPresets, + upsertInstalledGalleryPreset, + writeGalleryIndexUrl, + type GalleryEntry, + type InstalledGalleryPreset, +} from "./appearance-preset-gallery" +import { + serializeAppearancePreset, + type AppearancePreset, +} from "./appearance-preset" +import { BUNDLED_PRESET_BY_ID } from "./appearance-presets-bundled" +import { + STORAGE_KEY_PRESET_GALLERY_CACHE, + STORAGE_KEY_PRESET_GALLERY_INDEX_URL, +} from "./appearance-script" + +const INDEX_URL = "https://gallery.example.com/presets/index.json" + +function sha256(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex") +} + +const sharedLook: AppearancePreset = { + schemaVersion: 1, + id: "shared-look", + name: "Shared look", + description: "Came in from the gallery.", + author: "someone", + base: "rose", + mode: "dark", + colors: { light: { primary: "#336699" }, dark: { primary: "#99ccff" } }, + code: { light: "one-light", dark: "one-dark-pro" }, +} +const sharedText = serializeAppearancePreset(sharedLook) +const sharedDigest = sha256(sharedText) + +function entry(overrides: Partial> = {}) { + return { + id: "shared-look", + name: "Shared look", + author: "someone", + base: "rose", + mode: "dark", + url: "shared-look.codeg-preset.json", + sha256: sharedDigest, + bytes: Buffer.byteLength(sharedText), + swatches: { light: ["#ffffff", "#336699"], dark: ["#000000", "#99ccff"] }, + ...overrides, + } +} + +function indexText(presets: unknown[], extra: Record = {}) { + return JSON.stringify({ + schemaVersion: 1, + name: "Test gallery", + presets, + ...extra, + }) +} + +beforeEach(() => { + callMock.mockReset() + localStorage.clear() +}) + +describe("parsePresetGalleryIndex", () => { + it("accepts a well-formed index and normalizes each listing", () => { + const result = parsePresetGalleryIndex( + indexText([ + entry(), + entry({ + id: "other", + name: "Other", + url: "https://gallery.example.com/elsewhere/other.json", + sha256: sharedDigest.toUpperCase(), + swatches: undefined, + bytes: undefined, + author: undefined, + mode: undefined, + }), + ]), + INDEX_URL + ) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.skipped).toEqual([]) + expect(result.index.name).toBe("Test gallery") + expect(result.index.presets.map((p) => p.id)).toEqual([ + "shared-look", + "other", + ]) + const [first, second] = result.index.presets + // Relative URLs resolve against the index; digests come back lowercase. + expect(first.url).toBe( + "https://gallery.example.com/presets/shared-look.codeg-preset.json" + ) + expect(first.swatches).toEqual({ + light: ["#ffffff", "#336699"], + dark: ["#000000", "#99ccff"], + }) + expect(second.url).toBe("https://gallery.example.com/elsewhere/other.json") + expect(second.sha256).toBe(sharedDigest) + expect(second.swatches).toBeUndefined() + }) + + it("refuses an oversized document at the byte count, before parsing", () => { + const result = parsePresetGalleryIndex( + "x".repeat(MAX_GALLERY_INDEX_BYTES + 1), + INDEX_URL + ) + expect(result).toEqual({ + ok: false, + issues: [ + { + path: "", + code: "too-large", + detail: `${MAX_GALLERY_INDEX_BYTES + 1} > ${MAX_GALLERY_INDEX_BYTES}`, + }, + ], + }) + }) + + it("refuses malformed JSON and non-objects", () => { + const malformed = parsePresetGalleryIndex("{ not json", INDEX_URL) + expect(malformed.ok).toBe(false) + if (!malformed.ok) expect(malformed.issues[0].code).toBe("invalid-json") + + const list = parsePresetGalleryIndex("[]", INDEX_URL) + expect(list.ok).toBe(false) + if (!list.ok) expect(list.issues[0].code).toBe("not-object") + }) + + it("refuses a document whose shape is wrong, naming each field", () => { + const codes = (text: string) => { + const result = parsePresetGalleryIndex(text, INDEX_URL) + return result.ok + ? [] + : result.issues.map((issue) => `${issue.path}:${issue.code}`) + } + expect(codes(JSON.stringify({ presets: [] }))).toEqual([ + "schemaVersion:missing", + ]) + expect(codes(JSON.stringify({ schemaVersion: 2, presets: [] }))).toEqual([ + "schemaVersion:unsupported-version", + ]) + expect( + codes(JSON.stringify({ schemaVersion: 1, presets: [], extra: true })) + ).toEqual(["extra:unknown-key"]) + expect(codes(JSON.stringify({ schemaVersion: 1, presets: {} }))).toEqual([ + "presets:wrong-type", + ]) + expect(codes(JSON.stringify({ schemaVersion: 1 }))).toEqual([ + "presets:missing", + ]) + expect( + codes( + JSON.stringify({ + schemaVersion: 1, + presets: Array.from({ length: MAX_GALLERY_ENTRIES + 1 }, () => + entry() + ), + }) + ) + ).toEqual(["presets:too-many"]) + }) + + it("skips a listing it cannot trust and keeps the rest", () => { + const result = parsePresetGalleryIndex( + indexText([ + entry(), + // A digest that is not SHA-256 hex. + entry({ id: "bad-digest", sha256: "abc" }), + // A file on another site than the index. + entry({ id: "elsewhere", url: "https://evil.example/x.json" }), + // A downgrade to http, and a loopback host. + entry({ id: "plain", url: "http://gallery.example.com/x.json" }), + entry({ id: "loop", url: "https://127.0.0.1/x.json" }), + // A field the format does not have. + entry({ id: "extra", tags: ["dark"] }), + // A base palette the app does not ship. + entry({ id: "no-base", base: "plum" }), + // A swatch that is not a colour (a declaration-escape attempt). + entry({ + id: "escape", + swatches: { light: ["red; background: url(x)"] }, + }), + // Too many swatches. + entry({ id: "many", swatches: { light: Array(7).fill("#000000") } }), + // A duplicate of the first id. + entry({ name: "Shared look again" }), + // A listing that is not an object at all. + "nope", + ]), + INDEX_URL + ) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.index.presets.map((p) => p.id)).toEqual(["shared-look"]) + expect( + result.skipped.map((issue) => `${issue.path}:${issue.code}`) + ).toEqual([ + "presets[1].sha256:invalid-digest", + "presets[2].url:cross-origin", + "presets[3].url:invalid-url", + "presets[4].url:invalid-url", + "presets[5].tags:unknown-key", + "presets[6].base:unknown-base", + "presets[7].swatches.light[0]:invalid-color", + "presets[8].swatches.light:out-of-range", + "presets[9].id:duplicate-id", + "presets[10]:not-object", + ]) + }) +}) + +describe("URL policy", () => { + it("resolves relative file URLs against the index and refuses the rest", () => { + expect(resolveGalleryFileUrl("a/b.json", INDEX_URL)).toBe( + "https://gallery.example.com/presets/a/b.json" + ) + expect(resolveGalleryFileUrl("/root.json#frag", INDEX_URL)).toBe( + "https://gallery.example.com/root.json" + ) + expect( + resolveGalleryFileUrl("http://gallery.example.com/x", INDEX_URL) + ).toBe(null) + expect( + resolveGalleryFileUrl("https://u:p@gallery.example.com/x", INDEX_URL) + ).toBe(null) + expect(resolveGalleryFileUrl("https://localhost/x", INDEX_URL)).toBe(null) + expect(resolveGalleryFileUrl("https://[::1]/x", INDEX_URL)).toBe(null) + expect(resolveGalleryFileUrl("not a url at all", "not a url")).toBe(null) + }) + + it("normalizes a source the user typed and refuses what the backend would", () => { + expect( + normalizeGalleryIndexUrl(" https://Example.com/gallery/index.json#x ") + ).toBe("https://example.com/gallery/index.json") + expect(normalizeGalleryIndexUrl("")).toBe(null) + expect(normalizeGalleryIndexUrl("http://example.com/index.json")).toBe(null) + expect(normalizeGalleryIndexUrl("https://10.0.0.8/index.json")).toBe(null) + expect(normalizeGalleryIndexUrl("https://user:pw@example.com/i.json")).toBe( + null + ) + expect(normalizeGalleryIndexUrl("file:///etc/passwd")).toBe(null) + }) + + it("stores a custom source and treats the default as nothing stored", () => { + expect(readGalleryIndexUrl()).toBe(DEFAULT_PRESET_GALLERY_INDEX_URL) + writeGalleryIndexUrl("https://example.com/index.json") + expect(localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_INDEX_URL)).toBe( + "https://example.com/index.json" + ) + expect(readGalleryIndexUrl()).toBe("https://example.com/index.json") + writeGalleryIndexUrl(DEFAULT_PRESET_GALLERY_INDEX_URL) + expect(localStorage.getItem(STORAGE_KEY_PRESET_GALLERY_INDEX_URL)).toBe( + null + ) + // A stored value that no longer passes the policy falls back to the default. + localStorage.setItem( + STORAGE_KEY_PRESET_GALLERY_INDEX_URL, + "http://example.com/index.json" + ) + expect(readGalleryIndexUrl()).toBe(DEFAULT_PRESET_GALLERY_INDEX_URL) + }) +}) + +describe("search and swatches", () => { + const entries: GalleryEntry[] = [ + { + id: "a", + name: "Warm terminal", + author: "Ada", + url: "https://x/a", + sha256: sharedDigest, + }, + { + id: "b", + name: "Ink", + description: "Monochrome and square", + url: "https://x/b", + sha256: sharedDigest, + }, + ] + + it("filters case-insensitively across id, name, author and description", () => { + expect(filterGalleryEntries(entries, "").map((e) => e.id)).toEqual([ + "a", + "b", + ]) + expect(filterGalleryEntries(entries, "WARM").map((e) => e.id)).toEqual([ + "a", + ]) + expect(filterGalleryEntries(entries, "ada").map((e) => e.id)).toEqual(["a"]) + expect(filterGalleryEntries(entries, "square").map((e) => e.id)).toEqual([ + "b", + ]) + expect(filterGalleryEntries(entries, " b ").map((e) => e.id)).toEqual([ + "b", + ]) + expect(filterGalleryEntries(entries, "zzz")).toEqual([]) + }) + + it("draws a card from the listing's own colours, else from its base palette", () => { + expect( + galleryEntrySwatches( + { base: "rose", swatches: { light: ["#fff", "#000"] } }, + false + ) + ).toEqual(["#fff", "#000"]) + // No dark swatches listed: neutral surfaces around the base's dark primary. + const dark = galleryEntrySwatches( + { base: "rose", swatches: { light: ["#fff"] } }, + true + ) + expect(dark).toHaveLength(4) + expect(dark[0]).toBe("oklch(0.145 0 0)") + expect(dark[2]).not.toBe(galleryEntrySwatches({ base: "blue" }, true)[2]) + // An installed preset paints from its own document. + const warm = BUNDLED_PRESET_BY_ID["warm-terminal"] + expect(presetSwatches(warm, true)).toEqual([ + warm.colors.dark?.background, + warm.colors.dark?.sidebar, + warm.colors.dark?.primary, + warm.colors.dark?.foreground, + ]) + expect(presetSwatches(BUNDLED_PRESET_BY_ID["default"], false)).toEqual([ + "oklch(1 0 0)", + "oklch(0.985 0 0)", + "oklch(0.205 0 0)", + "oklch(0.145 0 0)", + ]) + }) +}) + +describe("installed library", () => { + const installed: InstalledGalleryPreset = { + preset: sharedLook, + url: "https://gallery.example.com/presets/shared-look.codeg-preset.json", + indexUrl: INDEX_URL, + sha256: sharedDigest, + installedAt: "2026-09-06T10:00:00.000Z", + } + + it("reports available, installed and update from the digest alone", () => { + expect(galleryEntryStatus({ sha256: sharedDigest }, undefined)).toBe( + "available" + ) + expect(galleryEntryStatus({ sha256: sharedDigest }, installed)).toBe( + "installed" + ) + expect(galleryEntryStatus({ sha256: sha256("changed") }, installed)).toBe( + "update" + ) + }) + + it("installs by appending, updates in place, removes only that id", () => { + const other: InstalledGalleryPreset = { + ...installed, + preset: { ...sharedLook, id: "other", name: "Other" }, + } + const one = upsertInstalledGalleryPreset([], installed) + expect(one).toHaveLength(1) + const two = upsertInstalledGalleryPreset(one, other) + expect(two.map((i) => i.preset.id)).toEqual(["shared-look", "other"]) + + const updated = upsertInstalledGalleryPreset(two, { + ...installed, + sha256: sha256("changed"), + installedAt: "2026-09-07T10:00:00.000Z", + }) + expect(updated.map((i) => i.preset.id)).toEqual(["shared-look", "other"]) + expect(updated[0].sha256).toBe(sha256("changed")) + // The inputs were not mutated. + expect(two[0].sha256).toBe(sharedDigest) + + const removed = removeInstalledGalleryPreset(updated, "shared-look") + expect(removed.map((i) => i.preset.id)).toEqual(["other"]) + expect(removeInstalledGalleryPreset(removed, "missing")).toEqual(removed) + }) + + it("round-trips through storage and forgets what no longer validates", () => { + const text = serializeInstalledGalleryPresets([installed]) + expect(parseInstalledGalleryPresets(text)).toEqual([installed]) + + const tampered = JSON.stringify([ + installed, + // Duplicate id: the first wins. + { ...installed, sha256: sha256("other") }, + // A preset document that fails validation. + { + ...installed, + preset: { + ...sharedLook, + id: "bad", + colors: { dark: { primary: "plum" } }, + }, + }, + // A digest that is not SHA-256 hex. + { ...installed, preset: { ...sharedLook, id: "nohash" }, sha256: "nope" }, + // Missing provenance. + { preset: { ...sharedLook, id: "orphan" } }, + "garbage", + ]) + expect( + parseInstalledGalleryPresets(tampered).map((i) => i.preset.id) + ).toEqual(["shared-look"]) + expect(parseInstalledGalleryPresets("not json")).toEqual([]) + expect(parseInstalledGalleryPresets(null)).toEqual([]) + expect(parseInstalledGalleryPresets("{}")).toEqual([]) + }) +}) + +describe("saved index", () => { + it("accepts a saved copy of this index only, within the cap", () => { + const cache = { + indexUrl: INDEX_URL, + fetchedAt: "2026-09-06T10:00:00.000Z", + text: "{}", + } + expect(parseGalleryCache(JSON.stringify(cache), INDEX_URL)).toEqual(cache) + expect( + parseGalleryCache( + JSON.stringify(cache), + "https://other.example/index.json" + ) + ).toBe(null) + expect( + parseGalleryCache( + JSON.stringify({ + ...cache, + text: "x".repeat(MAX_GALLERY_INDEX_BYTES + 1), + }), + INDEX_URL + ) + ).toBe(null) + expect( + parseGalleryCache(JSON.stringify({ ...cache, fetchedAt: 5 }), INDEX_URL) + ).toBe(null) + expect(parseGalleryCache("nope", INDEX_URL)).toBe(null) + expect(parseGalleryCache(null, INDEX_URL)).toBe(null) + }) + + it("is stale after a day, or when the timestamp is unreadable", () => { + const now = Date.parse("2026-09-06T12:00:00.000Z") + expect(isGalleryCacheStale("2026-09-06T11:00:00.000Z", now)).toBe(false) + expect( + isGalleryCacheStale( + new Date(now - GALLERY_CACHE_STALE_MS - 1).toISOString(), + now + ) + ).toBe(true) + expect(isGalleryCacheStale("yesterday", now)).toBe(true) + }) +}) + +describe("refreshGalleryIndex", () => { + it("fetches through the backend, validates, and saves a valid index", async () => { + const text = indexText([entry()]) + callMock.mockResolvedValue({ + text, + sha256: sha256(text), + bytes: Buffer.byteLength(text), + }) + const result = await refreshGalleryIndex(INDEX_URL, () => + Date.parse("2026-09-06T10:00:00.000Z") + ) + expect(callMock).toHaveBeenCalledWith("preset_gallery_fetch_index", { + url: INDEX_URL, + }) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.fetchedAt).toBe("2026-09-06T10:00:00.000Z") + expect(result.index.presets[0].id).toBe("shared-look") + expect(readGalleryCache(INDEX_URL)).toEqual({ + indexUrl: INDEX_URL, + fetchedAt: "2026-09-06T10:00:00.000Z", + text, + }) + }) + + it("leaves the saved copy alone when the fetched index is invalid or unreachable", async () => { + const good = { + indexUrl: INDEX_URL, + fetchedAt: "2026-09-06T10:00:00.000Z", + text: indexText([entry()]), + } + localStorage.setItem(STORAGE_KEY_PRESET_GALLERY_CACHE, JSON.stringify(good)) + + callMock.mockResolvedValue({ text: "{ broken", sha256: "", bytes: 8 }) + const invalid = await refreshGalleryIndex(INDEX_URL) + expect(invalid.ok).toBe(false) + if (!invalid.ok) expect(invalid.issues[0].code).toBe("invalid-json") + expect(readGalleryCache(INDEX_URL)).toEqual(good) + + callMock.mockRejectedValue(new Error("offline")) + await expect(refreshGalleryIndex(INDEX_URL)).rejects.toThrow("offline") + expect(readGalleryCache(INDEX_URL)).toEqual(good) + }) +}) + +describe("downloadGalleryPreset", () => { + const listed: GalleryEntry = { + id: "shared-look", + name: "Shared look", + url: "https://gallery.example.com/presets/shared-look.codeg-preset.json", + sha256: sharedDigest, + } + + it("hands the listed digest to the backend and accepts a matching, valid file", async () => { + callMock.mockResolvedValue({ + text: sharedText, + sha256: sharedDigest, + bytes: Buffer.byteLength(sharedText), + }) + const result = await downloadGalleryPreset(listed) + expect(callMock).toHaveBeenCalledWith("preset_gallery_fetch_preset", { + url: listed.url, + sha256: sharedDigest, + }) + expect(result).toEqual({ + ok: true, + preset: sharedLook, + sha256: sharedDigest, + }) + const record = installedFromDownload( + listed, + { preset: sharedLook, sha256: sharedDigest }, + INDEX_URL, + Date.parse("2026-09-06T10:00:00.000Z") + ) + expect(record).toEqual({ + preset: sharedLook, + url: listed.url, + indexUrl: INDEX_URL, + sha256: sharedDigest, + installedAt: "2026-09-06T10:00:00.000Z", + }) + }) + + it("refuses a file whose digest is not the listed one", async () => { + // The backend already refuses this; if it ever returned bytes under a + // different digest, they are refused here too. + callMock.mockResolvedValue({ + text: sharedText, + sha256: sha256("tampered"), + bytes: Buffer.byteLength(sharedText), + }) + expect(await downloadGalleryPreset(listed)).toEqual({ + ok: false, + code: "hash-mismatch", + }) + }) + + it("refuses a file that does not validate as a preset, with the field", async () => { + const text = JSON.stringify({ ...sharedLook, script: "alert(1)" }) + callMock.mockResolvedValue({ + text, + sha256: sharedDigest, + bytes: Buffer.byteLength(text), + }) + const result = await downloadGalleryPreset(listed) + expect(result.ok).toBe(false) + if (result.ok || result.code !== "invalid-preset") + throw new Error("expected invalid-preset") + expect(result.issues).toEqual([{ path: "script", code: "unknown-key" }]) + }) + + it("refuses a valid preset filed under a different id", async () => { + const text = serializeAppearancePreset({ ...sharedLook, id: "other-id" }) + callMock.mockResolvedValue({ + text, + sha256: sharedDigest, + bytes: Buffer.byteLength(text), + }) + expect(await downloadGalleryPreset(listed)).toEqual({ + ok: false, + code: "id-mismatch", + }) + }) + + it("propagates a backend refusal untouched", async () => { + callMock.mockRejectedValue(new Error("Preset file exceeds the 32 KiB cap.")) + await expect(downloadGalleryPreset(listed)).rejects.toThrow("32 KiB cap") + }) +}) diff --git a/src/lib/appearance-preset-gallery.ts b/src/lib/appearance-preset-gallery.ts new file mode 100644 index 0000000000..054b784723 --- /dev/null +++ b/src/lib/appearance-preset-gallery.ts @@ -0,0 +1,901 @@ +// src/lib/appearance-preset-gallery.ts +// +// The preset gallery: an index of shareable appearance presets published as +// one static JSON file (a public repository by default), the preset files it +// points at, and the local library of installed ones. +// +// Trust, in order: +// +// 1. The backend fetches (https only, size-capped, time-limited) and refuses a +// preset file whose SHA-256 differs from the digest the index listed. +// 2. This module validates the index strictly (unknown keys at the top level +// are errors; a listing that fails validation is skipped and counted), +// resolves each file URL against the index URL and keeps only same-origin +// entries, compares the digest again, and runs every downloaded file +// through `parseAppearancePreset`, the validator an imported file goes +// through, before anything is applied or stored. A file whose `id` differs +// from its listing is refused too. +// 3. Installed presets are plain preset documents in localStorage together +// with the URL and digest they came from. "Update available" is a digest +// comparison against the current index; removing one deletes the local copy +// and nothing else. +// +// Nothing here fetches on a timer. The index is loaded when the gallery is +// opened without a usable saved copy or with one a day old, and on request. + +import { getTransport } from "@/lib/transport" +import { + STORAGE_KEY_PRESET_GALLERY_CACHE, + STORAGE_KEY_PRESET_GALLERY_INDEX_URL, + STORAGE_KEY_PRESET_GALLERY_INSTALLED, +} from "./appearance-script" +import { + MAX_PRESET_BYTES, + PRESET_LIMITS, + PRESET_MODES, + isPresetColorValue, + parseAppearancePreset, + validateAppearancePreset, + type AppearancePreset, + type PresetIssue, + type PresetIssueCode, + type PresetMode, +} from "./appearance-preset" +import { byteLengthOf } from "./custom-style" +import { + THEME_COLORS, + THEME_COLOR_PREVIEW, + THEME_COLOR_TITLE, + type ThemeColor, +} from "./theme-presets" + +// ─── Format ─── + +export const GALLERY_INDEX_SCHEMA_VERSION = 1 as const + +/** Index cap, checked before parse. Mirrors `MAX_INDEX_BYTES` in the backend. */ +export const MAX_GALLERY_INDEX_BYTES = 256 * 1024 +export const MAX_GALLERY_ENTRIES = 500 +/** Colours per mode a listing may show on its card. */ +export const MAX_GALLERY_SWATCHES = 6 +/** A saved index older than this is refreshed when the gallery opens. */ +export const GALLERY_CACHE_STALE_MS = 24 * 60 * 60 * 1000 + +/** + * The index the gallery reads when no other source is set. Presets in it are + * the seven bundled looks; the repository documents how to submit one. + */ +export const DEFAULT_PRESET_GALLERY_INDEX_URL = + "https://raw.githubusercontent.com/Adam-Dalloul/codeg-presets/main/index.json" + +/** One listing: what the card shows, where the file is, what it must hash to. */ +export type GalleryEntry = { + id: string + name: string + description?: string + author?: string + version?: string + base?: ThemeColor + mode?: PresetMode + /** Absolute https URL of the preset file, on the index's origin. */ + url: string + /** Lowercase hex SHA-256 of the file's bytes. */ + sha256: string + bytes?: number + /** Card colours per mode, from the preset's own palette. */ + swatches?: { light?: string[]; dark?: string[] } +} + +export type GalleryIndex = { + schemaVersion: typeof GALLERY_INDEX_SCHEMA_VERSION + name?: string + updatedAt?: string + presets: GalleryEntry[] +} + +export type GalleryIssueCode = + | PresetIssueCode + | "invalid-url" + | "cross-origin" + | "invalid-digest" + | "duplicate-id" + | "too-many" + +export type GalleryIssue = { + path: string + code: GalleryIssueCode + detail?: string +} + +export type GalleryIndexParseResult = + | { ok: true; index: GalleryIndex; skipped: GalleryIssue[] } + | { ok: false; issues: GalleryIssue[] } + +// ─── Validation ─── + +const INDEX_KEYS = [ + "$schema", + "schemaVersion", + "name", + "updatedAt", + "presets", +] as const + +const ENTRY_KEYS = [ + "id", + "name", + "description", + "author", + "version", + "base", + "mode", + "url", + "sha256", + "bytes", + "swatches", +] as const + +const SWATCH_KEYS = ["light", "dark"] as const + +/** + * Mirror `ID_RE` / `VERSION_RE` in appearance-preset.ts. The listing is only + * a promise about the file; the file itself is re-checked by that module. + */ +const ID_RE = /^[a-z0-9][a-z0-9-]*$/ +const VERSION_RE = /^[0-9A-Za-z.+-]+$/ +const SHA256_RE = /^[0-9a-f]{64}$/i +const MAX_URL_CHARS = 2048 +const MAX_INDEX_NAME_CHARS = 80 +const MAX_UPDATED_AT_CHARS = 40 + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function join(path: string, key: string): string { + return path ? `${path}.${key}` : key +} + +class Checker { + readonly issues: GalleryIssue[] = [] + + add(path: string, code: GalleryIssueCode, detail?: string): void { + this.issues.push(detail ? { path, code, detail } : { path, code }) + } + + keys( + record: Record, + allowed: readonly string[], + path: string + ): void { + const allowedSet = new Set(allowed) + for (const key of Object.keys(record)) { + if (!allowedSet.has(key)) this.add(join(path, key), "unknown-key") + } + } + + object( + value: unknown, + path: string, + required: boolean + ): Record | null { + if (value === undefined) { + if (required) this.add(path, "missing") + return null + } + if (!isRecord(value)) { + this.add(path, value === null ? "wrong-type" : "not-object") + return null + } + return value + } + + string( + value: unknown, + path: string, + { + required, + max, + pattern, + }: { required: boolean; max: number; pattern?: RegExp } + ): string | undefined { + if (value === undefined) { + if (required) this.add(path, "missing") + return undefined + } + if (typeof value !== "string") { + this.add(path, "wrong-type") + return undefined + } + const trimmed = value.trim() + if (required && !trimmed) { + this.add(path, "invalid-value", "empty") + return undefined + } + if (trimmed.length > max) { + this.add(path, "out-of-range", `max ${max} characters`) + return undefined + } + if (pattern && trimmed && !pattern.test(trimmed)) { + this.add(path, "invalid-value") + return undefined + } + return trimmed + } +} + +/** A hostname the backend would refuse too: an IP literal or a loopback name. */ +function isIpOrLocalHost(hostname: string): boolean { + const lower = hostname.toLowerCase() + if (lower === "localhost" || lower.endsWith(".localhost")) return true + if (lower.startsWith("[")) return true // bracketed IPv6 + return /^\d{1,3}(\.\d{1,3}){3}$/.test(lower) +} + +/** + * The URL a preset file listing points at, absolute and https, or null. A + * relative `url` resolves against the index, so a repository can list + * `presets/.codeg-preset.json` and move as a whole. + */ +export function resolveGalleryFileUrl( + raw: string, + indexUrl: string +): string | null { + try { + const resolved = new URL(raw, indexUrl) + if (resolved.protocol !== "https:") return null + if (resolved.username || resolved.password) return null + if (isIpOrLocalHost(resolved.hostname)) return null + resolved.hash = "" + return resolved.href + } catch { + return null + } +} + +/** + * A gallery source as the user typed it, normalized, or null when it is not + * an https URL to a public host without credentials. + */ +export function normalizeGalleryIndexUrl(raw: string): string | null { + const trimmed = raw.trim() + if (!trimmed || trimmed.length > MAX_URL_CHARS) return null + try { + const url = new URL(trimmed) + if (url.protocol !== "https:") return null + if (url.username || url.password) return null + if (isIpOrLocalHost(url.hostname)) return null + url.hash = "" + return url.href + } catch { + return null + } +} + +type EntryResult = + | { ok: true; entry: GalleryEntry } + | { ok: false; issues: GalleryIssue[] } + +function validateGalleryEntry( + raw: unknown, + path: string, + indexUrl: string, + origin: string +): EntryResult { + const c = new Checker() + const record = c.object(raw, path, true) + if (!record) return { ok: false, issues: c.issues } + c.keys(record, ENTRY_KEYS, path) + + const id = c.string(record.id, join(path, "id"), { + required: true, + max: PRESET_LIMITS.id, + pattern: ID_RE, + }) + const name = c.string(record.name, join(path, "name"), { + required: true, + max: PRESET_LIMITS.name, + }) + const description = c.string(record.description, join(path, "description"), { + required: false, + max: PRESET_LIMITS.description, + }) + const author = c.string(record.author, join(path, "author"), { + required: false, + max: PRESET_LIMITS.author, + }) + const version = c.string(record.version, join(path, "version"), { + required: false, + max: PRESET_LIMITS.version, + pattern: VERSION_RE, + }) + + let base: ThemeColor | undefined + if (record.base !== undefined) { + if (typeof record.base !== "string") { + c.add(join(path, "base"), "wrong-type") + } else if (!(THEME_COLORS as readonly string[]).includes(record.base)) { + c.add(join(path, "base"), "unknown-base") + } else { + base = record.base as ThemeColor + } + } + + let mode: PresetMode | undefined + if (record.mode !== undefined) { + if (typeof record.mode !== "string") { + c.add(join(path, "mode"), "wrong-type") + } else if (!(PRESET_MODES as readonly string[]).includes(record.mode)) { + c.add(join(path, "mode"), "invalid-value") + } else { + mode = record.mode as PresetMode + } + } + + let url: string | undefined + const urlPath = join(path, "url") + const rawUrl = c.string(record.url, urlPath, { + required: true, + max: MAX_URL_CHARS, + }) + if (rawUrl) { + const resolved = resolveGalleryFileUrl(rawUrl, indexUrl) + if (!resolved) { + c.add(urlPath, "invalid-url") + } else if (new URL(resolved).origin !== origin) { + // The user chose one site to trust; a listing cannot widen that. + c.add(urlPath, "cross-origin") + } else { + url = resolved + } + } + + let sha256: string | undefined + const digestPath = join(path, "sha256") + const rawDigest = c.string(record.sha256, digestPath, { + required: true, + max: 64, + }) + if (rawDigest !== undefined) { + if (SHA256_RE.test(rawDigest)) sha256 = rawDigest.toLowerCase() + else c.add(digestPath, "invalid-digest") + } + + let bytes: number | undefined + if (record.bytes !== undefined) { + const bytesPath = join(path, "bytes") + if (typeof record.bytes !== "number" || !Number.isInteger(record.bytes)) { + c.add(bytesPath, "wrong-type") + } else if (record.bytes < 1 || record.bytes > MAX_PRESET_BYTES) { + c.add(bytesPath, "out-of-range", `1 to ${MAX_PRESET_BYTES}`) + } else { + bytes = record.bytes + } + } + + let swatches: GalleryEntry["swatches"] + const swatchPath = join(path, "swatches") + const swatchRecord = c.object(record.swatches, swatchPath, false) + if (swatchRecord) { + c.keys(swatchRecord, SWATCH_KEYS, swatchPath) + swatches = {} + for (const key of SWATCH_KEYS) { + const list = swatchRecord[key] + if (list === undefined) continue + const listPath = join(swatchPath, key) + if (!Array.isArray(list)) { + c.add(listPath, "wrong-type") + continue + } + if (list.length < 1 || list.length > MAX_GALLERY_SWATCHES) { + c.add(listPath, "out-of-range", `1 to ${MAX_GALLERY_SWATCHES} colors`) + continue + } + const colors: string[] = [] + list.forEach((color, index) => { + const colorPath = `${listPath}[${index}]` + if (typeof color !== "string") { + c.add(colorPath, "wrong-type") + return + } + const trimmed = color.trim() + if (!isPresetColorValue(trimmed)) { + c.add(colorPath, "invalid-color") + return + } + colors.push(trimmed) + }) + if (colors.length === list.length) swatches[key] = colors + } + } + + if (c.issues.length > 0 || !id || !name || !url || !sha256) { + return { ok: false, issues: c.issues } + } + const entry: GalleryEntry = { id, name, url, sha256 } + if (description) entry.description = description + if (author) entry.author = author + if (version) entry.version = version + if (base) entry.base = base + if (mode) entry.mode = mode + if (bytes !== undefined) entry.bytes = bytes + if (swatches && Object.keys(swatches).length > 0) entry.swatches = swatches + return { ok: true, entry } +} + +/** + * Validate an already-parsed index. The document must be well-formed as a + * whole (version, keys, a `presets` array); each listing then stands or falls + * on its own, so one bad entry hides itself rather than the gallery. + */ +export function validatePresetGalleryIndex( + input: unknown, + indexUrl: string +): GalleryIndexParseResult { + const c = new Checker() + const doc = c.object(input, "", true) + if (!doc) return { ok: false, issues: c.issues } + c.keys(doc, INDEX_KEYS, "") + + const version = doc.schemaVersion + if (version === undefined) { + c.add("schemaVersion", "missing") + } else if (typeof version !== "number" || !Number.isInteger(version)) { + c.add("schemaVersion", "wrong-type") + } else if (version !== GALLERY_INDEX_SCHEMA_VERSION) { + c.add( + "schemaVersion", + "unsupported-version", + version > GALLERY_INDEX_SCHEMA_VERSION + ? `newer than ${GALLERY_INDEX_SCHEMA_VERSION}` + : undefined + ) + } + if (doc.$schema !== undefined && typeof doc.$schema !== "string") { + c.add("$schema", "wrong-type") + } + const name = c.string(doc.name, "name", { + required: false, + max: MAX_INDEX_NAME_CHARS, + }) + const updatedAt = c.string(doc.updatedAt, "updatedAt", { + required: false, + max: MAX_UPDATED_AT_CHARS, + }) + + let origin: string | null = null + try { + origin = new URL(indexUrl).origin + } catch { + c.add("", "invalid-url", "index URL") + } + + const rawPresets = doc.presets + if (rawPresets === undefined) { + c.add("presets", "missing") + } else if (!Array.isArray(rawPresets)) { + c.add("presets", "wrong-type") + } else if (rawPresets.length > MAX_GALLERY_ENTRIES) { + c.add("presets", "too-many", `max ${MAX_GALLERY_ENTRIES}`) + } + if (c.issues.length > 0 || !Array.isArray(rawPresets) || origin === null) { + return { ok: false, issues: c.issues } + } + + const presets: GalleryEntry[] = [] + const skipped: GalleryIssue[] = [] + const seen = new Set() + rawPresets.forEach((raw, index) => { + const path = `presets[${index}]` + const result = validateGalleryEntry(raw, path, indexUrl, origin) + if (!result.ok) { + skipped.push(...result.issues) + return + } + if (seen.has(result.entry.id)) { + skipped.push({ + path: join(path, "id"), + code: "duplicate-id", + detail: result.entry.id, + }) + return + } + seen.add(result.entry.id) + presets.push(result.entry) + }) + + const index: GalleryIndex = { + schemaVersion: GALLERY_INDEX_SCHEMA_VERSION, + presets, + } + if (name) index.name = name + if (updatedAt) index.updatedAt = updatedAt + return { ok: true, index, skipped } +} + +/** Index text from the network or the saved copy -> a validated index. */ +export function parsePresetGalleryIndex( + text: string, + indexUrl: string +): GalleryIndexParseResult { + const bytes = byteLengthOf(text) + if (bytes > MAX_GALLERY_INDEX_BYTES) { + return { + ok: false, + issues: [ + { + path: "", + code: "too-large", + detail: `${bytes} > ${MAX_GALLERY_INDEX_BYTES}`, + }, + ], + } + } + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + return { + ok: false, + issues: [ + { + path: "", + code: "invalid-json", + detail: error instanceof Error ? error.message : undefined, + }, + ], + } + } + return validatePresetGalleryIndex(value, indexUrl) +} + +/** Case-insensitive match on id, name, author and description. */ +export function filterGalleryEntries( + entries: readonly GalleryEntry[], + query: string +): GalleryEntry[] { + const needle = query.trim().toLowerCase() + if (!needle) return [...entries] + return entries.filter((entry) => + [entry.id, entry.name, entry.author, entry.description].some((field) => + field?.toLowerCase().includes(needle) + ) + ) +} + +// ─── Swatches ─── + +function neutralSwatches(dark: boolean): [string, string, string] { + return dark + ? ["oklch(0.145 0 0)", "oklch(0.205 0 0)", "oklch(0.985 0 0)"] + : ["oklch(1 0 0)", "oklch(0.985 0 0)", "oklch(0.145 0 0)"] +} + +function basePrimary(base: ThemeColor, dark: boolean): string { + return dark ? THEME_COLOR_TITLE[base].dark : THEME_COLOR_PREVIEW[base] +} + +/** + * Four swatches that stand for a preset, for the mode the app is in: + * background, sidebar, primary, text. Explicit preset colours win; the + * fallbacks are the base palette's primary and the neutral surfaces, close for + * every built-in base. + */ +export function presetSwatches( + preset: AppearancePreset, + dark: boolean +): string[] { + const colors = (dark ? preset.colors.dark : preset.colors.light) ?? {} + const [background, sidebar, foreground] = neutralSwatches(dark) + return [ + colors.background ?? background, + colors.sidebar ?? sidebar, + colors.primary ?? basePrimary(preset.base, dark), + colors.foreground ?? foreground, + ] +} + +/** + * The card strip for a listing the user has not installed: the swatches the + * index carries for the mode, else the same fallbacks as a preset with no + * colour overrides on its base palette. + */ +export function galleryEntrySwatches( + entry: Pick, + dark: boolean +): string[] { + const listed = dark ? entry.swatches?.dark : entry.swatches?.light + if (listed && listed.length > 0) return listed + const [background, sidebar, foreground] = neutralSwatches(dark) + return [ + background, + sidebar, + basePrimary(entry.base ?? "neutral", dark), + foreground, + ] +} + +// ─── Storage helpers ─── + +function readStored(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function persist(key: string, value: string | null): void { + try { + if (value === null) localStorage.removeItem(key) + else localStorage.setItem(key, value) + } catch { + // localStorage unavailable; this session still works from memory + } +} + +// ─── Gallery source ─── + +/** The index URL in use: the stored one when it validates, else the default. */ +export function readGalleryIndexUrl(): string { + const stored = readStored(STORAGE_KEY_PRESET_GALLERY_INDEX_URL) + return ( + (stored && normalizeGalleryIndexUrl(stored)) ?? + DEFAULT_PRESET_GALLERY_INDEX_URL + ) +} + +/** Store a validated index URL; the default is stored as "nothing set". */ +export function writeGalleryIndexUrl(url: string): void { + persist( + STORAGE_KEY_PRESET_GALLERY_INDEX_URL, + url === DEFAULT_PRESET_GALLERY_INDEX_URL ? null : url + ) +} + +// ─── Installed library ─── + +/** An installed preset with where it came from. */ +export type InstalledGalleryPreset = { + preset: AppearancePreset + url: string + indexUrl: string + sha256: string + /** ISO timestamp of the install or last update. */ + installedAt: string +} + +/** + * The stored library. Each preset runs the full validation, so a document + * written by an older build that no longer validates is forgotten rather + * than trusted; the first entry wins a duplicate id. + */ +export function parseInstalledGalleryPresets( + raw: string | null +): InstalledGalleryPreset[] { + if (!raw) return [] + let value: unknown + try { + value = JSON.parse(raw) + } catch { + return [] + } + if (!Array.isArray(value)) return [] + const out: InstalledGalleryPreset[] = [] + const seen = new Set() + for (const item of value) { + if (!isRecord(item)) continue + const parsed = validateAppearancePreset(item.preset) + if (!parsed.ok) continue + const { url, indexUrl, sha256, installedAt } = item + if ( + typeof url !== "string" || + typeof indexUrl !== "string" || + typeof sha256 !== "string" || + typeof installedAt !== "string" || + !SHA256_RE.test(sha256) + ) { + continue + } + if (seen.has(parsed.preset.id)) continue + seen.add(parsed.preset.id) + out.push({ + preset: parsed.preset, + url, + indexUrl, + sha256: sha256.toLowerCase(), + installedAt, + }) + } + return out +} + +export function serializeInstalledGalleryPresets( + list: readonly InstalledGalleryPreset[] +): string { + return JSON.stringify(list) +} + +export function readInstalledGalleryPresets(): InstalledGalleryPreset[] { + return parseInstalledGalleryPresets( + readStored(STORAGE_KEY_PRESET_GALLERY_INSTALLED) + ) +} + +export function writeInstalledGalleryPresets( + list: readonly InstalledGalleryPreset[] +): void { + persist( + STORAGE_KEY_PRESET_GALLERY_INSTALLED, + serializeInstalledGalleryPresets(list) + ) +} + +/** Install or update: replace by preset id, else append. */ +export function upsertInstalledGalleryPreset( + list: readonly InstalledGalleryPreset[], + item: InstalledGalleryPreset +): InstalledGalleryPreset[] { + const index = list.findIndex((i) => i.preset.id === item.preset.id) + if (index < 0) return [...list, item] + const next = [...list] + next[index] = item + return next +} + +/** Remove deletes the local copy and nothing else. */ +export function removeInstalledGalleryPreset( + list: readonly InstalledGalleryPreset[], + id: string +): InstalledGalleryPreset[] { + return list.filter((item) => item.preset.id !== id) +} + +export type GalleryEntryStatus = "available" | "installed" | "update" + +/** "Update available" is a digest comparison, nothing more. */ +export function galleryEntryStatus( + entry: Pick, + installed: Pick | undefined +): GalleryEntryStatus { + if (!installed) return "available" + return installed.sha256 === entry.sha256 ? "installed" : "update" +} + +// ─── Saved index ─── + +export type GalleryCache = { + indexUrl: string + /** ISO timestamp of the fetch. */ + fetchedAt: string + text: string +} + +/** The saved copy, only when it is a copy of `indexUrl` and within the cap. */ +export function parseGalleryCache( + raw: string | null, + indexUrl: string +): GalleryCache | null { + if (!raw) return null + try { + const value: unknown = JSON.parse(raw) + if (!isRecord(value)) return null + if (value.indexUrl !== indexUrl) return null + if (typeof value.fetchedAt !== "string" || typeof value.text !== "string") { + return null + } + if (byteLengthOf(value.text) > MAX_GALLERY_INDEX_BYTES) return null + return { indexUrl, fetchedAt: value.fetchedAt, text: value.text } + } catch { + return null + } +} + +export function readGalleryCache(indexUrl: string): GalleryCache | null { + return parseGalleryCache( + readStored(STORAGE_KEY_PRESET_GALLERY_CACHE), + indexUrl + ) +} + +export function writeGalleryCache(cache: GalleryCache): void { + persist(STORAGE_KEY_PRESET_GALLERY_CACHE, JSON.stringify(cache)) +} + +export function isGalleryCacheStale( + fetchedAt: string, + now: number = Date.now() +): boolean { + const at = Date.parse(fetchedAt) + return !Number.isFinite(at) || now - at > GALLERY_CACHE_STALE_MS +} + +// ─── Transport bindings ─── + +/** camelCase mirror of the Rust `GalleryDocument`. */ +export type GalleryDocument = { + text: string + sha256: string + bytes: number +} + +export async function fetchGalleryIndexDocument( + url: string +): Promise { + return getTransport().call("preset_gallery_fetch_index", { url }) +} + +export async function fetchGalleryPresetDocument( + url: string, + sha256: string +): Promise { + return getTransport().call("preset_gallery_fetch_preset", { url, sha256 }) +} + +// ─── Operations ─── + +export type GalleryRefreshResult = + | { + ok: true + index: GalleryIndex + skipped: GalleryIssue[] + fetchedAt: string + } + | { ok: false; issues: GalleryIssue[] } + +/** + * Fetch and validate the index at `indexUrl`. A valid one becomes the saved + * copy; an invalid one leaves the previous saved copy alone. Transport and + * backend refusals (offline, https policy, size cap) throw. + */ +export async function refreshGalleryIndex( + indexUrl: string, + now: () => number = Date.now +): Promise { + const doc = await fetchGalleryIndexDocument(indexUrl) + const parsed = parsePresetGalleryIndex(doc.text, indexUrl) + if (!parsed.ok) return parsed + const fetchedAt = new Date(now()).toISOString() + writeGalleryCache({ indexUrl, fetchedAt, text: doc.text }) + return { ok: true, index: parsed.index, skipped: parsed.skipped, fetchedAt } +} + +export type GalleryDownloadResult = + | { ok: true; preset: AppearancePreset; sha256: string } + | { ok: false; code: "hash-mismatch" | "id-mismatch" } + | { ok: false; code: "invalid-preset"; issues: PresetIssue[] } + +/** + * Download one listed file. The backend has already refused a digest + * mismatch; the digest is compared again here, then the text goes through the + * preset validator, then the file's id must be the listing's. Nothing is + * applied or stored by this function. + */ +export async function downloadGalleryPreset( + entry: GalleryEntry +): Promise { + const doc = await fetchGalleryPresetDocument(entry.url, entry.sha256) + const sha256 = doc.sha256.toLowerCase() + if (sha256 !== entry.sha256) return { ok: false, code: "hash-mismatch" } + const parsed = parseAppearancePreset(doc.text) + if (!parsed.ok) + return { ok: false, code: "invalid-preset", issues: parsed.issues } + if (parsed.preset.id !== entry.id) return { ok: false, code: "id-mismatch" } + return { ok: true, preset: parsed.preset, sha256 } +} + +/** The library record for a verified download. */ +export function installedFromDownload( + entry: GalleryEntry, + download: { preset: AppearancePreset; sha256: string }, + indexUrl: string, + now: number = Date.now() +): InstalledGalleryPreset { + return { + preset: download.preset, + url: entry.url, + indexUrl, + sha256: download.sha256, + installedAt: new Date(now).toISOString(), + } +} diff --git a/src/lib/appearance-preset.ts b/src/lib/appearance-preset.ts index 0fc4b8c476..84bb612cca 100644 --- a/src/lib/appearance-preset.ts +++ b/src/lib/appearance-preset.ts @@ -218,6 +218,16 @@ export function isPresetColorToken(name: unknown): name is PresetColorToken { return typeof name === "string" && PRESET_COLOR_TOKEN_SET.has(name) } +/** + * Whether `value` is a colour a preset may carry: the token charset guard + * (declaration-escape defence) first, then the syntax check that says "this + * is a colour". Exported for the gallery index, whose card swatches are + * colours that belong to no token. + */ +export function isPresetColorValue(value: string): boolean { + return isValidTokenValue("primary", value) && COLOR_SYNTAX_RE.test(value) +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/src/lib/appearance-script.ts b/src/lib/appearance-script.ts index d633d03465..e55c6eb88e 100644 --- a/src/lib/appearance-script.ts +++ b/src/lib/appearance-script.ts @@ -51,6 +51,18 @@ export const STORAGE_KEY_MONO_FONT_STACK = "codeg-mono-font-stack" export const STORAGE_KEY_CODE_THEME = "codeg-code-theme" export const STORAGE_KEY_APPEARANCE_PRESET = "codeg-appearance-preset" +// Preset gallery. INSTALLED is the local library, `[{ preset, url, indexUrl, +// sha256, installedAt }]`: plain preset documents plus where each came from, +// so "update available" is a digest comparison. INDEX_URL is the gallery +// source when it differs from the default. CACHE is the last index fetched, +// `{ indexUrl, fetchedAt, text }`, so the gallery opens offline and can say +// when it was refreshed. None of this is read before paint. +export const STORAGE_KEY_PRESET_GALLERY_INSTALLED = + "codeg-preset-gallery-installed" +export const STORAGE_KEY_PRESET_GALLERY_INDEX_URL = + "codeg-preset-gallery-index-url" +export const STORAGE_KEY_PRESET_GALLERY_CACHE = "codeg-preset-gallery-cache" + // Workspace 背景图片。图片本身存磁盘(~/.codeg/backgrounds/),localStorage 只存 // 展示配置。仅 enabled 与 panel-opacity 需要预水合(它们作用于首帧就存在的结构性 // 表面);mask/blur/fill 水合后才有意义(图片异步到达),不进 inline 脚本。