From bed47387decaf765f600c0d3d6a234f0d9e13167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:08:48 +0200 Subject: [PATCH] fix(ui): show text-field focus as a neutral border, not a purple ring Every text-entry control (Input, Textarea, Select and NativeSelect triggers, the date and time fields, the calendar's dropdowns, the Coach composer and the hand-built note fields) now marks focus by turning its border the new neutral input-focus tone, about 5:1 on the dark background and 4:1 on the light one, with no ring. A text field matches :focus-visible on every mouse click, so the ring was on for everyone. An invalid field keeps its red border and draws its destructive ring only while focused. Buttons, links, checkboxes and switches keep their rings. text-field-focus-guard.test.ts fails when a text-field class string brings a focus ring or a primary/ring-coloured focus border back. --- src/__tests__/text-field-focus-guard.test.ts | 121 ++++++++++++++++++ src/app/globals.css | 6 + src/components/cycle/cycle-settings.tsx | 2 +- .../documents/document-detail-sheet.tsx | 2 +- .../insights/coach-panel/coach-input.tsx | 2 +- .../records/about-me-note-manager.tsx | 2 +- .../records/allergy-free-text-note.tsx | 2 +- src/components/records/conditions-manager.tsx | 2 +- .../ui/__tests__/date-field.test.tsx | 12 +- .../ui/__tests__/time-field.test.tsx | 8 +- src/components/ui/calendar.tsx | 2 +- src/components/ui/date-field.tsx | 6 +- src/components/ui/input.tsx | 2 +- src/components/ui/native-select.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/components/ui/textarea.tsx | 2 +- src/components/ui/time-field.tsx | 9 +- 17 files changed, 156 insertions(+), 28 deletions(-) create mode 100644 src/__tests__/text-field-focus-guard.test.ts 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)$/;