diff --git a/docs/specs/focus-rings-spec.html b/docs/specs/focus-rings-spec.html new file mode 100644 index 00000000..e753560d --- /dev/null +++ b/docs/specs/focus-rings-spec.html @@ -0,0 +1,1152 @@ + + +
+ + +Most interactive elements currently use browser-default focus rings. There is no --c-focus-ring semantic token, leading to 5 different ad-hoc approaches across the codebase. This spec defines a single token, proves WCAG 2.4.11 compliance in both modes, and gives exact Tailwind classes for every element type. Relates to issue #167.
focus-visible:ring-accent.
+ In light mode --c-accent: #a1dcd8. That mint ring on the white/sand background has a contrast ratio of 1.52:1 — the WCAG 2.4.11 minimum is 3:1 for the focus indicator against adjacent colors.focus-visible:ring-accent focus-visible:ring-offset-2) have the same failure — the ring-offset is white (#fff), so the contrast is #a1dcd8 vs #fff = 1.52:1.
+ ring-accent with ring-focus-ring throughout. In light mode --c-focus-ring: #012851 gives 14:1 on white and 13:1 on sand — WCAG AAA.
+ PersonMultiSelect.svelte:94 and TagInput.svelte:98 apply focus:outline-none to the chip × buttons with no replacement focus indicator. These are keyboard-operable interactive controls. Removing the browser outline without a replacement is a hard WCAG 2.4.11 fail — keyboard users cannot see which chip they are about to remove.TagInput.svelte:125 sets outline-none focus:ring-0 on the inner text input, also leaving it with zero focus indicator.
+ focus:outline-none from both chip close buttons and replace with focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring. For the TagInput inner input: remove focus:ring-0 and add focus:ring-2 focus:ring-focus-ring (show on focus, not just focus-visible, since this is a text field).
+ focus / focus-visible classes across all Svelte files reveals five distinct ring-color decisions:ring-accent — header elements, PanelHistory, MentionEditor, AppNav, UserGroupsSectionring-ink — form inputs, textareas, selects (WhoWhenSection, DescriptionSection, TranscriptionSection, SearchFilterBar, ConversationFilterBar, ForgotPassword, profile forms)ring-primary — PersonTypeahead compact modering-black — PersonTypeahead dropdown (Headless UI default)--c-focus-ring to layout.css (light + dark blocks) and --color-focus-ring: var(--c-focus-ring) in @theme inline. All components then use ring-focus-ring — one token to retheme all focus rings at once.
+ ring-1 (1px, barely visible), some ring-2 (2px), and form inputs omit the width class entirely (Tailwind 4 default ring is 1px). No element outside the notifications page uses ring-offset, so the ring is drawn on top of the element border rather than floating outside it — making it hard to distinguish the focus ring from the border color change.focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 for all buttons, links, and icon buttons on light backgrounds. For form inputs: focus:ring-2 focus:ring-focus-ring focus:ring-offset-0 (inputs need the ring to show on click, not just keyboard nav; offset-0 because the ring replaces the border-color signal). For header elements: focus-visible:ring-2 focus-visible:ring-focus-ring (no offset — ring reads clearly on navy bg).
+ | CSS Variable | +Mode | +Value | +Contrast (typical bg) | +WCAG | +
|---|---|---|---|---|
--c-focus-ring |
+ Light | +
+
+
+ #012851
+
+ |
+ 14:1 on white · 13:1 on sand (#f0efe9) | +AAA ✓ | +
--c-focus-ring |
+ Dark | +
+
+
+ #a1dcd8
+
+ |
+ 14.3:1 on #010e1e · 9.2:1 on #011526 | +AAA ✓ | +
| Tailwind utility class | +ring-focus-ring (via --color-focus-ring: var(--c-focus-ring)) |
+ |||
--c-primary (#012851), which already scores 14:1 on white for text — a free contrast win. Dark mode reuses --c-primary dark value (#a1dcd8 brand-mint), which scores 14.3:1 on the darkest canvas. Both exceed WCAG AAA (7:1) and comfortably pass WCAG 2.4.11's 3:1 minimum.
+ --c-primary is used for button backgrounds and interactive text. If we map --c-focus-ring to the same value as --c-primary, the token works identically — and that is exactly the right choice here.
The distinction matters for clarity: --c-focus-ring is a semantic token with a specific purpose (focus indicators). Even if it resolves to the same hex today, a future redesign can update one without touching the other.
Having an explicit --c-focus-ring also makes it immediately clear in component code that a focus style is intentional, not an accidental color reuse.
--c-accent as a focus ring in light mode. --c-accent in light mode is #a1dcd8, a mint that scores only 1.52:1 on white backgrounds — decorative use only. This is the root cause of the current WCAG 2.4.11 failures in the header.
+ Each element shown idle (no focus) and focused. Left panel = light mode. Right panel = dark mode. Mockup values are ~55% scale.
+ + +| Element | Tailwind classes to ADD / CHANGE | Real size | Notes |
|---|---|---|---|
| Any text input, textarea, select | +focus:outline-none focus:ring-2 focus:ring-focus-ring focus:border-focus-ring |
+ ring 2px | +Use focus: not focus-visible: — user must see which text field is active even on mouse click. Remove any focus:ring-ink, focus:ring-accent, focus:ring-primary, focus:ring-black. Do NOT add ring-offset — offset-0 is correct for inputs. |
+
| Error-state input (focused) | +focus:ring-focus-ring (ring color stays navy/mint) |
+ ring 2px | +The error border color (red-400) is the border — the focus ring is always ring-focus-ring. Two separate visual signals: border = validation state, ring = keyboard position. | +
| Files / components to update | +WhoWhenSection.svelte · DescriptionSection.svelte · TranscriptionSection.svelte · SearchFilterBar.svelte · ConversationFilterBar.svelte (both instances) · forgot-password/+page.svelte · PanelHistory.svelte · MentionEditor.svelte · UserPasswordSection.svelte · UserProfileSection.svelte · PersonalInfoForm.svelte · PasswordChangeForm.svelte · PersonTypeahead.svelte |
+ ||
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Any button (primary, ghost, destructive) | +focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 |
+ ring 2px, offset 2px | +focus-visible: not focus: — buttons only need the ring for keyboard navigation, not mouse clicks. Min height 44px for all touch targets. |
+
| ring-offset color (light) | +Tailwind default ring-offset-white or omit (default = white) |
+ — | +On light backgrounds the default white offset is correct. On sand background (bg-canvas), add focus-visible:ring-offset-canvas so the gap matches the page background. |
+
| ring-offset color (dark) | +focus-visible:ring-offset-canvas |
+ — | +Critical: without this, the white offset flashes on dark backgrounds. Add focus-visible:ring-offset-canvas to all buttons that appear on dark/canvas backgrounds. |
+
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Header icon button (light + dark) | +focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring |
+ ring 2px, no offset | +No ring-offset: ring appears directly on navy bg. In light mode, ring-focus-ring = navy → same as header bg → use mint instead? No: the navy header IS the background, so the ring colour is always mint (token resolves correctly in both modes). Contrast of mint (#a1dcd8) on navy header (#012851) = 4.1:1 — WCAG AA ✓. |
+
| AppNav links (desktop) | +Remove focus-visible:ring-accent, add focus-visible:ring-focus-ring |
+ ring 2px | +Files: AppNav.svelte lines 44, 54, 64, 74, 86, 145, 155, 165, 176 |
+
| UserMenu avatar + close | +Remove focus-visible:ring-accent, add focus-visible:ring-focus-ring |
+ ring 2px | +File: UserMenu.svelte lines 36, 47 |
+
| ThemeToggle, LanguageSwitcher, NotificationBell | +Remove focus-visible:ring-accent, add focus-visible:ring-focus-ring |
+ ring 2px | +Files: ThemeToggle.svelte:34, LanguageSwitcher.svelte:15, NotificationBell.svelte:157 |
+
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| Chip close button (×) | +focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring rounded |
+ ring 2px, no offset | +CURRENTLY: focus:outline-none with no ring — zero visible focus indicator. This is a hard WCAG 2.4.11 fail. Files: PersonMultiSelect.svelte:94, TagInput.svelte:98. |
+
| TagInput inner text input | +focus:outline-none focus:ring-2 focus:ring-focus-ring |
+ ring 2px | +CURRENTLY: outline-none focus:ring-0 — completely suppressed. Remove both classes, add the focus ring. File: TagInput.svelte:125. The ring appears on the input itself (narrow), which is visually fine inside the chip container. |
+
| PersonMultiSelect inner input | +focus:outline-none focus:ring-2 focus:ring-focus-ring |
+ ring 2px | +File: PersonMultiSelect.svelte:117 — same outline-none focus:ring-0 pattern. Same fix. |
+
| Touch target for close button | +min-h-[44px] min-w-[44px] or ensure chip row is ≥44px tall |
+ 44×44px min | +Most commonly undersized element — the × button inside chips is often 20–24px. Pad the chip row to 44px height or use p-2 on the button itself. |
+
| Element | Tailwind classes | Real size | Notes |
|---|---|---|---|
| input[type=checkbox] | +rounded focus:ring-2 focus:ring-focus-ring focus:ring-offset-2 |
+ ring 2px, offset 2px | +CURRENTLY: focus:ring-accent — fails light mode. File: UserGroupsSection.svelte:21. Tailwind does not need explicit focus:outline-none for checkboxes — it resets the outline via the preflight layer. |
+
| ring-offset-color (dark) | +focus:ring-offset-canvas |
+ — | +Without this, the 2px ring offset shows as white on dark backgrounds. Add alongside the ring classes. | +
focus-visible:ring-2 focus-visible:ring-offset-2 with ring-accent. Only change needed: replace ring-accent → ring-focus-ring.notifications/+page.svelte lines 114, 129, 152, 167 — four pill variants. Each currently has focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2.
+ Apply these changes first — all component-level fixes depend on the token existing.
+ +/* ─── 3. Semantic tokens ───────────────────────────────────────────────────── */ +@theme inline { + /* ... existing tokens ... */ ++ ++ /* Focus ring */ ++ --color-focus-ring: var(--c-focus-ring); +} + +/* ─── 4. Light mode (default) ─────────────────────────────────────────────── */ +:root { + /* ... existing tokens ... */ ++ ++ /* Focus ring — brand-navy on white/sand = 14:1 WCAG AAA */ ++ --c-focus-ring: #012851; +} + +/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + /* ... existing tokens ... */ ++ ++ /* Focus ring — brand-mint on dark canvas = 14.3:1 WCAG AAA */ ++ --c-focus-ring: #a1dcd8; + } +} + +/* Manual dark override */ +:root[data-theme='dark'] { + /* ... existing tokens ... */ ++ ++ --c-focus-ring: #a1dcd8; +}+
AppNav.svelte (lines 44, 54, 64, 74, 86, 145, 155, 165, 176): +- focus-visible:ring-accent ++ focus-visible:ring-focus-ring + +UserMenu.svelte (lines 36, 47): +- focus-visible:ring-accent ++ focus-visible:ring-focus-ring + +ThemeToggle.svelte (line 34): +- focus-visible:ring-accent ++ focus-visible:ring-focus-ring + +LanguageSwitcher.svelte (line 15): +- focus-visible:ring-accent ++ focus-visible:ring-focus-ring + +NotificationBell.svelte (line 157): +- focus-visible:ring-accent ++ focus-visible:ring-focus-ring + +notifications/+page.svelte (lines 114, 129, 152, 167): +- focus-visible:ring-accent ++ focus-visible:ring-focus-ring+
WhoWhenSection.svelte, DescriptionSection.svelte, TranscriptionSection.svelte, +SearchFilterBar.svelte, ConversationFilterBar.svelte (×2), +forgot-password/+page.svelte, UserPasswordSection.svelte, UserProfileSection.svelte, +PersonalInfoForm.svelte, PasswordChangeForm.svelte: +- focus:border-ink focus:ring-ink +- (or) focus:border-ink focus:outline-none +- (or) focus:border-ink focus:ring-1 focus:ring-ink ++ focus:outline-none focus:ring-2 focus:ring-focus-ring focus:border-focus-ring + +PanelHistory.svelte (lines 305, 320): +- focus:ring-1 focus:ring-accent focus:outline-none ++ focus:outline-none focus:ring-2 focus:ring-focus-ring + +MentionEditor.svelte (line 190): +- focus:ring-1 focus:ring-accent focus:outline-none ++ focus:outline-none focus:ring-2 focus:ring-focus-ring+
PersonMultiSelect.svelte (line 94) — chip close button: +- class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none" ++ class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring rounded" + +PersonMultiSelect.svelte (line 117) — inner text input: +- class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0" ++ class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-2 focus:ring-focus-ring" + +TagInput.svelte (line 98) — chip close button: +- class="text-ink/50 hover:text-red-500 focus:outline-none" ++ class="text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring rounded" + +TagInput.svelte (line 125) — inner text input: +- class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0" ++ class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-2 focus:ring-focus-ring" + +UserGroupsSection.svelte (line 21) — checkbox: +- class="rounded border-line text-ink focus:ring-accent" ++ class="rounded border-line text-ink focus:ring-2 focus:ring-focus-ring focus:ring-offset-2" + +PersonTypeahead.svelte (lines 157–158) — both input variants: +- focus:border-primary focus:outline-none +- focus:border-accent focus:ring-accent ++ focus:outline-none focus:ring-2 focus:ring-focus-ring focus:border-focus-ring + +PersonTypeahead.svelte (line 163) — dropdown list: +- ring-1 ring-black ++ ring-1 ring-line (decorative shadow border — not a focus ring, leave as semantic border color)+
ring-2 explicitly — never rely on the default. Any component that has only focus:ring without a width is getting a 1px ring, which is too thin for WCAG 2.4.11 (the indicator perimeter area requirement).
+ | File | +Current ring color | +Current ring width | +Light contrast | +Status | +Change | +
|---|---|---|---|---|---|
| AppNav.svelte (×9) | +ring-accent |
+ ring-2 ✓ |
+ 1.52:1 on navy bg | +Fail 2.4.11 | +→ ring-focus-ring (mint on navy = 4.1:1 ✓) |
+
| UserMenu.svelte (×2) | +ring-accent |
+ ring-2 ✓ |
+ 1.52:1 on navy bg | +Fail 2.4.11 | +→ ring-focus-ring |
+
| ThemeToggle.svelte | +ring-accent |
+ ring-2 ✓ |
+ 1.52:1 on navy bg | +Fail 2.4.11 | +→ ring-focus-ring |
+
| LanguageSwitcher.svelte | +ring-accent |
+ ring-2 ✓ |
+ 1.52:1 on navy bg | +Fail 2.4.11 | +→ ring-focus-ring |
+
| NotificationBell.svelte | +ring-accent |
+ ring-2 ✓ |
+ 1.52:1 on navy bg | +Fail 2.4.11 | +→ ring-focus-ring |
+
| notifications/+page.svelte (×4) | +ring-accent |
+ ring-2 ✓ |
+ 1.52:1 (white offset) | +Fail 2.4.11 | +→ ring-focus-ring |
+
| PersonMultiSelect.svelte chip close | +none (outline-none, no ring) | +— | +0 — invisible | +Fail 2.4.11 | +Add focus-visible:ring-2 focus-visible:ring-focus-ring |
+
| PersonMultiSelect.svelte inner input | +none (outline-none focus:ring-0) | +— | +0 — invisible | +Fail 2.4.11 | +Add focus:ring-2 focus:ring-focus-ring |
+
| TagInput.svelte chip close | +none (outline-none, no ring) | +— | +0 — invisible | +Fail 2.4.11 | +Add focus-visible:ring-2 focus-visible:ring-focus-ring |
+
| TagInput.svelte inner input | +none (outline-none focus:ring-0) | +— | +0 — invisible | +Fail 2.4.11 | +Add focus:ring-2 focus:ring-focus-ring |
+
| UserGroupsSection.svelte checkbox | +ring-accent |
+ default (1px) | +1.52:1 on white | +Fail 2.4.11 | +→ ring-2 ring-focus-ring ring-offset-2 |
+
| PanelHistory.svelte inputs (×2) | +ring-accent |
+ ring-1 (thin) |
+ 1.52:1 on white | +Fail 2.4.11 | +→ ring-2 ring-focus-ring |
+
| MentionEditor.svelte textarea | +ring-accent |
+ ring-1 (thin) |
+ 1.52:1 on white | +Fail 2.4.11 | +→ ring-2 ring-focus-ring |
+
| PersonTypeahead.svelte input (compact) | +border-primary only |
+ no ring | +border only, no ring | +No ring | +Add ring-2 ring-focus-ring |
+
| PersonTypeahead.svelte input (standard) | +ring-accent |
+ default (1px) | +1.52:1 on white | +Fail 2.4.11 | +→ ring-2 ring-focus-ring |
+
| WhoWhenSection, DescriptionSection, TranscriptionSection inputs | +ring-ink |
+ default (1px) | +14:1 on white ✓ | +Color OK Width thin | +→ ring-2 ring-focus-ring (and add border-focus-ring) |
+
| Profile forms, password forms | +border-ink only (outline-none, no ring) |
+ no ring | +border only | +No ring | +Add ring-2 ring-focus-ring alongside border-focus-ring |
+
| SearchFilterBar, ConversationFilterBar inputs | +ring-ink |
+ default (1px) | +14:1 on white ✓ | +Color OK Width thin | +→ ring-2 ring-focus-ring |
+
| forgot-password/+page.svelte input | +ring-ink |
+ ring-1 (thin) |
+ 14:1 on white ✓ | +Color OK Width thin | +→ ring-2 ring-focus-ring |
+