Files
ApiCore/ApiCore-Frontend/scripts/codemod-t5-focus.mjs
T
Nick.RosenloecherandClaude Opus 5.5 ce68729233 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]>
2026-10-16 18:06:20 +02:00

133 lines
5.1 KiB
JavaScript

#!/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));
}