From 9fd54c80fad6aacfcfb99d8a8174344a37057c5d Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 19 Aug 2026 00:03:23 +0200 Subject: [PATCH] =?UTF-8?q?uix(skip-link):=20el=20tier=20decidi=C3=B3=20en?= =?UTF-8?q?=20F2.1=20que=20los=20shells=20lo=20poseen,=20y=20no=20hab?= =?UTF-8?q?=C3=ADa=20nada=20que=20poseer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canon nuevo, eidos-native (1 parte, 0 eventos): la pieza de WCAG 2.4.1 que `site-header` difirió a los shells hace tres semanas y que `app-shell` (F3.1) necesita para existir. Nace de su fase 0, decisión Q4 firmada. Lo que decide, y por qué: - **Las palabras son suyas.** «Ir al contenido principal» no es copy: es el nombre de un contrato. La regla del tier (B-7 / D-BLK.5) apunta a esta puerta — si un string parece inevitable, lo posee el componente canónico vía `texts:` + langs. Por eso el prop es `region`, no `label`, y el vocabulario es la lista de landmarks de la APG. Cada clave es una FRASE ENTERA: en castellano el artículo se contrae con la región, así que un «Ir a» + sustantivo saldría mal en media catálogo. - **`region` no se estampa**: elige un texto y ninguna capa lo lee. Declararlo repetiría el error que `Affix` corrigió el 2026-08-15. - **No compone `Link`**: `Link` es tinta en línea y todo su API describe texto dentro de un párrafo. Esto es cromo que aparece de la nada, con su geometría y su suelo; envolverlo sería pisar todos sus props y aun así dejarlos en la API pública. Los componentes del canon renderizan sus nativos; son los blocks los que no pueden. - **El handler mueve el FOCO**, que es lo que el fragmento no hace: un `href` desplaza la vista y deja el foco en el enlace, y el siguiente Tab vuelve al cromo que el lector pidió saltar. El destino se hace enfocable sólo mientras dura el salto. El `href` se queda: es el camino sin JS. - **Peldaño propio de z (950), el más alto de la escalera estática.** No puede compartir el de `affix`: un empate lo rompe el orden del DOM y este enlace es por definición el primer elemento del documento, así que perdería contra cualquier aviso fijado debajo — el fallo medido en A-95. Medido con Playwright (el pane del navegador no tiene el foco del SO, así que `:focus` nunca casa ahí): en reposo 1x1 con clip-path; enfocado `position: fixed`, z 950, píldora de 181x36 sobre `primary-solid` con tinta blanca y anillo de foco, igual en claro y en oscuro; el salto deja el foco en `#demo-main` y el `tabindex` temporal se limpia en el blur; en RTL la píldora espeja al otro borde (x 338 -> 761) sin una segunda regla. Y la demo se corrigió a sí misma: decía «Tab desde el botón de abajo» y la medición enseñó que desde ahí el foco va hacia delante. El botón sube encima del marco, y de paso salió la otra mitad de la lección — por tabulación NUNCA se aterriza en el `main`, porque no tiene nada enfocable. Justo por eso el destino necesita el `tabindex` prestado. Guards: `component:audit --only skip-link` PASS · `morfo:check` PASS · `eidos-lint` 2 selectores morfo-backed, 0 inválidos · `docs:check` 0/0 · `svelte-check` 72, ninguno aquí. Co-Authored-By: Claude Opus 5 --- src/uix/eidos/components/skip-link/README.md | 123 ++++ src/uix/eidos/components/skip-link/index.ts | 11 + .../eidos/components/skip-link/skip-link.css | 69 ++ .../components/skip-link/skip-link.svelte | 92 +++ src/uix/eidos/components/skip-link/types.ts | 39 ++ src/uix/eidos/generated/base.css | 9 + src/uix/eidos/lib/config-types.ts | 12 +- src/uix/eidos/lib/primitives/static.ts | 69 +- src/uix/eidos/lib/recipes/base.ts | 17 + src/uix/langs/components/index.ts | 2 + src/uix/langs/components/skip-link.ts | 35 + src/uix/morfo/components/skip-link.ts | 76 ++ web/routes/uix/+layout@.svelte | 8 +- .../uix/components/skip-link/+page.svelte | 649 ++++++++++++++++++ 14 files changed, 1171 insertions(+), 40 deletions(-) create mode 100644 src/uix/eidos/components/skip-link/README.md create mode 100644 src/uix/eidos/components/skip-link/index.ts create mode 100644 src/uix/eidos/components/skip-link/skip-link.css create mode 100644 src/uix/eidos/components/skip-link/skip-link.svelte create mode 100644 src/uix/eidos/components/skip-link/types.ts create mode 100644 src/uix/langs/components/skip-link.ts create mode 100644 src/uix/morfo/components/skip-link.ts create mode 100644 web/routes/uix/components/skip-link/+page.svelte diff --git a/src/uix/eidos/components/skip-link/README.md b/src/uix/eidos/components/skip-link/README.md new file mode 100644 index 000000000..887aeaea0 --- /dev/null +++ b/src/uix/eidos/components/skip-link/README.md @@ -0,0 +1,123 @@ +# SkipLink + +The bypass affordance of **WCAG 2.4.1 Bypass Blocks (level A)**: a link that is +invisible until it takes focus, and that jumps sequential navigation past the +chrome every page repeats. It is meant to be the first focusable element on the +page, so it is the first thing a keyboard user meets. + +```svelte + + +Skip to filters +``` + +Built 2026-08-18 as F1 of the `app-shell` phase 0 (`PLAN-blocks.md` §F3.1, +decision Q4). The tier had already ruled in F2.1 that «the shells own the +skip-link» — and then there was nothing for a shell to own it WITH. + +## Baseline + +- **Classification**: eidos-native (`scope: ['eidos']`), **passive** — 0 sema + events, 0 keyboard handlers of its own. `apg: none`: it is a link, and no + WAI-ARIA widget pattern applies. +- **Anatomy**: one part, the anchor (`data-skip-link`). +- **Technique**: WCAG **G124** — «adding links at the top of the page to each + area of the content» — rather than **G1** («a link… that goes directly to the + main content area»). One `SkipLink` per region is G124 by composition; a + single one to `main` is G1. Both are sufficient techniques; which you get is + the consumer's call, not a variant of this component. + +## Comparativa + +| Ref | Qué trae | Qué adoptamos / qué no | +| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Atlassian** `navigation-system` (y el `page-layout` que sustituye) | La referencia más rica: genera un MENÚ de skip-links a partir de los slots montados; cada slot lleva `id` + `skipLinkLabel`, el menú aparece con el foco, Escape lo cierra y mueve el foco detrás; `useSkipLink(id, label)` para registrar los propios | Adoptamos que los enlaces salgan de las REGIONES montadas, no de una lista escrita a mano. No adoptamos el menú: es un widget con foco, orden y descarte propios — otro componente, y sin consumidor todavía (registrado en Gaps) | +| **Shopify Polaris** `Frame` | `skipToContentTarget` (una ref); si falta, apunta a `AppFrameMain`; el handler hace `preventDefault` y enfoca a mano | Adoptamos el handler: mover el foco a mano es lo único que funciona en todos los motores. No adoptamos la ref — pedimos un `id`, que es lo que el landmark ya tiene | +| **Mantine** `AppShell` · **shadcn** `Sidebar` · **AntD** `Layout` · **Toolpad** `DashboardLayout` | Nada: ninguno trae skip-link | — | +| **GOV.UK Design System** | El patrón de referencia del oficio: sr-only hasta el foco, píldora arriba a la izquierda, `href` real | Adoptamos los dos: la técnica sr-only (no `top: -100px`) y conservar el `href` como camino sin JS | + +⚠️ El dossier del tier afirmaba que **ninguna** referencia traía skip-link y que +era superación nuestra. Es falso, y está corregido en +`RESEARCH-blocks-references.md` (2026-08-18). Lo que sí es superación: que las +palabras vengan del sistema de idiomas y que el enlace salga del mismo contrato +que estampa el landmark — en las dos referencias que lo traen, el `id` y la +etiqueta los escribe el consumidor. + +## Decisiones + +- **Las palabras son del componente, no del consumidor.** «Ir al contenido + principal» no es copy: es el nombre de un contrato de accesibilidad. La regla + del tier (B-7 / D-BLK.5) apunta a esta puerta exacta — si un string parece + inevitable, es superficie de contrato y lo posee el componente canónico, vía + `texts:` del morfo + langs. Por eso el prop es `region`, no `label`. +- **El vocabulario es la lista de landmarks de la APG**, y cada clave es una + FRASE ENTERA, nunca un «Ir a» + sustantivo: en castellano el artículo se + contrae con la región («al contenido principal», «a la navegación»), así que + una etiqueta concatenada saldría mal en media catálogo. Una región que la + lista no sabe nombrar —dos paneles `complementary` que hay que distinguir— + pasa sus palabras como children, y ahí sí las posee la app. +- **`region` no se estampa en el DOM.** Elige un texto y nada más; ninguna capa + selecciona por él. Declararlo repetiría el error que `Affix` shippeó un día y + corrigió el 2026-08-15 (morfo es el contrato ENTRE capas). +- **No compone `Link`, y es a propósito.** `Link` es tinta en línea: hereda la + tipografía ambiente, pinta subrayado, y toda su superficie (`variant` / + `underline` / `size` / `color`) describe texto dentro de un párrafo. Esto no + es texto dentro de un párrafo: es cromo que aparece de la nada, con su + geometría y su suelo. Componerlo sería un envoltorio que pisa todos los props + del envuelto y aun así los deja en el API pública, prometiendo un tratamiento + en línea que este componente no puede cumplir. Los componentes del canon + renderizan sus propios nativos (`Link` renderiza ``, `Sidebar.MenuButton` + también); son los BLOCKS los que no pueden (B-2). +- **`` con `href`, no ` + + + +
+ + {#if customWords} + noteFocus('skip link')}> + Skip to filters + + {:else if links === 1} + noteFocus('skip link')} /> + {:else} + noteFocus('skip link · main')} /> + noteFocus('skip link · nav')} + /> + noteFocus('skip link · aside')} + /> + {/if} + +
(landedOn = 'banner')} + style="display: flex; gap: var(--space-2); align-items: center; padding: var(--space-2) var(--space-3); background: var(--color-neutral-track); font-size: var(--font-size-sm);" + > + Chrome + the part a reader asks to skip +
+ + + +
+
(landedOn = 'main')} + style="flex: 1 1 auto; min-inline-size: 0;" + > +

+ Main content +

+

+ From the button above: one Tab reaches the skip link, Enter lands + here. Measured. Keep tabbing instead and you get the other half of the lesson — five chrome + links, and then the focus leaves the stage entirely: this paragraph is not a tab stop, so + sequential navigation never LANDS on the content at all. That is why the jump makes its + target focusable for the length of the jump. +

+
+ +
+
+ +
+ focus + {focusedLabel} + · + tabs + {tabCount} + + landed on + {landedOn} · + events + {events.length === 0 ? 'none — navigation is mute' : events.length} + +
+ + +
+ + + + + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Three props and no variants. region picks the SENTENCE (it is not stamped on + the DOM — nothing selects on it); to is the target's + id, not a # fragment; children override the words for a region the role + vocabulary cannot name. +

+ +
+ eidos props +
+
+ + + +
+ +

+ Read the sentence in the current language. The label above resolves through + langs, so switching the shell's language switches the link: + {uix.langs.t(`#?components.skip-link.to-${region}|Skip to…`)}. That is the + whole argument for the words living in the component — a product that has to write «Ir al + contenido principal» itself will write it in one language. +

+ +
+
+ eidos + first focusable element on the page + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'system'} +
+

System axes

+

+ The pill is placed with LOGICAL insets (inset-inline-start), so + dir: rtl mirrors it to the right corner with no second rule and no + [dir] selector. Density and scaling reach its padding through the space scale; mode + reaches its ground through the colour roles. +

+ +
+ {/if} + + {#if tab === 'motion'} +
+

Motion

+ +
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+

+ SkipLink declares no semantic events, and the reason is the system's own rule rather than an + omission: navigation is mute here. A row of the + Sidebar + that navigates does not sound either, and neither does Link. The perceptible + act belongs to what the reader does after ARRIVING, never to the jump. +

+ stageRef?.querySelector('[data-skip-link]') ?? stageRef} + /> +
+ {/if} + + {#if tab === 'services'} +
+

Services

+

+ Two. langs resolves the sentence (#?components.skip-link.to-*) + — the component owns the words because they are contract, not copy. adom + resolves the document the target lives in (uix.dom.getDocument(node), never the + global document: in an iframe, a popup or a happy-dom test the global one is + the wrong document). No format, no clipboard, no announce: the focus move IS the + announcement — the screen reader reads the region it lands in. +

+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + + + + + + + + +
PropTypeNotes
tostring (required) + The target's id — the value, not a # fragment. The target does + not need to be focusable: the component makes it focusable for the jump and puts it back + on blur. +
regionmain | navigation | complementary | search | banner | contentinfo + Which landmark the target is. Picks the sentence; not stamped on + the DOM. Default main. +
childrenSnippet + Custom words, for a region the role vocabulary cannot name (two + complementary panels). Overrides region's sentence — and + then the app owns that string. +
+
+

+ Emits data-skip-link on the anchor, plus the + href="#{'{to}'}" fallback. Everything else passes through to the + <a>. +

+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+
+ + + + + + + + + +
FieldValue
name{skipLinkMorfo.name}
kebab{skipLinkMorfo.kebab}
scope{skipLinkMorfo.scope.join(', ')}
parts{partsList.length}
events{events.length}
+
+ +
Parts
+
+ + + + + + + + {#each partsList as part (part.kebab)} + + + + + + + + {/each} + +
kebabmarkerelementarchetypeoptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.archetype ?? '—'}{part.optional ? 'yes' : 'no'}
+
+ +
Texts
+
+ + + + {#each Object.entries(skipLinkMorfo.texts) as [key, ref] (key)} + + + + + + {/each} + +
keyidlangrefresolved
{key}{ref}{uix.langs.t(ref)}
+
+ +

+ One part and no data-attrs beyond the identity. region is deliberately NOT + declared: it selects a text and no layer reads it, and morfo is the contract BETWEEN layers + — the same correction Affix made on 2026-08-15 after shipping its placement knobs + here for a day. +

+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ src/uix/eidos/components/skip-link/skip-link.css — two states and nothing in between. +

+
+ + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-skip-link]morfo + sr-only: in the tab order and in the accessibility tree, out of the picture. Not + display:none/visibility:hidden/negative + tabindex — all three would remove the one thing it exists to be in. +
[data-skip-link]:focusmorfo + The pill: position: fixed on logical insets (RTL mirrors for free), + --skip-link-z-index (950, the top of the static ladder), the colour roles, + and the focus ring — which stays, because on a surface close to the pill's ground «appearing» + is not a visible focus indicator (2.4.11). +
+
+

+ Tokens: --skip-link-z-index, --skip-link-offset, + --skip-link-padding-block, --skip-link-padding-inline, + --skip-link-radius, --skip-link-bg, + --skip-link-fg, --skip-link-shadow. +

+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConcernContract
Success criterion + WCAG 2.4.1 Bypass Blocks (A). One link to the main content is + technique G1; one per area is G124. Both are + sufficient — which one you get is what the page composes, not a prop. +
Order + It has to be the FIRST focusable element, or it is skipping nothing. The stage above + renders it before any chrome; a shell renders it before its regions. +
Focus, not scroll + A bare #fragment scrolls but leaves focus on the link in every engine + that does not find the target focusable — so the next Tab walks straight back into + the chrome. The handler moves focus itself and cleans up the temporary + tabindex="-1" on blur. +
Name + The visible sentence IS the accessible name — no aria-label, nothing to + go out of sync. Localised through langs by landmark role. +
Visible focus + :focus, not only :focus-visible: it must also appear when + an app moves focus to it programmatically (after a route change). The ring is kept + on top of the pill (2.4.11). +
Never covered + Its own z rung (--z-index-skip-link: 950), above every piece of page + chrome. It cannot share the affix rung: ties break by DOM order and this + link is by definition first, so it would lose to any affixed notice — the measured A-95 + failure. +
+
+
+ {/if} +