diff --git a/docs/audit/theming/skip-link.md b/docs/audit/theming/skip-link.md index 8507a65f6..6efd4e9a6 100644 --- a/docs/audit/theming/skip-link.md +++ b/docs/audit/theming/skip-link.md @@ -5,8 +5,8 @@ > Vista de conjunto: [README](./README.md) · método y protocolo: > [`PLAN-theming.md`](../../process/PLAN-theming.md) §1, §2, §7. -- **Medido**: 2026-08-23 · **Alcance**: **71%** — 5 de 7 knobs por token público -- **Knobs de apariencia**: 8 — público 5 · privado 0 · global 0 · literal 2 · sistema 1 · excepción 0 _(los dos últimos, fuera del ratio)_ +- **Medido**: 2026-08-23 · **Alcance**: **100%** — 5 de 5 knobs por token público +- **Knobs de apariencia**: 6 — público 5 · privado 0 · global 0 · literal 0 · sistema 1 · excepción 2 _(los dos últimos, fuera del ratio)_ - **Contrato hoy** (`lib/recipes/base.ts`): 8 pública(s) — `z-index`, `offset`, `padding-block`, `padding-inline`, `radius`, `bg`, `fg`, `shadow` - **Eje `size`**: no · **ficheros**: `skip-link.css` @@ -20,21 +20,21 @@ _Ninguno._ _Ninguno._ -### 1.3 Literales (2) +### 1.3 Literales (0) -| # | fichero:línea | selector | propiedad | valor | -| ---: | --- | --- | --- | --- | -| 1 | `skip-link.css:25` | `[data-skip-link]` | `inline-size` | `1px` | -| 2 | `skip-link.css:26` | `[data-skip-link]` | `block-size` | `1px` | +_Ninguno._ -### 1.4 Excepciones firmadas (0) — fuera del ratio +### 1.4 Excepciones firmadas (2) — fuera del ratio Literales que llevan su anotación `/* literal: */` en la propia declaración: la válvula de recipe-contract §3, la misma que honra `component-audit`. **Una desviación firmada no es deuda** — se listan para que la razón se lea, no para acuñarlas. -_Ninguno._ +| # | fichero:línea | selector | propiedad | valor | +| ---: | --- | --- | --- | --- | +| 1 | `skip-link.css:25` | `[data-skip-link]` | `inline-size` | `1px` | +| 2 | `skip-link.css:26` | `[data-skip-link]` | `block-size` | `1px` | ## 2. Sistema transversal (1) — informativo, fuera del ratio Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2). @@ -74,6 +74,39 @@ no distingue lo que debería — se marca `⚠`. -_(pendiente — lo escribe el autor; se conserva al regenerar)_ +**EJECUTADO 2026-08-23 — 71 % → 100 %, y sin una sola clave nueva.** Diff de +computed **0** sobre 192 valores en 7 estados, más 52 valores del estado +ENFOCADO —el único que se ve— medidos aparte; capturas 2× byte a byte +idénticas; centinela **0/8 → 8/8, exit 0, cero adjudicaciones**. + +**La §4 pedía `width` y `height`, y las dos son la TÉCNICA.** El `1px × 1px` no +es un tamaño: es la mitad del sr-only canónico (la otra mitad es el par +`clip` / `clip-path`). Un tema que lo mueva no cambia una apariencia, rompe el +mecanismo — y a `0 × 0` algunos motores sacan el enlace del árbol de +accesibilidad, que es exactamente lo único que este componente no puede +permitirse (su propia cabecera lo razona para `display: none`, `visibility` y el +`tabindex` negativo). Van firmados con `/* literal: */`, la válvula de +recipe-contract §3: salen del ratio como ausencia ESCRITA, no como deuda. Es la +misma clase que `text-blur` y `text-scramble`. + +**Su 29 % era eso y nada más.** Los 5 knobs restantes ya pasaban por token +público, y las dos ausencias que quedan son doctrina aplicada, no hueco: la +tipografía se HEREDA (no hay `font-*` declarado; acuñarla fijaría el default, +D-TH.5) y el anillo de foco es del SISTEMA (`--focus-ring-*`, recipe-contract +§2). El `background:` en shorthand no mata ninguna capa de estado: su parte +lleva `archetype: 'provider'`, que no recibe velo. + +⚠ **El instrumento leía 0 de 8 — la octava clase de punto ciego.** Toda la +superficie de este componente existe SÓLO bajo `:focus`: sr-only mientras no lo +tiene, píldora cuando sí. El guard no lo enfocaba nunca, y el `blur` que hace +tras abrir lo habría deshecho igual; y clicarlo tampoco vale, porque su handler +manda el foco a la región de destino y la píldora se va por el camino. Nace +`openBy: 'focus'` (y con él `openingIsFragile`, el conjunto de aperturas que el +blur y el aparcado del puntero NO deben deshacer — hasta hoy sólo `hover`). Con +eso, 8/8 y ninguna adjudicación. Re-verificados `dialog` (37/44, la cifra exacta +de su commit), `tooltip` (15/23) y `context-menu` (27/28): sin regresión. + +**Lo que queda fuera y no es deuda**: nada medible. Los 5 knobs pasan por token +público, 2 son excepción firmada y 1 es sistema. diff --git a/scripts/theming-sentinel.ts b/scripts/theming-sentinel.ts index 338f6dcd9..943079c51 100644 --- a/scripts/theming-sentinel.ts +++ b/scripts/theming-sentinel.ts @@ -135,7 +135,13 @@ const COMPONENT_OVERRIDES: Record< { attrPrefix?: string; openWith?: string[]; - openBy?: 'click' | 'hover' | 'contextmenu'; + /** + * `focus` is for a surface that exists ONLY while focused: skip-link is + * sr-only until Tab reaches it, so every one of its eight tokens read dead + * (0/8, measured 2026-08-23) — the guard never focused it, and the blur it + * does after opening would have undone it anyway. + */ + openBy?: 'click' | 'hover' | 'contextmenu' | 'focus'; urls?: string[]; /** Nodes to measure that carry NO `data-{c}-*` attr (prose styles bare HTML). */ extraNodes?: string; @@ -174,6 +180,13 @@ const COMPONENT_OVERRIDES: Record< // host picker (6/31, the figure its own commit `67b4c810c` recorded). urls: ['/uix/components/date-picker'] }, + // Its WHOLE surface exists only under `:focus` — sr-only until Tab reaches + // it, a pill afterwards — and there is no trigger to click: the guard read + // 0/8, every token of a recipe that works. Focusing it is the opening, and + // the blur that follows a click opening is exactly what would undo it. + // Clicking is NOT an option either: its handler moves the focus to the + // destination region, so the pill would vanish on the way in. + 'skip-link': { openBy: 'focus', openWith: ['[data-skip-link]'] }, // A hover card opens on POINTER-OVER, not on click: clicking its trigger // (an ``) navigates instead of revealing the panel, so the portaled // content never enters the document and 32 of its 35 tokens read dead. @@ -399,6 +412,9 @@ async function main() { const url = urlArg ?? `http://localhost:5173/uix/components/${component}`; const override = COMPONENT_OVERRIDES[component] ?? {}; const attrPrefix = override.attrPrefix ?? `data-${component}`; + // Openings that the blur + pointer-park would UNDO. A hover panel dismisses + // when the cursor leaves; a focus-only surface disappears when the focus does. + const openingIsFragile = override.openBy === 'hover' || override.openBy === 'focus'; const contract = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace( /\r\n/g, '\n' @@ -485,6 +501,7 @@ async function main() { if (await el.count()) { try { if (override.openBy === 'hover') await el.hover({ timeout: 1500 }); + else if (override.openBy === 'focus') await el.focus({ timeout: 1500 }); else if (override.openBy === 'contextmenu') await el.click({ button: 'right', timeout: 1500 }); else await el.click({ timeout: 1500 }); @@ -495,7 +512,7 @@ async function main() { } } } - if (override.openBy !== 'hover') + if (!openingIsFragile) await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.()); // ...and PARK THE POINTER (never for a hover-opened panel: moving the cursor // away is exactly what dismisses it). blur() drops the focus but Playwright leaves the @@ -505,7 +522,7 @@ async function main() { // invalid (0,2,0) beats rest (0,1,0), so THREE rest-state tokens read dead). // Same class as the click-focus false negative above, and the half that fix // left behind. The per-token hover pass re-hovers on purpose further down. - if (override.openBy !== 'hover') await page.mouse.move(0, 0); + if (!openingIsFragile) await page.mouse.move(0, 0); // Runs before EVERY token. A component with no `content` part (textarea, // any flat control) falls through to the click branch on every single key, @@ -528,11 +545,12 @@ async function main() { if (await el.count()) { try { if (override.openBy === 'hover') await el.hover({ timeout: 1000 }); + else if (override.openBy === 'focus') await el.focus({ timeout: 1000 }); else if (override.openBy === 'contextmenu') await el.click({ button: 'right', timeout: 1000 }); else await el.click({ timeout: 1000 }); await page.waitForTimeout(250); - if (override.openBy !== 'hover') { + if (!openingIsFragile) { await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.()); await page.mouse.move(0, 0); } @@ -713,7 +731,7 @@ async function main() { // in isolation and read dead in a full run, because the grip's own // `:hover` rule re-points its colour to the accent. Same class as the // click-focus poisoning fixed in F2-A, one pass later. - if (override.openBy !== 'hover') await page.mouse.move(0, 0); + if (!openingIsFragile) await page.mouse.move(0, 0); } if (moved) live.push(key); else stillDead.push(key); diff --git a/src/uix/eidos/components/skip-link/README.md b/src/uix/eidos/components/skip-link/README.md index 887aeaea0..139818542 100644 --- a/src/uix/eidos/components/skip-link/README.md +++ b/src/uix/eidos/components/skip-link/README.md @@ -27,6 +27,36 @@ skip-link» — and then there was nothing for a shell to own it WITH. single one to `main` is G1. Both are sufficient techniques; which you get is the consumer's call, not a variant of this component. +## Talla y tema + +**8 claves públicas** en `lib/recipes/base.ts`, todas del estado enfocado —que es +el único que se ve—: el peldaño de z (`z-index`), la separación a la esquina +(`offset`, que alimenta los DOS insets lógicos), la caja +(`padding-block`, `padding-inline`, `radius`) y la pintura (`bg`, `fg`, +`shadow`). La pestaña **Tokens** de su demo las resuelve en vivo. **No tiene eje +`size`**: no acompaña a ningún control, así que no hay talla que seguir. + +Lo que entró el **2026-08-23** (71 % → 100 %): **ninguna clave nueva**. Su 29 % +de deuda eran los **dos literales de la técnica sr-only** (`inline-size: 1px` / +`block-size: 1px`), ahora firmados con la anotación `/* literal: */` de +recipe-contract §3. No se acuñan porque no son knobs: un tema que los mueva no +cambia una apariencia, **rompe la técnica** — y a 0×0 algunos motores sacan el +enlace del árbol de accesibilidad, que es lo único que no puede pasarle. + +Dos ausencias, las dos deliberadas y ninguna es deuda: + +- **La tipografía se hereda.** La receta no declara `font-*`: la píldora toma la + del documento, como `Link` (doctrina F2-B, regla 2). Acuñarla la fijaría, que + es mover el default. +- **El anillo de foco es del sistema** (`--focus-ring-*`), nunca un color por + componente — recipe-contract §2. + +⚠ **Su superficie sólo existe bajo `:focus`**, así que el guard R-5.4 leía +**0 de 8** tokens: nunca lo enfocaba, y el `blur` que hace tras abrir lo habría +deshecho igual. Clicarlo tampoco vale — su handler manda el foco a la región de +destino y la píldora se va por el camino. Desde el 2026-08-23 el guard sabe abrir +por FOCO (`openBy: 'focus'`) y lee **8 de 8**. + ## Comparativa | Ref | Qué trae | Qué adoptamos / qué no | diff --git a/src/uix/eidos/components/skip-link/skip-link.css b/src/uix/eidos/components/skip-link/skip-link.css index edab485ea..8f9ae1cf0 100644 --- a/src/uix/eidos/components/skip-link/skip-link.css +++ b/src/uix/eidos/components/skip-link/skip-link.css @@ -22,8 +22,8 @@ picture. `position: absolute` (not fixed) here so a zero-size box never participates in the viewport-fixed stacking context it does not need. */ position: absolute; - inline-size: 1px; - block-size: 1px; + inline-size: 1px; /* literal: the sr-only box IS 1×1 — the technique, not a knob: a 0×0 box is dropped from the accessibility tree by some engines, which is the one thing this link cannot afford */ + block-size: 1px; /* literal: same 1×1 box; what HIDES it is the clip pair below, so a theme moving this would only break the technique */ margin: -1px; padding: 0; overflow: hidden; diff --git a/web/routes/uix/components/skip-link/+page.svelte b/web/routes/uix/components/skip-link/+page.svelte index 95a9566c4..8dc0cc36c 100644 --- a/web/routes/uix/components/skip-link/+page.svelte +++ b/web/routes/uix/components/skip-link/+page.svelte @@ -17,12 +17,23 @@ import SystemAxes from '../../lib/SystemAxes.svelte'; import MotionPanel from '../../lib/MotionPanel.svelte'; import SemaPanel from '../../lib/SemaPanel.svelte'; + import TokensPanel from '../../lib/TokensPanel.svelte'; import { DemoTrace } from '../../lib/harness.svelte'; const uix = getActiveUix(); // prettier-ignore - type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y'; + type Tab = + | 'live' + | 'system' + | 'motion' + | 'sema' + | 'services' + | 'api' + | 'morfo' + | 'recipe' + | 'tokens' + | 'a11y'; let tab = $state('live'); // ── Stage + System axes ────────────────────────────────────────────── @@ -297,6 +308,9 @@ + @@ -545,6 +559,10 @@ {/if} + {#if tab === 'tokens'} + + {/if} + {#if tab === 'recipe'}

Eidos recipe