Studio: let the custom accent recolor the brand variable too

A custom accent set --control-accent and --chart-1 but not --primary,
so picking a new color left primary buttons, active composer pills,
the panel switches, the artifact shimmer and the monitor percentages
on the palette green while the meters beside them changed.

Set --primary and --primary-foreground alongside them. Both are only
written while an accent is chosen, so palettes are untouched by default.
--ring is its own neutral and --verified is a status signal, so neither
follows the accent.
This commit is contained in:
Unsloth 2026-07-29 01:51:17 -07:00
commit 75ef0f2e23
2 changed files with 117 additions and 10 deletions

View file

@ -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<ImportedFont>;
// 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<string, { face: FontFace; dataUrl: string }>();
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 <html> 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 (

View file

@ -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 <html> stand-in: the applier only needs style, attributes, classes. */
function stubDocument() {
const vars = new Map<string, string>();
const attributes = new Set<string>();
const classes = new Set<string>();
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);
});