diff --git a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts index 0e449092e5..76386406fb 100644 --- a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts +++ b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts @@ -2,7 +2,11 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { create } from "zustand"; -import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { + createJSONStorage, + persist, + type StateStorage, +} from "zustand/middleware"; import type { ResolvedTheme } from "./theme-store"; // Best-effort persistence: localStorage can be blocked (private browsing) and @@ -200,7 +204,9 @@ function sanitizeImportedFonts(value: unknown): ImportedFont[] { const source = (entry ?? {}) as Partial; // Cap to the backend name length so an over-long name can't fail the PUT. const rawName = sanitizeFont(source.name); - const name = rawName ? rawName.slice(0, MAX_IMPORTED_FONT_NAME_LENGTH) : null; + const name = rawName + ? rawName.slice(0, MAX_IMPORTED_FONT_NAME_LENGTH) + : null; if (!name || seen.has(name)) continue; const dataUrl = source.dataUrl; if ( @@ -397,7 +403,10 @@ function readableForeground(hex: string): string { * FontFaces registered for imported fonts, keyed by family name. The dataUrl is * tracked too so a re-import under the same name (new bytes) replaces the face. */ -const registeredFontFaces = new Map(); +const registeredFontFaces = new Map< + string, + { face: FontFace; dataUrl: string } +>(); function syncImportedFonts(fonts: ImportedFont[]): void { if (typeof document === "undefined" || !("fonts" in document)) return; @@ -436,12 +445,20 @@ function syncImportedFonts(fonts: ImportedFont[]): void { } /** - * The custom "Accent" recolors the accent family (toggles, badges, chart-1). - * Focus/selection rings and button colors (--primary) are deliberately left - * alone: highlight borders stay neutral and Classic's buttons stay neutral. + * The custom "Accent" recolors the whole accent family: toggles and badges + * (--control-accent), charts (--chart-1), and the brand color behind primary + * buttons, active pills and meter labels (--primary). Only set while the user + * has picked an accent, so every palette keeps its own colors by default. + * + * Focus rings are unaffected: --ring is its own neutral in every palette. + * --verified stays pinned to the brand green on purpose, since it signals + * status rather than theme. */ -const ACCENT_VARS = ["--control-accent", "--chart-1"] as const; -const ACCENT_FG_VARS = ["--control-accent-foreground"] as const; +const ACCENT_VARS = ["--control-accent", "--chart-1", "--primary"] as const; +const ACCENT_FG_VARS = [ + "--control-accent-foreground", + "--primary-foreground", +] as const; /** * Push the customization onto as inline CSS variables, attributes, and @@ -515,7 +532,10 @@ export function applyCustomizationToDocument( // scale reaches text through the --text-* / --text-ui-* / --leading-* // tokens in index.css. if (c.uiFontSize !== null && c.uiFontSize !== UI_FONT_SIZE_RANGE.default) { - setVar("--ui-font-scale", String(c.uiFontSize / UI_FONT_SIZE_RANGE.default)); + setVar( + "--ui-font-scale", + String(c.uiFontSize / UI_FONT_SIZE_RANGE.default), + ); el.setAttribute("data-ui-font-size", String(c.uiFontSize)); } else { setVar("--ui-font-scale", null); @@ -563,7 +583,8 @@ export function applyCustomizationToDocument( * (canvas-confetti, view transitions) that CSS/MotionConfig cannot reach. */ export function prefersReducedMotion(): boolean { - const setting = useAppearanceCustomStore.getState().customization.reduceMotion; + const setting = + useAppearanceCustomStore.getState().customization.reduceMotion; if (setting === "on") return true; if (setting === "off") return false; return ( diff --git a/studio/frontend/tests/appearance-accent-vars.test.ts b/studio/frontend/tests/appearance-accent-vars.test.ts new file mode 100644 index 0000000000..555d76e5ca --- /dev/null +++ b/studio/frontend/tests/appearance-accent-vars.test.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +/** Minimal stand-in: the applier only needs style, attributes, classes. */ +function stubDocument() { + const vars = new Map(); + const attributes = new Set(); + const classes = new Set(); + const element = { + style: { + setProperty: (name: string, value: string) => vars.set(name, value), + removeProperty: (name: string) => vars.delete(name), + }, + setAttribute: (name: string) => attributes.add(name), + removeAttribute: (name: string) => attributes.delete(name), + toggleAttribute: (name: string, on: boolean) => + on ? attributes.add(name) : attributes.delete(name), + classList: { + toggle: (name: string, on: boolean) => + on ? classes.add(name) : classes.delete(name), + }, + }; + // No "fonts" key, so syncImportedFonts bails before touching FontFace. + (globalThis as { document?: unknown }).document = { + documentElement: element, + }; + return vars; +} + +const vars = stubDocument(); + +const { applyCustomizationToDocument, DEFAULT_CUSTOMIZATION } = await import( + "../src/features/settings/stores/appearance-custom-store.ts" +); + +const withAccent = (accent: string | null) => ({ + ...DEFAULT_CUSTOMIZATION, + colors: { + light: { ...DEFAULT_CUSTOMIZATION.colors.light, accent }, + dark: { ...DEFAULT_CUSTOMIZATION.colors.dark, accent }, + }, +}); + +test("a custom accent recolors the brand variable, not just the control one", () => { + applyCustomizationToDocument(withAccent("#7c3aed"), "light"); + + // --primary drives primary buttons, active composer pills and the meter + // percentages; leaving it out is what stranded them on the palette green. + assert.equal(vars.get("--primary"), "#7c3aed"); + assert.equal(vars.get("--control-accent"), "#7c3aed"); + assert.equal(vars.get("--chart-1"), "#7c3aed"); +}); + +test("both foregrounds follow the accent so button labels stay readable", () => { + applyCustomizationToDocument(withAccent("#7c3aed"), "light"); + const onDark = vars.get("--primary-foreground"); + assert.equal(vars.get("--control-accent-foreground"), onDark); + + applyCustomizationToDocument(withAccent("#fde68a"), "light"); + assert.notEqual(vars.get("--primary-foreground"), onDark); +}); + +test("no accent leaves every palette variable alone", () => { + applyCustomizationToDocument(withAccent("#7c3aed"), "light"); + applyCustomizationToDocument(withAccent(null), "light"); + + for (const name of [ + "--primary", + "--primary-foreground", + "--control-accent", + "--control-accent-foreground", + "--chart-1", + ]) { + assert.equal(vars.has(name), false, `${name} should be removed`); + } +}); + +test("focus rings are never touched, so highlight borders stay neutral", () => { + applyCustomizationToDocument(withAccent("#7c3aed"), "light"); + assert.equal(vars.has("--ring"), false); + // Status green is a signal, not a theme color. + assert.equal(vars.has("--verified"), false); +});