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:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 18:06:20 +02:00
1 parent 62ab080725
commit ce68729233
85 files changed
+935 -139

No files matched your search

@@ -0,0 +1,242 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Baukasten guard (spec 10.7). Each T5 task adds the rule it fulfils, with every exception and
* its reason. A rule scans the files of its scope (spec files excluded) for its pattern; an
* exception names one file, relative to ApiCore-Frontend, and must still be needed.
*/
type GuardScope = "src/**/*.tsx" | "src/**/*.{ts,tsx}" | "src/**/*.{ts,tsx,css}";
interface GuardRule {
id: string;
pattern: RegExp;
scope: GuardScope;
allow: { file: string; reason: string }[];
}
const FRONTEND = join(__dirname, "..", "..");
const SRC = join(FRONTEND, "src");
const RULES: GuardRule[] = [
// Task A3/A4: the area tone replaced the gradients (spec 11); the gradient tokens are gone.
{
id: "no gradient prop or gradient token",
pattern: /gradient=|--gradient-|\[background-image:var\(--gradient/,
scope: "src/**/*.{ts,tsx,css}",
allow: [],
},
// Task A3/A4: white text fails on honey and on the light sets. The hive stage uses
// --holo-label-fg instead, so it needs no exception either.
{
id: "no text-white",
pattern: /(?<![\w-])text-white\b/,
scope: "src/**/*.{ts,tsx,css}",
allow: [],
},
// Task A5: one focus style, drawn by the base rule :where(:focus-visible) (spec 4.6).
{
id: "no ring-ring/50",
pattern: /ring-ring\/50/,
scope: "src/**/*.{ts,tsx}",
allow: [],
},
{
id: "no outline-none",
pattern: /(?<![\w-])outline-none\b/,
scope: "src/**/*.{ts,tsx}",
allow: [],
},
// Task A5: no own focus outline next to the global rule.
{
id: "no own focus outline",
// An offset alone is no own ring (calendar days pull the global ring inside the cell).
pattern: /(?<![\w-])focus(?:-visible)?:outline-(?!hidden|offset)/,
scope: "src/**/*.{ts,tsx}",
allow: [
{
file: "src/app/(app)/bottling/[id]/print-labels-card.tsx",
reason:
"The missing-fields section is focused by script after a click on „Drucken“; :focus-visible does not match such a focus, so the section keeps a focus: outline as the visible cue.",
},
{
file: "src/app/(app)/settings/automation/social-media-tab.tsx",
reason:
"The „Gespeichert“ message is focused by script after a click on „Speichern“; :focus-visible does not match such a focus, so the message keeps a focus: outline as the visible cue.",
},
],
},
// Task A5: compact sizes only for a fine pointer, never by viewport width (spec 4.5).
{
id: "no sm:h-, sm:min-h- or sm:size-",
pattern: /(?<![\w-])sm:(?:[^\s:"'`]+:)*(?:(?:min-)?h-|size-)/,
scope: "src/**/*.{ts,tsx}",
allow: [],
},
// Task A5: inputs stay at 16 px (readable, and iOS does not zoom into them).
{
id: "no md:text-sm",
pattern: /(?<![\w-])md:text-sm\b/,
scope: "src/**/*.{ts,tsx}",
allow: [],
},
];
const SCOPE_FILE: Record<GuardScope, RegExp> = {
"src/**/*.tsx": /\.tsx$/,
"src/**/*.{ts,tsx}": /\.tsx?$/,
"src/**/*.{ts,tsx,css}": /\.(tsx?|css)$/,
};
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.spec\./.test(name) ? [] : [path];
});
}
const FILES = sourceFiles(SRC).map((path) => ({
file: relative(FRONTEND, path).replaceAll("\\", "/"),
text: readFileSync(path, "utf8"),
}));
function offenders(rule: GuardRule): string[] {
return FILES.filter(({ file, text }) => SCOPE_FILE[rule.scope].test(file) && rule.pattern.test(text)).map(
({ file }) => file,
);
}
function violations(rule: GuardRule): string[] {
const allowed = new Set(rule.allow.map((entry) => entry.file));
return offenders(rule).filter((file) => !allowed.has(file));
}
const GLOBALS = readFileSync(join(SRC, "app", "globals.css"), "utf8");
describe("baukasten guard (spec 10.7)", () => {
it("scans the source tree", () => {
expect(FILES.filter(({ file }) => file.endsWith(".tsx")).length).toBeGreaterThan(200);
});
it.each(RULES.map((rule) => [rule.id, rule] as const))("%s", (_id, rule) => {
expect(violations(rule)).toEqual([]);
});
it("every exception names an existing file that still needs it, with a reason", () => {
for (const rule of RULES) {
const hits = new Set(offenders(rule));
for (const entry of rule.allow) {
expect(entry.reason.trim(), `${rule.id}: ${entry.file}`).not.toBe("");
expect(existsSync(join(FRONTEND, entry.file)), `${rule.id}: ${entry.file} exists`).toBe(true);
expect(hits.has(entry.file), `${rule.id}: ${entry.file} still needs the exception`).toBe(true);
}
}
});
it("no component removes the outline without the global rule", () => {
expect(GLOBALS).toMatch(/:where\(:focus-visible\)\s*\{[^}]*outline:\s*var\(--focus-width\)\s+solid\s+var\(--ring\)/);
for (const id of ["no ring-ring/50", "no outline-none"]) {
const rule = RULES.find((entry) => entry.id === id);
expect(rule, id).toBeDefined();
expect(violations(rule as GuardRule), id).toEqual([]);
}
// .honey-check took its own shadow ring and `outline: none`; it now takes the global rule.
expect(GLOBALS).not.toMatch(/outline:\s*none/);
expect(GLOBALS).not.toMatch(/outline-ring\/50/);
});
it("the patterns catch what they are meant to catch", () => {
const rule = (id: string) => RULES.find((entry) => entry.id === id) as GuardRule;
expect(rule("no outline-none").pattern.test('"px-2 focus-visible:outline-none"')).toBe(true);
expect(rule("no outline-none").pattern.test('"outline-none px-2"')).toBe(true);
const sizes = rule("no sm:h-, sm:min-h- or sm:size-").pattern;
expect(sizes.test('"h-11 sm:h-9"')).toBe(true);
expect(sizes.test('"sm:data-[size=default]:h-9"')).toBe(true);
expect(sizes.test('"sm:min-h-9"')).toBe(true);
expect(sizes.test('"size-11 sm:size-8"')).toBe(true);
expect(sizes.test('"sm:hidden pointer-fine:h-9 sm:max-h-96"')).toBe(false);
const ownOutline = rule("no own focus outline").pattern;
expect(ownOutline.test('"p-3 focus:outline-2 focus:outline-ring"')).toBe(true);
expect(ownOutline.test('"focus-visible:outline-2"')).toBe(true);
expect(ownOutline.test('"outline-hidden focus:bg-accent"')).toBe(false);
expect(rule("no text-white").pattern.test('"text-white/80"')).toBe(true);
expect(rule("no gradient prop or gradient token").pattern.test("<PageHeader gradient=\"honey\" />")).toBe(true);
});
});
/**
* Touch targets (spec 4.5, Global Constraints): icon buttons get a 44 px hit area under a coarse
* pointer. Two icon buttons side by side need a gap of at least 44 px minus their size there,
* or the hit areas overlap and a tap on the edge of one triggers the other. The scan is a
* heuristic over the JSX source: `<Button>` siblings with only whitespace between them, inside a
* `<div>`/`<span>` that opens right before the first of them.
*/
const ICON_SIZE_PX: Record<string, number> = { "icon-xs": 24, "icon-sm": 28, icon: 32, "icon-lg": 36 };
const HIT_AREA_PX = 44;
interface ButtonTag {
start: number;
end: number;
size: number;
}
/** `<Button …>…</Button>` and `<Button … />` of a file with their size (44 px unless an icon size). */
function buttonsOf(text: string): ButtonTag[] {
return [...text.matchAll(/<Button\b/g)].map((match) => {
const start = match.index;
let depth = 0;
let i = start;
for (; i < text.length; i += 1) {
if (text[i] === "{") depth += 1;
else if (text[i] === "}") depth -= 1;
else if (text[i] === ">" && depth === 0) break;
}
const tag = text.slice(start, i + 1);
const end = tag.endsWith("/>") ? i + 1 : text.indexOf("</Button>", i) + "</Button>".length;
const size = /\bsize="(icon(?:-xs|-sm|-lg)?)"/.exec(tag)?.[1];
const isLarge = /\bsize-1[1-9]\b/.test(tag);
return { start, end, size: size && !isLarge ? ICON_SIZE_PX[size] : HIT_AREA_PX };
});
}
/** Gap under a coarse pointer of the row that opens right before `index`; null without one. */
function rowGapPx(text: string, index: number): number | null {
const open = /<(?:div|span)\b[^>]*className="([^"]*)"[^>]*>\s*$/.exec(text.slice(Math.max(0, index - 400), index));
if (!open) return null;
const coarse = /(?:^|\s)pointer-coarse:gap-(?:x-)?(\d+(?:\.5)?)(?=\s|$)/.exec(open[1]);
const plain = /(?:^|\s)gap-(?:x-)?(\d+(?:\.5)?)(?=\s|$)/.exec(open[1]);
const step = coarse?.[1] ?? plain?.[1];
return step === undefined ? 0 : Number(step) * 4;
}
const isBlank = (text: string) => /^\s*$/.test(text);
function adjacentIconPairs(): { where: string; gap: number | null; need: number }[] {
return FILES.filter(({ file }) => file.endsWith(".tsx")).flatMap(({ file, text }) => {
const buttons = buttonsOf(text);
return buttons.slice(1).flatMap((button, index) => {
const previous = buttons[index];
const smallest = Math.min(previous.size, button.size);
if (!isBlank(text.slice(previous.end, button.start)) || smallest >= HIT_AREA_PX) return [];
let head = index;
while (head > 0 && isBlank(text.slice(buttons[head - 1].end, buttons[head].start))) head -= 1;
const line = text.slice(0, previous.start).split("\n").length;
return [{ where: `${file}:${line}`, gap: rowGapPx(text, buttons[head].start), need: HIT_AREA_PX - smallest }];
});
});
}
describe("icon buttons side by side (spec 4.5)", () => {
it("finds the icon button rows (the scan is not empty)", () => {
expect(adjacentIconPairs().length).toBeGreaterThanOrEqual(2);
});
it("their 44 px hit areas never overlap under a coarse pointer", () => {
const overlapping = adjacentIconPairs()
.filter(({ gap, need }) => gap === null || gap < need)
.map(({ where, gap, need }) => `${where}: gap ${gap ?? "?"} px < ${need} px`);
expect(overlapping).toEqual([]);
});
});