diff --git a/src/__tests__/text-field-focus-guard.test.ts b/src/__tests__/text-field-focus-guard.test.ts new file mode 100644 index 000000000..061bf0eba --- /dev/null +++ b/src/__tests__/text-field-focus-guard.test.ts @@ -0,0 +1,121 @@ +/** + * Structural guard on the focus treatment of text-entry fields. + * + * A text field shows focus by its border alone: `border-input` turns the + * neutral `input-focus` tone (a foreground-based grey, at least 3:1 on the + * background in both themes). No colour ring, no purple. The ring was the + * loudest thing on a form, and for a text field it is on for every mouse + * click too, because `:focus-visible` always matches a field that takes + * typed input. Buttons, links, checkboxes and switches keep their rings; + * they are not in scope here. + * + * The one ring a field still draws is the destructive one on an invalid + * field that has focus (`aria-invalid:focus-visible:ring-[3px]`): its border + * is already red, so the ring is what tells focus apart there. + * + * Two checks: + * + * 1. Every text-entry primitive (and the Coach composer) uses the token. + * 2. Every class string in `src/` that styles a text field (it names + * `border-input`, the focus token, or a `placeholder:` colour) carries no focus ring and no + * primary/ring-coloured focus border. The sweep must find a floor of + * such strings, so a matcher that stops matching fails instead of + * passing on nothing. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, "..", ".."); +const SRC = join(ROOT, "src"); + +/** Text-entry primitives and the hand-built text fields that own a focus style. */ +const TEXT_FIELD_FILES = [ + "src/components/ui/input.tsx", + "src/components/ui/textarea.tsx", + "src/components/ui/select.tsx", + "src/components/ui/native-select.tsx", + "src/components/ui/date-field.tsx", + "src/components/ui/time-field.tsx", + "src/components/ui/calendar.tsx", + "src/components/insights/coach-panel/coach-input.tsx", + "src/components/cycle/cycle-settings.tsx", + "src/components/records/about-me-note-manager.tsx", + "src/components/records/conditions-manager.tsx", + "src/components/records/allergy-free-text-note.tsx", +]; + +const FOCUS_VARIANT = /(^|:)(focus|focus-visible|focus-within|has-focus)(:|$)/; +const BANNED_UTILITY = + /^(ring(-\[[^\]]+\]|-\d+)?$|ring-(ring|primary|offset)|border-(ring|primary)|outline-(ring|primary))/; + +/** Focus tokens in a class string that put a ring or a colour border on focus. */ +function bannedFocusTokens(classString: string): string[] { + return classString.split(/\s+/).filter((token) => { + if (!token.includes(":")) return false; + const parts = token.split(":"); + const utility = parts.pop()!; + const variants = parts.join(":"); + if (!FOCUS_VARIANT.test(variants)) return false; + // The invalid field's destructive focus ring is the documented exception. + if (/(^|:)aria-invalid(:|$)/.test(variants)) return false; + return BANNED_UTILITY.test(utility); + }); +} + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (name === "generated" || name === "__tests__") continue; + if (statSync(path).isDirectory()) walk(path, out); + else if (/\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name)) + out.push(path); + } + return out; +} + +const STRING_LITERAL = /"([^"\n]*)"|`([^`]*)`|'([^'\n]*)'/g; +const TEXT_FIELD_MARKER = + /(^|\s)(border-input|placeholder:text-)|border-input-focus/; + +describe("text-field focus", () => { + it("the detector flags a ring and a purple border, and spares the exceptions", () => { + expect( + bannedFocusTokens( + "focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-within:border-primary/50 focus-within:ring-2 has-focus:border-ring focus-visible:ring-offset-2", + ), + ).toHaveLength(6); + expect( + bannedFocusTokens( + "focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px] focus-visible:outline-none aria-invalid:ring-destructive/20", + ), + ).toEqual([]); + }); + + it.each(TEXT_FIELD_FILES)("%s uses the neutral focus border", (file) => { + const source = readFileSync(join(ROOT, file), "utf8"); + expect(source).toContain("border-input-focus"); + expect(source).not.toMatch(/ring-primary/); + }); + + it("no class string that styles a text field draws a focus ring", () => { + const offenders: string[] = []; + let fieldStrings = 0; + for (const file of walk(SRC)) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(STRING_LITERAL)) { + const literal = match[1] ?? match[2] ?? match[3] ?? ""; + if (!TEXT_FIELD_MARKER.test(literal)) continue; + fieldStrings++; + const bad = bannedFocusTokens(literal); + if (bad.length > 0) + offenders.push(`${relative(ROOT, file)}: ${bad.join(" ")}`); + } + } + // The primitives alone account for more than this; fewer means the + // matcher went blind, not that the fields went away. + expect(fieldStrings).toBeGreaterThanOrEqual(10); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/app/globals.css b/src/app/globals.css index b00af340d..5946209ec 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -56,6 +56,7 @@ --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); + --color-input-focus: var(--input-focus); --color-border: var(--border); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); @@ -241,6 +242,9 @@ --destructive: #ff5555; --border: #44475a; --input: #44475a; + /* Text-field focus border: a neutral, foreground-based tone, no colour + * ring. ~5.2:1 on --background, ~6:1 on --card. */ + --input-focus: #9a9b9d; --ring: #bd93f9; --chart-1: #bd93f9; --chart-2: #50fa7b; @@ -369,6 +373,8 @@ --success-foreground: #ffffff; --border: oklch(0.885 0.008 300); --input: oklch(0.87 0.009 300); + /* Text-field focus border, neutral: ~4.2:1 on --background. */ + --input-focus: oklch(0.56 0.012 295); --ring: oklch(0.6 0.13 292); --chart-1: #644ac9; --chart-2: #14720a; diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx index ce0aec3dd..30d0dc20e 100644 --- a/src/components/cycle/cycle-settings.tsx +++ b/src/components/cycle/cycle-settings.tsx @@ -371,7 +371,7 @@ function PriorField({ max={max} value={value} onChange={(e) => onChange(e.target.value)} - className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none" + className="border-input bg-background focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none" /> ); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index da20cedda..396e1db28 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -989,7 +989,7 @@ export function DocumentDetailSheet({ setTitleDraft(doc.title ?? ""); setEditingTitle(true); }} - className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none" + className="border-input hover:bg-muted/50 focus-visible:border-input-focus flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:outline-none" > ", () => { expect(html).toContain('aria-label="Open date picker"'); }); - it("uses the Input focus vocabulary — a ring that hugs the box, no offset", () => { - // Drift guard: the field must share the app's focus ring (soft 3 px, no - // offset) so the highlight never paints wider than the div or jumps on - // focus. The old hard `ring-offset-2` ring is banned here. + it("uses the Input focus vocabulary — a neutral border, no ring", () => { + // Drift guard: the field shares the app's text-field focus (the border + // turns the neutral input-focus tone, no ring), so the highlight never + // paints wider than the div or jumps on focus. const html = render(); - expect(html).toContain("focus-within:ring-ring/50"); - expect(html).toContain("focus-within:ring-[3px]"); + expect(html).toContain("focus-within:border-input-focus"); + expect(html).not.toMatch(/focus-within:ring-/); expect(html).not.toContain("ring-offset-2"); expect(html).not.toContain("ring-offset-background"); }); diff --git a/src/components/ui/__tests__/time-field.test.tsx b/src/components/ui/__tests__/time-field.test.tsx index ed155c726..f5f406687 100644 --- a/src/components/ui/__tests__/time-field.test.tsx +++ b/src/components/ui/__tests__/time-field.test.tsx @@ -132,12 +132,12 @@ describe(" SSR", () => { expect(html).toContain("min-h-11"); expect(html).toContain("sm:h-10"); }); - it("uses the Input focus vocabulary — a ring that hugs the box, no offset", () => { - // Drift guard shared with : soft 3 px ring, no offset, so the + it("uses the Input focus vocabulary — a neutral border, no ring", () => { + // Drift guard shared with : neutral border, no ring, so the // highlight never overflows the div or jumps on focus. const html = render(, "de"); - expect(html).toContain("focus-within:ring-ring/50"); - expect(html).toContain("focus-within:ring-[3px]"); + expect(html).toContain("focus-within:border-input-focus"); + expect(html).not.toMatch(/focus-within:ring-/); expect(html).not.toContain("ring-offset-2"); expect(html).not.toContain("ring-offset-background"); }); diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index b0721b904..f5fb159d5 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -74,7 +74,7 @@ function Calendar({ defaultClassNames.dropdowns, ), dropdown_root: cn( - "relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50", + "relative rounded-md border border-input shadow-xs has-focus:border-input-focus", defaultClassNames.dropdown_root, ), dropdown: cn( diff --git a/src/components/ui/date-field.tsx b/src/components/ui/date-field.tsx index 42e3ab896..199cec69b 100644 --- a/src/components/ui/date-field.tsx +++ b/src/components/ui/date-field.tsx @@ -56,15 +56,15 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10"; // ~230 px no matter what width the parent hands it, and the surplus spills out // of whatever dialog or sheet it sits in. See the matching note on the overlay // input below; both ends are needed, one alone still floors the box. -// The focus vocabulary matches `` exactly: a soft translucent 3 px ring -// that hugs the border box with no offset. The earlier `ring-2 ring-offset-2` +// The focus vocabulary matches `` exactly: the border turns the neutral +// `input-focus` tone, with no ring. The earlier `ring-2 ring-offset-2` // painted a hard 4 px ring OUTSIDE the box — wider than the field's div and // visibly mismatched next to the app's other inputs, and it shifted the // highlight box on focus. `text-base md:text-sm` mirrors `` too: a // sub-16 px input triggers iOS Safari's focus auto-zoom, which reads as a // layout jump; 16 px below `md:` suppresses it. const FIELD_BASE_CLASSES = - "border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] focus-within:outline-none"; + "border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none"; export interface DateFieldProps { id?: string; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index a00531658..ed2a3bcf0 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -77,7 +77,7 @@ function Input({ // v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5; // shrink to 40 px on sm+ where pointer precision is higher. "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 md:text-sm", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, )} diff --git a/src/components/ui/native-select.tsx b/src/components/ui/native-select.tsx index 5cfac9a66..b344ba353 100644 --- a/src/components/ui/native-select.tsx +++ b/src/components/ui/native-select.tsx @@ -55,7 +55,7 @@ import { cn } from "@/lib/utils"; // chevron uses. const NATIVE_SELECT_CLASS = - "border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat"; + "border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat"; export type NativeSelectProps = React.SelectHTMLAttributes; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 45cb3da2b..3d3230c6d 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -49,7 +49,7 @@ function SelectTrigger({ // value are inset equally; `gap-2` between them keeps the chevron // from crowding a long value, and `shrink-0` guarantees it never // clips. - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 data-[size=default]:sm:h-10 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-input-focus aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] data-[size=default]:h-11 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 data-[size=default]:sm:h-10 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index 5db2e5d71..43273a45e 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -66,7 +66,7 @@ function Textarea({ // iOS zoom defence + WCAG 2.5.5 tap-target floor; see the // primitive's docblock for the full reasoning. "border-input bg-background text-foreground placeholder:text-muted-foreground dark:bg-input/30 min-h-11 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:text-sm", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, )} diff --git a/src/components/ui/time-field.tsx b/src/components/ui/time-field.tsx index 39300187b..60a6c530a 100644 --- a/src/components/ui/time-field.tsx +++ b/src/components/ui/time-field.tsx @@ -42,12 +42,13 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10"; // automatic minimum size would otherwise be the 20-character intrinsic width of // the text input inside, which floors the whole field at ~230 px and overflows // the dialog that hosts it. See the note in `date-field.tsx`. -// Focus vocabulary and font-size parity with `` / ``: a soft -// 3 px ring hugging the box (not the old hard `ring-2 ring-offset-2`, which -// painted 4 px outside the div and jumped on focus) and `text-base md:text-sm` +// Focus vocabulary and font-size parity with `` / ``: the +// border turns the neutral `input-focus` tone with no ring (not the old hard +// `ring-2 ring-offset-2`, which painted 4 px outside the div and jumped on +// focus) and `text-base md:text-sm` // to keep iOS Safari from auto-zooming a sub-16 px input. const FIELD_BASE_CLASSES = - "border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] focus-within:outline-none"; + "border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none"; const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;