uix(skip-link): temable — 71 % → 100 %, y el guard aprende a abrir por FOCO

Cero claves nuevas: su 29 % de deuda eran DOS literales que son la técnica, y
ahora van firmados. Diff de computed 0 sobre 192 valores en 7 estados más los 52
del estado enfocado —el único que se ve— medidos aparte; capturas 2× idénticas
byte a byte; R-5.4 de 0/8 a 8/8 SIN una sola adjudicación.

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 ya razona eso mismo para display:none, visibility
y el tabindex negativo. Firmados con /* literal: */, la válvula de
recipe-contract §3: salen del ratio como ausencia ESCRITA, no como deuda. Misma
clase que text-blur y text-scramble.

LOS OTROS 5 KNOBS YA ESTABAN BIEN, Y LAS DOS AUSENCIAS SON DOCTRINA

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 su superficie 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. Clicarlo tampoco vale: su handler manda el foco a la región de
destino, así que la píldora se va por el camino de entrada.

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'.
Es aditivo: las cuatro condiciones que decían `openBy !== 'hover'` pasan a
consultarlo y ninguna configuración existente cambia de rama. Re-verificado sobre
tres del ledger: dialog 37/44 (la cifra exacta de su commit), tooltip 15/23,
context-menu 27/28 — cero regresiones.

ARTEFACTOS

Sonda antes/después → 0 diffs, 0 nodos perdidos (192 valores, 7 estados) · estado
ENFOCADO medido aparte con un script propio (la sonda estándar mide reposo,
tallas, abierto y hover: nunca foco) → 52 valores, 0 diffs · capturas 2×
idénticas byte a byte · R-5.4 8/8, exit 0 · censo --only 71 % → 100 % (knobs 8 →
6: los dos literales pasan a la clase excepción, fuera del ratio) ·
component:audit PASS · eidos-lint 2 morfo-backed / 0 invalid · rtl:check 0 ·
docs:check 0 · suite eidos 439/440 (el rojo conocido, skin-media-player) ·
npm run check COMPLETED sin errores en ficheros tocados · pestaña Tokens añadida
a su demo y VERIFICADA en el navegador: 8 filas, 0 errores de consola.

LO QUE NO ENTRA Y POR QUÉ

docs/audit/theming/README.md es un agregado global y otra sesión tiene `form` en
vuelo en el mismo árbol; se regenerará con su commit. Por lo mismo, de
theming-sentinel.ts sólo entran mis hunks (verificado con git diff --cached).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent 2e708adae8
commit ebe3fe6ed7

@ -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: <razón> */` 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 `⚠`.
<!-- veredicto:start -->
_(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.
<!-- veredicto:end -->

@ -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 `<a>`) 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);

@ -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 |

@ -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;

@ -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<Tab>('live');
// ── Stage + System axes ──────────────────────────────────────────────
@ -297,6 +308,9 @@
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>
Recipe
</button>
<button data-uix-tab data-active={tab === 'tokens'} onclick={() => (tab = 'tokens')}>
Tokens <span data-uix-tab-count>8</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -545,6 +559,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="skip-link" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>

Loading…
Cancel
Save

Powered by TurnKey Linux.