Summary
Every focusable element in the design-system layer gets its visible focus indicator from
--ring (and, on the sidebar, --sidebar-ring) composited at 50% opacity —
focus-visible:ring-ring/50 on Button and Input, and the global
@apply border-border outline-ring/50; rule applied to every element. --ring is a light,
near-achromatic gray (oklch(0.708 0 0) in light mode, oklch(0.556 0 0) in dark), and at
50% opacity over the surfaces it actually sits on, the resulting focus ring falls far short of
the 3:1 minimum WCAG 1.4.11 requires for a UI component's visual boundary.
The same @layer base rule that fails also contains a token that passes: border-border
(--border: oklch(0.92 0.004 286.32) in light mode) is a low-contrast, decorative divider
color by design and is not subject to 1.4.11 — it isn't the accessible-focus-indicator token.
--ring is: it is the value every focus-visible:ring-* utility in the design system reaches
for. So this isn't "every color in default.css fails" — the border token is fine for what it
is; the ring token specifically fails the contrast floor for what it is used for.
Two of the four numbers below come from a criterion the theme doesn't obviously advertise:
--sidebar-ring (packages/ui/src/components/ui/sidebar.tsx uses it 5 times) is a distinct
token from --ring, and it is worse than --ring in dark mode (oklch(0.439 0 0) vs.
oklch(0.556 0 0)).
Environment
|
|
| Defect type |
Static (code) |
| Commit |
975c7a02a963d80bf140bb269d773b4912b8e7b6 (upstream/main) |
| How measured |
git fetch upstream && git rev-parse upstream/main, then reading and running a small TypeScript program against packages/ui/src/styles/default.css checked out at that commit (WCAG 2.x relative-luminance contrast math, sRGB alpha composition — see Evidence) |
| Supplementary observation |
The circular composited color was also confirmed to actually render this way in a browser against a self-hosted deployment of the ghcr.io/chatbotxio/*:latest images (not the commit above; deployed image, dated separately — see "What we did not verify") |
How to see it
- Read
packages/ui/src/styles/default.css:
:root (light): --ring: oklch(0.708 0 0); (line 99), --sidebar-ring: oklch(0.708 0 0); (line 112)
.dark: --ring: oklch(0.556 0 0); (line 133), --sidebar-ring: oklch(0.439 0 0); (line 146)
@layer base { * { @apply border-border outline-ring/50; } } (line 151) — applies outline-ring/50 to every element in the app
packages/ui/src/components/ui/button.tsx:7 and packages/ui/src/components/ui/input.tsx:12: focus-visible:ring-ring/50 ring-[3px]
- Compute the WCAG 2.x contrast ratio of
--ring (and --sidebar-ring) composited at 50%
alpha over the background it actually sits on (white/--background, --card, --sidebar),
in both modes, using standard sRGB alpha compositing (color-mix//50 is exactly this: the
browser paints the ring color at 50% opacity over whatever is underneath).
Evidence
Script (comprobar/leerTokensCss, portable WCAG 2.x math, no dependencies) run against
default.css at the commit above:
$ pnpm exec tsx medir.ts default.css
claro [
'claro/texto-secundario 4.39',
'claro/anillo 1.52',
... (unrelated rows omitted, see draft 03/05 for those)
]
oscuro [
'oscuro/enlace 2.60',
'oscuro/boton-contra-fondo 2.60',
'oscuro/anillo 1.45',
'oscuro/lateral 3.39',
...
]
Isolated ring/50-over-white composition (the simplest case, no sidebar token involved):
$ pnpm exec tsx medir-anillo-blanco.ts
ring/50 sobre blanco: 1.545
So: 1.545:1 over plain white, 1.52:1 over --sidebar (oklch(0.985 0 0), light), and 1.45:1 in
dark mode once --sidebar-ring: oklch(0.439 0 0) (worse than plain --ring there) is the
value actually composited. All three are well under the 3:1 floor for a UI-component boundary.
Usage counts confirming this isn't a corner case (measured against upstream/main at the
commit above, restricted to apps/**/*.tsx and packages/**/*.tsx):
$ git grep -n -E "(ring|outline)-ring/50" upstream/main -- 'apps/*.tsx' 'packages/*.tsx' | wc -l
25
$ git grep -n -E "(ring|outline|border)-ring([^/-]|$)" upstream/main -- 'apps/*.tsx' 'packages/*.tsx' | wc -l
40
(Witness that the query itself returns results: git grep -c "ring-ring" upstream/main -- packages/ui/src/components/ui/button.tsx → 1.)
Where it comes from
packages/ui/src/styles/default.css (commit 975c7a02a963d80bf140bb269d773b4912b8e7b6):
:root line 99 (--ring), line 112 (--sidebar-ring)
.dark line 133 (--ring), line 146 (--sidebar-ring)
- line 151, the global
outline-ring/50 rule
packages/ui/src/components/ui/button.tsx:7, packages/ui/src/components/ui/input.tsx:12 (the two most-used components that also apply ring-ring/50 directly)
Why we think this is a defect rather than the intended design
A visible focus indicator is a WCAG 1.4.11 (Non-text Contrast) requirement, not a stylistic
choice — component libraries built on shadcn (which this design system extends) generally treat
the ring color as the thing that must clear the non-text contrast floor, precisely because it
is the only signal a keyboard user gets that focus moved. The 50% opacity itself isn't the
problem in principle (a sufficiently dark/saturated ring can still clear 3:1 at 50%); the
problem is that --ring/--sidebar-ring are light, low-chroma grays that were seemingly picked
to look "neutral" rather than to survive being halved. We found no test, comment, or PR in the
history of default.css asserting this ratio is intentional.
Suggested fix
Values from a1-anillo.css (verified against the criteria above, using the same measurement
script — see Evidence):
:root {
--ring: oklch(0.278 0 0);
--sidebar-ring: oklch(0.278 0 0);
}
.dark {
--ring: oklch(0.76 0 0);
--sidebar-ring: oklch(0.76 0 0);
}
These are, respectively, the lightest gray in light mode and the darkest gray in dark mode that
still clear 3:1 both at 100% and at the 50% opacity the components actually use, against
--background, --card, and --sidebar. Re-running the same measurement script against this
patched file removes exactly the anillo rows in both modes and adds none:
$ pnpm exec tsx medir.ts a1-anillo.css
claro [ ...(same rows as before, minus 'claro/anillo 1.52')... ]
oscuro [ ...(same rows as before, minus 'oscuro/anillo 1.45')... ]
Alternative: drop the /50 and use the ring color at full opacity. This is strictly safer
contrast-wise but touches every component that references ring-ring/50/outline-ring/50
(dozens of call sites per the counts above), a much larger surface than a two-variable fix.
What we did not verify
- Screen-reader behavior around focus — this is a visual-contrast finding only (WCAG 1.4.11),
not a screen-reader or keyboard-navigation-order finding.
- APCA / WCAG 3 contrast (the numbers above are WCAG 2.x, the ratio the spec's own tests use).
- The exact deployed image digest for the "in a browser" observation of the computed
outline-color — it was checked against a self-hosted deployment of the
ghcr.io/chatbotxio/*:latest images on an earlier date than the commit cited in Environment,
without recording its sha256: digest at the time; the code-level measurement above is the
one that is pinned to a specific commit and is what this issue relies on.
- Whether any consumer intentionally relies on the current low-contrast ring (e.g. as a subtle
"focus without visual noise" affordance) — we found no such comment or test.
Related issues — none of these describes it
Searched with gh search issues --repo ChatbotXIO/ChatbotX "<term>" for focus ring,
outline-ring, and ring contrast (no --state filter, so both open and closed are included);
all three returned zero results. Witness that the search mechanism itself works: the same
command with "bug" and "error" returns multiple real issues (e.g. #1158, #1159, #1170).
Summary
Every focusable element in the design-system layer gets its visible focus indicator from
--ring(and, on the sidebar,--sidebar-ring) composited at 50% opacity —focus-visible:ring-ring/50onButtonandInput, and the global@apply border-border outline-ring/50;rule applied to every element.--ringis a light,near-achromatic gray (
oklch(0.708 0 0)in light mode,oklch(0.556 0 0)in dark), and at50% opacity over the surfaces it actually sits on, the resulting focus ring falls far short of
the 3:1 minimum WCAG 1.4.11 requires for a UI component's visual boundary.
The same
@layer baserule that fails also contains a token that passes:border-border(
--border: oklch(0.92 0.004 286.32)in light mode) is a low-contrast, decorative dividercolor by design and is not subject to 1.4.11 — it isn't the accessible-focus-indicator token.
--ringis: it is the value everyfocus-visible:ring-*utility in the design system reachesfor. So this isn't "every color in
default.cssfails" — the border token is fine for what itis; the ring token specifically fails the contrast floor for what it is used for.
Two of the four numbers below come from a criterion the theme doesn't obviously advertise:
--sidebar-ring(packages/ui/src/components/ui/sidebar.tsxuses it 5 times) is a distincttoken from
--ring, and it is worse than--ringin dark mode (oklch(0.439 0 0)vs.oklch(0.556 0 0)).Environment
975c7a02a963d80bf140bb269d773b4912b8e7b6(upstream/main)git fetch upstream && git rev-parse upstream/main, then reading and running a small TypeScript program againstpackages/ui/src/styles/default.csschecked out at that commit (WCAG 2.x relative-luminance contrast math, sRGB alpha composition — see Evidence)ghcr.io/chatbotxio/*:latestimages (not the commit above; deployed image, dated separately — see "What we did not verify")How to see it
packages/ui/src/styles/default.css::root(light):--ring: oklch(0.708 0 0);(line 99),--sidebar-ring: oklch(0.708 0 0);(line 112).dark:--ring: oklch(0.556 0 0);(line 133),--sidebar-ring: oklch(0.439 0 0);(line 146)@layer base { * { @apply border-border outline-ring/50; } }(line 151) — appliesoutline-ring/50to every element in the apppackages/ui/src/components/ui/button.tsx:7andpackages/ui/src/components/ui/input.tsx:12:focus-visible:ring-ring/50 ring-[3px]--ring(and--sidebar-ring) composited at 50%alpha over the background it actually sits on (white/
--background,--card,--sidebar),in both modes, using standard sRGB alpha compositing (
color-mix//50is exactly this: thebrowser paints the ring color at 50% opacity over whatever is underneath).
Evidence
Script (
comprobar/leerTokensCss, portable WCAG 2.x math, no dependencies) run againstdefault.cssat the commit above:Isolated ring/50-over-white composition (the simplest case, no sidebar token involved):
So: 1.545:1 over plain white, 1.52:1 over
--sidebar(oklch(0.985 0 0), light), and 1.45:1 indark mode once
--sidebar-ring: oklch(0.439 0 0)(worse than plain--ringthere) is thevalue actually composited. All three are well under the 3:1 floor for a UI-component boundary.
Usage counts confirming this isn't a corner case (measured against
upstream/mainat thecommit above, restricted to
apps/**/*.tsxandpackages/**/*.tsx):(Witness that the query itself returns results:
git grep -c "ring-ring" upstream/main -- packages/ui/src/components/ui/button.tsx→1.)Where it comes from
packages/ui/src/styles/default.css(commit975c7a02a963d80bf140bb269d773b4912b8e7b6)::rootline 99 (--ring), line 112 (--sidebar-ring).darkline 133 (--ring), line 146 (--sidebar-ring)outline-ring/50rulepackages/ui/src/components/ui/button.tsx:7,packages/ui/src/components/ui/input.tsx:12(the two most-used components that also applyring-ring/50directly)Why we think this is a defect rather than the intended design
A visible focus indicator is a WCAG 1.4.11 (Non-text Contrast) requirement, not a stylistic
choice — component libraries built on shadcn (which this design system extends) generally treat
the ring color as the thing that must clear the non-text contrast floor, precisely because it
is the only signal a keyboard user gets that focus moved. The 50% opacity itself isn't the
problem in principle (a sufficiently dark/saturated ring can still clear 3:1 at 50%); the
problem is that
--ring/--sidebar-ringare light, low-chroma grays that were seemingly pickedto look "neutral" rather than to survive being halved. We found no test, comment, or PR in the
history of
default.cssasserting this ratio is intentional.Suggested fix
Values from
a1-anillo.css(verified against the criteria above, using the same measurementscript — see Evidence):
These are, respectively, the lightest gray in light mode and the darkest gray in dark mode that
still clear 3:1 both at 100% and at the 50% opacity the components actually use, against
--background,--card, and--sidebar. Re-running the same measurement script against thispatched file removes exactly the
anillorows in both modes and adds none:Alternative: drop the
/50and use the ring color at full opacity. This is strictly safercontrast-wise but touches every component that references
ring-ring/50/outline-ring/50(dozens of call sites per the counts above), a much larger surface than a two-variable fix.
What we did not verify
not a screen-reader or keyboard-navigation-order finding.
outline-color— it was checked against a self-hosted deployment of theghcr.io/chatbotxio/*:latestimages on an earlier date than the commit cited in Environment,without recording its
sha256:digest at the time; the code-level measurement above is theone that is pinned to a specific commit and is what this issue relies on.
"focus without visual noise" affordance) — we found no such comment or test.
Related issues — none of these describes it
Searched with
gh search issues --repo ChatbotXIO/ChatbotX "<term>"forfocus ring,outline-ring, andring contrast(no--statefilter, so both open and closed are included);all three returned zero results. Witness that the search mechanism itself works: the same
command with
"bug"and"error"returns multiple real issues (e.g. #1158, #1159, #1170).