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
@@ -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] : [];
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user