feat(t5): neue Farbwerte, Dunkel für alle, Farb-Codemod (T5a A4)
- globals.css: Hell, Dunkel und Druck mit den Spec-Werten aus 4.2, Fokusring = --primary-ink, Verlaufs-Token entfernt. - Codemod scripts/codemod-t5-colors.mjs: text-primary/-warning auf -ink, Vordergrund nur auf voller Fläche, Auswahlrahmen dunkel; --check-Modus und Spec halten src sauber. - Kontrast-Wächter misst die echte globals.css (VALUES_SWITCHED), alle 21 Zeilen aus 4.4 in allen vier Sätzen; keine dark:-Klassen mehr in src. - Dunkel ist ohne Entwickler-Schalter wählbar, Standard bleibt Hell. - Review-Nacharbeit: Auswahl-Umrisse und aktueller Schritt in Vordergrund, deaktivierte Felder bg-muted, Avatar ohne Blend, Honig-Check-Rahmen über --input. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6edae3efec
commit
d8238855fc
91 files changed
+673
-391
No files matched your search
@@ -1,14 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { atRuleDeclarations, blend, contrastOf, inGamut, oklchOf, tokenSet, type TokenSet } from "./css-tokens";
|
||||
import {
|
||||
atRuleDeclarations,
|
||||
blend,
|
||||
contrastOf,
|
||||
hexOf,
|
||||
inGamut,
|
||||
oklchOf,
|
||||
tokenSet,
|
||||
type TokenSet,
|
||||
} from "./css-tokens";
|
||||
|
||||
/**
|
||||
* Task A4 sets this to true. Until then globals.css keeps the old base values for the tokens in
|
||||
* TARGET; the guard measures the table against the end values so the plan numbers are proven
|
||||
* now, and the switch then pins globals.css to exactly these values.
|
||||
* Task A4 switched globals.css to the end values (spec 4.2): the guard reads the real sets and
|
||||
* pins the base sets to TARGET. Before A4 it measured TARGET instead of the old values.
|
||||
*/
|
||||
export const VALUES_SWITCHED = false;
|
||||
export const VALUES_SWITCHED = true;
|
||||
|
||||
const css = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf8");
|
||||
const SETS: TokenSet[] = ["light", "dark", "sun-light", "sun-dark"];
|
||||
@@ -88,7 +96,7 @@ const TARGET: Record<"light" | "dark", Record<string, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A token as it will be after task A4: new tokens and the sun sets are final today. */
|
||||
/** A token of a set as globals.css resolves it (before task A4: the TARGET end values). */
|
||||
function value(set: TokenSet, token: string): string {
|
||||
const base = set === "light" || set === "sun-light" ? "light" : "dark";
|
||||
const fromSun = set.startsWith("sun") ? own(set)[token] : undefined;
|
||||
@@ -99,6 +107,9 @@ function value(set: TokenSet, token: string): string {
|
||||
return found;
|
||||
}
|
||||
const rgb = (set: TokenSet, token: string): Rgb => oklchOf(value(set, token));
|
||||
/** `oklch(0.80 …)` and `oklch(0.8 …)` are the same colour. */
|
||||
const sameColour = (a: string | undefined, b: string) =>
|
||||
a !== undefined && oklchOf(a).every((part, i) => part === oklchOf(b)[i]);
|
||||
|
||||
const TONES = ["excellent", "good", "average", "weak", "critical"] as const;
|
||||
const AREA_TONES = ["betrieb", "erfassung", "arbeit", "ernte", "neutral"] as const;
|
||||
@@ -178,8 +189,6 @@ const NEW_COLOUR_TOKENS = [
|
||||
...AREA_TONES.map((a) => `area-${a}`),
|
||||
...QUEEN_COLOURS.map((q) => `queen-${q}`),
|
||||
];
|
||||
/** Old light values slightly outside sRGB (near-white with chroma); task A4 replaces them. */
|
||||
const LEGACY_OUT_OF_GAMUT = ["primary-foreground", "sidebar-primary-foreground"];
|
||||
const SUN_TABLE = [
|
||||
"background", "card", "popover", "sidebar", "foreground", "primary-foreground", "ring",
|
||||
"muted", "muted-foreground", "border", "input", "primary-ink",
|
||||
@@ -202,7 +211,6 @@ describe("token sets", () => {
|
||||
|
||||
it.each(SETS)("all colour tokens are inside the sRGB gamut in %s", (set) => {
|
||||
const outside = Object.entries(sets[set])
|
||||
.filter(([key]) => VALUES_SWITCHED || !LEGACY_OUT_OF_GAMUT.includes(key))
|
||||
.filter(([, v]) => /^oklch\(/.test(v))
|
||||
.filter(([, v]) => !inGamut(oklchOf(v)))
|
||||
.map(([k]) => k);
|
||||
@@ -276,11 +284,43 @@ describe("token sets", () => {
|
||||
|
||||
it("VALUES_SWITCHED is flipped as soon as globals.css holds the end values", () => {
|
||||
const allEqual = (["light", "dark"] as const).every((base) =>
|
||||
Object.entries(TARGET[base]).every(([token, v]) => sets[base][token] === v),
|
||||
Object.entries(TARGET[base]).every(([token, v]) => sameColour(sets[base][token], v)),
|
||||
);
|
||||
expect(allEqual && !VALUES_SWITCHED).toBe(false);
|
||||
});
|
||||
|
||||
it("no --gradient-* token remains", () => {
|
||||
expect(css).not.toMatch(/--gradient-/);
|
||||
});
|
||||
|
||||
it("the honey of the brand mark (spec 12.1, #f0b13f) is the light --primary", () => {
|
||||
expect(hexOf(oklchOf(sets.light.primary))).toBe("#f0b13f");
|
||||
});
|
||||
|
||||
it("the sidebar follows card, primary, accent, border and ring in light and dark", () => {
|
||||
for (const set of ["light", "dark"] as const) {
|
||||
const pairs: Array<[string, string]> = [
|
||||
["sidebar", "card"],
|
||||
["sidebar-foreground", "foreground"],
|
||||
["sidebar-primary", "primary"],
|
||||
["sidebar-primary-foreground", "primary-foreground"],
|
||||
["sidebar-accent", "accent"],
|
||||
["sidebar-accent-foreground", "accent-foreground"],
|
||||
["sidebar-border", "border"],
|
||||
["sidebar-ring", "ring"],
|
||||
];
|
||||
for (const [sidebar, base] of pairs)
|
||||
expect(sets[set][sidebar], `--${sidebar} in ${set}`).toBe(sets[set][base]);
|
||||
}
|
||||
});
|
||||
|
||||
it("card and popover text is the foreground in every set", () => {
|
||||
for (const set of SETS) {
|
||||
expect(sets[set]["card-foreground"]).toBe(sets[set].foreground);
|
||||
expect(sets[set]["popover-foreground"]).toBe(sets[set].foreground);
|
||||
}
|
||||
});
|
||||
|
||||
it("forced-colors maps ring, border and input to CanvasText", () => {
|
||||
const forced = atRuleDeclarations(css, "@media (forced-colors: active)");
|
||||
expect(forced).toMatchObject({ ring: "CanvasText", border: "CanvasText", input: "CanvasText" });
|
||||
@@ -290,7 +330,7 @@ describe("token sets", () => {
|
||||
if (!VALUES_SWITCHED) return;
|
||||
for (const base of ["light", "dark"] as const)
|
||||
for (const [token, expected] of Object.entries(TARGET[base]))
|
||||
expect(sets[base][token], `--${token} in ${base}`).toBe(expected);
|
||||
expect(oklchOf(sets[base][token]), `--${token} in ${base}`).toEqual(oklchOf(expected));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,7 +367,23 @@ describe.each(SETS)("contrast guard in %s", (set) => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(SETS)("condition tones in %s", (set) => {
|
||||
it.each(TONES)("%s has surface, text-on-surface and ink, each above its limit", (tone) => {
|
||||
const surface = rgb(set, `condition-${tone}`);
|
||||
const ink = rgb(set, `condition-${tone}-ink`);
|
||||
expect(contrastOf(rgb(set, `condition-${tone}-foreground`), surface)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastOf(ink, tint(set, `condition-${tone}`))).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastOf(ink, rgb(set, "card"))).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastOf(surface, rgb(set, "card"))).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the stage frame", () => {
|
||||
it("holo edge keeps 4.5:1 on the stage in light and dark (was rating-contrast.spec)", () => {
|
||||
for (const set of ["light", "dark"] as const)
|
||||
expect(contrastOf(rgb(set, "holo-edge"), rgb(set, "holo-bg"))).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it(".holo-stage sets --ring to --holo-edge-strong", () => {
|
||||
expect(css).toMatch(/\.holo-stage\s*\{[^}]*--ring:\s*var\(--holo-edge-strong\)/);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { alphaOf, atRuleDeclarations, blend, contrastOf, inGamut, oklchOf, tokenSet } from "./css-tokens";
|
||||
import { alphaOf, atRuleDeclarations, blend, contrastOf, hexOf, inGamut, oklchOf, tokenSet } from "./css-tokens";
|
||||
|
||||
const CSS = `
|
||||
@theme inline { --color-x: var(--x); }
|
||||
@@ -19,6 +19,12 @@ describe("css-tokens", () => {
|
||||
expect(() => oklchOf("1rem")).toThrow();
|
||||
});
|
||||
|
||||
it("converts oklch to an sRGB hex code", () => {
|
||||
expect(hexOf([1, 0, 0])).toBe("#ffffff");
|
||||
expect(hexOf([0, 0, 0])).toBe("#000000");
|
||||
expect(hexOf(oklchOf("oklch(0.968 0.014 85)"))).toBe("#f9f4ea");
|
||||
});
|
||||
|
||||
it("contrast of black on white is 21", () => {
|
||||
expect(contrastOf([0, 0, 0], [1, 0, 0])).toBeCloseTo(21, 1);
|
||||
expect(contrastOf([1, 0, 0], [1, 0, 0])).toBeCloseTo(1, 5);
|
||||
|
||||
@@ -155,6 +155,15 @@ function linearRgbToOklch([r, g, b]: number[]): Oklch {
|
||||
return [lightness, Math.hypot(a, bb), hue];
|
||||
}
|
||||
|
||||
/** sRGB hex code (`#rrggbb`) of an OKLCH colour, clipped to the gamut. */
|
||||
export function hexOf(color: Oklch): string {
|
||||
const byte = (channel: number) =>
|
||||
Math.round(toGamma(clip(channel)) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `#${rawLinearRgb(color).map(byte).join("")}`;
|
||||
}
|
||||
|
||||
export function inGamut(color: Oklch): boolean {
|
||||
return rawLinearRgb(color).every((c) => c >= -GAMUT_EPSILON && c <= 1 + GAMUT_EPSILON);
|
||||
}
|
||||
Reference in new issue
Block a user