feat(t5): Fokusring für alle, 44-px-Ziele bei Touch, 16-px-Schrift (T5a A5)
- globals.css: eine Basisregel :where(:focus-visible) mit --focus-width und --focus-offset (Sonne 3 px), Firefox-:-moz-focusring gleichgezogen; --text-xs 0.8125rem. - Fokus-Codemod (scripts/codemod-t5-focus.mjs): eigene Ringe und outline-none raus, sm:h-/sm:size- zu pointer-fine:, Label-Zeilen mit focus-ring-within; Skript-Fokus (Lücken-Liste, „Gespeichert“) bleibt. - Button/Input 44 px, kompakt nur mit Maus; Icon-Knöpfe mit unsichtbarer 44-px-Trefferfläche bei Touch, Nachbarn mit genug Abstand. - 16 px in Feldern, Labels, Auswahl, Leerzuständen, Listentiteln. - .holo-stage auf allen Beute-Bühnen, .stage-card holt den Seitenring. - Baukasten-Wächter (src/styles/baukasten-guard.spec.ts) mit den Regeln aus A3–A5, Ausnahmen mit Grund; Kalender-Auswahl als Inset-Ring. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
62ab080725
commit
ce68729233
85 files changed
+935
-139
No files matched your search
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* T5 focus and size codemod (spec 4.5, 4.6 and 10.8 step 5). Pure and idempotent:
|
||||
*
|
||||
* - One focus style: the base rule `:where(:focus-visible)` in globals.css draws it. Component
|
||||
* classes that remove the outline or draw an own ring go: `outline-none` (also behind
|
||||
* `focus:`/`focus-visible:`), `focus-visible:ring-3`, `focus-visible:ring-ring/50`,
|
||||
* `focus-visible:border-ring`, own outline rings (`focus-visible:outline-2 …-ring`, the hive
|
||||
* glass edge ring, which `.holo-stage` now provides) and variant focus colours.
|
||||
* - A row whose focus target sits inside (label with its input) keeps one ring on the row:
|
||||
* `has-[:focus-visible]:outline-*`/`ring-*` become the utility `focus-ring-within`.
|
||||
* - Compact sizes follow the pointer, not the viewport: `sm:h-*`/`sm:min-h-*`/`sm:size-*` (also
|
||||
* behind a further variant) become `pointer-fine:…`.
|
||||
* - `focus:outline-2 focus:outline-ring` stays: it marks elements focused by script after a
|
||||
* click, where :focus-visible does not match (guarded with an allow-list).
|
||||
* - A class string that held nothing but focus classes is dropped together with its line.
|
||||
*
|
||||
* Selection outlines (`outline-2 outline-foreground`) and menu items' `outline-hidden` stay.
|
||||
*
|
||||
* CLI: node scripts/codemod-t5-focus.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";
|
||||
|
||||
/** Characters that belong to a class token (so a match never starts or ends inside one). */
|
||||
const TOKEN_CHAR = String.raw`[\w:/\[\]().%-]`;
|
||||
|
||||
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
||||
|
||||
/** Classes removed outright. */
|
||||
const REMOVED = [
|
||||
"outline-none",
|
||||
"focus:outline-none",
|
||||
"focus-visible:outline-none",
|
||||
"focus-visible:ring-3",
|
||||
"focus-visible:ring-[3px]",
|
||||
"focus-visible:ring-ring/50",
|
||||
"focus-visible:border-ring",
|
||||
"focus:ring-3",
|
||||
"focus:ring-ring/50",
|
||||
"focus-visible:outline-1",
|
||||
"focus-visible:outline-2",
|
||||
"focus-visible:outline-ring",
|
||||
"focus-visible:outline-offset-2",
|
||||
"focus-visible:outline-holo-edge-strong",
|
||||
"focus-visible:ring-destructive/20",
|
||||
"focus-visible:border-destructive/40",
|
||||
"focus-visible:ring-warning/30",
|
||||
];
|
||||
|
||||
/** Own rings on a row with an inner focus target; replaced by one `focus-ring-within`. */
|
||||
const WITHIN = [
|
||||
"has-[:focus-visible]:outline-2",
|
||||
"has-[:focus-visible]:outline-ring",
|
||||
"has-[:focus-visible]:ring-3",
|
||||
"has-[:focus-visible]:ring-ring/50",
|
||||
"has-focus-visible:ring-3",
|
||||
"has-focus-visible:ring-ring/50",
|
||||
];
|
||||
const WITHIN_UTILITY = "focus-ring-within";
|
||||
|
||||
function alternativesOf(classes) {
|
||||
return `(?:${classes.map(escape).join("|")})(?!${TOKEN_CHAR})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the classes so that exactly one separator stays: a class after a space goes with
|
||||
* that space, a class at the start of a string goes with the space after it.
|
||||
*/
|
||||
function removeClasses(line, classes) {
|
||||
const alternatives = alternativesOf(classes);
|
||||
return line
|
||||
.replace(new RegExp(` ${alternatives}`, "g"), "")
|
||||
.replace(new RegExp(`(?<!${TOKEN_CHAR})${alternatives} ?`, "g"), "");
|
||||
}
|
||||
|
||||
const WITHIN_FIRST = new RegExp(`(?<!${TOKEN_CHAR})${alternativesOf(WITHIN)}`);
|
||||
/** `sm:` plus any further variants (`sm:data-[size=default]:`) in front of `h-`/`min-h-`/`size-`. */
|
||||
const SM_HEIGHT = new RegExp(String.raw`(?<!${TOKEN_CHAR})sm:((?:[^\s:"'\x60]+:)*)((?:min-)?h-|size-)`, "g");
|
||||
const EMPTY_CLASS_LINE = /^\s*(["'`])\1,?\s*\r?$/;
|
||||
|
||||
function transformLine(line) {
|
||||
let out = removeClasses(line, REMOVED);
|
||||
if (WITHIN_FIRST.test(out)) {
|
||||
out = removeClasses(out.replace(WITHIN_FIRST, WITHIN_UTILITY), WITHIN);
|
||||
}
|
||||
return out.replace(SM_HEIGHT, "pointer-fine:$1$2");
|
||||
}
|
||||
|
||||
/** Applies the focus and size rules to one source text; line endings are kept. */
|
||||
export function transform(source) {
|
||||
const lines = source.split("\n");
|
||||
const result = [];
|
||||
for (const line of lines) {
|
||||
const out = transformLine(line);
|
||||
if (out !== line && EMPTY_CLASS_LINE.test(out)) continue;
|
||||
result.push(out);
|
||||
}
|
||||
return result.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-focus.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,121 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transform } from "./codemod-t5-focus.mjs";
|
||||
|
||||
describe("codemod t5 focus and sizes (spec 4.5, 4.6, 10.8 step 5)", () => {
|
||||
it("drops outline-none and the component focus ring classes", () => {
|
||||
expect(
|
||||
transform(
|
||||
'"min-h-11 rounded-lg px-2 outline-none hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-60"',
|
||||
),
|
||||
).toBe('"min-h-11 rounded-lg px-2 hover:bg-muted disabled:opacity-60"');
|
||||
expect(transform('className="px-2 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"')).toBe(
|
||||
'className="px-2"',
|
||||
);
|
||||
});
|
||||
|
||||
it("drops the own outline rings, including the stage edge ring of the hive glass", () => {
|
||||
expect(transform('"rounded-full focus-visible:outline-2 focus-visible:outline-ring"')).toBe('"rounded-full"');
|
||||
expect(
|
||||
transform("`h-11 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong ${X}`"),
|
||||
).toBe("`h-11 ${X}`");
|
||||
});
|
||||
|
||||
it("keeps the focus: outline of elements focused by script after a click", () => {
|
||||
// :focus-visible does not match a script focus that follows a pointer click.
|
||||
const scripted = '"rounded-xl border p-3 focus:outline-2 focus:outline-ring"';
|
||||
expect(transform(scripted)).toBe(scripted);
|
||||
});
|
||||
|
||||
it("drops the skip link's own focus ring", () => {
|
||||
expect(transform('"sr-only focus:not-sr-only focus:ring-3 focus:ring-ring/50 focus:outline-none"')).toBe(
|
||||
'"sr-only focus:not-sr-only"',
|
||||
);
|
||||
});
|
||||
|
||||
it("drops variant focus colours on the destructive button and badges", () => {
|
||||
expect(transform('"bg-destructive/10 text-destructive focus-visible:border-destructive/40 focus-visible:ring-destructive/20"')).toBe(
|
||||
'"bg-destructive/10 text-destructive"',
|
||||
);
|
||||
expect(transform('"bg-warning/15 focus-visible:ring-warning/30 [a]:hover:bg-warning/25"')).toBe(
|
||||
'"bg-warning/15 [a]:hover:bg-warning/25"',
|
||||
);
|
||||
});
|
||||
|
||||
it("a row with an inner focus target keeps one ring on the outer element", () => {
|
||||
expect(transform('"flex gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"')).toBe(
|
||||
'"flex gap-3 rounded-xl border p-3 focus-ring-within"',
|
||||
);
|
||||
expect(transform('"flex min-h-14 px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50"')).toBe(
|
||||
'"flex min-h-14 px-3 focus-ring-within"',
|
||||
);
|
||||
expect(transform('"flex min-h-11 px-1 has-focus-visible:ring-3 has-focus-visible:ring-ring/50"')).toBe(
|
||||
'"flex min-h-11 px-1 focus-ring-within"',
|
||||
);
|
||||
});
|
||||
|
||||
it("selection outlines that are not focus styles stay", () => {
|
||||
const calendar = 'isSelected && "outline-2 -outline-offset-2 outline-foreground",';
|
||||
expect(transform(calendar)).toBe(calendar);
|
||||
const canvas = 'selected && "outline outline-2 outline-foreground",';
|
||||
expect(transform(canvas)).toBe(canvas);
|
||||
const menuItem = '"relative flex rounded-md text-sm outline-hidden select-none focus:bg-accent"';
|
||||
expect(transform(menuItem)).toBe(menuItem);
|
||||
});
|
||||
|
||||
it("a class string that only held focus classes disappears with its line", () => {
|
||||
expect(transform('cn(\n "min-h-11 px-2",\n "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",\n x,\n)')).toBe(
|
||||
'cn(\n "min-h-11 px-2",\n x,\n)',
|
||||
);
|
||||
});
|
||||
|
||||
it("sm:h-* and sm:min-h-* become pointer-fine, also behind a further variant", () => {
|
||||
expect(transform('"h-10 sm:h-8 gap-1.5"')).toBe('"h-10 pointer-fine:h-8 gap-1.5"');
|
||||
expect(transform('"min-h-11 sm:min-h-9 rounded-lg"')).toBe('"min-h-11 pointer-fine:min-h-9 rounded-lg"');
|
||||
expect(transform('"data-[size=default]:h-11 sm:data-[size=default]:h-9"')).toBe(
|
||||
'"data-[size=default]:h-11 pointer-fine:data-[size=default]:h-9"',
|
||||
);
|
||||
});
|
||||
|
||||
it("sm:size-* becomes pointer-fine:size-*", () => {
|
||||
expect(transform('className="size-11 sm:size-8"')).toBe('className="size-11 pointer-fine:size-8"');
|
||||
});
|
||||
|
||||
it("other sm: classes stay", () => {
|
||||
const layout = '"hidden sm:flex sm:hidden sm:gap-2 sm:px-4 sm:max-w-xs sm:max-h-96"';
|
||||
expect(transform(layout)).toBe(layout);
|
||||
});
|
||||
|
||||
it("keeps CRLF line endings", () => {
|
||||
expect(transform('a\r\nclassName="px-2 outline-none"\r\n')).toBe('a\r\nclassName="px-2"\r\n');
|
||||
});
|
||||
|
||||
it("the focus codemod is idempotent and keeps pointer-fine classes", () => {
|
||||
const source = [
|
||||
'"h-11 sm:h-8 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"',
|
||||
'"pointer-fine:h-9 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"',
|
||||
].join("\n");
|
||||
const once = transform(source);
|
||||
expect(once).toBe('"h-11 pointer-fine:h-8"\n"pointer-fine:h-9 focus-ring-within"');
|
||||
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