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([]);
});
});
+17 -1
View File
@@ -273,7 +273,7 @@ describe("token sets", () => {
"comb-stroke-3", "shadow-soft", "shadow-elevated"])
expect(print[token], `--${token}`).toBe(sets.light[token]);
expect(print["text-sm"]).toBe("0.875rem");
expect(print["text-xs"]).toBe("0.75rem");
expect(print["text-xs"]).toBe("0.8125rem"); // spec 4.5: 13 px since task A5
});
it(".tint defaults sit in @layer components so utility classes can override --tint", () => {
@@ -367,6 +367,22 @@ describe.each(SETS)("contrast guard in %s", (set) => {
});
});
describe.each(SETS)("focus ring around a selected honey option in %s (task A5)", (set) => {
// The ring sits --focus-offset outside the element, so on a honey fill (a selected ChoiceGroup
// option, the main button) it is framed by the page or card colour on both sides, never by
// the fill. In dark --ring (= --primary-ink) is close to --primary itself, so the gap is
// what keeps it visible.
it("the base rule draws the ring outside the element (offset > 0 in every set)", () => {
const offset = own(set)["focus-offset"] ?? sets.light["focus-offset"];
expect(parseFloat(offset)).toBeGreaterThan(0);
expect(css).toMatch(/:where\(:focus-visible\)\s*\{[^}]*outline-offset:\s*var\(--focus-offset\)/);
});
it.each(["card", "background"])("the ring reaches 3:1 on the %s colour in the gap", (surface) => {
expect(contrastOf(rgb(set, "ring"), rgb(set, surface))).toBeGreaterThanOrEqual(3);
});
});
describe.each(SETS)("condition tones in %s", (set) => {
it.each(TONES)("%s has surface, text-on-surface and ink, each above its limit", (tone) => {
const surface = rgb(set, `condition-${tone}`);
@@ -0,0 +1,71 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const css = readFileSync(join(__dirname, "..", "app", "globals.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
/** Body of the first block whose header matches `header` (one nesting level is enough here). */
function block(source: string, header: RegExp): string {
const match = header.exec(source);
if (!match) return "";
let depth = 0;
for (let i = match.index + match[0].length - 1; i < source.length; i += 1) {
if (source[i] === "{") depth += 1;
else if (source[i] === "}") {
depth -= 1;
if (depth === 0) return source.slice(match.index + match[0].length, i);
}
}
return "";
}
describe("focus style and type scale (spec 4.5, 4.6)", () => {
it("the base layer draws the focus ring with --focus-width and --focus-offset", () => {
const base = block(css, /@layer base\s*\{/);
const rule = block(base, /:where\(:focus-visible\)\s*\{/);
expect(rule).toMatch(/outline:\s*var\(--focus-width\)\s+solid\s+var\(--ring\)/);
expect(rule).toMatch(/outline-offset:\s*var\(--focus-offset\)/);
// The old half-transparent default outline on every element is gone.
expect(base).not.toMatch(/outline-ring\/50/);
});
it("Firefox draws the same ring: the preflight's :-moz-focusring outline: auto is overridden after it", () => {
const base = block(css, /@layer base\s*\{/);
const rule = block(base, /:-moz-focusring:where\(:not\(iframe\)\)\s*\{/);
expect(rule).toMatch(/outline:\s*var\(--focus-width\)\s+solid\s+var\(--ring\)/);
expect(rule).toMatch(/outline-offset:\s*var\(--focus-offset\)/);
// Same specificity as the preflight rule (0,1,0), so it must come after our base rule too.
expect(base.indexOf(":-moz-focusring")).toBeGreaterThan(base.indexOf(":where(:focus-visible)"));
});
it("--focus-width and --focus-offset default to 2 px", () => {
const root = block(css, /(^|\n):root\s*\{/);
expect(root).toMatch(/--focus-width:\s*2px/);
expect(root).toMatch(/--focus-offset:\s*2px/);
});
it("--text-xs is 0.8125rem", () => {
const theme = block(css, /@theme\s*\{/);
expect(theme).toMatch(/--text-xs:\s*0\.8125rem;/);
});
it("print restores the 13 px default after sun", () => {
const print = block(css, /@media print\s*\{/);
expect(print).toMatch(/--text-xs:\s*0\.8125rem;/);
});
it("a row with an inner focus target draws one ring on the row", () => {
const utility = block(css, /@utility focus-ring-within\s*\{/);
expect(utility).toMatch(/&:has\(:focus-visible\)\s*\{[^}]*outline:\s*var\(--focus-width\)\s+solid\s+var\(--ring\)/);
});
it("card surfaces inside the hive stage take the page ring back", () => {
expect(css).toMatch(/html\s*\{[^}]*--page-ring:\s*var\(--ring\)/);
expect(css).toMatch(/\.holo-stage\s+\.stage-card\s*\{[^}]*--ring:\s*var\(--page-ring\)/);
});
it("the honey check takes the global focus rule", () => {
expect(css).not.toMatch(/\.honey-check:focus-visible/);
expect(block(css, /\.honey-check\s*\{/)).not.toMatch(/outline/);
});
});