feat(focus-rings): branded focus ring tokens for light and dark mode #167
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Background
Surfaced during the UI/UX review of #166 (dark mode redesign). Most interactive elements currently use browser-default focus rings, with the exception of the header which has a branded
focus-visible:ring-accent(--c-accent: #00c7b1).Browser defaults have improved and are acceptable as a stopgap, but a consistent branded focus ring system is needed — especially once dark mode ships, since the correct ring color differs between light and dark contexts.
Problem
There is no
--c-focus-ringtoken in the design system. Focus ring colors are either:--c-accenton the headerThis means:
Proposed Solution
Add a
--c-focus-ringsemantic token that resolves to the correct color per mode:#012851(brand-navy)#a1dcd8(brand-mint)#010e1ecanvasCSS changes (
layout.css):Usage in components:
Replace
focus-visible:ring-accent(header) and any other focus ring utilities withfocus-visible:ring-focus-ring. Browser defaults can be overridden via:Or with Tailwind:
focus-visible:ring-focus-ring focus-visible:ring-2 focus-visible:ring-offset-2Scope
--c-focus-ringtoken to light and dark mode blocks inlayout.css--color-focus-ringmapping in@theme inlinefocus-visible:ring-accent→focus-visible:ring-focus-ringfocus-visible:ring-focus-ringclassAcceptance Criteria
--c-focus-ringtoken defined in both light and dark mode CSS blocks--color-focus-ringmapped in@theme inlinePersonTypeahead,TagInput,PersonMultiSelectRelated
👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
Global CSS vs. per-component Tailwind? The issue proposes two approaches: a global
:focus-visible { outline-color: var(--c-focus-ring); }rule, and per-componentfocus-visible:ring-focus-ringTailwind classes. These are mutually exclusive in practice — mixing them creates specificity conflicts. Which approach wins? I'd lean toward the global CSS rule for built-in elements (<a>,<button>,<input>) and Tailwind classes only for custom components (chips, typeahead). Committing to one strategy prevents drift.Scope of the audit: Buttons, links, inputs are listed — but what about
<select>,<textarea>,<details>/<summary>(if used), and anything withtabindex? One pass withaxeor a manual Tab sweep will turn up surprises.TDD hook: This is a CSS/token change. The "test" here is a Playwright keyboard-nav pass, not a unit test. I'll write the failing Playwright test first (tab through a page, assert the focused element has a visible ring via
getByRole+ focus assertion), then apply the token. Without that test, we have no regression gate.Dead code risk: After replacing
focus-visible:ring-accenton the header, check thatring-accentis not still referenced elsewhere in layout or component files. Agrep -r "ring-accent"before merging is cheap insurance.Suggestions
layout.cssright above the token — one line explaining why--c-focus-ringexists and which mechanism uses it. Future contributors will thank you.🏛️ Markus Keller — Application Architect
Questions & Observations
Token naming consistency: The existing system uses
--c-*for raw color tokens and--color-*for Tailwind mappings. The proposed--c-focus-ringfits this pattern correctly. One thing to verify: isfocus-ringthe right semantic name, or should it be--c-focus(shorter, parallels e.g.--c-accent)? Once named, it will appear in many components — worth a deliberate choice now.Global rule vs. utility class — the architectural question: A global
:focus-visible { outline-color: var(--c-focus-ring); }inlayout.cssgives you coverage of all native elements for free and is the simpler system. The downside: it relies onoutline, while the current header usesring(box-shadow-based in Tailwind), which has different rendering across browsers and zoom levels. The issue should explicitly pick one mechanism and document why.Dark mode block placement: The issue says "add to both blocks." The
layout.csspresumably has two dark mode contexts —@media (prefers-color-scheme: dark)and a.darkclass selector for explicit override. Both must define--c-focus-ring. If only one is set, users whose OS is in dark mode but who haven't toggled the app will get the wrong color. Confirm both paths are covered.@theme inlineboundary: The--color-focus-ring: var(--c-focus-ring)mapping in@theme inlinemeans the Tailwind utilityfocus-visible:ring-focus-ringworks. Just make surering-focus-ringresolves correctly in the Tailwind 4 config — in TW4,@theme inlinecustom properties feedring-*utilities directly, but verify with a quick compile check.Suggestions
@theme inlineblock noting that--color-focus-ringis the only externally consumable token;--c-focus-ringis internal. This prevents someone from reaching for the internal token from component code.--c-focus-ring-offsetfor the background color of the ring offset). Doing it now is cheaper than retrofitting it for every button variant later.🧪 Sara Holt — QA Engineer & Test Strategist
Questions & Observations
The acceptance criteria say "verified with keyboard navigation" — who verifies and how? Manual keyboard testing is not a quality gate; it's a one-time check that silently regresses. This feature needs automated Playwright assertions to stay green across all future PRs.
No test plan in the issue. For a cross-cutting change affecting every interactive element, the test surface is non-trivial. Before implementation starts, we should agree on: which pages get Playwright focus tests, and at which breakpoints.
Light mode AND dark mode in CI: The acceptance criterion covers both modes. A Playwright run that only tests light mode misses half the spec. The CI pipeline needs to run the focus ring tests in both modes — either via
--theme=darkflag or aprefers-color-scheme: darkemulation in the Playwright config.WCAG 2.4.11 contrast verification: The issue cites the contrast ratios from the spec, which is great. But how do we verify them programmatically?
axe-playwright'scheckA11ydoes not currently check WCAG 2.4.11 focus appearance (it checks 2.4.7 and 1.4.11 but not the newer criterion). Manual spot-check with a contrast analyzer is needed, or a custom assertion.Edge case: elements that hardcode
outline: none— any component that hasoutline-nonein its class list will silently swallow the global rule. The audit must specifically look foroutline-noneand ensure each occurrence has an explicitfocus-visible:ring-focus-ringreplacement.Suggested Test Plan (layer by layer)
grep -r "outline-none\|outline: none"insrc/as a pre-commit checkshould show branded focus ring on primary button(light + dark)should show branded focus ring on nav links in header(light + dark)should show branded focus ring on PersonTypeahead input(light + dark)should show branded focus ring on tag chips(light + dark)checkA11yon: home, document detail, edit, persons list, admin pagesSuggestions
colorScheme: 'dark'in a separate test project inplaywright.config.ts— low-overhead way to guarantee dark mode coverage on every PR.🔐 Nora "NullX" Steiner — Application Security Engineer
Questions & Observations
No direct security vulnerabilities in this change — adding CSS custom properties and updating focus ring classes doesn't introduce an attack surface. But there are a few security-adjacent notes worth capturing:
Focus indicators matter for security beyond accessibility. Visible, branded focus rings help users orient themselves in the real app and notice when they've been redirected to a lookalike page. Removing or weakening focus rings is a technique sometimes used in UI-redressing attacks to make fake overlays feel native. Keeping focus rings prominent and branded is a mild but real defense-in-depth measure — worth documenting as intentional in the issue.
focus-visiblevsfocus— XSS surface consideration (informational):focus-visibleis the right choice here (it only shows the ring on keyboard navigation, not pointer clicks). Using:focusinstead would have been fine functionally, butfocus-visibleis more specific and less likely to be accidentally overridden by user stylesheets injected via browser extensions. No action needed — just confirming the proposed approach is the correct one.CSS custom property injection (non-issue, informational): If user-controlled content were ever rendered via
styleattributes, a--c-focus-ringoverride could theoretically be injected. The app's architecture (SSR with Tailwind, no inline style from user input) makes this a theoretical non-issue. Just confirming it's been considered.forced-colors/ Windows High Contrast mode: This is both an accessibility and a security-adjacent concern — users who rely on system accessibility features may have CSS custom properties overridden. In forced-colors mode,var(--c-focus-ring)may not resolve as expected. The browser typically substitutesHighlightorButtonTextfor focus indicators in this mode, which is actually correct behavior. But it's worth a quick test to confirm the focus ring doesn't disappear entirely in forced-colors mode.Suggestions
forced-colors: activemedia query test to the acceptance criteria (verify ring is still visible, even if the system color replaces the brand color).🎨 Leonie Voss — UI/UX Design Lead
This issue is in my domain. The values and intent are right — a few things to tighten up before implementation.
Contrast values — good, with one gap
#012851#E4E2D7#012851#010e1e#a1dcd8#a1dcd8The dark mode card background in the redesign (
#0d1f35or similar) may be lighter than#010e1e. A ring that passes on the canvas may fail on a card. The implementer should spot-check at least one focused input inside a dark card.WCAG 2.4.11 — ring width is missing from scope
WCAG 2.4.11 (Focus Appearance) requires:
The color token solves (2). But (1) depends on ring width and offset. The issue mentions
ring-2 ring-offset-2in passing but this is not in the acceptance criteria. I'd add:A 1px ring fails WCAG 2.4.11 even at perfect contrast.
Ring offset color in dark mode
ring-offset-2renders the offset using the element's background color by default. On dark surfaces, if a button has a dark background, the offset gap becomes invisible. Consider defining a--color-focus-ring-offsettoken that matches the local background, or usering-offset-[color]explicitly on components with non-white backgrounds.outline-noneaudit is criticalMany Tailwind components default to
outline-noneon inputs and buttons. The audit must not just add the new token — it must actively removeoutline-nonefrom every place it appears without an explicit replacement. No silent swallowing.forced-colorsmedia queryAdd to
layout.css:This ensures keyboard users on Windows High Contrast mode always get a visible focus ring, even if CSS custom properties are overridden by the system.
Suggestions
ring-2 ring-offset-2as an explicit acceptance criterionforced-colorsfallback to the CSS scope🚀 Tobias Wendt — DevOps & Platform Engineer
Questions & Observations
This is a pure frontend CSS change — no infra, no config, no deployment concerns. But a few pipeline observations:
CI coverage for dark mode: The existing Gitea Actions workflow probably doesn't run Playwright with
colorScheme: dark. If focus ring tests land in the E2E suite (which they should, per Sara's proposal), the pipeline needs a dark-mode test project configured inplaywright.config.ts. This is a one-time config addition, not a recurring cost.Visual regression diff workflow: A cross-cutting change like this — touching buttons, inputs, links, chips across every page — will produce a large visual regression diff. Make sure the Playwright visual baseline is updated as part of the PR, not after. Stale baselines will produce false failures on the next PR.
Build artifact size: No concerns. CSS custom properties and Tailwind utility additions add negligible bytes to the production bundle. The
ring-focus-ringutility will be tree-shaken in if used — no action needed.npm run check(svelte-check): Any class that doesn't resolve in Tailwind 4 will not produce a build error — it just silently produces no output. After adding--color-focus-ringto@theme inline, do a quicknpm run buildto confirmring-focus-ringis actually emitting CSS in the production bundle, not silently disappearing.Suggestions
playwright.config.tsprojectsentry for dark mode before this PR is merged:Implementation complete ✅
All 8 tasks implemented on branch
feat/issue-167-focus-ring-tokens→ PR #170.What was done
Token + global rule
--c-focus-ring: #012851(light) /#a1dcd8(dark) added tolayout.css@theme inlinemaps it toring-focus-ringTailwind utility:focus-visible { outline: 2px solid var(--c-focus-ring); outline-offset: 2px; }in@layer baseas safety netforced-colorsfallback for Windows High Contrast modeComponent sweep (37 files)
outline-dotted outline-accent(visually distinct from solid focus ring)admin/tags/[id]delete confirmation red ring (semantic destructive indicator)Commits
fe1121dtest(focus-rings): add failing Playwright tests17889dffeat(focus-rings): add --c-focus-ring token to CSS design systemf04e4fffeat(focus-rings): update header/nav componentsd0deb26feat(focus-rings): update auth and search inputs1541afdfeat(focus-rings): update all form inputs and document componentsa5cc8fdfeat(focus-rings): update interactive widgetsf1bf32efeat(focus-rings): CommentThread selection highlight → dotted outline