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] : [];
|
||||
});
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export function ColonyOverviewLinks({ overview, hrefFor }: ColonyOverviewLinksPr
|
||||
<Link
|
||||
key={row.id}
|
||||
href={hrefFor(row.id)}
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -26,7 +26,7 @@ const PRINT_DONE = "Etiketten-PDF heruntergeladen.";
|
||||
const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht.";
|
||||
const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
|
||||
const LINK_CLASS =
|
||||
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary-ink underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring";
|
||||
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary-ink underline underline-offset-4";
|
||||
|
||||
type FixTarget = "master" | "product" | "designer";
|
||||
|
||||
|
||||
@@ -192,6 +192,20 @@ describe("CalendarPage entries", () => {
|
||||
expect(link).toHaveTextContent("Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig");
|
||||
});
|
||||
|
||||
it("marks the chosen day with an inset ring and keeps the focus ring inside the clipped grid", async () => {
|
||||
renderPage();
|
||||
await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" });
|
||||
const day = screen.getByRole("button", { name: /Dienstag, 11\. August/ });
|
||||
await userEvent.click(day);
|
||||
|
||||
const classes = day.className.split(/\s+/);
|
||||
expect(classes).toEqual(expect.arrayContaining(["ring-2", "ring-inset", "ring-foreground"]));
|
||||
// A selection outline would replace the focus ring; the grid clips (overflow-hidden), so
|
||||
// the focus ring of every day is drawn inside the cell, next to the selection ring.
|
||||
expect(day.className).not.toMatch(/(^|\s)outline-(2|foreground)|-outline-offset-2/);
|
||||
expect(classes).toContain("focus-visible:outline-offset-[calc(-2px_-_var(--focus-width))]");
|
||||
});
|
||||
|
||||
it("counts a day with several entries in the plural", async () => {
|
||||
vi.mocked(fetchCalendarEvents).mockResolvedValue([
|
||||
{ id: "a", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "A", relatedType: "Task", relatedId: "1" },
|
||||
|
||||
@@ -146,7 +146,7 @@ function EventRow({ event, isContinuation }: { event: CalendarEvent; isContinuat
|
||||
</span>
|
||||
);
|
||||
return href ? (
|
||||
<Link href={href} className="block rounded-lg focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<Link href={href} className="block rounded-lg">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
@@ -353,9 +353,11 @@ export default function CalendarPage() {
|
||||
aria-pressed={isSelected}
|
||||
aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${entryCountText(dayEvents.length)}`}
|
||||
className={cn(
|
||||
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0",
|
||||
// The grid clips (overflow-hidden): the focus ring sits inside the cell,
|
||||
// next to the inset selection ring, so edge days keep it whole.
|
||||
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0 focus-visible:outline-offset-[calc(-2px_-_var(--focus-width))]",
|
||||
isCurrentMonth ? "bg-card" : "bg-muted/40",
|
||||
isSelected && "outline-2 -outline-offset-2 outline-foreground",
|
||||
isSelected && "ring-2 ring-inset ring-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -83,7 +83,7 @@ function ActionFooter({
|
||||
ref={errorRef}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
className="text-sm font-medium text-destructive outline-none"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
|
||||
@@ -283,7 +283,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
ref={conflictRef}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
className="text-sm font-medium text-destructive outline-none"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{conflictText}
|
||||
</p>
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function HiveEditorPage() {
|
||||
);
|
||||
}
|
||||
if (!colony.data || !hive.data || !catalog) {
|
||||
return <div role="status" aria-label="Beute wird geladen" className="fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
|
||||
return <div role="status" aria-label="Beute wird geladen" className="holo-stage fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
|
||||
}
|
||||
return (
|
||||
<HiveEditor
|
||||
|
||||
@@ -238,7 +238,7 @@ export default function NewColonyPage() {
|
||||
ref={errorRef}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
className="text-sm font-medium text-destructive outline-none"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{submitError}
|
||||
</p>
|
||||
|
||||
@@ -39,7 +39,7 @@ function Step({ number, title, titleId, state, children }: StepProps) {
|
||||
<span
|
||||
id={titleId}
|
||||
tabIndex={titleId ? -1 : undefined}
|
||||
className="font-heading text-base font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="font-heading text-base font-semibold"
|
||||
>{`${number}. ${title}`}</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium">
|
||||
<Icon className="size-4" aria-hidden />
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function NoticeDetailPage() {
|
||||
id="notice-flow-heading"
|
||||
ref={flowHeading}
|
||||
tabIndex={-1}
|
||||
className="font-heading text-lg font-semibold tracking-tight outline-none"
|
||||
className="font-heading text-lg font-semibold tracking-tight"
|
||||
>
|
||||
{flowTitle}
|
||||
</h2>
|
||||
|
||||
@@ -197,7 +197,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
||||
<p aria-live="polite" className="min-h-5 text-sm text-destructive">
|
||||
{showProblem ? problem : null}
|
||||
</p>
|
||||
<div ref={errorRef} tabIndex={-1} role="alert" className="outline-none">
|
||||
<div ref={errorRef} tabIndex={-1} role="alert">
|
||||
{error ? (
|
||||
<div className="flex flex-col gap-2 text-sm text-destructive">
|
||||
<p>{error.text}</p>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function NoticesPage() {
|
||||
id="notice-kind-filter"
|
||||
value={kind}
|
||||
onChange={(event) => setKind(event.target.value as KindFilter)}
|
||||
className="h-11 w-full rounded-xl border border-input bg-card px-3 text-base sm:max-w-xs md:text-sm"
|
||||
className="h-11 w-full rounded-xl border border-input bg-card px-3 text-base sm:max-w-xs"
|
||||
>
|
||||
<option value="ALL">Alle</option>
|
||||
{NOTICE_KINDS.map((entry) => (
|
||||
@@ -85,7 +85,7 @@ export default function NoticesPage() {
|
||||
aria-invalid={yearInvalid ? true : undefined}
|
||||
aria-describedby={yearInvalid ? "notice-year-problem" : undefined}
|
||||
onChange={(event) => setYearText(event.target.value)}
|
||||
className="h-11 w-28 rounded-xl border border-input bg-card px-3 text-base md:text-sm"
|
||||
className="h-11 w-28 rounded-xl border border-input bg-card px-3 text-base"
|
||||
/>
|
||||
{yearInvalid ? (
|
||||
<p id="notice-year-problem" className="text-sm text-destructive">
|
||||
@@ -131,7 +131,7 @@ function NoticeLink({ notice }: { notice: AuthorityNotice }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/compliance/notices/${encodeURIComponent(notice.id)}`}
|
||||
className="flex min-h-11 flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="flex min-h-11 flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 hover:bg-muted"
|
||||
>
|
||||
<span className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-base font-semibold">{noticeTitle(notice)}</span>
|
||||
|
||||
@@ -17,7 +17,7 @@ const STATE_ICON: Record<ReadinessItemState, LucideIcon> = {
|
||||
};
|
||||
|
||||
const FIX_LINK_CLASS =
|
||||
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary-ink underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary-ink underline-offset-4 hover:underline";
|
||||
|
||||
function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) {
|
||||
const { href, label, available } = fixLinkFor(itemKey, subject);
|
||||
@@ -117,7 +117,7 @@ export function ReadinessItemCard({ item, canManage }: ReadinessItemCardProps) {
|
||||
<h3
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="min-w-0 font-heading text-base font-semibold break-words outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="min-w-0 font-heading text-base font-semibold break-words"
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
@@ -46,7 +46,7 @@ function Section({
|
||||
const headingId = `restrictions-${id}`;
|
||||
return (
|
||||
<section aria-labelledby={headingId} className="flex flex-col gap-2">
|
||||
<h2 id={headingId} tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight outline-none">
|
||||
<h2 id={headingId} tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight">
|
||||
{`${title} (${rows.length})`}
|
||||
</h2>
|
||||
{rows.length === 0 ? (
|
||||
|
||||
@@ -244,7 +244,7 @@ export default function DashboardPage() {
|
||||
<li key={tile.label}>
|
||||
<Link
|
||||
href={tile.href}
|
||||
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted"
|
||||
>
|
||||
<Icon
|
||||
className={`size-5 shrink-0 ${tile.attention ? "text-warning-ink" : "text-primary-ink"}`}
|
||||
|
||||
@@ -202,7 +202,7 @@ export default function NewExtractionPage() {
|
||||
data-field="harvestIds"
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={describedBy}
|
||||
className="flex flex-col gap-2 outline-none"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{harvests?.map((harvest) => (
|
||||
<ToggleChip
|
||||
|
||||
@@ -253,7 +253,7 @@ export default function NewHarvestPage() {
|
||||
data-field="colonyIds"
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={describedBy}
|
||||
className="grid grid-cols-2 gap-2 outline-none"
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
{apiaryColonies.map((colony) => {
|
||||
const withdrawal = withdrawalByColony.get(colony.id);
|
||||
|
||||
@@ -147,7 +147,7 @@ export function InspectionHiveSection({
|
||||
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
|
||||
<div
|
||||
data-testid="hive-section"
|
||||
className="flex flex-col gap-4 outline-none"
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h3 ref={headingRef} tabIndex={-1} className="sr-only">
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
pageContentRef.current?.focus();
|
||||
pageContentRef.current?.scrollIntoView?.({ block: "start" });
|
||||
}}
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)] focus:ring-3 focus:ring-ring/50 focus:outline-none"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)]"
|
||||
>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
@@ -148,7 +148,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
id={MAIN_CONTENT_ID}
|
||||
ref={pageContentRef}
|
||||
tabIndex={-1}
|
||||
className={`flex-1 ${contentPaddingClass} outline-none sm:pb-(--safe-area-bottom)`}
|
||||
className={`flex-1 ${contentPaddingClass} sm:pb-(--safe-area-bottom)`}
|
||||
>
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@ interface LotFormValues {
|
||||
|
||||
type LotFormErrors = Partial<Record<keyof LotFormValues, string>>;
|
||||
|
||||
const INPUT_CLASS = "h-11 sm:h-11";
|
||||
const INPUT_CLASS = "h-11 pointer-fine:h-11";
|
||||
|
||||
function initialValues(medicine: LotMedicine, lot?: LotView): LotFormValues {
|
||||
if (lot) {
|
||||
|
||||
@@ -129,7 +129,7 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
|
||||
<Input
|
||||
id="disposal-quantity"
|
||||
inputMode="decimal"
|
||||
className="h-11 sm:h-11"
|
||||
className="h-11 pointer-fine:h-11"
|
||||
value={quantityText}
|
||||
onChange={(event) => setQuantityText(event.target.value)}
|
||||
/>
|
||||
@@ -141,7 +141,7 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
|
||||
</Label>
|
||||
<Input
|
||||
id="disposal-reason"
|
||||
className="h-11 sm:h-11"
|
||||
className="h-11 pointer-fine:h-11"
|
||||
value={reason}
|
||||
maxLength={500}
|
||||
placeholder="z. B. abgelaufen, verschüttet"
|
||||
|
||||
@@ -128,7 +128,7 @@ function InventoryForm({ lotId, remaining, unit, onDone }: InventoryFormProps) {
|
||||
<Input
|
||||
id="inventory-counted"
|
||||
inputMode="decimal"
|
||||
className="h-11 sm:h-11"
|
||||
className="h-11 pointer-fine:h-11"
|
||||
value={counted}
|
||||
aria-describedby="inventory-status"
|
||||
onChange={(event) => setCounted(event.target.value)}
|
||||
|
||||
@@ -89,7 +89,7 @@ interface MedicineFormProps {
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
const INPUT_CLASS = "h-11 sm:h-11";
|
||||
const INPUT_CLASS = "h-11 pointer-fine:h-11";
|
||||
|
||||
function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function OfflinePage() {
|
||||
<Link
|
||||
key={stored.apiaryId}
|
||||
href={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`}
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="font-medium">{stored.pack.apiary.name}</span>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function RuleCardShell({ rule, open, onToggle, children }: RuleCardShellP
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
onClick={onToggle}
|
||||
className="flex min-h-11 w-full items-start gap-3 rounded-xl px-4 py-1 text-left focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="flex min-h-11 w-full items-start gap-3 rounded-xl px-4 py-1 text-left"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
|
||||
+3
-2
@@ -44,7 +44,8 @@ export function ElementPropertiesPanel({
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{element.type === "text" ? "Text" : element.type === "qrCode" ? "QR-Code" : element.type === "image" ? "Bild" : "Linie"}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
{/* pointer-coarse:gap-4: the 44 px hit areas of the two 28 px buttons must not overlap. */}
|
||||
<div className="flex gap-1 pointer-coarse:gap-4">
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={onDuplicate} title="Duplizieren">
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
@@ -202,7 +203,7 @@ function TextFields({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex gap-1.5 pointer-coarse:gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant={element.bold ? "default" : "outline"}
|
||||
|
||||
@@ -659,7 +659,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
id="designer-min-x-height"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="min-h-11 sm:h-11"
|
||||
className="min-h-11 pointer-fine:h-11"
|
||||
value={minXHeightText}
|
||||
aria-invalid={minXHeightInvalid ? true : undefined}
|
||||
aria-describedby="designer-min-x-height-hint"
|
||||
@@ -786,7 +786,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border border-border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring">
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border border-border p-3 focus-ring-within">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 size-4 accent-[var(--primary)]"
|
||||
|
||||
@@ -282,7 +282,7 @@ export default function SuggestionsPage() {
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="suggestion-count"
|
||||
className="min-h-5 text-sm font-medium outline-none"
|
||||
className="min-h-5 text-sm font-medium"
|
||||
>
|
||||
{countText(loaded, total)}
|
||||
</p>
|
||||
|
||||
@@ -257,7 +257,7 @@ function WinterContent() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11 sm:size-8"
|
||||
className="size-11 pointer-fine:size-8"
|
||||
aria-label="Vorheriger Winter"
|
||||
onClick={() => setWinterYear((year) => year - 1)}
|
||||
>
|
||||
@@ -267,7 +267,7 @@ function WinterContent() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11 sm:size-8"
|
||||
className="size-11 pointer-fine:size-8"
|
||||
aria-label="Nächster Winter"
|
||||
onClick={() => setWinterYear((year) => year + 1)}
|
||||
>
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
@custom-variant pointer-fine (@media (pointer: fine));
|
||||
@custom-variant pointer-coarse (@media (pointer: coarse));
|
||||
|
||||
/* Type scale (spec 4.5): text-xs is 13 px (sun: 14 px), only for axes, legends and footers. */
|
||||
@theme {
|
||||
--text-xs: 0.8125rem;
|
||||
--text-xs--line-height: calc(1.125 / 0.8125);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -332,6 +338,14 @@
|
||||
.holo-stage {
|
||||
--ring: var(--holo-edge-strong);
|
||||
}
|
||||
/* A page-coloured card on the stage (the outline panel) takes the page ring back. The root
|
||||
keeps a copy of its ring for that, resolved for the current set (light, dark, sun). */
|
||||
html {
|
||||
--page-ring: var(--ring);
|
||||
}
|
||||
.holo-stage .stage-card {
|
||||
--ring: var(--page-ring);
|
||||
}
|
||||
|
||||
/* Tinted surfaces: set --tint (and --tint-ink) on the element. In sun the tint becomes the
|
||||
plain card with a 2 px border in the ink instead of a 15 % wash (spec 4.3). */
|
||||
@@ -430,7 +444,7 @@
|
||||
--queen-green: oklch(0.58 0.15 145);
|
||||
--queen-blue: oklch(0.52 0.15 255);
|
||||
--text-sm: 0.875rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs: 0.8125rem;
|
||||
--focus-width: 2px;
|
||||
--control-border-width: 1px;
|
||||
--comb-stroke-1: 2px;
|
||||
@@ -480,7 +494,21 @@
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@apply border-border;
|
||||
}
|
||||
/* One focus style for everything (spec 4.6): a solid ring in --ring with a gap, 3 px in sun.
|
||||
Components add no focus classes; a row whose focus target sits inside uses
|
||||
focus-ring-within. */
|
||||
:where(:focus-visible) {
|
||||
outline: var(--focus-width) solid var(--ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
/* Firefox: the preflight sets `:-moz-focusring:where(:not(iframe)) { outline: auto }` (0,1,0),
|
||||
which beats the rule above. Firefox matches :-moz-focusring exactly where it matches
|
||||
:focus-visible (keyboard, not a mouse click), so restating the ring here adds nothing else. */
|
||||
:-moz-focusring:where(:not(iframe)) {
|
||||
outline: var(--focus-width) solid var(--ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@@ -513,6 +541,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* A row whose focus target sits inside (a label with its checkbox, radio or a visually hidden
|
||||
input) also rings the whole row, so the focus is easy to spot from arm's length (spec 4.6). */
|
||||
@utility focus-ring-within {
|
||||
&:has(:focus-visible) {
|
||||
outline: var(--focus-width) solid var(--ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Motion levels (see lib/motion/motion-preference.ts)
|
||||
html[data-motion] = full | subtle | off, chosen per device in the profile.
|
||||
@@ -574,7 +611,6 @@
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
color: var(--primary-foreground);
|
||||
outline: none;
|
||||
}
|
||||
.honey-check::before {
|
||||
content: "";
|
||||
@@ -600,9 +636,6 @@
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in oklch, var(--primary) 8%, var(--card));
|
||||
}
|
||||
.honey-check:focus-visible::before {
|
||||
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 45%, transparent);
|
||||
}
|
||||
.honey-check svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@@ -19,18 +19,7 @@ function read(relative: string): string {
|
||||
}
|
||||
|
||||
describe("area migration", () => {
|
||||
it("no file passes a gradient prop any more", () => {
|
||||
const offenders = sourceFiles(SRC).filter((file) => {
|
||||
const text = readFileSync(file, "utf8");
|
||||
return /gradient=|var\(--gradient-|\[background-image:var\(--gradient/.test(text);
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("no source uses or defines the --gradient tokens any more (removed in task A4)", () => {
|
||||
const offenders = sourceFiles(SRC).filter((file) => /--gradient-/.test(readFileSync(file, "utf8")));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
// The gradient and white-text rules live in src/styles/baukasten-guard.spec.ts (task A5).
|
||||
|
||||
it("harvest detail uses Scissors, extraction create FlaskConical and storage only Warehouse", () => {
|
||||
expect(read("app/(app)/harvests/[id]/page.tsx")).toContain('area="harvests"');
|
||||
@@ -54,15 +43,4 @@ describe("area migration", () => {
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("no white text outside the hive stage", () => {
|
||||
const offenders = sourceFiles(SRC)
|
||||
.filter((file) => !file.replaceAll("\\", "/").includes("/components/hive/"))
|
||||
.filter((file) => /\btext-white\b/.test(readFileSync(file, "utf8")));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the stage overlay on the hive stage uses the holo label colour instead of white", () => {
|
||||
expect(read("components/hive/editor/editor-stage-overlay.tsx")).not.toMatch(/\btext-white\b/);
|
||||
});
|
||||
});
|
||||
@@ -138,7 +138,7 @@ export function NextStepsView({
|
||||
<h2
|
||||
id={NEXT_STEPS_HEADING_ID}
|
||||
tabIndex={-1}
|
||||
className="font-heading text-base font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="font-heading text-base font-semibold"
|
||||
>
|
||||
Als Nächstes
|
||||
</h2>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function DetailHeader({
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight break-words">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle ? <p className="text-sm text-muted-foreground">{subtitle}</p> : null}
|
||||
{subtitle ? <p className="text-base text-muted-foreground">{subtitle}</p> : null}
|
||||
{status ? <div className="flex flex-wrap items-center gap-2">{status}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,7 +155,7 @@ function BackLink({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="-ml-2 inline-flex min-h-11 items-center gap-1 self-start rounded-lg px-2 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="-ml-2 inline-flex min-h-11 items-center gap-1 self-start rounded-lg px-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-5" aria-hidden />
|
||||
{label}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
{description ? <p className="text-base text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="mt-1">{action}</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function EntityCard({
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<AreaIcon area={area} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{title}</p>
|
||||
<p className="truncate text-base font-medium">{title}</p>
|
||||
{subtitle ? (
|
||||
<p className="truncate text-sm text-muted-foreground">{subtitle}</p>
|
||||
) : null}
|
||||
|
||||
@@ -221,4 +221,12 @@ describe("choiceOptions", () => {
|
||||
{ value: "NONE", label: "Keine" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("options are body text (16 px) and take the global focus ring", () => {
|
||||
render(<Harness />);
|
||||
for (const radio of screen.getAllByRole("radio")) {
|
||||
expect(radio.className.split(/\s+/)).toContain("text-base");
|
||||
expect(radio.className).not.toMatch(/outline-none|ring-ring\/50|focus-visible:ring-|focus-visible:border-ring/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -135,8 +135,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-2 text-sm outline-none motion-safe:transition-colors",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"min-h-11 rounded-lg border px-2 text-base motion-safe:transition-colors",
|
||||
!selected && "border-border bg-background hover:bg-muted",
|
||||
option.disabled && "cursor-not-allowed opacity-50 hover:bg-background",
|
||||
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground",
|
||||
|
||||
@@ -147,7 +147,7 @@ export function ColonyMultiSelect({
|
||||
<li key={colony.id}>
|
||||
<label
|
||||
htmlFor={`${id}-${colony.id}`}
|
||||
className="flex min-h-14 cursor-pointer items-center gap-3 rounded-lg border border-border px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50"
|
||||
className="flex min-h-14 cursor-pointer items-center gap-3 rounded-lg border border-border px-3 focus-ring-within"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${id}-${colony.id}`}
|
||||
|
||||
@@ -36,4 +36,12 @@ describe("Field", () => {
|
||||
const group = screen.getByRole("radiogroup", { name: /^Futterart/ });
|
||||
expect(group).toHaveAccessibleDescription("Eine Art antippen.");
|
||||
});
|
||||
|
||||
it("the label is body text (16 px), the hint stays secondary", () => {
|
||||
render(<Field label="Datum" hint="Tag der Durchsicht">{({ id }) => <input id={id} type="date" />}</Field>);
|
||||
|
||||
const label = screen.getByText("Datum");
|
||||
expect(label.className.split(/\s+/)).toContain("text-base");
|
||||
expect(label.className.split(/\s+/)).not.toContain("text-sm");
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export function Field({
|
||||
<Caption
|
||||
id={`${id}-label`}
|
||||
htmlFor={group ? undefined : id}
|
||||
className="flex items-baseline gap-2 text-sm font-medium"
|
||||
className="flex items-baseline gap-2 text-base font-medium"
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
|
||||
@@ -57,7 +57,7 @@ export function FormBackLink({ href, label, dirty, draft, className }: FormBackL
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none",
|
||||
"-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -316,4 +316,18 @@ describe("NumberStepper", () => {
|
||||
expect(onEmit).toHaveBeenLastCalledWith("");
|
||||
});
|
||||
});
|
||||
|
||||
it("NumberStepper buttons keep min-h-11 and drop their own ring classes", () => {
|
||||
render(<Harness initial="3" />);
|
||||
const buttons = [
|
||||
screen.getByRole("button", { name: /Eine Milbe weniger/ }),
|
||||
screen.getByRole("button", { name: /Eine Milbe mehr/ }),
|
||||
screen.getByRole("button", { name: /leeren/ }),
|
||||
];
|
||||
for (const button of buttons) {
|
||||
expect(button.className.split(/\s+/)).toContain("min-h-11");
|
||||
expect(button.className).not.toMatch(/outline-none|ring-ring\/50|focus-visible:ring-|focus-visible:border-ring/);
|
||||
}
|
||||
expect(screen.getByRole("textbox").className).not.toMatch(/(^|\s)sm:h-/);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export interface NumberStepperProps {
|
||||
}
|
||||
|
||||
const STEP_BUTTON_CLASS =
|
||||
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold outline-none motion-safe:transition-colors hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50";
|
||||
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold motion-safe:transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
const MAX_DIGITS = 4;
|
||||
const MAX_VALUE = 10 ** MAX_DIGITS - 1;
|
||||
@@ -196,7 +196,7 @@ export function NumberStepper({
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-invalid={invalid || offGrid || undefined}
|
||||
className="w-20 text-center text-lg font-semibold sm:h-11"
|
||||
className="w-20 text-center text-lg font-semibold pointer-fine:h-11"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -228,7 +228,7 @@ export function NumberStepper({
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="min-h-11 rounded-lg px-2 text-sm text-muted-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="min-h-11 rounded-lg px-2 text-sm text-muted-foreground underline underline-offset-4"
|
||||
>
|
||||
leeren{" "}
|
||||
<span className="sr-only">– {unitLabel}</span>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function PreviousValueHint({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTakeOver}
|
||||
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4"
|
||||
>
|
||||
übernehmen{" "}
|
||||
<span className="sr-only">– {fieldLabel}</span>
|
||||
|
||||
@@ -8,9 +8,9 @@ import { ReactNode } from "react";
|
||||
* `position: sticky` only works while no ancestor between this bar and the
|
||||
* window is a scroll container (overflow auto/scroll/hidden).
|
||||
*
|
||||
* Buttons in the bar keep a 48px touch target on every breakpoint - the
|
||||
* default button size shrinks to 32px from `sm` up (`sm:h-8`), which would
|
||||
* otherwise win over the forms' `h-12`.
|
||||
* Buttons in the bar keep a 48px touch target for every pointer - the
|
||||
* default button size shrinks to 32px for a mouse (`pointer-fine:h-8`), which
|
||||
* would otherwise win over the forms' `h-12`.
|
||||
*/
|
||||
export function StickySubmit({
|
||||
children,
|
||||
|
||||
@@ -14,7 +14,7 @@ interface SwitchRowProps {
|
||||
export function SwitchRow({ label, description, checked, onChange, disabled }: SwitchRowProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring">
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 focus-ring-within">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
|
||||
@@ -40,7 +40,7 @@ const INSERT_HOST_KIND: Partial<Record<HivePartKind, HivePartKind>> = {
|
||||
const SINGLE_INSERTS: readonly HivePartKind[] = ["FLOOR_INSERT", "EVAPORATOR"];
|
||||
|
||||
const SELECT_CLASS =
|
||||
"h-11 w-full rounded-xl border border-input bg-card px-3 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm";
|
||||
"h-11 w-full rounded-xl border border-input bg-card px-3 text-base shadow-xs";
|
||||
|
||||
export interface AddPartDialogProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface EditorNoticeProps {
|
||||
/** Full-screen message of the editor route (offline, no right, no hive): the world stays, the way back is clear. */
|
||||
export function EditorNotice({ title, backHref, children }: EditorNoticeProps) {
|
||||
return (
|
||||
<main className="fixed inset-0 z-40 flex items-center justify-center bg-holo-bg p-4 text-holo-label-fg">
|
||||
<main className="holo-stage fixed inset-0 z-40 flex items-center justify-center bg-holo-bg p-4 text-holo-label-fg">
|
||||
<div className={`flex max-w-md flex-col items-center gap-4 rounded-2xl p-6 text-center ${GLASS_SURFACE}`}>
|
||||
<h1 className="text-lg font-semibold">{title}</h1>
|
||||
{children}
|
||||
|
||||
@@ -33,7 +33,7 @@ function Row({ part, name, isSelected, onSelect, moveUp, moveDown, remove }: Row
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-current={isSelected ? "true" : undefined}
|
||||
className="min-h-11 min-w-0 flex-1 truncate rounded-lg px-2 text-left text-sm focus-visible:outline-2 focus-visible:outline-holo-edge-strong aria-[current=true]:bg-holo-edge/25 aria-[current=true]:font-semibold"
|
||||
className="min-h-11 min-w-0 flex-1 truncate rounded-lg px-2 text-left text-sm aria-[current=true]:bg-holo-edge/25 aria-[current=true]:font-semibold"
|
||||
data-part-id={part.id}
|
||||
>
|
||||
{name}
|
||||
|
||||
@@ -313,7 +313,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg">
|
||||
<main className="holo-stage fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg">
|
||||
<EditorToolbar
|
||||
back={
|
||||
<Link href={backHref} onClick={onBackClick} className={GLASS_BUTTON}>
|
||||
@@ -377,7 +377,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
|
||||
/>
|
||||
</section>
|
||||
<section aria-labelledby="editor-stack" className="flex flex-col gap-3">
|
||||
<h2 id="editor-stack" ref={stackHeadingRef} tabIndex={-1} className="text-base font-semibold outline-none">
|
||||
<h2 id="editor-stack" ref={stackHeadingRef} tabIndex={-1} className="text-base font-semibold">
|
||||
Aufbau
|
||||
</h2>
|
||||
<EditorStackList
|
||||
|
||||
@@ -174,6 +174,24 @@ describe("HiveCard", () => {
|
||||
expect(screen.getByText("1 Änderung wartet auf Netz")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the stage is a .holo-stage and the outline panel on it takes the page focus ring back", async () => {
|
||||
renderCard();
|
||||
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
|
||||
|
||||
expect(screen.getByRole("img", { name: /Beute mit/ }).closest(".holo-stage")).not.toBeNull();
|
||||
expect(toggle.closest(".holo-stage")).not.toBeNull();
|
||||
await userEvent.click(toggle);
|
||||
const panel = document.getElementById(toggle.getAttribute("aria-controls")!)!;
|
||||
expect(panel).toHaveClass("stage-card");
|
||||
});
|
||||
|
||||
it("the loading stage is a .holo-stage too", () => {
|
||||
vi.mocked(fetchHive).mockReturnValue(new Promise(() => {}));
|
||||
renderCard();
|
||||
|
||||
expect(screen.getByRole("status", { name: "Beute wird geladen" })).toHaveClass("holo-stage");
|
||||
});
|
||||
|
||||
it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => {
|
||||
renderCard();
|
||||
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
|
||||
|
||||
@@ -135,7 +135,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Beute wird geladen"
|
||||
className="min-h-40 flex-1 bg-holo-bg motion-safe:animate-pulse"
|
||||
className="holo-stage min-h-40 flex-1 bg-holo-bg motion-safe:animate-pulse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -318,7 +318,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative isolate min-h-0 flex-1 overflow-hidden bg-holo-bg text-holo-label-fg">
|
||||
<div className="holo-stage relative isolate min-h-0 flex-1 overflow-hidden bg-holo-bg text-holo-label-fg">
|
||||
<div className="absolute inset-0">
|
||||
<HiveVisualSlot
|
||||
layout={layout}
|
||||
@@ -414,7 +414,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in
|
||||
id={outlineId}
|
||||
className={
|
||||
showOutline
|
||||
? "pointer-events-auto z-10 flex w-[min(18rem,100%)] flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/95 text-card-foreground shadow-xl backdrop-blur-md duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-right-4"
|
||||
? "stage-card pointer-events-auto z-10 flex w-[min(18rem,100%)] flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/95 text-card-foreground shadow-xl backdrop-blur-md duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-right-4"
|
||||
: "sr-only"
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// Frosted glass surfaces over the holo stage (mockups g2/h2). Text is
|
||||
// --holo-label-fg on --holo-label-bg at 70 % over the midnight stage, well
|
||||
// above 4.5:1; focus rings use the strong edge colour.
|
||||
// above 4.5:1. The focus ring comes from the global rule; .holo-stage on the
|
||||
// stage root draws it in the strong edge colour (spec 17.8).
|
||||
|
||||
const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md";
|
||||
|
||||
export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] ${GLASS_FILL}`;
|
||||
|
||||
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-holo-edge/70 aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] ${GLASS_FILL}`;
|
||||
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-holo-edge/70 aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] ${GLASS_FILL}`;
|
||||
|
||||
export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong ${GLASS_SURFACE}`;
|
||||
export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 ${GLASS_SURFACE}`;
|
||||
@@ -94,7 +94,7 @@ export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHis
|
||||
type="button"
|
||||
disabled={loadingId !== null}
|
||||
onClick={() => void show(item)}
|
||||
className="flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-3 py-1.5 text-start text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-60"
|
||||
className="flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-3 py-1.5 text-start text-sm hover:bg-muted disabled:opacity-60"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{shortGermanDay(item.occurredOn)} · {sourceLabel(item.source)}
|
||||
|
||||
@@ -153,7 +153,7 @@ export function HiveInit({ colonyId, colony, canEdit }: HiveInitProps) {
|
||||
{copying ? "Aufbau eines anderen Volks, ohne Namen und Füllstände." : "Startaufbau aus der Vorlage des Beutensystems."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
|
||||
<div className="holo-stage relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
|
||||
<div className="absolute inset-0">
|
||||
{proposal ? <Preview state={proposal.state} catalog={proposal.catalog} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -437,7 +437,7 @@ export function HiveOutline({ ref, state, catalog, canEdit, announcement, onEdit
|
||||
activateNode(node);
|
||||
}}
|
||||
style={{ paddingInlineStart: `${0.75 + (node.level - 1) * 1.25}rem` }}
|
||||
className="flex min-h-11 cursor-pointer items-center gap-1 rounded-lg pe-3 text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current=true]:bg-muted aria-[current=true]:font-medium"
|
||||
className="flex min-h-11 cursor-pointer items-center gap-1 rounded-lg pe-3 text-sm hover:bg-muted aria-[current=true]:bg-muted aria-[current=true]:font-medium"
|
||||
>
|
||||
<span className={isFrame ? "text-muted-foreground" : undefined}>
|
||||
{node.hint ? `${node.name} · ${node.hint}` : node.name}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SRC = join(__dirname, "..", "..");
|
||||
const read = (relative: string) => readFileSync(join(SRC, relative), "utf8");
|
||||
|
||||
/**
|
||||
* Spec 17.8: the page focus ring (--primary-ink) has less than 3:1 on --holo-bg, so every root
|
||||
* that paints the stage carries .holo-stage, which sets --ring to --holo-edge-strong.
|
||||
*/
|
||||
describe("hive stage roots carry .holo-stage", () => {
|
||||
it.each([
|
||||
["the editor", "components/hive/editor/hive-editor.tsx"],
|
||||
["the editor notice", "components/hive/editor/editor-notice.tsx"],
|
||||
["the loading fallback of the editor route", "app/(app)/colonies/[id]/hive/page.tsx"],
|
||||
["the setup preview", "components/hive/hive-init.tsx"],
|
||||
])("%s", (_name, file) => {
|
||||
const roots = read(file).match(/className="[^"]*\bbg-holo-bg\b[^"]*"/g) ?? [];
|
||||
expect(roots.length).toBeGreaterThan(0);
|
||||
for (const root of roots) expect(root).toMatch(/\bholo-stage\b/);
|
||||
});
|
||||
|
||||
it("no hive component draws its own stage ring any more", () => {
|
||||
expect(read("components/hive/hive-glass.ts")).not.toMatch(/outline-holo-edge-strong/);
|
||||
expect(read("components/hive/editor/editor-stack-list.tsx")).not.toMatch(/outline-holo-edge-strong/);
|
||||
});
|
||||
});
|
||||
@@ -259,7 +259,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, startWithFrameF
|
||||
aria-labelledby={`part-${part.id}-confirm`}
|
||||
className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3"
|
||||
>
|
||||
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm outline-none">
|
||||
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm">
|
||||
{removalNoun} wirklich entfernen?
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
/** A tappable row in a nav sheet: 56px high for gloved or hurried thumbs. */
|
||||
export const NAV_SHEET_ROW_CLASS =
|
||||
"flex min-h-14 items-center gap-3 rounded-xl px-3 text-base font-medium focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none";
|
||||
"flex min-h-14 items-center gap-3 rounded-xl px-3 text-base font-medium";
|
||||
|
||||
interface NavSheetProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -137,7 +137,6 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
|
||||
aria-live="polite"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
hasContent &&
|
||||
"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2 text-sm sm:px-6",
|
||||
hasContent && (isOffline ? "bg-warning/15" : "bg-muted/60"),
|
||||
|
||||
@@ -455,7 +455,7 @@ export function PendingQueueSheet({
|
||||
id="queue-voice-heading"
|
||||
ref={voiceHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="font-medium"
|
||||
>
|
||||
Sprachnotizen
|
||||
</h3>
|
||||
|
||||
@@ -147,8 +147,8 @@ function ReportForm({
|
||||
}
|
||||
className={cn(
|
||||
"flex min-h-14 w-full items-center gap-3 rounded-xl border border-border bg-background px-4",
|
||||
"text-left text-base font-medium outline-none transition-colors",
|
||||
"hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"text-left text-base font-medium transition-colors",
|
||||
"hover:bg-muted",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -195,7 +195,7 @@ function ScopeChoice({
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex min-h-11 items-center gap-3 rounded-lg px-1 text-base has-focus-visible:ring-3 has-focus-visible:ring-ring/50"
|
||||
className="flex min-h-11 items-center gap-3 rounded-lg px-1 text-base focus-ring-within"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -264,7 +264,7 @@ function ReportsOfTheYear({
|
||||
id="phenology-reports-heading"
|
||||
ref={heading}
|
||||
tabIndex={-1}
|
||||
className="text-sm font-medium outline-none"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Meldungen dieses Jahres
|
||||
</h3>
|
||||
|
||||
@@ -63,7 +63,7 @@ function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm
|
||||
<Input
|
||||
id="suggestion-due-date"
|
||||
type="date"
|
||||
className="h-11 sm:h-11"
|
||||
className="h-11 pointer-fine:h-11"
|
||||
value={date}
|
||||
required
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function SuggestionCard(props: SuggestionCardProps) {
|
||||
<h3
|
||||
tabIndex={-1}
|
||||
data-suggestion-heading={suggestion.id}
|
||||
className="font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="font-medium"
|
||||
>
|
||||
{suggestion.title}
|
||||
</h3>
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("TabSwitcher", () => {
|
||||
/>,
|
||||
);
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
expect(tab).toHaveClass("min-h-11", "sm:min-h-9");
|
||||
expect(tab).toHaveClass("min-h-11", "pointer-fine:min-h-9");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ export function TabSwitcher<T extends string>({ tabs, value, onChange }: TabSwit
|
||||
aria-selected={value === tab.value}
|
||||
onClick={() => onChange(tab.value)}
|
||||
className={cn(
|
||||
"min-h-11 sm:min-h-9 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors motion-reduce:transition-none",
|
||||
"min-h-11 pointer-fine:min-h-9 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors motion-reduce:transition-none",
|
||||
value === tab.value
|
||||
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ComplianceLine() {
|
||||
return (
|
||||
<Link
|
||||
href="/compliance"
|
||||
className="flex min-h-11 items-center gap-3 rounded-xl border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm outline-none hover:bg-destructive/10 focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="flex min-h-11 items-center gap-3 rounded-xl border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm hover:bg-destructive/10"
|
||||
>
|
||||
<XCircle className="size-5 shrink-0 text-destructive" aria-hidden />
|
||||
<span className="flex-1">
|
||||
|
||||
@@ -297,7 +297,7 @@ function AutopilotStepCard({ step, busy, ...handlers }: AutopilotStepCardProps)
|
||||
<h2
|
||||
id="next-step-heading"
|
||||
tabIndex={-1}
|
||||
className="font-heading text-base font-semibold text-muted-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="font-heading text-base font-semibold text-muted-foreground"
|
||||
>
|
||||
Als Nächstes
|
||||
</h2>
|
||||
@@ -359,7 +359,7 @@ function AutopilotChips({ autopilot }: { autopilot: AutopilotToday }) {
|
||||
<li>
|
||||
<Link
|
||||
href="/suggestions"
|
||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-full bg-primary/12 px-4 text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-full bg-primary/12 px-4 text-sm font-medium"
|
||||
>
|
||||
<Lightbulb className="size-4" aria-hidden />
|
||||
Vorschläge {autopilot.suggestionsOpen}
|
||||
@@ -431,7 +431,7 @@ function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps)
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={OPEN_RELATED_LABEL[task.relatedType]}
|
||||
className="grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowRight className="size-5" aria-hidden />
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Sprout } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DetailHeader } from "./detail-header";
|
||||
import { EmptyState } from "./empty-state";
|
||||
import { EntityCard } from "./entity-card";
|
||||
|
||||
const classesOf = (text: string) => screen.getByText(text).className.split(/\s+/);
|
||||
|
||||
describe("content text is 16 px (spec 4.5)", () => {
|
||||
it("EmptyState description", () => {
|
||||
render(<EmptyState icon={Sprout} title="Noch keine Völker" description="Lege das erste Volk an." />);
|
||||
expect(classesOf("Lege das erste Volk an.")).toContain("text-base");
|
||||
});
|
||||
|
||||
it("DetailHeader subtitle", () => {
|
||||
render(<DetailHeader area="colonies" title="V-01" subtitle="Stand Waldrand" backHref="/colonies" backLabel="Alle Völker" />);
|
||||
expect(classesOf("Stand Waldrand")).toContain("text-base");
|
||||
});
|
||||
|
||||
it("list title in EntityCard", () => {
|
||||
render(<EntityCard area="colonies" title="V-01" subtitle="Waldrand" />);
|
||||
expect(classesOf("V-01")).toContain("text-base");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -13,9 +13,9 @@ const badgeVariants = cva(
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 [a]:hover:bg-destructive/20",
|
||||
"bg-destructive/10 text-destructive [a]:hover:bg-destructive/20",
|
||||
warning:
|
||||
"bg-warning/15 text-warning-ink focus-visible:ring-warning/30 [a]:hover:bg-warning/25",
|
||||
"bg-warning/15 text-warning-ink [a]:hover:bg-warning/25",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
|
||||
@@ -3,8 +3,13 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Icon-only buttons get a 44 px hit area under a coarse pointer, centred and invisible, so they
|
||||
// stay small on screen but are easy to hit with a thumb or a glove (spec 4.5).
|
||||
const HIT_AREA =
|
||||
"relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-1/2"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all select-none active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -17,24 +22,24 @@ const buttonVariants = cva(
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20",
|
||||
link: "text-primary-ink underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
// Phones get at least 40 px: tapped with a thumb, often with gloves.
|
||||
// 44 px for a thumb (often with gloves); compact only for a mouse or pen.
|
||||
default:
|
||||
"h-10 sm:h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
"h-11 pointer-fine:h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-9 sm:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-9 pointer-fine:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
// 44 px: reliable with gloves or sticky fingers at the apiary.
|
||||
touch: "h-11 gap-2 px-4 text-[0.95rem] [&_svg:not([class*='size-'])]:size-5",
|
||||
icon: "size-8",
|
||||
touch: "h-11 gap-2 px-4 text-base [&_svg:not([class*='size-'])]:size-5",
|
||||
icon: `size-8 ${HIT_AREA}`,
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
`size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3 ${HIT_AREA}`,
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
`size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg ${HIT_AREA}`,
|
||||
"icon-lg": `size-9 ${HIT_AREA}`,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input bg-background shadow-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-foreground data-checked:bg-primary data-checked:text-primary-foreground",
|
||||
"peer flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input bg-background shadow-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-foreground data-checked:bg-primary data-checked:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -53,7 +53,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl bg-popover p-5 text-sm text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 motion-full:data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-full:data-closed:zoom-out-95",
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl bg-popover p-5 text-sm text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain duration-100 sm:max-w-sm data-open:animate-in data-open:fade-in-0 motion-full:data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-full:data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -33,7 +33,7 @@ function DropdownMenuContent({
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
className="isolate z-50"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
@@ -41,7 +41,7 @@ function DropdownMenuContent({
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
|
||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-11 w-full min-w-0 sm:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm",
|
||||
"h-11 w-full min-w-0 pointer-fine:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -41,7 +41,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 sm:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all select-none hover:border-foreground/20 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive data-placeholder:text-muted-foreground data-[size=default]:h-11 pointer-fine:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -58,7 +58,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-[var(--shadow-soft)] group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-[var(--shadow-soft)] group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-card data-active:text-primary-ink",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
@@ -73,7 +73,7 @@ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
className={cn("flex-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-xl border border-input bg-card px-3 py-2 text-base shadow-xs transition-all outline-none placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm",
|
||||
"flex field-sizing-content min-h-16 w-full rounded-xl border border-input bg-card px-3 py-2 text-base shadow-xs transition-all placeholder:text-muted-foreground hover:border-foreground/20 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Button } from "./button";
|
||||
import { Input } from "./input";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
const has = (className: string, token: string) => className.split(/\s+/).includes(token);
|
||||
const FOCUS_CLASSES = /outline-none|ring-ring\/50|focus-visible:ring-|focus-visible:border-ring/;
|
||||
|
||||
describe("ui sizes, type and focus (spec 4.5, 4.6)", () => {
|
||||
it("Button default is 44 px high and compact only with pointer-fine", () => {
|
||||
render(<Button>Speichern</Button>);
|
||||
const classes = screen.getByRole("button", { name: "Speichern" }).className;
|
||||
expect(has(classes, "h-11")).toBe(true);
|
||||
expect(has(classes, "pointer-fine:h-8")).toBe(true);
|
||||
expect(classes).not.toMatch(/(^|\s)sm:h-/);
|
||||
});
|
||||
|
||||
it("Button touch is 44 px with 16 px text", () => {
|
||||
render(<Button size="touch">Weiter</Button>);
|
||||
const classes = screen.getByRole("button", { name: "Weiter" }).className;
|
||||
expect(has(classes, "h-11")).toBe(true);
|
||||
expect(has(classes, "text-base")).toBe(true);
|
||||
expect(classes).not.toMatch(/text-\[0\.95rem\]/);
|
||||
});
|
||||
|
||||
it.each(["icon", "icon-sm", "icon-xs", "icon-lg"] as const)(
|
||||
"icon buttons expose a 44 px hit area under pointer-coarse (%s)",
|
||||
(size) => {
|
||||
render(<Button size={size} aria-label="Menü" />);
|
||||
const classes = screen.getByRole("button", { name: "Menü" }).className;
|
||||
expect(has(classes, "relative")).toBe(true);
|
||||
expect(has(classes, "pointer-coarse:after:absolute")).toBe(true);
|
||||
expect(has(classes, "pointer-coarse:after:size-11")).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("buttons drop their own focus ring classes", () => {
|
||||
render(
|
||||
<>
|
||||
<Button>Eins</Button>
|
||||
<Button variant="destructive">Zwei</Button>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Eins" }).className).not.toMatch(FOCUS_CLASSES);
|
||||
expect(screen.getByRole("button", { name: "Zwei" }).className).not.toMatch(FOCUS_CLASSES);
|
||||
});
|
||||
|
||||
it("Input and Textarea use text-base without md:text-sm", () => {
|
||||
render(
|
||||
<>
|
||||
<Input aria-label="Name" />
|
||||
<Textarea aria-label="Notiz" />
|
||||
</>,
|
||||
);
|
||||
for (const name of ["Name", "Notiz"]) {
|
||||
const classes = screen.getByRole("textbox", { name }).className;
|
||||
expect(has(classes, "text-base")).toBe(true);
|
||||
expect(classes).not.toMatch(/md:text-sm/);
|
||||
expect(classes).not.toMatch(FOCUS_CLASSES);
|
||||
}
|
||||
});
|
||||
|
||||
it("Input is 44 px and 36 px only with pointer-fine", () => {
|
||||
render(<Input aria-label="Name" />);
|
||||
const classes = screen.getByRole("textbox", { name: "Name" }).className;
|
||||
expect(has(classes, "h-11")).toBe(true);
|
||||
expect(has(classes, "pointer-fine:h-9")).toBe(true);
|
||||
expect(classes).not.toMatch(/(^|\s)sm:h-/);
|
||||
});
|
||||
|
||||
it("an invalid field draws a 2 px destructive frame and keeps the ring colour", () => {
|
||||
render(<Input aria-label="Menge" aria-invalid />);
|
||||
const classes = screen.getByRole("textbox", { name: "Menge" }).className;
|
||||
expect(has(classes, "aria-invalid:border-destructive")).toBe(true);
|
||||
expect(has(classes, "aria-invalid:ring-1")).toBe(true);
|
||||
expect(has(classes, "aria-invalid:ring-inset")).toBe(true);
|
||||
expect(has(classes, "aria-invalid:ring-destructive")).toBe(true);
|
||||
expect(classes).not.toMatch(/ring-destructive\/20/);
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,7 @@ export function VoiceCaptureBox({
|
||||
id={VOICE_CAPTURE_BOX_ID}
|
||||
tabIndex={-1}
|
||||
aria-labelledby={`${VOICE_CAPTURE_BOX_ID}-title`}
|
||||
className="flex scroll-mt-4 flex-col gap-3 rounded-xl border border-border bg-muted/40 p-3 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="flex scroll-mt-4 flex-col gap-3 rounded-xl border border-border bg-muted/40 p-3"
|
||||
>
|
||||
<h2 id={`${VOICE_CAPTURE_BOX_ID}-title`} className="font-heading text-base font-semibold">
|
||||
{heading}
|
||||
|
||||
@@ -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