Skip to content

Surface aware outline focus indicator; rethink field outlines - #92

Merged
microbit-matt-hillsdon merged 15 commits into
mainfrom
focus-rethink
Aug 14, 2026
Merged

Surface aware outline focus indicator; rethink field outlines#92
microbit-matt-hillsdon merged 15 commits into
mainfrom
focus-rethink

Conversation

@microbit-matt-hillsdon

@microbit-matt-hillsdon microbit-matt-hillsdon commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

One focus system for the app family, replacing the per-surface ring choice
(outline/outlineDark/outlineLight) that had drifted into three
meanings across the apps and was in some cases (nextgen data particularly,
ill suited for its surface). This kills the last of Chakra UI's blue which was
varyingly inappropriate across the apps.

The ring

  • A single 2px outline at 2px offset — the surface shows through the gap.
    Colour comes from the focusRing semantic token: ink by default, white
    inside an element tagged data-surface="dark" (spread the exported
    darkSurface constant on a dark-by-design bar; the inherited var covers
    everything in it, and portalled overlays escape with the DOM). A real
    outline, so forced-colors modes recolour it natively — the old
    transparent-outline hack is gone.
  • The utility is renamed focusShadowfocusRing (it emits no shadow),
    with an outlineInset value for full-bleed rows in popovers. It shadows
    Panda preset-base's utility of the same name; the leaked foreign values
    are documented and inert.
  • Focus indication never animates: box-shadow removed from transition
    lists; menu/option highlights snap with the ring.

Modality

  • Rings are keyboard-only on text inputs, via react-aria's tracking rather
    than the native heuristic (which fires on any focus for text inputs).
    Native :focus-visible counts only on non-RAC elements
    (:not([data-rac])) — fixes rings appearing on mouse-only menu
    dismissal. ComboBox and the bare Input component render the modality
    state themselves where RAC's per-element attributes can't help.

Fields

  • 2px borders at every state (focus/hover change colour only),
    light gray.300 — valid while something other than the boundary
    identifies the field (label, or a ≥3:1 icon/placeholder); checkbox
    boundaries keep gray.400. Any focus takes the border to focusBorder
    (now brand.600, white under the tag); the keyboard ring composes on
    top. Invalid is border-colour-only, with the error message as the
    non-colour cue. Error text and the required asterisk move to
    danger.600 (red.500 is border-grade, 4.13:1). NumberField's stepper
    insets and hover follow the new border. The 1px versions looked poor
    in the red/focused status (especially at the corners) and this seems
    to be the normal weight.

Docs

  • README gains a "Dark surfaces" section (the tag contract). Rationale
    lives in ui-private: docs/a11y-positions.md (auditor-facing positions,
    very much a WIP and will need to discuss brand colours).

Breaking (see focus-rethink branch on all four apps)

  • shadows.outline* deleted; focusShadow renamed with new values; field
    borders 2px and lighter at rest; focusBorder re-graded to brand.600
    (each brand's 600 needs 3:1); dark bars must carry darkSurface.
  • react-aria joins the peer dependencies

One focus indicator family-wide: a 2px outline at 2px offset (the
surface shows through the gap), coloured by a new focusRing semantic
token — ink by default, white inside an element tagged with the
exported darkSurface constant (data-surface="dark", the onDark
condition). Custom properties inherit, so tagging a bar covers every
control in it, and portalled overlays escape the tag with the DOM.
Reviewed against react-spectrum S2 throughout; the shape is theirs
(outline longhands, offset gap, keyboard-only modality) with the tag
replacing their lch colour math.

Replaces the per-surface ring choice: shadows.outline/outlineDark/
outlineLight and Chakra's blue glow are gone (outlineDark meant two
different things across the family), along with the toolbar variant's
focusVisible fork and the transparent-outline forced-colors hack — the
ring is a real outline, which forced-colors modes recolour natively.
Ring layers never animate (box-shadow removed from transition lists):
a focus indicator must appear instantly.

Modality: the ring is keyboard-only on text inputs (react-aria's
isTextInput handling; RAC's data attributes, deliberately not native
:focus-visible, which fires on any focus for text inputs). The widened
focusVisible condition now gates native :focus-visible with
:not([data-rac]) — react-aria tracks modality more strictly than the
browser, fixing a ring on mouse-only focus restore from menus.
NativeSelect (no RAC attributes) opts back in via native
:focus-visible.

Fields: borders go 2px at rest (S2's move — focus changes only colour,
so the stroke carries its weight; also matches Checkbox/Radio), any
focus takes them to a resurrected focusBorder token (brand.600, white
under the tag) so the focused state keeps brand interest for everyone,
and the keyboard ring stacks outside. The old border+spread-shadow
treatment seamed at the corners; invalid states are border-colour-only
now too (the error message is the WCAG 1.4.1 cue, as S2). Menu and
Select options keep their background highlight but gain the ring for
keyboard navigation (gray.100 is ~1.07:1 — no indicator), inset via a
new outlineInset utility value because full-bleed rows would overhang
the popover.

Breaking for the app sweep (family lockstep): outlineDark/outlineLight
deleted, focusShadow values changed, field borders 2px, focusBorder
re-graded to brand.600 (needs 3:1 per brand), dark bars must tag.
The Button Variants story (light + tagged-dark strips) is the
acceptance harness.
danger.500 (Chakra red.500, #e53e3e) is 4.13:1 on white — border-grade,
not the 4.5:1 small text needs. 600 (#c53030) is 5.47:1; the Button
warning variant already made the same move. The invalid border stays
500 (borders need 3:1). The ramp itself is Chakra-inherited and
ungraded against the family contract — parked in ui-private's
docs/brand-review.md.
It has emitted no shadow since the rethink; the name was kept to dodge
@pandacss/preset-base's focusRing utility, but empirically Panda's
extend-merge replaces the transform wholesale (the emitted CSS is ours
alone, verified) while unioning the values arrays — so preset-base's
outside/inside/mixed/none still typecheck and fall through to the
standard ring. Documented at the utility; the class becomes
focus-ring_*. Utility and colour token now share the focusRing name,
matching S2's vocabulary. Rename lands before any release, so the app
sweep only ever sees the new name.
focusVisibleRing and the focusRing* companion utilities are stuck in
the API surface (extend-merge can't remove them) with a #005FCC
default. They stay off-limits — their selector lacks the
:not([data-rac]) gating and the alias resolves on <html>, so no
dark-surface awareness — but a stray use now renders in the family
ink rather than blue.
Comment thread packages/ui/src/Input.recipe.ts Outdated
Comment thread packages/ui/src/Input.recipe.ts Outdated
Comment thread packages/ui/src/Input.recipe.ts Outdated
Comment thread packages/ui/src/Input.recipe.ts Outdated
Comment thread packages/ui/src/Input.recipe.ts Outdated
Comment thread packages/ui/src/NativeSelect.tsx Outdated
Comment thread packages/ui/src/Select.recipe.ts Outdated
Comment thread packages/ui/src/Select.recipe.ts Outdated
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 14, 2026

Copy link
Copy Markdown

Deploying ui with  Cloudflare Pages  Cloudflare Pages

Latest commit: f28f1e3
Status: ✅  Deploy successful!
Preview URL: https://f1484e7f.ui-2wg.pages.dev
Branch Preview URL: https://focus-rethink.ui-2wg.pages.dev

View logs

The gray.400 rest came from the common over-reading of WCAG 1.4.11 —
3:1 applies to visual information required to IDENTIFY a control, and
a labelled field is identified by its label, so the resting boundary
is decorative and may be light (as S2/Radix/Apple rest). At the 2px
weight the dark rest also read heavy. Hover now steps gray.300 -> 500
(a bigger, clearer change); focus/invalid unchanged and all
interactive states clear 3:1.

Conditions documented at the recipe and in ui-private's
docs/a11y-positions.md: every field must be labelled, and
Checkbox/Radio keep gray.400 — their box IS the identifier. The ramp
comment now scopes 400's boundary duty accordingly.
The stepper column and its fills' corner radii were built against the
old 1px field border (margin 1px, height -2px, radius -1px), so the
hover/pressed fills painted over the inner half of the new 2px border
and squared off against its corner arcs. All three insets follow the
border width now.
ComboBox was the odd one out: its ring showed on mouse focus, because
the input's RAC attributes are stripped by the synthetic blur during
virtual focus, and native :focus-visible fires on any focus for text
inputs. The fix combines the two signals that survive that blur: the
component renders a data-keyboard-modality attribute from react-aria's
global modality tracker (useFocusVisible with the text-input key
filter), and the ring rule gates its :has(input:focus) arm on it.

Click: caret + brand border, no ring — as TextField. Arrows with the
list open: the active option carries the keyboard indicator; the
trigger rings again on Escape/Tab. Verified in-browser; the existing
ComboBox continuity tests pass unchanged (jsdom's programmatic focus
registers as virtual modality, which counts as focus-visible).

react-aria joins the peer dependencies — it is already every
consumer's transitive dependency via react-aria-components at the
same version.
The stepper column overlays the input as a sibling, so pointer-over-
stepper never fired the input's own :hover and the border stayed at
rest. The group now carries the hover tint (same gray.500 as the
input recipe).

First attempt relied on specificity to keep focused/invalid winning —
wrong: slot recipes emit into @layer recipes.slots, ordered after
recipes, so the group rule beat the input recipe's focused and
invalid borders outright. The rule now excludes those states in a
:not() list, and the trap is recorded in docs/hints.md.
The focused-item background faded in at ultra-fast while the inset
ring snapped, so keyboard navigation moved a lagging highlight under
an instant ring. The highlight moves with focus, which makes it focus
indication — and focus indication never animates. ListBox/GridList
keep their background transitions: theirs are hover/selected states,
not the moving keyboard indicator.
The Input component renders a native input, so it never gets RAC's
data-focus-visible and the recipe's ring rule could never match it —
ml-trainer's project search had no keyboard focus ring at all. Track
modality in the component (useFocusRing with the text-input filter,
the ComboBox pattern) and render the attribute ourselves. Verified:
Tab rings, click doesn't, matching TextField exactly.
'Every field labelled' was too narrow — visual identification is the
condition, and an unlabelled search box's >=3:1 icon/placeholder
carries it (a11y-positions.md refined to match).
The focus work accreted more rationale than anyone wants to read at
call sites. Comments now state the constraint and point at ui-private's
docs (a11y-positions, brand-review) or docs/hints.md for the reasoning;
stale claims fixed along the way (the input header still described
any-focus rings, Button's transition note still described the deleted
shadow layer).
@microbit-matt-hillsdon microbit-matt-hillsdon changed the title Focus rethink Surface aware outline focus indicator; rethink field outlines Aug 14, 2026
Toast ships the only dark surface in the package and never tagged it:
the close button's ink ring sat on teal at 1.87:1, against the 3:1
WCAG 2.4.11 wants. The tag goes on the button rather than the card,
because the flip covers the tagged element too and the card is
focusable itself — its own ring is drawn outside it, on the page,
where ink is right. That ring is new: the card fell back to the UA
default until now.

The input recipe's ring fired only on [data-focus-visible], which only
RAC and our own Input set, so a bare element wearing the recipe kept
the border tint and lost the ring — NativeSelect had to patch it back
per call site. Native :focus-visible is the fallback now, minus
input/textarea, where browsers match it on a pointer click and would
undo the modality tracking ca11ec6 added. NativeSelect's patch goes.

zIndex moves back to any-modality focus: it had followed the ring to
keyboard-only, so a pointer-focused field's 2px border was no longer
lifted over its attached neighbour's.

focusRing: "none" typechecks (the values arrays union with
preset-base's) and painted a permanent un-gated ring, the opposite of
the call site's intent. preset-base's surviving focusRingWidth/Offset/
Style wrote --focus-ring-* properties our transform never reads; they
point at the outline longhands now.

NumberField's :user-invalid takes its own :not(), which is not
forgiving: one list would drop the hover rule outright on a browser
that doesn't know the pseudo-class (Safari < 16.5).

react-aria became a peer in a20f996 but no workspace declared it, so
it resolved only through react-aria-components' hoist.

Docs: focusRing/focusBorder overrides must keep their { base, _onDark }
shape or silently lose the flip; the tag covers the element it sits on,
not just its descendants; and an app adopting the toolbar button
variant needs darkSurface in the same change.
@microbit-matt-hillsdon
microbit-matt-hillsdon merged commit 8bb8ae3 into main Aug 14, 2026
3 checks passed
@microbit-matt-hillsdon
microbit-matt-hillsdon deleted the focus-rethink branch August 14, 2026 22:34
microbit-matt-hillsdon added a commit that referenced this pull request Aug 14, 2026
Button's Variants story gained a tagged-dark strip in #92; IconButton's
and LinkButton's stayed on white, where ghost, plain and toolbar are
pixel-identical — all three are transparent-or-white with dark text at
rest, and toolbar's whiteAlpha hover is invisible on a white page.
Nothing in either story told them apart.

The dark strip separates them and shows why: ghost is black on black
there, plain takes the page's text colour unless the bar sets one, and
toolbar is the on-dark variant. The note goes on Button's story, which
the other two point at.

IconButton's labels take their colour from a ternary of literals rather
than a prop forwarded through the row component — the first attempt
rendered them in the inherited colour with no CSS emitted at all
(docs/hints.md).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant