feat(focus-rings): branded focus ring tokens (#167) #170
Reference in New Issue
Block a user
Delete Branch "feat/issue-167-focus-ring-tokens"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
--c-focus-ringCSS design token:#012851(brand-navy) in light mode,#a1dcd8(brand-mint) in dark mode — both WCAG AA+ contrast on their respective backgroundsring-focus-ringvia@theme inlineinlayout.css:focus-visiblerule in@layer baseas safety-net fallback;forced-colorsoverride for Windows High Contrast modering-accent/ring-primary/ring-inkfocus utilities across every interactive element (header, nav, auth pages, all forms, admin, document editor, PersonTypeahead, MentionEditor, notifications, etc.)CommentThreadselection highlight switched from solid ring to dotted outline (outline-dotted outline-accent) to stay visually distinct from keyboard focusCommits
fe1121dtest(focus-rings): add failing Playwright tests for --c-focus-ring token and element ring colors17889dffeat(focus-rings): add --c-focus-ring token to CSS design systemf04e4fffeat(focus-rings): update header/nav components to ring-focus-ringd0deb26feat(focus-rings): update auth and search inputs to ring-focus-ring1541afdfeat(focus-rings): update all form inputs and document components to ring-focus-ringa5cc8fdfeat(focus-rings): update interactive widgets to ring-focus-ringf1bf32efeat(focus-rings): CommentThread selection highlight → dotted outlineTest plan
:focus-visibleonly activates on keyboard)admin/tags/[id]delete confirmation input — red ring preservedCommentThreadanchor highlight — dotted accent outline (not solid)Closes #167
🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: 🚫 Changes requested
Blockers
ConversationFilterBar.svelte(both/conversations/and/korrespondenz/) — broken Tailwind class stringThe automated replacement garbled both wrapper
<div>class strings. The old arbitrary-variant selectors[&_input]:focus:border-inkand[&_input]:focus:ring-inkwere the input to a substitution, and the tool accidentally treated them as a Tailwind class prefix, producing this invalid output:These are not valid Tailwind classes. They will be silently ignored, meaning the PersonTypeahead inputs inside both conversation filter bars have no focus ring at all — an accessibility regression on a keyboard-navigation feature.
Fix: The
PersonTypeaheadcomponent now manages its ownfocus-visible:ring-2 focus-visible:ring-focus-ringdirectly (changed inPersonTypeahead.svelte). The parent wrapper no longer needs to target the child input. Remove the broken selectors entirely:Suggestions
WhoWhenSection.svelte— inconsistent focus pseudo-class in error stateThe valid state now correctly uses
focus-visible:ring-2, but the error state still usesfocus:ring-red-500(fires on mouse click too):Suggestion — make the error state consistent:
TDD evidence: Commit ordering is correct —
fe1121d(failing tests) precedes the implementation commits. Red/green flow followed. ✅Overall code quality: The token design (
--c-focus-ring→--color-focus-ring→ring-focus-ring) is a clean single-responsibility abstraction. The global:focus-visiblefallback is a correct safety net. Theforced-colorsoverride is the right pattern. The breadth of application (35 components) is thorough.🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
This is a textbook design token migration: single source of truth, clear naming, correct layer placement, no business logic entangled. Architecturally sound.
What I checked
Token chain is correct:
Three-level indirection is exactly right. The raw
--c-focus-ringis the semantic token,--color-focus-ringis the Tailwind bridge, andring-focus-ringis the utility. This pattern allows future color remaps without touching any component.Global fallback in
@layer baseis architecturally justified. The explicitring-focus-ringutility takes precedence over the global rule (specificity + layer order), so the fallback only activates for third-party widgets or future components that miss the explicit class. This is the correct approach — defence in depth at the CSS layer.forced-colorsoverride is correct.ButtonTextis the right system keyword for interactive control outlines under Windows High Contrast Mode.3px(vs2pxnormal) is also correct — high contrast mode warrants a slightly thicker outline.No layer boundary violations. This is entirely a frontend styling change with no backend, service, or data-access concerns.
Observation
The global
:focus-visiblefallback is placed inside@layer base, which is correct. One future consideration: if a third-party component library adds its ownoutline: nonein a higher-specificity rule, the fallback will be silently overridden. This is acceptable for now given the project scale — just worth noting for future library integrations.🧪 Sara Holt — QA Engineer
Verdict: ⚠️ Approved with concerns
The test-first commit (
fe1121d) is there, which I appreciate. The tests are at the correct layer (E2E/Playwright for CSS rendering). A few gaps worth addressing.Blockers
None — but the
[data-hydrated]concern below is close to a blocker depending on implementation.Concerns
[data-hydrated]selector — is this attribute actually set?All tests open with:
If the application never sets
data-hydratedon the document element (or anywhere), this wait will time out silently on every test run. I don't see where this attribute is set in the diff. If it's set elsewhere in the app (e.g. in+layout.svelteafter hydration), this is fine — but it should be verified. If it doesn't exist, replace with a stable selector likeawait page.waitForLoadState('networkidle')or wait for a known element.Missing test cases from the PR's own test plan
The PR's test plan lists:
admin/tags/[id]delete confirmation input — not in the test fileCommentThreaddotted accent outline — not in the test file (the PR changes this but no test covers it)These omissions aren't blockers for merge but they leave regression risk on the most unusual cases.
box-shadowassertion fragilityThe tests check:
Tailwind
ring-2renders asbox-shadow: 0 0 0 3px rgb(...)(with ring-offset). This is a valid strategy, buttoContainon a fullbox-shadowstring is fragile if Tailwind changes its ring implementation or if the element has an existingbox-shadow. A more resilient approach uses.toMatch(/0 0 0 \d+px rgb\(1, 40, 81\)/).What looks good
frontend/e2e/✅test.use({ storageState: ... })for the login page tests ✅🔒 Nora "NullX" Steiner — Security Engineer
Verdict: ✅ Approved
This PR is a pure CSS design token migration — no new endpoints, no new data flows, no changes to authentication, authorization, or session handling.
What I checked
innerHTML,dangerouslySetInnerHTML, or DOM injection patterns introducedCSS security note (informational, not a finding)
The
--c-focus-ringcustom property is set at:rootlevel. CSS custom properties are inheritable and readable by JavaScript viagetComputedStyle(). This is by design and is not a vulnerability — the color values are brand tokens, not sensitive data. The Playwright tests themselves use exactly this API (getPropertyValue('--c-focus-ring')) to verify the token, which is correct.Nothing to flag. Clean from a security perspective.
🎨 Leonie Voss — UI/UX & Accessibility Lead
Verdict: ⚠️ Approved with concerns
The token design is excellent — semantically named, mode-aware, WCAG-compliant contrast values documented in comments. The switch from
focus:tofocus-visible:is the right UX decision (no flash ring on mouse click). Theforced-colorsoverride is WCAG SC 1.4.11 compliant. Most of this is exactly what I wanted to see.One accessibility regression and one inconsistency need addressing before merge.
Blockers
ConversationFilterBar.svelte(×2) — focus ring missing on PersonTypeahead inputs in filter barsThe class attribute on the PersonTypeahead wrapper
<div>was replaced with a malformed string. The resulting Tailwind classes are invalid and will be silently ignored. The PersonTypeahead inputs in the Conversations and Korrespondenz filter bars will have no visible focus indicator when navigated by keyboard. This violates WCAG 2.2 SC 2.4.11 (Focus Appearance) — a hard accessibility failure.Fix: Remove the broken
[&_input]:focus:*parent selectors entirely.PersonTypeahead.sveltealready appliesfocus-visible:ring-2 focus-visible:ring-focus-ringto its own<input>— the parent no longer needs to override it.Concerns
WhoWhenSection.svelte— error state usesfocus:ring-red-500(fires on mouse click)The valid state correctly shows a ring only on keyboard focus (
focus-visible:). The error state shows a ring on every focus event including mouse click. For a date validation error, this means a mouse user who clicks the invalid field sees an intrusive red ring — that's visual noise that thefocus-visiblemigration was explicitly designed to remove. Make the error state consistent:focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500.What I'm happy about
#012851on white = 14.1:1 (WCAG AAA). Excellent.#a1dcd8on#0a1628canvas = 9.2:1 (WCAG AAA). Excellent.CommentThreadchange: Switching the anchor-highlight fromring-2 ring-accent(solid box shadow) tooutline-dotted outline-accentis the right semantic distinction — "you navigated here" vs "your keyboard is here". Visual reading is immediately different. ✅forced-colorsoverride:ButtonTextat3pxis correct for Windows High Contrast mode. ✅:focus-visiblerule in@layer baseensures future components that miss the explicit class don't regress to browser-default outlines. ✅outline: nonewithout a replacement anywhere in the diff. ✅⚙️ Tobias Wendt — DevOps & Platform
Verdict: ✅ Approved
No infrastructure, CI, Docker, or deployment changes in this PR. Everything stays the same on the platform side.
What I checked
docker-compose.yml— unchanged ✅.gitea/workflows/— unchanged ✅package.json/pom.xml— no new dependencies ✅frontend/e2e/focus-rings.spec.ts) will be picked up automatically by the existing E2E test job — no CI configuration changes needed ✅The
[data-hydrated]selector concern raised by Sara is worth a quick check before this hits CI at scale — a hung Playwright test will eat the full E2E timeout (currently budgeted at 8 minutes) and block the pipeline. If it's not already set in+layout.svelte, fix it before merge to avoid wasted runner time.LGTM from an infrastructure perspective.
🎨 Leonie Voss — Pre-merge UI/Accessibility Discussion
Follow-up to my review (#1007) — worked through the open items before the developer sits down with this.
Resolved
ConversationFilterBar.svelte(×2) — fix confirmed, scope clarifiedPersonTypeaheadalready manage their own focus rings directly. The parent[&_input]:selectors were redundant even before the broken replacement.[[&_input]:focus:*fragments from the two wrapper<div>s in both files. Keep the valid[&_input]:border-line,[&_input]:py-2.5, and[&_label]:*selectors — those are border/padding styling, not focus rings.@layer basefallback introduced in this PR. Acceptable.WhoWhenSection.svelte— fix in this PR, not a follow-upfocus:ring-red-500(fires on mouse click) is the only remaining exception to thefocus-visiblemigration in the entire codebase. Leaving it would be inconsistent by design.focus:ring-red-500→focus-visible:ring-red-500(andfocus:border-red-500→focus:outline-none focus-visible:border-red-500).[data-hydrated]Playwright wait guard — non-issue+layout.svelte: the attribute is set on the root<div>after hydration. Sara's and Tobias's concern is moot — the wait guard works correctly on every page.outline-dottedon iOS Safari — consciously deferredUnresolved / skipped
None — all items were worked through.
Overall read
Two small fixes needed (both mechanical), then this is ready to merge. The token design is clean, contrast values are WCAG AAA in both modes, the
CommentThreadsemantic distinction is correct, and the global fallback provides solid defence in depth. Strong PR.