Skip to content

fix(theme): colour every focus outline from the design system's focus token - #474

Merged
IgorShevchik merged 7 commits into
mainfrom
refactor/focus-accent-token
Aug 24, 2026
Merged

fix(theme): colour every focus outline from the design system's focus token#474
IgorShevchik merged 7 commits into
mainfrom
refactor/focus-accent-token

Conversation

@IgorShevchik

@IgorShevchik IgorShevchik commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Linked issue

Resolves #191

Follow-ups filed: #473 (the red token's dark value), #476 (the focus token cannot reach 3:1 in the edge contexts).

Type of change

  • Documentation (updates to the documentation or readme)
  • Bug fix (a non-breaking change that fixes an issue)
  • Enhancement (improving an existing functionality)
  • New feature (a non-breaking change that adds functionality)
  • Chore (updates to the build process or auxiliary tools and libraries)
  • Revert (undoing a merged change — retitle this PR revert(Scope): ...)
  • Breaking change (fix or feature that would cause existing functionality to change)

Description

#191 asks whether b24ui wants a uniform focus model, having skipped upstream's version as a design regression. The maintainer scoped the answer to colour only: form stays per component, and a stroke that is the control's own border — --b24ui-*, --ui-btn-* — stays with the component palette.

Eleven focus outlines now read --ui-color-design-outline-focused-stroke. Nine were found by hand; the last two were found by the guard, after review showed the guard was the wrong shape.

Read the two disclosure sections before approving. This is not an unqualified accessibility win, and the first description of this PR made a claim about the token that is not true.

What the measurement found

WCAG 2.2 SC 1.4.11 asks 3:1 of a focus indicator:

colour, as used on focus-visible:outline-* contrast
outline-primary--color-primary #2fc6f6 1.99:1 on white fails
outline-inverted--color-inverted no colour at all undefined
--ui-color-accent-soft-element-blue #0056bf 2.22:1 on #262626 fails
--ui-color-design-outline-content-divider a divider grey wrong job
--ui-color-accent-main-primary #0075ff 4.21:1 on white passes
--ui-color-design-outline-focused-stroke 4.21:1 light, 4.24:1 dark passes

outline-inverted in file-upload.ts is the one to pause on. Nothing in this repository defines --color-inverted, so Tailwind emitted outline-color: var(--color-inverted), which resolves to nothing — 57 snapshot lines recorded a focus ring that had no colour of its own, and every one of them looked like a valid class.

Disclosure 1 — the edge contexts get worse

Review measured what I had not:

context before after
light 1.99 ✗ 4.21 ✓ fixed
dark 2.22 ✗ 4.24 ✓ fixed
edge-light 6.05 ✓ 2.41 ✗ regression
edge-dark 3.34 ✓ 2.46 ✗ regression

So: a fix in the two contexts where the backdrop is known, a regression in the two where it is not. Accordion, Splitter and Table lose a visible focus ring in edge-light.

Kept anyway, with the maintainer's agreement, for three reasons. There is nothing to roll back to — one variable serves all four contexts, so reverting means restoring the four-way split, 1.99:1 in light and 2.22:1 in dark included. The old edge figures were luck rather than a property: 6.05:1 is measured against edge-light's default backdrop, and an edge theme exists precisely so the portal can replace it, often with a photograph — the same opaque blue over a dark image would be worse than the token is. And a translucent luminance shift degrades evenly instead of gambling.

Whether evenly-mediocre clears the bar is a question about the token's values, not about its use here, and 2.4:1 says it does not. #476.

Disclosure 2 — the central claim was wrong

The first version of this description, and of the PORTING.md rule, said the focus token is "the only one of the four defined in all four theme contexts". Two reviewers flagged it independently and they are right: accent-main-primary and accent-soft-element-blue are defined in all four files too.

The real distinction is better:

token light dark edge-light edge-dark
focus token #0075ff #1587fa black @35% white @40%
accent-main-primary #0075ff #1587fa #1587fa #1587fa
soft-element-blue #0154c8 #0056bf #0056bf #0056bf
--color-primary #2fc6f6 — one value, no per-context override at all

The focus token changes kind — saturated in light and dark, a translucent inversion in the edge contexts. The others stay one blue. And outline-primary reads from the Tailwind theme block, which has no contexts.

Corrected in PORTING.md and in the spec, with a note saying the earlier draft got it wrong.

The two design-agnostic bits (issue checkbox 2)

Both taken: a:focus-visible { outline-offset: 0 } in index.css, and overflow: hidden on every frame of the accordion and collapsible height animations.

On the second, review is right that every consumer already carries overflow-hidden unconditionally, so it changes nothing today. Kept because it costs nothing and makes the keyframes correct on their own terms rather than by the good behaviour of five call sites — but flagging it as redundant rather than claiming it as a fix.

What is deliberately left

The guard, and why it changed shape

The first version was a denylist of the three colours I had found, with a >= 7 threshold over the themes using the token. Review showed that says nothing when a component stops using the token, and nothing at all about a colour arriving that is not on the list.

Inverted: every coloured focus outline reads the token, exceptions named one at a time with a test that each exception still describes something. Unknown colours are now the failure case rather than the gap — and it immediately found file-upload and toast, which the grep had never looked at.

Also fixed there: comments were stripped from the first //, which is also the one inside https://, so a real class after a URL on the same line vanished with it.

Verification

Snapshots: 511 occurrences across eleven components in both projects, with the arithmetic closing exactly in each pass and zero lines changing for any other reason.

happy-dom neither resolves custom properties nor computes contrast, so none of this is visible to the suite — every one of those 511 lines was a valid class string before and after, outline-inverted included.

Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

No docs change: focus colour was never documented, confirmed against the theme pages and the component pages for the affected components.

Gate run locally with CI=true: lint · typecheck · test.

claude added 5 commits August 24, 2026 04:28
… token

#191 reads as an inconsistency — four colours doing one job across the theme
files — and the scope agreed was colour only: form stays per component, and the
`ring-(--b24ui-border-color)` pattern on inputs is left alone, because there the
ring is the field's own border rather than a focus accent.

Measuring the four turned it into an accessibility fix. WCAG 2.2 SC 1.4.11 asks
3:1 of a focus indicator; against the background it renders on:

  outline-primary                        #2fc6f6   1.99:1 on white      fails
  --ui-color-accent-soft-element-blue    #0056bf   2.22:1 on #262626    fails
  --ui-color-design-outline-focused-stroke         4.21:1 / 4.24:1      passes

Seven of the nine outline-coloured sites failed in at least one theme. On
chat-reasoning, chat-tool and footer-columns the focus ring is nearly invisible
in the light theme, and on accordion, splitter and table it is nearly invisible
in the dark one.

`outline-primary` is the worst of them because it does not look like a problem:
it resolves to `--color-primary`, a legacy Bitrix cyan with no relationship to
the design system at all.

`--ui-color-design-outline-focused-stroke` is the design system's own name for
this — `design/outline/focused/design-outline-focused-stroke` in
`design-tokens/app.json` — and it is the only one of the four defined in all
four theme contexts, which is why it adapts and the others do not. Two of the
nine sites already used what it resolves to in light contexts, so those change
name without changing pixels.

Two things are deliberately left. The `isAction` link's red focus matches its
hover state, and repainting it blue would break the variant rather than fix it;
the real defect there is the token having no dark value, filed as #473, which
costs that link's hover *text* 4.5:1 as well. And the fifteen
`--b24ui-border-color` rings on inputs are out of scope by the agreed reading.

Also taken, both design-agnostic and both from upstream: `outline-offset: 0` on
`a:focus-visible`, so an inline link's ring is measured from the link rather
than landing on the line above; and `overflow: hidden` on every frame of the
accordion and collapsible height animations, so content is not drawn outside
the box while it collapses.

A dead class went with them: `hover:text(` in `link.ts`, missing its dash, so
Tailwind generated nothing for it. The correct spelling sits beside it on the
same line, so nothing changes.

None of this is visible to the suite as it stands — happy-dom neither resolves
custom properties nor computes contrast, so a port swapping the token back
changes one string in a class list and nothing goes red.
`test/utils/focus-accent-token.spec.ts` holds it, including that the token stays
defined in every context: a theme pointing at a token no context defines
resolves to an invalid declaration, silently, and the outline falls back to the
browser's.

Resolves #191

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
436 class-list occurrences across nine components, and nothing else: every
changed line carries a `focus-visible:outline-*` class, and the old colours
account for the new ones exactly — 208 `soft-element-blue`, 151
`outline-primary`, 77 `accent-main-primary`, against 436 of the focus token.

Worth noting what that count says about the shape of the problem. Three
colours, one job, and the two that lost the most ground were the two that
failed contrast: `outline-primary` in the light theme and `soft-element-blue`
in the dark one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
The same 436 occurrences, in the `vue` project's copies. Both projects render
the same themes, so the counts match exactly: 208 `soft-element-blue`, 151
`outline-primary`, 77 `accent-main-primary`, replaced by 436 of the focus
token, with no line changing for any other reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
The ledger recorded the upstream focus commit as skipped and said the two
generic bits — the `a:focus-visible` reset and `overflow: hidden` in the
keyframes — were "also deferred". They are not deferred any more, so the entry
now describes a state that has not been true since the previous commit on this
branch.

The skip itself stands, and the entry still says so: the upstream pattern
rewrites form across ~50 themes onto a model this fork does not have, and form
stays per component by maintainer decision. What changed is narrower and worth
recording because it was not the reason the commit was skipped — the colour
consolidation turned out to fix contrast failures rather than inconsistency.

No new key, so the upstream-reference check does not ask this commit to name a
port. That is the case it was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
The docstring gave two numbers, light and dark, and left the impression that
they characterise the token. They do not, and the two contexts left out are the
ones that make the case.

  light        #0075ff   4.21:1     was #0154c8 at 6.77:1
  dark         #1587fa   4.24:1     was #0056bf at 2.22:1
  edge-light   #000000 at 35%       was #0056bf
  edge-dark    #ffffff at 40%       was #0056bf

Two things worth having written down. In the light theme the contrast **falls**
— 6.77:1 to 4.21:1 — which is a real cost, disclosed rather than buried: still
comfortably past the 3:1 the criterion asks, and the price of one colour that
works everywhere over one that is excellent in half the contexts and unusable
in the rest.

And in the edge contexts both the surface and the token are translucent, so no
static ratio exists to quote. What can be said is stronger than a number: the
token inverts, black at 35% over a light surface and white at 40% over a dark
one, while `soft-element-blue` stayed the same opaque dark blue in all four.
That is what "the only one defined in every context" buys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
claude added 2 commits August 24, 2026 04:54
The sweep in this branch was a grep for three known colours, so it found three
known colours. Replacing it with the inverse property — *every* coloured focus
outline reads the token, with exceptions named one by one — found two more the
first pass had no way to see.

`file-upload.ts` used `outline-inverted`, and nothing in this repository
defines `--color-inverted`. Tailwind emits `outline-color: var(--color-inverted)`
for it, which resolves to nothing, so that focus ring was not merely the wrong
colour — it had no colour of its own at all and fell back to the browser's.
Same family as `outline-primary` and a degree worse.

`toast.ts` coloured focus from `--ui-color-design-outline-content-divider`, a
divider grey. A fourth accent doing focus duty, in a file the original grep
never looked at.

The spec is the change that matters here. It had a `>= 7` threshold over the
eight themes using the token, which review showed says nothing when one
component stops using it, and nothing at all about a colour arriving that is
simply not on a banned list. An allowlist inverts that: unknown colours are the
failure case rather than the gap. Widths (`(length:…)`) and the control-palette
carve-out (`--b24ui-*`, `--ui-btn-*`) are filtered out by shape; the isAction
red is an entry in `EXCEPTIONS` with its reason and #473 beside it, and a test
asserts each exception still describes something, because one that does not is
a standing licence.

Also from review: stripping comments from the first `//` also strips from the
one inside `https://`, so a real class written after a URL on the same line
disappeared with it. No theme is written that way today, which is the point.

Refs #191

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
75 occurrences, and the arithmetic closes again: 57 `outline-inverted` plus 18
`outline-content-divider` against 75 of the focus token, with no line changing
for any other reason.

The 57 are worth a second look. `outline-inverted` resolved to nothing —
`--color-inverted` is not defined anywhere in this repository — so those 57
class strings were rendering a focus ring with no colour of its own for as long
as they have existed, and every snapshot recorded them as valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
@IgorShevchik
IgorShevchik merged commit a72b0cc into main Aug 24, 2026
3 checks passed
@IgorShevchik
IgorShevchik deleted the refactor/focus-accent-token branch August 24, 2026 05:06
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.

theme: evaluate a uniform focus-style model in air tokens (upstream nuxt/ui#6576)

2 participants