From 4eab3306b841504ab11d4758bcc0f8e5f5b42f23 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 00:32:58 +0200 Subject: [PATCH] feat(eidos): forced-colors focus a11y (P3-2) + role border ramp 6->7 (P3-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two color-quality items from THEMING_AUDIT P3. P3-2 · forced-colors (Windows High Contrast): under @media (forced-colors: active) the browser auto-maps borders/text/backgrounds to system colors BUT drops box-shadow — so the box-shadow focus ring (--focus-ring) vanishes and keyboard focus disappears. The foundation now always emits a system-colored outline fallback: @media (forced-colors: active) { :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } } Components that already focus via outline (e.g. Button) keep theirs by specificity; this is the fallback for the box-shadow ones. renderForcedColorsBlock in render-css.ts. P3-3 · role border ramp: the per-role `border` slot moved step 6 -> 7. In Radix's functional scale 6 is a subtle separator and 7 is the UI element border; step 6 read washed-out on real element borders (outline/surface/controls). element/hover/active (3/4/5) stay — Radix-canonical for component bg. DEFAULT_COLOR_ROLE_SLOT_STEPS. - generated/base.css regenerated (forced-colors block + --color-{role}-border -> step 7). - Verified in browser: --color-primary-border now resolves to primitive-7 (oklch 0.80 0.092 vs the softer step-6 0.86 0.072); checkbox borders render defined, not broken. - test: renderStaticCss emits the forced-colors outline block. - docs: THEMING §28 + audit P3-1/2/3 marked resolved + README ref row. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/README.md | 1 + src/uix/eidos/THEMING.md | 30 +++++++++++++++- src/uix/eidos/THEMING_AUDIT_2026-06-01.md | 6 ++-- src/uix/eidos/active-eidos.test.ts | 12 +++++++ src/uix/eidos/generated/base.css | 43 +++++++++++++---------- src/uix/eidos/lib/render-css.ts | 28 ++++++++++++++- 6 files changed, 97 insertions(+), 23 deletions(-) diff --git a/src/uix/eidos/README.md b/src/uix/eidos/README.md index c32e2c60a..6981948b6 100644 --- a/src/uix/eidos/README.md +++ b/src/uix/eidos/README.md @@ -578,6 +578,7 @@ Resumen rápido de lo que cubre, para no duplicar aquí: | **Modelo de color: paleta (31 escalas) + roles (alias) + intents (auto-derivados)** | §25 | | **Theme builder en runtime: `eidos.applyColorScheme(seed)` (motor `uix.color`)** | §26 | | **Salida wide-gamut OKLCH default-on (hex fallback + `oklch()` sibling)** | §27 | +| **a11y forced-colors (focus outline fallback) + ramp de bordes (slot 6→7)** | §28 | Lo que sigue en este README son las decisiones operativas de la **capa visual como módulo** (typography sourcing, picker patterns, API diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index 92f8eed8c..6d3f5488f 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -56,6 +56,7 @@ 25. [Modelo de color — paleta + roles/intents derivados](#25-modelo-de-color--paleta--rolesintents-derivados-2026-06-02) 26. [Theme builder en runtime — `eidos.applyColorScheme`](#26-theme-builder-en-runtime--eidosapplycolorscheme-2026-06-04) 27. [Salida wide-gamut OKLCH (default-on)](#27-salida-wide-gamut-oklch-default-on-2026-06-04) +28. [Accesibilidad forced-colors + ramp de bordes](#28-accesibilidad-forced-colors--ramp-de-bordes-2026-06-05) --- @@ -2215,6 +2216,33 @@ navegador lo soporta (Chrome 111+ / Safari 15.4+ / Firefox 113+). --- -**Última revisión**: 2026-06-04. Si algo en este doc no coincide con +## 28. Accesibilidad forced-colors + ramp de bordes (2026-06-05) + +**Forced-colors (Windows High Contrast)** — bajo `@media (forced-colors: active)` el +navegador auto-mapea bordes / texto / fondos a system colors (`forced-color-adjust: +auto`), PERO **elimina `box-shadow`** — y el focus ring de eidos (`--focus-ring`) es un +box-shadow, así que el foco **desaparecía**. Fix: la foundation emite siempre + +```css +@media (forced-colors: active) { + :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } +} +``` + +Los componentes que ya enfocan con `outline` (p. ej. Button) conservan el suyo por +especificidad; este es el fallback para los de box-shadow. `renderForcedColorsBlock` +en `render-css.ts`. Pendiente futuro: `prefers-contrast: more` (variante high-contrast, +distinta de forced-colors). + +**Ramp de bordes** — el slot de rol `border` pasó de **step 6 → step 7**. En la escala +funcional de Radix el 6 es un *separador sutil* y el 7 es el *UI element border*; el 6 +se leía lavado en bordes reales (outline / surface / controles). `element` / `hover` / +`active` (3 / 4 / 5) se mantienen (canónicos de Radix para component-bg). +`DEFAULT_COLOR_ROLE_SLOT_STEPS`. Verificado en navegador (checkbox + token +`--color-{role}-border` → step 7). + +--- + +**Última revisión**: 2026-06-05. Si algo en este doc no coincide con el código, el código gana — pero abre un issue para que actualicemos el doc. diff --git a/src/uix/eidos/THEMING_AUDIT_2026-06-01.md b/src/uix/eidos/THEMING_AUDIT_2026-06-01.md index ea5135e63..078eea37c 100644 --- a/src/uix/eidos/THEMING_AUDIT_2026-06-01.md +++ b/src/uix/eidos/THEMING_AUDIT_2026-06-01.md @@ -104,9 +104,9 @@ definiciones vs referencias). Orden de prioridad para empezar a resolver. ## P3 — Menores / backlog -- **P3-1** · Sin P3/wide-gamut (`color(display-p3 …)` + `@media (color-gamut: p3)`) en ningún sitio. (No está ni en backlog.) -- **P3-2** · Sin soporte high-contrast / `forced-colors` / `prefers-contrast`. (No está en backlog.) -- **P3-3** · Mapa slot→step apretado abajo (element=3/hover=4/active=5 adyacentes) e infrautiliza 7–8; border=6 más suave que el 7/8 de Radix. (`render-css.ts:58-68`.) +- **P3-1** · ✅ _(resuelto 2026-06-04)_ Wide-gamut: salida OKLCH-nativa default-on (hex fallback + `oklch()` sibling por paso, `appendColorScaleDeclarations`) + generador wide-gamut-true (`buildScheme`/`applyColorScheme` retienen el OKLCH raw). THEMING §27, RFC §7. +- **P3-2** · ✅ _(resuelto 2026-06-05, parcial)_ `forced-colors`: el `box-shadow` del focus ring se elimina en HCM → bloque `@media (forced-colors: active)` con `outline` de foco system-colored (`Highlight`) como fallback universal (`renderForcedColorsBlock`). El resto (bordes/texto/fondos) lo auto-mapea `forced-color-adjust: auto`. Pendiente futuro: `prefers-contrast: more` (variante high-contrast) — no es lo mismo que forced-colors. +- **P3-3** · ✅ _(resuelto 2026-06-05)_ El slot de rol `border` pasó de step 6 (separador sutil de Radix) a **step 7** (UI element border de Radix) — `DEFAULT_COLOR_ROLE_SLOT_STEPS`. element/hover/active=3/4/5 se quedan (son los canónicos de Radix para component-bg). Verificado en navegador (checkbox / token `--color-{role}-border`). - **P3-4** · La densidad activa depende del **orden de fuente**, no de especificidad (`:root` default vs `[data-density]`, misma especificidad). *Fix*: `:where(:root)` para el default. - **P3-5** · `appendDensityScaledDeclarations` usa `parseFloat` para el guard de cero → frágil si un valor futuro es keyword/`var()` (`render-css.ts:1383-1387`). - **P3-6** · `dispose()` lee `documentElement` con DOM directo en vez de vía `dom` (`active-eidos.svelte.ts:402-416`). diff --git a/src/uix/eidos/active-eidos.test.ts b/src/uix/eidos/active-eidos.test.ts index eaff5eed3..f62f6ccb6 100644 --- a/src/uix/eidos/active-eidos.test.ts +++ b/src/uix/eidos/active-eidos.test.ts @@ -480,3 +480,15 @@ describe('ActiveEidos color scheme', () => { dom.dispose(); }); }); + +describe('ActiveEidos forced-colors (a11y)', () => { + it('emits a system-colored focus outline fallback for forced-colors mode', () => { + const { preferences } = preferencesHarness(); + const eidos = createActiveEidos({ preferences, applyDom: false }); + const css = eidos.renderStaticCss(); + // box-shadow focus rings vanish under forced-colors; the outline survives. + expect(css).toContain('@media (forced-colors: active)'); + expect(css).toContain(':focus-visible'); + expect(css).toContain('outline: 2px solid Highlight'); + }); +}); diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 9df5fa2b9..fc42da4cd 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -4919,6 +4919,13 @@ } } +@media (forced-colors: active) { + :focus-visible { + outline: 2px solid Highlight; + outline-offset: 2px; + } +} + :root, [data-theme='base-light'], [data-theme='light'] { @@ -6066,7 +6073,7 @@ --color-primary-element: var(--primitive-primary-3); --color-primary-hover: var(--primitive-primary-4); --color-primary-active: var(--primitive-primary-5); - --color-primary-border: var(--primitive-primary-6); + --color-primary-border: var(--primitive-primary-7); --color-primary-solid: var(--primitive-primary-9); --color-primary-solid-hover: var(--primitive-primary-10); --color-primary-text: var(--primitive-primary-11); @@ -6101,7 +6108,7 @@ --color-secondary-element: var(--primitive-secondary-3); --color-secondary-hover: var(--primitive-secondary-4); --color-secondary-active: var(--primitive-secondary-5); - --color-secondary-border: var(--primitive-secondary-6); + --color-secondary-border: var(--primitive-secondary-7); --color-secondary-solid: var(--primitive-secondary-9); --color-secondary-solid-hover: var(--primitive-secondary-10); --color-secondary-text: var(--primitive-secondary-11); @@ -6136,7 +6143,7 @@ --color-tertiary-element: var(--primitive-tertiary-3); --color-tertiary-hover: var(--primitive-tertiary-4); --color-tertiary-active: var(--primitive-tertiary-5); - --color-tertiary-border: var(--primitive-tertiary-6); + --color-tertiary-border: var(--primitive-tertiary-7); --color-tertiary-solid: var(--primitive-tertiary-9); --color-tertiary-solid-hover: var(--primitive-tertiary-10); --color-tertiary-text: var(--primitive-tertiary-11); @@ -6171,7 +6178,7 @@ --color-neutral-element: var(--primitive-neutral-3); --color-neutral-hover: var(--primitive-neutral-4); --color-neutral-active: var(--primitive-neutral-5); - --color-neutral-border: var(--primitive-neutral-6); + --color-neutral-border: var(--primitive-neutral-7); --color-neutral-solid: var(--primitive-neutral-9); --color-neutral-solid-hover: var(--primitive-neutral-10); --color-neutral-text: var(--primitive-neutral-11); @@ -6206,7 +6213,7 @@ --color-affirm-element: var(--primitive-affirm-3); --color-affirm-hover: var(--primitive-affirm-4); --color-affirm-active: var(--primitive-affirm-5); - --color-affirm-border: var(--primitive-affirm-6); + --color-affirm-border: var(--primitive-affirm-7); --color-affirm-solid: var(--primitive-affirm-9); --color-affirm-solid-hover: var(--primitive-affirm-10); --color-affirm-text: var(--primitive-affirm-11); @@ -6241,7 +6248,7 @@ --color-fulfill-element: var(--primitive-fulfill-3); --color-fulfill-hover: var(--primitive-fulfill-4); --color-fulfill-active: var(--primitive-fulfill-5); - --color-fulfill-border: var(--primitive-fulfill-6); + --color-fulfill-border: var(--primitive-fulfill-7); --color-fulfill-solid: var(--primitive-fulfill-9); --color-fulfill-solid-hover: var(--primitive-fulfill-10); --color-fulfill-text: var(--primitive-fulfill-11); @@ -6276,7 +6283,7 @@ --color-risk-element: var(--primitive-risk-3); --color-risk-hover: var(--primitive-risk-4); --color-risk-active: var(--primitive-risk-5); - --color-risk-border: var(--primitive-risk-6); + --color-risk-border: var(--primitive-risk-7); --color-risk-solid: var(--primitive-risk-9); --color-risk-solid-hover: var(--primitive-risk-10); --color-risk-text: var(--primitive-risk-11); @@ -6311,7 +6318,7 @@ --color-threat-element: var(--primitive-threat-3); --color-threat-hover: var(--primitive-threat-4); --color-threat-active: var(--primitive-threat-5); - --color-threat-border: var(--primitive-threat-6); + --color-threat-border: var(--primitive-threat-7); --color-threat-solid: var(--primitive-threat-9); --color-threat-solid-hover: var(--primitive-threat-10); --color-threat-text: var(--primitive-threat-11); @@ -6346,7 +6353,7 @@ --color-loss-element: var(--primitive-loss-3); --color-loss-hover: var(--primitive-loss-4); --color-loss-active: var(--primitive-loss-5); - --color-loss-border: var(--primitive-loss-6); + --color-loss-border: var(--primitive-loss-7); --color-loss-solid: var(--primitive-loss-9); --color-loss-solid-hover: var(--primitive-loss-10); --color-loss-text: var(--primitive-loss-11); @@ -7527,7 +7534,7 @@ --color-primary-element: var(--primitive-primary-3); --color-primary-hover: var(--primitive-primary-4); --color-primary-active: var(--primitive-primary-5); - --color-primary-border: var(--primitive-primary-6); + --color-primary-border: var(--primitive-primary-7); --color-primary-solid: var(--primitive-primary-9); --color-primary-solid-hover: var(--primitive-primary-10); --color-primary-text: var(--primitive-primary-11); @@ -7562,7 +7569,7 @@ --color-secondary-element: var(--primitive-secondary-3); --color-secondary-hover: var(--primitive-secondary-4); --color-secondary-active: var(--primitive-secondary-5); - --color-secondary-border: var(--primitive-secondary-6); + --color-secondary-border: var(--primitive-secondary-7); --color-secondary-solid: var(--primitive-secondary-9); --color-secondary-solid-hover: var(--primitive-secondary-10); --color-secondary-text: var(--primitive-secondary-11); @@ -7597,7 +7604,7 @@ --color-tertiary-element: var(--primitive-tertiary-3); --color-tertiary-hover: var(--primitive-tertiary-4); --color-tertiary-active: var(--primitive-tertiary-5); - --color-tertiary-border: var(--primitive-tertiary-6); + --color-tertiary-border: var(--primitive-tertiary-7); --color-tertiary-solid: var(--primitive-tertiary-9); --color-tertiary-solid-hover: var(--primitive-tertiary-10); --color-tertiary-text: var(--primitive-tertiary-11); @@ -7632,7 +7639,7 @@ --color-neutral-element: var(--primitive-neutral-3); --color-neutral-hover: var(--primitive-neutral-4); --color-neutral-active: var(--primitive-neutral-5); - --color-neutral-border: var(--primitive-neutral-6); + --color-neutral-border: var(--primitive-neutral-7); --color-neutral-solid: var(--primitive-neutral-9); --color-neutral-solid-hover: var(--primitive-neutral-10); --color-neutral-text: var(--primitive-neutral-11); @@ -7667,7 +7674,7 @@ --color-affirm-element: var(--primitive-affirm-3); --color-affirm-hover: var(--primitive-affirm-4); --color-affirm-active: var(--primitive-affirm-5); - --color-affirm-border: var(--primitive-affirm-6); + --color-affirm-border: var(--primitive-affirm-7); --color-affirm-solid: var(--primitive-affirm-9); --color-affirm-solid-hover: var(--primitive-affirm-10); --color-affirm-text: var(--primitive-affirm-11); @@ -7702,7 +7709,7 @@ --color-fulfill-element: var(--primitive-fulfill-3); --color-fulfill-hover: var(--primitive-fulfill-4); --color-fulfill-active: var(--primitive-fulfill-5); - --color-fulfill-border: var(--primitive-fulfill-6); + --color-fulfill-border: var(--primitive-fulfill-7); --color-fulfill-solid: var(--primitive-fulfill-9); --color-fulfill-solid-hover: var(--primitive-fulfill-10); --color-fulfill-text: var(--primitive-fulfill-11); @@ -7737,7 +7744,7 @@ --color-risk-element: var(--primitive-risk-3); --color-risk-hover: var(--primitive-risk-4); --color-risk-active: var(--primitive-risk-5); - --color-risk-border: var(--primitive-risk-6); + --color-risk-border: var(--primitive-risk-7); --color-risk-solid: var(--primitive-risk-9); --color-risk-solid-hover: var(--primitive-risk-10); --color-risk-text: var(--primitive-risk-11); @@ -7772,7 +7779,7 @@ --color-threat-element: var(--primitive-threat-3); --color-threat-hover: var(--primitive-threat-4); --color-threat-active: var(--primitive-threat-5); - --color-threat-border: var(--primitive-threat-6); + --color-threat-border: var(--primitive-threat-7); --color-threat-solid: var(--primitive-threat-9); --color-threat-solid-hover: var(--primitive-threat-10); --color-threat-text: var(--primitive-threat-11); @@ -7807,7 +7814,7 @@ --color-loss-element: var(--primitive-loss-3); --color-loss-hover: var(--primitive-loss-4); --color-loss-active: var(--primitive-loss-5); - --color-loss-border: var(--primitive-loss-6); + --color-loss-border: var(--primitive-loss-7); --color-loss-solid: var(--primitive-loss-9); --color-loss-solid-hover: var(--primitive-loss-10); --color-loss-text: var(--primitive-loss-11); diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index e5caa23a0..cfacacd6f 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -69,12 +69,16 @@ export interface RenderCssVariablesOptions { readonly strict?: boolean } +// Radix functional steps: 1-2 backgrounds · 3-5 component bg (rest/hover/active) · +// 6 subtle separator · 7 UI element border · 8 hovered element border · 9 solid · +// 10 solid hover · 11-12 text. The role `border` slot maps to step 7 (Radix's "UI +// element border") — step 6 reads as a washed-out separator on real element borders. const DEFAULT_COLOR_ROLE_SLOT_STEPS: Record = { track: '1', element: '3', hover: '4', active: '5', - border: '6', + border: '7', solid: '9', solidHover: '10', text: '11', @@ -236,9 +240,31 @@ export function renderStaticCss(options: EidosConfig): string { if (options.motion) blocks.push(renderMotionBlocks(options.motion)) + blocks.push(renderForcedColorsBlock()) + return blocks.join('\n\n') } +/** + * Accessibility — Windows High Contrast / `forced-colors`. The OS forces colors via + * `forced-color-adjust: auto`, which auto-maps borders / text / backgrounds to system + * colors BUT drops `box-shadow` entirely — so the box-shadow focus ring (`--focus-ring`) + * vanishes. Guarantee a visible keyboard-focus indicator with a system-colored + * `outline` (which forced-colors preserve). Components that already focus via `outline` + * (e.g. Button) keep theirs by higher specificity; this is the fallback for the + * box-shadow ones. Emitted always — the `@media` gate makes it inert otherwise. + */ +function renderForcedColorsBlock(): string { + return [ + '@media (forced-colors: active) {', + '\t:focus-visible {', + '\t\toutline: 2px solid Highlight;', + '\t\toutline-offset: 2px;', + '\t}', + '}' + ].join('\n') +} + function indentBlock(block: string): string { return block .split('\n')