Repository navigation
Replace focus rings with a token shift in the chrome surface #2718
Description
Overview
Replace the five inconsistent shadcn focus-ring idioms in the chrome surfaces with a painted-only token shift: a focused control adopts its own --*-hover appearance. No rings, no outlines, no offsets, and — the hard constraint — nothing that can change an element's size or position.
The hard constraint: focus must never move anything
A focus treatment may change painted properties only:
| Allowed | Forbidden |
|---|---|
background-color |
border-width, border shorthand |
color |
padding, margin |
outline, outline-offset |
|
box-shadow / Tailwind ring-* / ring-offset-* |
|
transform, font-weight, letter-spacing |
The mechanism matters, because the three forbidden groups fail for different reasons:
border-width/paddinggenuinely reflow. Going fromborder: 0toborder: 2pxgrows the box by 4px and shoves every sibling after it. This is the one that actually "jerks" the layout.outlinewith a negative offset is what the historical rules used (outline: 2px solid; outline-offset: -2px, removed in Split-Pane Layout System #300). It does not reflow, but it paints inside the element's edge, reading as the element having suddenly grown a thick inner edge.ring-offset-*paints a gap-plus-ring around the control. It does not reflow either, but it visually enlarges the control's footprint, which is indistinguishable from movement to a user.
Forbidding all three is deliberate: the distinction between "actually reflows" and "looks like it reflows" is not one a reader should have to re-derive, and only painted properties are safe in every case.
Current state is already compliant — every focus utility in the codebase today is ring-* or outline-none; nothing changes geometry. This rule prevents regression as much as it fixes the present.
The two surfaces are not the same, and only one is in scope
Editor surface — OUT OF SCOPE, leave exactly as is. In the node tree, tab system and sidebar, Tab is bound to indent/outdent, not focus traversal. Focus styles were removed there deliberately (#300), with the reasoning recorded in the source:
/* Removed :focus-visible border - Tab key used for indent/outdent, not UI navigation */
(node-tree.svelte:183, tab-system.svelte:464,562, navigation-sidebar.svelte:771,827)
That decision stands.
Chrome surface — IN SCOPE. Settings panes, dialogs and modals are ordinary forms where Tab is the only way to move between controls, and where a user currently cannot tell which control Enter will activate.
Current state: five idioms, three defects
| Component | Current | Issue |
|---|---|---|
ui/button/types.ts:30 |
outline-none, nothing else |
no indicator at all |
ui/select/select-trigger.svelte:17 |
focus-visible:outline-none |
no indicator at all |
schema/schema-field-leaf.svelte:72 |
focus-visible:outline-none |
no indicator at all |
schema/nested-field-trigger.svelte:31 |
focus-visible:outline-none |
no indicator at all |
ui/checkbox/checkbox.svelte:23 |
ring-ring/50 ring-[3px] |
1.84:1 light / 2.28:1 dark |
ui/switch/switch.svelte:17 |
ring-2 ring-offset-2 |
ring idiom |
ui/tabs/tabs-trigger.svelte:12 |
ring-2 ring-offset-2 |
ring idiom |
ui/tabs/tabs-content.svelte:12 |
ring-2 ring-offset-2 |
ring on a scroll container |
ui/radio-group/radio-group-item.svelte:17 |
focus: + ring-1 |
ring idiom; focus: fires on mouse |
ui/dialog/dialog-content.svelte:36 |
focus:ring-2 |
ring idiom; focus: fires on mouse |
relationships/relationship-viewer-modal.svelte ×5 |
ring-1 |
ring idiom |
The checkbox's ring-ring/50 is the theme-blind alpha compositing DESIGN.md already bans for hover fills — the ban's wording just does not reach focus. Verified: 1.84:1 light, 2.28:1 dark, against a 3:1 floor.
Button having no indicator is the sharpest case: it is the most-used control in the app, and in the delete-confirmation dialog a keyboard user cannot see whether Enter cancels or removes a database.
Approach
A focused control adopts its --*-hover fill — the tokens shipped in #2673. Focus and hover look the same; both mean "this is the control you are about to act on." Precedent already exists and ships without complaint: dropdown-menu-item uses focus:bg-accent focus:text-accent-foreground.
focus-visible:bg-primary-hover /* filled buttons reuse their hover token */
focus-visible:bg-hover-background /* neutral surfaces */
Controls with no fill to shift get no focus treatment. Checkbox, switch and radio have only a border or track to change, and a border treatment is the idiom that has twice been removed from this codebase. They are left alone rather than given a treatment that invites the geometry change this issue exists to prevent.
Rules:
:focus-visibleonly, never bare:focus— bare:focusfires on mouse clicks and would put the treatment on the resting UI- Painted properties only, per the table above
- Opaque tokens only; no alpha fills (the checkbox bug)
- One idiom for the whole chrome surface, defined once
Acceptance Criteria
- A single painted-only focus idiom replaces all five current ones across the chrome surface
-
Button,select-trigger,schema-field-leafandnested-field-triggerhave a visible focus treatment - Zero
ring-*,ring-offset-*,outline(other thanoutline-none),border-width, orpaddingchanges in any focus declaration, app-wide - No alpha-composited focus values; every focus treatment clears 3:1 against its surface in both themes
- Bare
:focusreplaced by:focus-visibleonradio-group-itemanddialog-content - Checkbox, switch and radio have no focus treatment rather than a border-based one
- The editor surface is untouched — the four "Removed :focus-visible border" sites still have no focus treatment
- A test enumerates every focus utility in the codebase and fails on any that changes geometry, in the manner of
filled-button-variants.test.ts -
DESIGN.mdrecords the painted-only rule and the editor/chrome distinction
Related
- Shared shadcn Button variants use alpha hovers and lose contrast on hover in every variant #2689 / Add --destructive-hover / --primary-hover tokens and unify filled-button hover states #2673 — the
--*-hovertokens this reuses, and the precedent for guarding a component-consumption rule with a test - Split-Pane Layout System #300 — removed
:focus-visibleborders app-wide; this issue keeps that decision for the editor and does not reintroduce the idiom anywhere
Activity
malibio commentedon Sep 17, 2026
Frontend baseline: 251 test files, 5154 tests passed (59.15s).