feat(t5): Fokusring für alle, 44-px-Ziele bei Touch, 16-px-Schrift (T5a A5)

- globals.css: eine Basisregel :where(:focus-visible) mit --focus-width
  und --focus-offset (Sonne 3 px), Firefox-:-moz-focusring gleichgezogen;
  --text-xs 0.8125rem.
- Fokus-Codemod (scripts/codemod-t5-focus.mjs): eigene Ringe und
  outline-none raus, sm:h-/sm:size- zu pointer-fine:, Label-Zeilen mit
  focus-ring-within; Skript-Fokus (Lücken-Liste, „Gespeichert“) bleibt.
- Button/Input 44 px, kompakt nur mit Maus; Icon-Knöpfe mit unsichtbarer
  44-px-Trefferfläche bei Touch, Nachbarn mit genug Abstand.
- 16 px in Feldern, Labels, Auswahl, Leerzuständen, Listentiteln.
- .holo-stage auf allen Beute-Bühnen, .stage-card holt den Seitenring.
- Baukasten-Wächter (src/styles/baukasten-guard.spec.ts) mit den Regeln
  aus A3–A5, Ausnahmen mit Grund; Kalender-Auswahl als Inset-Ring.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 18:06:20 +02:00
1 parent 62ab080725
commit ce68729233
85 files changed
+935 -139

No files matched your search

@@ -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");
});
});
+3 -3
View File
@@ -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:
+15 -10
View File
@@ -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>
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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}