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