feat(eidos): prefers-contrast: more — stronger neutral chrome a11y (P3-2 follow-up)

Completes the forced-colors a11y work. Users who request more contrast (macOS
"Increase contrast", Windows, etc.) now get strengthened neutral chrome: borders
bumped to neutral 7/8/9 (subtle/default/strong) and de-emphasized text to 12/11
(secondary/muted). Solid fills + primary text are already high-contrast, so they stay.

renderPrefersContrastBlock emits a @media (prefers-contrast: more) block using
`:root:root` (specificity 0,2,0) so it wins over the theme's :root regardless of
stylesheet order. Values reference --primitive-neutral-* (resolve from the cascade; a
theme omitting them just no-ops the declaration — graceful). Strictly additive (gated
by the media query) and strictly STRONGER, so it can't regress the default look.

Also marks audit P3-6 resolved: dispose() already routes documentElement access via
dom (#lastAttrs.target + dom.apply), no direct document access remains.

- generated/base.css regenerated. test: renderStaticCss emits the prefers-contrast
  block. docs: THEMING §28 + audit P3-2/P3-6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 76e1877281
commit 21b2329a66

@ -2231,8 +2231,17 @@ box-shadow, así que el foco **desaparecía**. Fix: la foundation emite siempre
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).
en `render-css.ts`.
**`prefers-contrast: more`** (macOS "Aumentar contraste", etc.) — bloque aparte que
**refuerza el chrome neutral** para quien pide más contraste: bordes a pasos más
fuertes (`subtle/default/strong` → neutral 7/8/9) + texto de-enfatizado más legible
(`secondary` → 12, `muted` → 11). Sólidos + texto primario ya son alto-contraste. Usa
`:root:root` (especificidad 0,2,0) para ganar al `:root` del tema sin depender del orden;
referencia `--primitive-neutral-*` (resuelven del cascade; si un tema los omite, la
declaración se ignora — degrada con gracia). Estrictamente aditivo (gated por el media
query) y estrictamente MÁS fuerte, así que no puede regresar el look por defecto.
`renderPrefersContrastBlock` en `render-css.ts`.
**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

@ -105,11 +105,11 @@ definiciones vs referencias). Orden de prioridad para empezar a resolver.
## P3 — Menores / backlog
- **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-2** · ✅ _(resuelto 2026-06-05)_ `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`. **+ `prefers-contrast: more`** (`renderPrefersContrastBlock`): refuerza bordes (neutral 7/8/9) + texto de-enfatizado (12/11) vía `:root:root`, aditivo y gated. THEMING §28.
- **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`).
- **P3-6** · ✅ _(ya resuelto)_ `dispose()` usa `#lastAttrs.target` (el `documentElement` capturado vía `dom.getDocument()` en `#writeThemeAttrs`) + `dom.apply`/`dom.removeStyle` — sin acceso directo a `document`. El único `globalThis.matchMedia` restante es el fallback SSR-guarded de color-scheme (`createSystemColorSchemeSource`), no manipulación de DOM. Probablemente cerrado en el commit "DOM via ActiveDom" (928b3c15).
- **P3-7** · `active-eidos.svelte.ts` no usa runes; el sufijo `.svelte.ts` engaña (lecturas de tema no son reactivas).
- **P3-8** · `index.ts` re-exporta los render-fns crudos (duplican la API de la clase, sin gating).
- **P3-9** · `editable._accent-solid-hover` se salta los forwarders del recipe (`base.ts:2652-2661`); forwarders huérfanos varios (select/tags-input/editable) sobreviven al test de orphans por la cadena `_accent-*`.

@ -491,4 +491,14 @@ describe('ActiveEidos forced-colors (a11y)', () => {
expect(css).toContain(':focus-visible');
expect(css).toContain('outline: 2px solid Highlight');
});
it('emits a prefers-contrast: more block that strengthens neutral chrome', () => {
const { preferences } = preferencesHarness();
const eidos = createActiveEidos({ preferences, applyDom: false });
const css = eidos.renderStaticCss();
expect(css).toContain('@media (prefers-contrast: more)');
// Borders bumped to stronger neutral steps; won via :root:root specificity.
expect(css).toContain('--color-border-default: var(--primitive-neutral-8)');
expect(css).toContain(':root:root');
});
});

@ -4926,6 +4926,16 @@
}
}
@media (prefers-contrast: more) {
:root:root {
--color-border-subtle: var(--primitive-neutral-7);
--color-border-default: var(--primitive-neutral-8);
--color-border-strong: var(--primitive-neutral-9);
--color-content-secondary: var(--primitive-neutral-12);
--color-content-muted: var(--primitive-neutral-11);
}
}
:root,
[data-theme='base-light'],
[data-theme='light'] {

@ -241,6 +241,7 @@ export function renderStaticCss(options: EidosConfig): string {
if (options.motion) blocks.push(renderMotionBlocks(options.motion))
blocks.push(renderForcedColorsBlock())
blocks.push(renderPrefersContrastBlock())
return blocks.join('\n\n')
}
@ -265,6 +266,32 @@ function renderForcedColorsBlock(): string {
].join('\n')
}
/**
* Accessibility — `prefers-contrast: more` (macOS "Increase contrast", Windows, etc.).
* Strengthen the neutral CHROME for users who ask for more contrast: darker/stronger
* borders + higher-contrast de-emphasized text. Solid fills + primary text are already
* high-contrast, so they stay. Strictly additive (only active under the media query)
* and strictly STRONGER (never weaker), so it can't regress the default look.
*
* `:root:root` (specificity 0,2,0) so these win over the theme's `:root` (0,1,0)
* regardless of stylesheet order. Values reference the theme's `--primitive-neutral-*`,
* which resolve from the cascade; if a theme omits them the declaration is simply
* ignored (graceful — keeps the theme's own chrome).
*/
function renderPrefersContrastBlock(): string {
return [
'@media (prefers-contrast: more) {',
'\t:root:root {',
'\t\t--color-border-subtle: var(--primitive-neutral-7);',
'\t\t--color-border-default: var(--primitive-neutral-8);',
'\t\t--color-border-strong: var(--primitive-neutral-9);',
'\t\t--color-content-secondary: var(--primitive-neutral-12);',
'\t\t--color-content-muted: var(--primitive-neutral-11);',
'\t}',
'}'
].join('\n')
}
function indentBlock(block: string): string {
return block
.split('\n')

Loading…
Cancel
Save

Powered by TurnKey Linux.