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:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 17:23:21 +02:00
1 parent 6edae3efec
commit d8238855fc
91 files changed
+673 -391

No files matched your search

@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* T5 colour codemod (spec 4.2 and 10.8 step 3). Pure and idempotent:
*
* - `text-primary` -> `text-primary-ink` (honey is a fill, the ink is the text colour);
* `text-primary-foreground`, `text-primary/NN`, `bg-primary…` stay.
* - `text-warning` -> `text-warning-ink`.
* - `text-warning-foreground` and `text-condition-x-foreground` -> `-ink`, unless the same line
* puts the text on the solid surface (`bg-warning`, `bg-condition-x` without `/NN`): on a tint
* or on the card only the ink reaches 4.5:1 in every set.
* - `border-primary` -> `border-foreground` where the line, or one of the two lines above it,
* names a selection state (checked, pressed, selected); `border-primary/NN` stays.
*
* CLI: node scripts/codemod-t5-colors.mjs [--check] <file or directory…>
* --check lists the files that would change and exits 1 if there are any.
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/** Start of a class token: line start, whitespace, quote, backtick, `{`, `(` or a variant `:`. */
const BEFORE = String.raw`(?<=^|[\s"'\`{(:!])`;
/** End of a class token: no further name characters, no `/NN` opacity, no `-suffix`. */
const AFTER = String.raw`(?![\w/-])`;
const TONES = String.raw`(?:warning|condition-(?:excellent|good|average|weak|critical))`;
const SELECTION_STATE = /checked|pressed|selected/;
const SELECTION_CONTEXT_LINES = 2;
const TEXT_PRIMARY = new RegExp(`${BEFORE}text-primary${AFTER}`, "g");
const TEXT_WARNING = new RegExp(`${BEFORE}text-warning${AFTER}`, "g");
const TONE_FOREGROUND = new RegExp(`${BEFORE}text-(${TONES})-foreground${AFTER}`, "g");
const BORDER_PRIMARY = new RegExp(`${BEFORE}border-primary${AFTER}`, "g");
function hasSolidSurface(line, tone) {
return new RegExp(`${BEFORE}bg-${tone}${AFTER}`).test(line);
}
function transformLine(line, context) {
let out = line
.replace(TEXT_PRIMARY, "text-primary-ink")
.replace(TEXT_WARNING, "text-warning-ink")
.replace(TONE_FOREGROUND, (match, tone) => (hasSolidSurface(line, tone) ? match : `text-${tone}-ink`));
if (SELECTION_STATE.test(context)) out = out.replace(BORDER_PRIMARY, "border-foreground");
return out;
}
/** Applies the colour rules to one source text; line endings are kept. */
export function transform(source) {
const lines = source.split("\n");
return lines
.map((line, index) => {
const context = lines.slice(Math.max(0, index - SELECTION_CONTEXT_LINES), index + 1).join("\n");
return transformLine(line, context);
})
.join("\n");
}
function sourceFiles(path) {
if (statSync(path).isDirectory()) {
return readdirSync(path).flatMap((name) => sourceFiles(join(path, name)));
}
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
}
function main(args) {
const check = args.includes("--check");
const targets = args.filter((arg) => arg !== "--check");
if (targets.length === 0) {
console.error("usage: node scripts/codemod-t5-colors.mjs [--check] <file or directory…>");
return 2;
}
const changed = targets.flatMap(sourceFiles).filter((file) => {
const before = readFileSync(file, "utf8");
const after = transform(before);
if (after === before) return false;
if (!check) writeFileSync(file, after);
return true;
});
for (const file of changed) console.log(file);
console.log(`${changed.length} file(s) ${check ? "to migrate" : "migrated"}`);
return check && changed.length > 0 ? 1 : 0;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}
@@ -0,0 +1,98 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { transform } from "./codemod-t5-colors.mjs";
describe("codemod t5 colours (spec 10.8 step 3)", () => {
it("text-primary becomes text-primary-ink", () => {
expect(transform('<a className="text-sm text-primary hover:underline">')).toBe(
'<a className="text-sm text-primary-ink hover:underline">',
);
expect(transform('cn("data-active:text-primary", x)')).toBe('cn("data-active:text-primary-ink", x)');
});
it("text-primary-foreground, bg-primary and text-primary/80 stay unchanged", () => {
const source = 'className="bg-primary text-primary-foreground text-primary/80 text-primary/[0.07] bg-primary/10"';
expect(transform(source)).toBe(source);
});
it("text-warning becomes text-warning-ink but text-warning-foreground stays on a solid warning surface", () => {
expect(transform('<AlertTriangle className="size-4 text-warning" />')).toBe(
'<AlertTriangle className="size-4 text-warning-ink" />',
);
const solid = 'className="bg-warning text-warning-foreground"';
expect(transform(solid)).toBe(solid);
});
it("warning text on a tint or on the card becomes the ink", () => {
expect(transform('"bg-warning/15 text-warning-foreground"')).toBe('"bg-warning/15 text-warning-ink"');
expect(transform('<span className="text-warning-foreground">')).toBe('<span className="text-warning-ink">');
});
it("badge classes with text-condition-good-foreground become -ink", () => {
expect(transform('GOOD: "border-transparent bg-condition-good/15 text-condition-good-foreground",')).toBe(
'GOOD: "border-transparent bg-condition-good/15 text-condition-good-ink",',
);
expect(transform('<CheckCircle2 className="size-5 text-condition-critical-foreground" />')).toBe(
'<CheckCircle2 className="size-5 text-condition-critical-ink" />',
);
});
it("text on a solid condition surface keeps its foreground", () => {
const solid = '4: "border-condition-good bg-condition-good text-condition-good-foreground",';
expect(transform(solid)).toBe(solid);
});
it("border-primary on a selection state becomes border-foreground", () => {
expect(transform('"data-checked:border-primary data-checked:bg-primary"')).toBe(
'"data-checked:border-foreground data-checked:bg-primary"',
);
expect(transform('className="has-[:checked]:border-primary has-[:checked]:bg-primary/10"')).toBe(
'className="has-[:checked]:border-foreground has-[:checked]:bg-primary/10"',
);
// The state may sit on the line(s) just above the class string.
expect(transform('selected &&\n "border-primary bg-primary font-medium",')).toBe(
'selected &&\n "border-foreground bg-primary font-medium",',
);
});
it("border-primary without a selection state and translucent borders stay", () => {
const plain = 'className={ok ? "rounded-lg border border-primary bg-primary/5" : "border-destructive"}';
expect(transform(plain)).toBe(plain);
const translucent = 'className={selected ? "border-primary/40" : ""}';
expect(transform(translucent)).toBe(translucent);
});
it("keeps CRLF line endings", () => {
expect(transform('a\r\nclassName="text-primary"\r\n')).toBe('a\r\nclassName="text-primary-ink"\r\n');
});
it("is idempotent", () => {
const source = [
'className="text-primary text-warning bg-warning/15 text-warning-foreground"',
'checked ? "border-primary bg-primary/10" : "border-border"',
'"bg-condition-weak/15 text-condition-weak-foreground"',
].join("\n");
const once = transform(source);
expect(once).not.toBe(source);
expect(transform(once)).toBe(once);
});
it("leaves nothing to migrate in src", () => {
const files = sourceFiles(join(process.cwd(), "src"));
expect(files.length).toBeGreaterThan(100);
const pending = files.filter((file) => {
const text = readFileSync(file, "utf8");
return transform(text) !== text;
});
expect(pending).toEqual([]);
});
});
function sourceFiles(dir) {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
});
}