Compare commits
2 commits
main
...
studio-acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25b238cc4b | ||
|
|
75ef0f2e23 |
2 changed files with 184 additions and 11 deletions
|
|
@ -2,7 +2,11 @@
|
||||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||||
|
|
||||||
import { create } from "zustand";
|
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";
|
import type { ResolvedTheme } from "./theme-store";
|
||||||
|
|
||||||
// Best-effort persistence: localStorage can be blocked (private browsing) and
|
// 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>;
|
const source = (entry ?? {}) as Partial<ImportedFont>;
|
||||||
// Cap to the backend name length so an over-long name can't fail the PUT.
|
// Cap to the backend name length so an over-long name can't fail the PUT.
|
||||||
const rawName = sanitizeFont(source.name);
|
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;
|
if (!name || seen.has(name)) continue;
|
||||||
const dataUrl = source.dataUrl;
|
const dataUrl = source.dataUrl;
|
||||||
if (
|
if (
|
||||||
|
|
@ -389,15 +395,37 @@ function hexLuminance(hex: string): number {
|
||||||
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FOREGROUND_DARK = "#111417";
|
||||||
|
const FOREGROUND_LIGHT = "#ffffff";
|
||||||
|
|
||||||
|
/** WCAG contrast ratio between two relative luminances. */
|
||||||
|
function contrastRatio(a: number, b: number): number {
|
||||||
|
const [high, low] = a >= b ? [a, b] : [b, a];
|
||||||
|
return (high + 0.05) / (low + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whichever foreground actually contrasts more. A fixed luminance threshold
|
||||||
|
* put white on mid-tone accents: #22c55e scored 2.28:1 on white against
|
||||||
|
* 8.11:1 on the dark ink. The crossover for this pair is near 0.19, but
|
||||||
|
* comparing the ratios needs no constant at all.
|
||||||
|
*/
|
||||||
function readableForeground(hex: string): string {
|
function readableForeground(hex: string): string {
|
||||||
return hexLuminance(hex) > 0.45 ? "#111417" : "#ffffff";
|
const accent = hexLuminance(hex);
|
||||||
|
return contrastRatio(accent, hexLuminance(FOREGROUND_DARK)) >=
|
||||||
|
contrastRatio(accent, hexLuminance(FOREGROUND_LIGHT))
|
||||||
|
? FOREGROUND_DARK
|
||||||
|
: FOREGROUND_LIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FontFaces registered for imported fonts, keyed by family name. The dataUrl is
|
* 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.
|
* 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 {
|
function syncImportedFonts(fonts: ImportedFont[]): void {
|
||||||
if (typeof document === "undefined" || !("fonts" in document)) return;
|
if (typeof document === "undefined" || !("fonts" in document)) return;
|
||||||
|
|
@ -436,12 +464,20 @@ function syncImportedFonts(fonts: ImportedFont[]): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The custom "Accent" recolors the accent family (toggles, badges, chart-1).
|
* The custom "Accent" recolors the whole accent family: toggles and badges
|
||||||
* Focus/selection rings and button colors (--primary) are deliberately left
|
* (--control-accent), charts (--chart-1), and the brand color behind primary
|
||||||
* alone: highlight borders stay neutral and Classic's buttons stay neutral.
|
* 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_VARS = ["--control-accent", "--chart-1", "--primary"] as const;
|
||||||
const ACCENT_FG_VARS = ["--control-accent-foreground"] as const;
|
const ACCENT_FG_VARS = [
|
||||||
|
"--control-accent-foreground",
|
||||||
|
"--primary-foreground",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push the customization onto <html> as inline CSS variables, attributes, and
|
* Push the customization onto <html> as inline CSS variables, attributes, and
|
||||||
|
|
@ -515,7 +551,10 @@ export function applyCustomizationToDocument(
|
||||||
// scale reaches text through the --text-* / --text-ui-* / --leading-*
|
// scale reaches text through the --text-* / --text-ui-* / --leading-*
|
||||||
// tokens in index.css.
|
// tokens in index.css.
|
||||||
if (c.uiFontSize !== null && c.uiFontSize !== UI_FONT_SIZE_RANGE.default) {
|
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));
|
el.setAttribute("data-ui-font-size", String(c.uiFontSize));
|
||||||
} else {
|
} else {
|
||||||
setVar("--ui-font-scale", null);
|
setVar("--ui-font-scale", null);
|
||||||
|
|
@ -563,7 +602,8 @@ export function applyCustomizationToDocument(
|
||||||
* (canvas-confetti, view transitions) that CSS/MotionConfig cannot reach.
|
* (canvas-confetti, view transitions) that CSS/MotionConfig cannot reach.
|
||||||
*/
|
*/
|
||||||
export function prefersReducedMotion(): boolean {
|
export function prefersReducedMotion(): boolean {
|
||||||
const setting = useAppearanceCustomStore.getState().customization.reduceMotion;
|
const setting =
|
||||||
|
useAppearanceCustomStore.getState().customization.reduceMotion;
|
||||||
if (setting === "on") return true;
|
if (setting === "on") return true;
|
||||||
if (setting === "off") return false;
|
if (setting === "off") return false;
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
133
studio/frontend/tests/appearance-accent-vars.test.ts
Normal file
133
studio/frontend/tests/appearance-accent-vars.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** WCAG relative luminance, independent of the implementation under test. */
|
||||||
|
function luminance(hex: string): number {
|
||||||
|
const channel = (index: number) => {
|
||||||
|
const value = Number.parseInt(hex.slice(index, index + 2), 16) / 255;
|
||||||
|
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratio(a: string, b: string): number {
|
||||||
|
const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||||
|
return ((high ?? 0) + 0.05) / ((low ?? 0) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the foreground is the higher-contrast of the two, not a luminance guess", () => {
|
||||||
|
// Mid-tone accents are the ones a fixed 0.45 cutoff got wrong.
|
||||||
|
for (const accent of [
|
||||||
|
"#22c55e",
|
||||||
|
"#17b88b",
|
||||||
|
"#339cff",
|
||||||
|
"#f59e0b",
|
||||||
|
"#4ade80",
|
||||||
|
"#7c3aed",
|
||||||
|
"#e11d48",
|
||||||
|
"#0d0d0d",
|
||||||
|
"#fde68a",
|
||||||
|
"#ececec",
|
||||||
|
]) {
|
||||||
|
applyCustomizationToDocument(withAccent(accent), "light");
|
||||||
|
const chosen = vars.get("--primary-foreground") ?? "";
|
||||||
|
const other = chosen === "#ffffff" ? "#111417" : "#ffffff";
|
||||||
|
assert.ok(
|
||||||
|
ratio(accent, chosen) >= ratio(accent, other),
|
||||||
|
`${accent}: picked ${chosen} at ${ratio(accent, chosen).toFixed(2)}:1 over ${other} at ${ratio(accent, other).toFixed(2)}:1`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a saturated green label clears WCAG AA instead of failing it", () => {
|
||||||
|
applyCustomizationToDocument(withAccent("#22c55e"), "light");
|
||||||
|
const chosen = vars.get("--primary-foreground") ?? "";
|
||||||
|
assert.ok(
|
||||||
|
ratio("#22c55e", chosen) >= 4.5,
|
||||||
|
`#22c55e on ${chosen} is only ${ratio("#22c55e", chosen).toFixed(2)}:1`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue