uix(skip-link): el tier decidió en F2.1 que los shells lo poseen, y no había nada que poseer

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 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent f7be59cdcf
commit 9fd54c80fa

@ -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
<SkipLink to="app-main" />
<SkipLink to="app-nav" region="navigation" />
<SkipLink to="app-filters" region="complementary">Skip to filters</SkipLink>
```
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 `<a>`, `Sidebar.MenuButton`
también); son los BLOCKS los que no pueden (B-2).
- **`<a>` con `href`, no `<button>`.** El destino es un sitio del documento, así
que la navegación por fragmento del navegador se queda de camino de reserva:
sin JS, antes de hidratar, o si el handler falla, el enlace sigue llevando.
- **El handler mueve el FOCO, que es lo que el fragmento no hace.** Un `href`
desplaza la vista pero deja el foco en el enlace en cualquier motor que no
encuentre enfocable el destino — y entonces el siguiente Tab vuelve al cromo
que el lector acaba de pedir saltar. El destino se hace enfocable
(`tabindex="-1"`) sólo mientras dura el salto y se limpia en el `blur`: dejarlo
puesto sería inocuo para el orden de tabulación pero deshonesto en el árbol de
accesibilidad, un `<main>` marcado como enfocable para siempre.
- **Oculto por la técnica sr-only, nunca por `display: none`, `visibility:
hidden` ni `tabindex` negativo**: las tres lo sacan del orden de tabulación,
que es lo único para lo que existe. Tampoco por `inset-block-start: -100px`,
el truco clásico: mover un elemento enfocable fuera de pantalla hace que
algunos motores desplacen la página a perseguirlo.
- **`:focus`, no sólo `:focus-visible`.** Se alcanza con Tab, así que
`:focus-visible` bastaría para el caso normal; `:focus` cubre además el
programático (una app que le manda el foco al cambiar de ruta), que es lo que
hacen Polaris y Atlassian.
- **Peldaño propio en la escalera de z (`--z-index-skip-link: 950`), y es el más
alto de la 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, 49px de cabecera tapados por una tira. Mientras hay un modal o
un menú abiertos los dos nunca compiten: el foco está atrapado dentro y este
enlace no puede recibirlo.
- **El anillo de foco se queda** aunque la píldora sólo exista estando enfocada:
sobre una superficie parecida al color de la píldora, aparecer no es un
indicador de foco visible (WCAG 2.4.11). Y es un valor de configuración
(`--focus-ring-*`), nunca un color por componente.
## Passive justification
SkipLink declara **0 eventos de sema**. Lo que hace —mover el foco a una región
del documento— es navegación, y la navegación no suena en este sistema: la misma
razón por la que una fila del `Sidebar` que navega es muda («navigating a row is
native and does not sound», README de `sidebar`), y por la que `Link` no emite.
El acto perceptible pertenece a lo que el lector hace DESPUÉS de llegar, no al
salto.
Tampoco tiene capa soma: no hay estado que sostener ni nada que observar. El
contraste que hace la decisión no trivial es `Sticky`, que también declara 0
eventos y sí gana soma — porque DETECTA (sentinel + IntersectionObserver) y
publica `data-stuck`. Aquí no hay nada que detectar: el handler corre, mueve el
foco y termina.
## Gaps
| Gap | Disposición |
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Menú de skip-links** (Atlassian: primer foco, lista de regiones, Escape cierra y mueve el foco detrás) | **diferir** — es un widget con foco, orden y descarte propios, o sea otro componente canónico, no un `variant` de éste. Su disparador natural es una página con ≥4 regiones saltables; hoy el `app-shell` tiene tres y los enlaces sueltos las cubren |
| Registro automático desde el landmark (que la región publique su `id` y su nombre, y el enlace salga solo) | **diferir** — lo hace `app-shell` para SUS regiones, que es donde el conocimiento vive. Un registro global pediría un servicio, y un componente pasivo no lo abre |
| `scroll-margin-block-start` sobre el destino (para que no quede bajo una cabecera pegada) | **del consumidor** — el que sabe la altura de la cabecera es el shell, y sólo bajo `scroll="body"`. Anotado en su ficha |

@ -0,0 +1,11 @@
// SkipLink — the bypass affordance of WCAG 2.4.1 (level A).
//
// import { SkipLink } from '$uix/eidos/components/skip-link';
//
// <SkipLink to="app-main" />
// <SkipLink to="app-nav" region="navigation" />
import SkipLink from './skip-link.svelte';
export { SkipLink };
export default SkipLink;
export type { SkipLinkProps, SkipLinkRegion } from './types';

@ -0,0 +1,69 @@
/*
* SkipLink recipe — the bypass affordance of WCAG 2.4.1.
*
* Two states and nothing in between: out of sight while it does not have
* focus, a pill against the start corner of the viewport while it does.
*
* The hidden state is the canonical sr-only technique and NOT
* `display: none` / `visibility: hidden` / a negative `tabindex`: all three
* take the link out of the tab order, which is the one thing it exists to be
* in. It also is not `inset-block-start: -100px` — the classic trick — because
* moving a focusable element off-screen makes some engines scroll the page to
* chase it before the focus style lands.
*
* Public 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`.
*/
[data-skip-link] {
/* sr-only: in the accessibility tree and in the tab order, out of the
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;
margin: -1px;
padding: 0;
overflow: hidden;
white-space: nowrap;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
border: 0;
}
/*
* Focused: a real pill, fixed to the START corner — logical insets, so RTL
* mirrors it with no second rule and no `[dir]` selector.
*
* `:focus-visible` alone would be wrong: a skip link is reached by Tab, and
* the whole point is that it appears then. `:focus` covers the programmatic
* case too (an app moving focus to it after a route change), which is the
* behaviour Polaris and Atlassian both ship.
*/
[data-skip-link]:focus {
position: fixed;
inset-block-start: var(--skip-link-offset);
inset-inline-start: var(--skip-link-offset);
z-index: var(--skip-link-z-index);
inline-size: auto;
block-size: auto;
margin: 0;
padding: var(--skip-link-padding-block) var(--skip-link-padding-inline);
overflow: visible;
clip: auto;
clip-path: none;
border-radius: var(--skip-link-radius);
background: var(--skip-link-bg);
color: var(--skip-link-fg);
box-shadow: var(--skip-link-shadow);
text-decoration: none;
/* The pill IS the focus indicator — it appears only while focused — but the
ring stays: on a page whose surface is close to the pill's colour the
appearance alone is not a visible focus indicator (WCAG 2.4.11), and the
ring is a config-level value the theme owns, never a per-component
colour. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}

@ -0,0 +1,92 @@
<script lang="ts">
import './skip-link.css';
/**
* Eidos `<SkipLink>` — the bypass affordance of WCAG 2.4.1 (level A).
*
* <SkipLink to="app-main" />
* <SkipLink to="app-nav" region="navigation" />
* <SkipLink to="app-filters" region="complementary">Skip to filters</SkipLink>
*
* Invisible until it takes focus, then a pill against the start corner of
* the viewport. It is the FIRST focusable thing on a page, so it is the
* first thing a keyboard user meets: put it before everything else.
*
* scope:['eidos'] — no runtime. `skipLinkMorfo` declares the identity
* (`data-skip-link`) and the sentences; this wrapper stamps them.
*
* WHY IT DOES NOT COMPOSE `Link`, since the tier's rule is to compose:
* `Link` is inline ink — it inherits the ambient typography and paints an
* underline, and its whole surface (`variant` / `underline` / `size` /
* `color`) describes text inside a paragraph. This is not text inside a
* paragraph; it is chrome that appears out of nowhere, with its own
* geometry and its own ground. Composing `Link` would mean a wrapper that
* overrides every prop of the thing it wraps — and would still leave those
* props on the public API, promising an inline treatment that this
* component cannot honour. Canon components render their own natives
* (`Link` renders `<a>`, `Sidebar.MenuButton` renders `<a>`); it is BLOCKS
* that may not (B-2).
*/
import { ActiveEidos } from '$uix/eidos';
import type { SkipLinkProps } from './types';
let { to, region = 'main', class: className, children, ...restProps }: SkipLinkProps = $props();
const eidos = ActiveEidos.require();
const REGION_FALLBACKS = {
main: 'Skip to main content',
navigation: 'Skip to navigation',
complementary: 'Skip to sidebar',
search: 'Skip to search',
banner: 'Skip to header',
contentinfo: 'Skip to footer'
} as const;
const label = $derived(
eidos.langs.t(`#?components.skip-link.to-${region}|${REGION_FALLBACKS[region]}`)
);
/**
* The jump. The `href` fragment alone scrolls the target into view but
* leaves focus on the link in every engine that does not find the target
* focusable — so the next Tab walks back into the chrome the reader just
* asked to skip, which is the failure this component exists to prevent.
*
* So the handler moves focus itself, and makes the target focusable for
* exactly as long as that takes: `tabindex="-1"` before `focus()`, removed
* again on blur. Leaving it behind would be harmless for the tab order but
* dishonest in the accessibility tree — a `<main>` permanently marked
* programmatically focusable — and this way a page never accumulates
* attributes nobody asked for.
*
* The `href` stays: it is the path that still works with no JS, before
* hydration, or if this handler ever throws.
*/
function jump(event: MouseEvent): void {
const node = event.currentTarget as HTMLElement;
const target = eidos.dom.getDocument(node).getElementById(to);
if (!target) return;
event.preventDefault();
const hadTabIndex = target.hasAttribute('tabindex');
if (!hadTabIndex) {
target.setAttribute('tabindex', '-1');
target.addEventListener('blur', () => target.removeAttribute('tabindex'), { once: true });
}
target.focus();
// Focus alone does not always bring the target into view when it sits
// inside a scroll container; the scroll is what the fragment would have
// done, so it is not an extra behaviour, it is the other half of one.
target.scrollIntoView({ block: 'start' });
}
</script>
<a {...restProps} href={`#${to}`} class={className} data-skip-link="" onclick={jump}>
{#if children}
{@render children()}
{:else}
{label}
{/if}
</a>

@ -0,0 +1,39 @@
import type { Snippet } from 'svelte';
import type { HTMLAnchorAttributes } from 'svelte/elements';
/**
* The landmark the link jumps to. The vocabulary is the APG landmark list,
* not a list of copy: naming the ROLE is what lets the component own the
* sentence (`morfo/components/skip-link.ts` explains why the words live
* there). A region outside this list passes its own words as children.
*/
export type SkipLinkRegion =
| 'main'
| 'navigation'
| 'complementary'
| 'search'
| 'banner'
| 'contentinfo';
export type SkipLinkProps = Omit<HTMLAnchorAttributes, 'href' | 'children'> & {
/**
* `id` of the element to jump to — the value, not a `#` fragment. The
* target does not have to be focusable: the component makes it focusable
* for the jump and puts it back, so the page keeps a clean tab order.
*/
to: string;
/**
* Which landmark the target is. Picks the sentence the link shows; it is
* not stamped on the DOM, because nothing selects on it.
* @default 'main'
*/
region?: SkipLinkRegion;
/**
* Custom words, for a region the role vocabulary cannot name — two
* `complementary` panels, say, where «Skip to sidebar» would name both.
* Overrides `region`'s sentence; the app then owns that string.
*/
children?: Snippet;
/** Extra class names. */
class?: string;
};

@ -264,6 +264,7 @@
--z-index-tooltip: 500;
--z-index-modal: 700;
--z-index-toast: 900;
--z-index-skip-link: 950;
--z-index-overlay-inline: 50;
--z-index-overlay-backdrop: 60;
--z-index-overlay-content: 70;
@ -3282,6 +3283,14 @@
--banner-loss-border: var(--color-loss-border);
--banner-loss-text: var(--color-loss-text);
--sticky-z-index: var(--z-index-sticky);
--skip-link-z-index: var(--z-index-skip-link);
--skip-link-offset: var(--space-2);
--skip-link-padding-block: var(--space-2);
--skip-link-padding-inline: var(--space-4);
--skip-link-radius: var(--radius-md);
--skip-link-bg: var(--color-primary-solid);
--skip-link-fg: var(--color-primary-contrast);
--skip-link-shadow: var(--shadow-overlay);
--prose-measure: 65ch;
--prose-line-height: 1.75;
--anchor-nav-indent: var(--space-3);

@ -433,7 +433,11 @@ export const Z_INDEX_KEYS = [
'popover',
'tooltip',
'modal',
'toast'
'toast',
// The bypass affordance (`SkipLink`) — the top of the ladder. Added
// 2026-08-18; rationale (and why it cannot share the `affix` rung) lives on
// the value in `lib/primitives/static.ts`.
'skip-link'
] as const;
export type ZIndexKey = (typeof Z_INDEX_KEYS)[number];
export type ZIndexPrimitiveSet = Record<ZIndexKey, string | number>;
@ -590,11 +594,7 @@ export interface RecipeTokenMultiDeclaration {
* - `RecipeTokenSingle` — single declaration with explicit scope
* - `RecipeTokenMultiDeclaration` — multiple declarations across scopes
*/
export type RecipeTokenValue =
| string
| number
| RecipeTokenSingle
| RecipeTokenMultiDeclaration;
export type RecipeTokenValue = string | number | RecipeTokenSingle | RecipeTokenMultiDeclaration;
/**
* TSC v2.2 — Cross-recipe composition.

@ -4,8 +4,8 @@ import type {
ScalingParticipationMap,
ShapePrimitiveSet,
SizePrimitiveSet
} from '../config-types'
import { STATIC_TYPOGRAPHY } from './typography'
} from '../config-types';
import { STATIC_TYPOGRAPHY } from './typography';
export const STATIC_SPACE = {
'0': '0px',
@ -26,7 +26,7 @@ export const STATIC_SPACE = {
'10': '40px',
'12': '48px',
'16': '64px'
} as const
} as const;
export const STATIC_CONTROL_HEIGHT = {
xxs: '22px',
@ -36,7 +36,7 @@ export const STATIC_CONTROL_HEIGHT = {
lg: '44px',
xl: '52px',
xxl: '60px'
} as const
} as const;
export const STATIC_RADIUS = {
none: '0px',
@ -46,7 +46,7 @@ export const STATIC_RADIUS = {
xl: '16px',
xxl: '20px',
full: '9999px'
} as const
} as const;
// Blur scale — backdrop / frost radii. Numeric primitive, values aligned to
// Tailwind's blur scale (xs→sm 4 · sm→md 8 · md→lg 12 · lg→xl 16 · xl→xxl 24).
@ -60,7 +60,7 @@ export const STATIC_BLUR = {
lg: '12px',
xl: '16px',
xxl: '24px'
} as const
} as const;
// Gradient direction tokens — Tailwind's 8 compass directions as CSS angles
// (0deg = "to top"). The raw axis; named gradients compose these.
@ -73,7 +73,7 @@ export const STATIC_GRADIENT_ANGLE = {
'to-bl': '225deg',
'to-l': '270deg',
'to-tl': '315deg'
} as const
} as const;
// Gradient interpolation presets — the `in <space> [<hue>]` clause. `oklch` is the
// perceptual default; Tailwind v4 and the CSS platform both moved off the muddy
@ -86,7 +86,7 @@ export const STATIC_GRADIENT_INTERPOLATION = {
'oklch-longer': 'in oklch longer hue',
'oklch-shorter': 'in oklch shorter hue',
srgb: 'in srgb'
} as const
} as const;
// Gradient finish — the same-hue ramp treatment (`data-gradient`). ONE dial
// scales the whole ramp; `0%` ≈ off. The ramp ANCHORS to the ink's shadow
@ -104,7 +104,7 @@ export const STATIC_GRADIENT_FINISH = {
named: {
aurora: { ink: 'var(--color-content-on-solid)' }
}
} as const
} as const;
// Named gradients — the semantic layer. Compose direction tokens + role/surface
// color tokens, so they retint per theme + mode. The base ships ONE (`shimmer`,
@ -121,7 +121,7 @@ export const STATIC_GRADIENTS = {
'radial-gradient(58% 72% at 16% 18%, color-mix(in oklch, var(--color-primary-solid) 62%, transparent), transparent 70%), ' +
'radial-gradient(48% 64% at 82% 24%, color-mix(in oklch, var(--color-tertiary-solid) 55%, transparent), transparent 70%), ' +
'radial-gradient(60% 78% at 62% 88%, color-mix(in oklch, var(--color-affirm-solid) 45%, transparent), transparent 72%)'
} as const
} as const;
export const STATIC_BORDER = {
width: {
@ -140,7 +140,7 @@ export const STATIC_BORDER = {
defaultStyle: 'solid',
// Crisp inset-ring default (THEMING §29) — the `medium` (2px) step.
insetRingWidth: 'medium'
} as const
} as const;
/* State-layer strengths (THEMING §38) — M3's numbers (hover 8 / pressed 12)
over `currentColor`, so the veil is mode-correct for free. The mechanism is
@ -149,7 +149,7 @@ export const STATIC_STATE = {
hover: '8%',
press: '12%',
selected: '12%'
} as const
} as const;
/* Floating-overlay gap canon (THEMING §36): menu-class overlays sit tight
(space-1), panel-class overlays breathe (space-1-5). Values reference the
@ -157,7 +157,7 @@ export const STATIC_STATE = {
export const STATIC_FLOATING = {
gapMenu: 'var(--space-1)',
gapPanel: 'var(--space-1-5)'
} as const
} as const;
export const STATIC_FOCUS_RING = {
offset: '1px',
@ -168,7 +168,7 @@ export const STATIC_FOCUS_RING = {
line inside the field edge. Both are parameterised at theme level so every
input component shares ONE focus model. */
innerWidth: '0px'
} as const
} as const;
export const STATIC_PRESS = {
/* Press feedback — a subtle scale-down "squeeze" on `:active`. ONE shared
@ -180,7 +180,7 @@ export const STATIC_PRESS = {
property, not `transform`, so the scale doesn't wipe their offset. */
scale: '0.985',
duration: '80ms'
} as const
} as const;
export const STATIC_LAYOUT = {
// Container widths are INTERRELATED with the breakpoints — same key, same
@ -216,7 +216,7 @@ export const STATIC_LAYOUT = {
wide: '21 / 9',
golden: '1.618 / 1'
}
} as const
} as const;
// Density tightens layout only (space + control-height); text stays readable.
export const STATIC_DENSITY = {
@ -230,7 +230,7 @@ export const STATIC_DENSITY = {
comfortable: 1,
spacious: 1.12
}
} as const
} as const;
// Scaling = global zoom. Scales px metrics incl. typography.
export const STATIC_SCALING = {
@ -239,7 +239,7 @@ export const STATIC_SCALING = {
'100': 1,
'105': 1.05,
'110': 1.1
} as const
} as const;
// The scaling AXIS DEFINITION — canonical per-family participation (user
// decision 2026-07-06). Metric families scale (what things occupy); chrome
@ -260,7 +260,7 @@ export const STATIC_SCALING_PARTICIPATION: ScalingParticipationMap = {
borderWidth: false,
shadow: false,
motionDistance: false
}
};
export const STATIC_MOTION = {
duration: {
@ -315,7 +315,7 @@ export const STATIC_MOTION = {
in: 'cubic-bezier(0.4, 0.14, 1, 1)'
}
}
} as const
} as const;
// Icon size scale — paired with the type scale (typography.ts). The icon sits a
// touch above its paired text: ≈ font + 2 in body sizes, growing toward the
@ -337,7 +337,7 @@ export const STATIC_ICON = {
md: 1.5,
lg: 1.75
}
} as const
} as const;
export const STATIC_OPACITY = {
// Numeric tier (Tailwind step-5) — fine granularity for ethereal / glass layering.
@ -373,7 +373,7 @@ export const STATIC_OPACITY = {
press: 0.85,
hover: 0.9,
full: 1
} as const
} as const;
export const STATIC_Z_INDEX = {
base: 0,
@ -392,8 +392,19 @@ export const STATIC_Z_INDEX = {
popover: 400,
tooltip: 500,
modal: 700,
toast: 900
} as const
toast: 900,
// The bypass affordance (`SkipLink`), and the top of this ladder on
// purpose. Every other rung orders things that compete for attention; this
// one orders the thing that must be SEEN the instant it takes focus, or
// WCAG 2.4.1 is not met — a link nobody can see is not a mechanism to
// bypass anything. It cannot share `affix`: a tie is broken by DOM order
// and the skip link is by definition the FIRST element in the document, so
// it would lose to every affixed notice below it (the measured A-95
// failure, 49px of a header covered by a strip). While a modal or a menu is
// open the two never compete — focus is trapped inside them and the skip
// link cannot be focused at all.
'skip-link': 950
} as const;
// Overlay micro-band — a SEPARATE scale from STATIC_Z_INDEX above. The ladder
// above orders the in-page depth planes (raised/overlay/modal). Floating
@ -414,7 +425,7 @@ export const STATIC_Z_INDEX_OVERLAY = {
tooltip: 90, // tooltips — above interactive overlays
detached: 100, // cursor-tracking floats (drag preview · link-preview)
toast: 1200 // toasts — always topmost, above the FloatPanel band
} as const
} as const;
// Depth planes — named positions in the attention hierarchy. Each composes the existing
// surface / shadow / z primitives (or a raw value). `data-depth='{plane}'` applies the safe
@ -456,7 +467,7 @@ export const STATIC_DEPTH: DepthPrimitiveSet = {
z: 'var(--z-index-base)'
}
}
}
};
// Shape — corner continuity + perceptual families. Magnitude stays in STATIC_RADIUS; this adds
// the `corner-shape` axis: `continuous` = superellipse (squircle), opt-in via `data-shape`.
@ -471,7 +482,7 @@ export const STATIC_SHAPE: ShapePrimitiveSet = {
cut: 'bevel',
scoop: 'scoop'
}
}
};
export const STATIC_SIZE: SizePrimitiveSet = {
xxs: {
@ -537,7 +548,7 @@ export const STATIC_SIZE: SizePrimitiveSet = {
gap: 'var(--space-4)',
radius: 'xxl'
}
}
};
export const STATIC_PRIMITIVES: Pick<
PrimitiveSet,
@ -593,4 +604,4 @@ export const STATIC_PRIMITIVES: Pick<
zIndex: STATIC_Z_INDEX,
depth: STATIC_DEPTH,
shape: STATIC_SHAPE
}
};

@ -4670,6 +4670,23 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'z-index': 'var(--z-index-sticky)'
},
// ─────────────────────────────────────────────────────────────────────
// SkipLink — the bypass affordance. Invisible until focused, then a
// pill against the start corner of the viewport. Every token is a
// role/scale reference: a skip link is the app's own chrome and has to
// follow its theme, not a hard-coded contrast pair.
// ─────────────────────────────────────────────────────────────────────
'skip-link': {
'z-index': 'var(--z-index-skip-link)',
offset: 'var(--space-2)',
'padding-block': 'var(--space-2)',
'padding-inline': 'var(--space-4)',
radius: 'var(--radius-md)',
bg: 'var(--color-primary-solid)',
fg: 'var(--color-primary-contrast)',
shadow: 'var(--shadow-overlay)'
},
// ─────────────────────────────────────────────────────────────────────
// Prose — long-form typography. The reading column + the reading
// line-height are public; the em-relative element scale is recipe-internal.

@ -98,6 +98,7 @@ import { searchFieldLangs } from './search-field';
import { selectLangs } from './select';
import { sidebarLangs } from './sidebar';
import { skeletonLangs } from './skeleton';
import { skipLinkLangs } from './skip-link';
import { sliderLangs } from './slider';
import { splitButtonLangs } from './split-button';
import { spinnerLangs } from './spinner';
@ -232,6 +233,7 @@ export const componentLangs = {
select: selectLangs,
sidebar: sidebarLangs,
skeleton: skeletonLangs,
'skip-link': skipLinkLangs,
slider: sliderLangs,
'split-button': splitButtonLangs,
spinner: spinnerLangs,

@ -0,0 +1,35 @@
import type { LangNode } from '$libs/langs';
export const skipLinkLangs = {
label: {
es: 'Enlace de salto',
en: 'Skip link'
},
// One whole sentence per landmark role, never a «Skip to» prefix plus a
// noun: Spanish contracts the article with the region («al contenido», «a
// la navegación»), so a concatenated label is wrong half the time.
'to-main': {
es: 'Ir al contenido principal',
en: 'Skip to main content'
},
'to-navigation': {
es: 'Ir a la navegación',
en: 'Skip to navigation'
},
'to-complementary': {
es: 'Ir a la barra lateral',
en: 'Skip to sidebar'
},
'to-search': {
es: 'Ir a la búsqueda',
en: 'Skip to search'
},
'to-banner': {
es: 'Ir a la cabecera',
en: 'Skip to header'
},
'to-contentinfo': {
es: 'Ir al pie de página',
en: 'Skip to footer'
}
} satisfies LangNode;

@ -0,0 +1,76 @@
import type { Morfo } from '../types';
/**
* 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 repeated on every page. Built as F1 of the
* `app-shell` phase 0 (`docs/process/PLAN-blocks.md` §F3.1, decision Q4),
* because the tier had already decided in F2.1 that «the shells own it» and
* there was nothing for a shell to compose.
*
* scope: ['eidos'] — no runtime. There is no state to hold and nothing to
* observe: it is a link with one behaviour (move focus to a region) that runs
* inside its own click handler. `Sticky` earns a soma layer with
* IntersectionObserver; here there is nothing to detect. 0-event
* justification lives in the README (`## Passive justification`).
*
* WHY THE WORDS LIVE HERE, and this is the part that matters. A skip link is
* one of the few components whose whole content is a sentence the product
* never writes: «Skip to main content» is not copy, it is the name of an
* accessibility contract, and the reason blocks may not own strings (B-7 /
* D-BLK.5) points at exactly this door — «if a string looks unavoidable, it is
* contract surface and the canonical component owns it, through `texts:` +
* langs». So the vocabulary is keyed by LANDMARK ROLE (the APG list), each key
* a whole sentence rather than a «Skip to» prefix glued to a noun: in Spanish
* the article changes with the region («al contenido principal», «a la
* navegación»), and a concatenated label would be wrong in half the catalogue.
* A region with no role in this list — two `complementary` panels that need
* telling apart — passes its own words as children.
*
* WHAT THIS MORFO DOES NOT DECLARE: no `data-region`. The region picks a text
* and nothing else; no layer selects on it. Declaring it would repeat the
* mistake `Affix` shipped for a day and corrected on 2026-08-15 (morfo is the
* contract BETWEEN layers, so an attribute a single layer reads — or here,
* none — has no business in it).
*/
export const skipLinkMorfo = {
name: 'SkipLink',
kebab: 'skip-link',
scope: ['eidos'],
apg: 'none — a link; WCAG 2.4.1 technique G124 (links to each area of the content), no widget pattern applies',
texts: {
label: '#?components.skip-link.label|Skip link',
// One sentence per landmark role of the APG list. The key is the role
// so the caller names a CONTRACT, not a piece of copy.
'to-main': '#?components.skip-link.to-main|Skip to main content',
'to-navigation': '#?components.skip-link.to-navigation|Skip to navigation',
'to-complementary': '#?components.skip-link.to-complementary|Skip to sidebar',
'to-search': '#?components.skip-link.to-search|Skip to search',
'to-banner': '#?components.skip-link.to-banner|Skip to header',
'to-contentinfo': '#?components.skip-link.to-contentinfo|Skip to footer'
},
parts: [
{
// The anchor itself. `kebab: 'provider'` → bare `data-skip-link`.
// `<a>` and not a button: the destination is a place in the document,
// so the browser's own fragment navigation stays the fallback when
// the handler never runs (no JS, an error before hydration).
// `href` carries the `#{to}` fragment, written by the wrapper. It is
// not declared as contract because no layer selects on it — it is
// the FALLBACK path: without JS, or before hydration, the browser's
// own fragment navigation still moves the reader. The handler exists
// on top of it because a fragment scrolls but leaves focus behind on
// most engines unless the target is focusable.
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'a',
optional: false,
data: [],
// The accessible name IS the visible sentence, so there is nothing to
// add. Empty on purpose: a decision, not an omission.
aria: []
}
]
} as const satisfies Morfo;

@ -1,10 +1,7 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { createActiveUix, setActiveUix } from '$active-uix';
import {
readActivePrefsSlot,
standardPrefsDimensions
} from '$prefs';
import { readActivePrefsSlot, standardPrefsDimensions } from '$prefs';
import { page } from '$app/state';
import { Soma } from '$soma/core/soma.svelte';
import { siumLangs } from '$sium/langs/langs';
@ -325,7 +322,8 @@
{ slug: '/uix/components/pagination', label: 'Pagination' },
{ slug: '/uix/components/navigation-menu', label: 'Navigation menu' },
{ slug: '/uix/components/nav-tree', label: 'Nav tree' },
{ slug: '/uix/components/sidebar', label: 'Sidebar' }
{ slug: '/uix/components/sidebar', label: 'Sidebar' },
{ slug: '/uix/components/skip-link', label: 'Skip link' }
]
},
{

@ -0,0 +1,649 @@
<script lang="ts">
/**
* SkipLink — the bypass affordance of WCAG 2.4.1.
*
* The stage is the hard part of this demo and it is not decoration: a skip
* link is INVISIBLE until it takes focus, so a screenshot of it doing its
* job is a screenshot of nothing. The stage therefore ships a real chrome —
* a strip, a header, a nav of five links — and a Tab counter, because the
* whole claim of the component is arithmetic: how many tabs it takes to
* reach the content with the link and without it.
*/
import { SkipLink, type SkipLinkRegion } from '$uix/eidos/components/skip-link';
import { compileMorfo } from '$uix/morfo';
import { skipLinkMorfo } from '@/uix/morfo/components/skip-link';
import { getActiveUix } from '$active-uix';
import SystemAxes from '../../lib/SystemAxes.svelte';
import MotionPanel from '../../lib/MotionPanel.svelte';
import SemaPanel from '../../lib/SemaPanel.svelte';
import { DemoTrace } from '../../lib/harness.svelte';
const uix = getActiveUix();
// prettier-ignore
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Stage + System axes ──────────────────────────────────────────────
const trace = new DemoTrace();
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
if (stageRef) return trace.observe(stageRef);
});
let density = $state('comfortable');
let scaling = $state(1);
let mode = $state<'inherit' | 'light' | 'dark'>('inherit');
let dirMode = $state<'auto' | 'ltr' | 'rtl'>('auto');
const dir = $derived(dirMode === 'auto' ? undefined : dirMode);
let borderWidth = $state(1);
// ── Live state ───────────────────────────────────────────────────────
const regions: SkipLinkRegion[] = [
'main',
'navigation',
'complementary',
'search',
'banner',
'contentinfo'
];
let region = $state<SkipLinkRegion>('main');
/**
* How many links the stage renders. `1` is technique G1 (a link to the main
* content); `3` is G124 (a link to each area). Both are sufficient for
* 2.4.1 — the component is the same; what changes is what the page composes.
*/
let links = $state<1 | 3>(1);
/** Demo-only: custom words, the escape hatch for a region the roles cannot name. */
let customWords = $state(false);
/**
* The stage's own bookkeeping. `tabIndexInStage` counts how deep into the
* chrome the focus currently is, so the claim — «one tab instead of eight» —
* is on screen instead of in a paragraph.
*/
let focusedLabel = $state('—');
let landedOn = $state('—');
let tabCount = $state(0);
function noteFocus(label: string): void {
focusedLabel = label;
tabCount += 1;
}
function resetCount(): void {
tabCount = 0;
focusedLabel = '—';
landedOn = '—';
}
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(skipLinkMorfo);
const partsList = [...compiled.parts.byKebab.values()];
const events = [...compiled.actions.byName.values()];
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { SkipLink } from '$uix/eidos/components/skip-link';",
'</' + 'script>',
'',
'<!-- FIRST focusable thing on the page -->',
links === 1
? `<SkipLink to="app-main"${region === 'main' ? '' : ` region="${region}"`} />`
: [
'<SkipLink to="app-main" />',
'<SkipLink to="app-nav" region="navigation" />',
'<SkipLink to="app-aside" region="complementary" />'
].join('\n'),
'',
'<header id="app-banner">…</header>',
'<nav id="app-nav" aria-label="Primary">…</nav>',
'<main id="app-main">…</main>',
'',
customWords
? '<!-- a region the role vocabulary cannot name: the app owns those words -->\n<SkipLink to="app-filters" region="complementary">Skip to filters</SkipLink>'
: '<!-- the words come from langs; the app names a CONTRACT, not copy -->'
].join('\n')
);
</script>
{#snippet chromeLink(label: string)}
<a
href="#/"
onclick={(e) => e.preventDefault()}
onfocus={() => noteFocus(`chrome · ${label}`)}
style="color: var(--color-content-muted); text-decoration: none; padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm);"
>
{label}
</a>
{/snippet}
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Navigation · SkipLink</div>
<h1 data-uix-page-title>SkipLink</h1>
<p data-uix-page-lede>
The bypass affordance of <strong>WCAG 2.4.1 (level A)</strong>: invisible until it takes
focus, then a pill against the start corner of the viewport. It jumps sequential navigation
past the chrome every page repeats — and it moves the FOCUS, which a bare
<code>#fragment</code>
does not. The words are the component's, by landmark role, because «Skip to main content» is not
copy: it is the name of an accessibility contract.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>technique</span>{links === 1 ? 'G1' : 'G124'}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Always-on stage — a page's worth of chrome, so the link has something to skip -->
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
data-density={density}
data-theme={mode === 'inherit' ? undefined : mode}
data-mode={mode === 'inherit' ? undefined : mode}
{...dir ? { dir } : {}}
style={`--scaling: ${scaling}; --border-width: ${borderWidth}px; display: block;`}
>
<!--
The starting line goes BEFORE the frame, and that is not layout
taste: a skip link works by TAB ORDER, so a «press Tab from here»
button placed after the chrome would send focus forward, past
everything the link exists to skip. Measured with Playwright the
first time it was written the other way round — the first four tab
stops were the docs shell's own chrome, not this stage.
-->
<div style="margin-block-end: var(--space-3);">
<button
type="button"
onclick={resetCount}
onfocus={() => (focusedLabel = 'start here — press Tab')}
style="padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); border: 1px solid var(--color-border-default); background: var(--color-surface-raised, transparent); cursor: pointer;"
>
Start here · press Tab, then Enter
</button>
</div>
<!--
`contain: layout` makes the stage the containing block for the
focused pill: without it the fixed box flies to the corner of the
PAGE, over the docs chrome, and the demo shows the component
escaping its own frame. Same mechanism the Affix demo teaches.
-->
<div
style="contain: layout; position: relative; overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default);"
>
<!-- The skip links: FIRST, before any chrome -->
{#if customWords}
<SkipLink to="demo-aside" region="complementary" onfocus={() => noteFocus('skip link')}>
Skip to filters
</SkipLink>
{:else if links === 1}
<SkipLink to="demo-main" {region} onfocus={() => noteFocus('skip link')} />
{:else}
<SkipLink to="demo-main" onfocus={() => noteFocus('skip link · main')} />
<SkipLink
to="demo-nav"
region="navigation"
onfocus={() => noteFocus('skip link · nav')}
/>
<SkipLink
to="demo-aside"
region="complementary"
onfocus={() => noteFocus('skip link · aside')}
/>
{/if}
<div
id="demo-banner"
onfocus={() => (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);"
>
<strong>Chrome</strong>
<span style="color: var(--color-content-muted);">the part a reader asks to skip</span>
</div>
<nav
id="demo-nav"
aria-label="Demo"
onfocus={() => (landedOn = 'navigation')}
style="display: flex; gap: var(--space-1); flex-wrap: wrap; padding: var(--space-2) var(--space-3); border-block-end: 1px solid var(--color-border-subtle);"
>
{@render chromeLink('Overview')}
{@render chromeLink('Guides')}
{@render chromeLink('Components')}
{@render chromeLink('Blocks')}
{@render chromeLink('Changelog')}
</nav>
<div style="display: flex; gap: var(--space-3); padding: var(--space-3);">
<main
id="demo-main"
onfocus={() => (landedOn = 'main')}
style="flex: 1 1 auto; min-inline-size: 0;"
>
<h3 style="margin: 0 0 var(--space-2); font-size: var(--font-size-md);">
Main content
</h3>
<p
style="margin: 0; color: var(--color-content-muted); font-size: var(--font-size-sm);"
>
From the button above: one <kbd>Tab</kbd> reaches the skip link, <kbd>Enter</kbd> 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.
</p>
</main>
<aside
id="demo-aside"
aria-label="Demo filters"
onfocus={() => (landedOn = 'complementary')}
style="flex: 0 0 10rem; padding: var(--space-2); background: var(--color-surface-muted); border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-content-muted);"
>
Filters
</aside>
</div>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>focus</span>
<span><span data-uix-stage-trace-event>{focusedLabel}</span></span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>tabs</span>
<span>{tabCount}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>landed on</span>
{landedOn} ·
<span data-uix-stage-trace-key>events</span>
{events.length === 0 ? 'none — navigation is mute' : events.length}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'system'} onclick={() => (tab = 'system')}>
<span data-uix-layer-badge="eidos">eidos</span> System
</button>
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}>
Motion
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}>
Services
</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>
Recipe
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Three props and no variants. <code>region</code> picks the SENTENCE (it is not stamped on
the DOM — nothing selects on it); <code>to</code> is the target's
<code>id</code>, not a <code>#</code> fragment; children override the words for a region the role
vocabulary cannot name.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>
region <span data-uix-control-hint>the APG landmark list — picks the sentence</span>
</span>
<span data-uix-chips role="radiogroup">
{#each regions as r (r)}
<button
data-uix-chip
data-active={region === r}
disabled={links === 3 || customWords}
onclick={() => (region = r)}
>
{r}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>
how many links
<span data-uix-control-hint>1 = technique G1 · 3 = G124 (one per area)</span>
</span>
<span data-uix-chips role="radiogroup">
<button
data-uix-chip
data-active={links === 1}
disabled={customWords}
onclick={() => (links = 1)}>1 · to main</button
>
<button
data-uix-chip
data-active={links === 3}
disabled={customWords}
onclick={() => (links = 3)}>3 · one per region</button
>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>
children <span data-uix-control-hint>custom words — the app owns that string</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={customWords} />
<span data-uix-switch-label>{customWords ? 'on' : 'off'}</span>
</span>
</label>
</div>
<p data-uix-section-desc>
<strong>Read the sentence in the current language.</strong> The label above resolves through
langs, so switching the shell's language switches the link:
<code>{uix.langs.t(`#?components.skip-link.to-${region}|Skip to…`)}</code>. 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.
</p>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>first focusable element on the page</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'system'}
<section data-uix-section>
<h2 data-uix-section-title>System axes</h2>
<p data-uix-section-desc>
The pill is placed with LOGICAL insets (<code>inset-inline-start</code>), so
<code>dir: rtl</code> mirrors it to the right corner with no second rule and no
<code>[dir]</code> selector. Density and scaling reach its padding through the space scale; mode
reaches its ground through the colour roles.
</p>
<SystemAxes bind:density bind:scaling bind:mode bind:dir={dirMode} bind:borderWidth />
</section>
{/if}
{#if tab === 'motion'}
<section data-uix-section>
<h2 data-uix-section-title>Motion</h2>
<MotionPanel
note="SkipLink has no motion prop, and no transition on purpose: it appears the instant it takes focus. Animating the arrival would delay the one affordance whose value is being there immediately — and an entrance a reader has to wait for is worse than none under prefers-reduced-motion."
/>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
SkipLink declares no semantic events, and the reason is the system's own rule rather than an
omission: <strong>navigation is mute here</strong>. A row of the
<code>Sidebar</code>
that navigates does not sound either, and neither does <code>Link</code>. The perceptible
act belongs to what the reader does after ARRIVING, never to the jump.
</p>
<SemaPanel
actions={events}
{uix}
getTarget={() => stageRef?.querySelector('[data-skip-link]') ?? stageRef}
/>
</section>
{/if}
{#if tab === 'services'}
<section data-uix-section>
<h2 data-uix-section-title>Services</h2>
<p data-uix-section-desc>
Two. <strong>langs</strong> resolves the sentence (<code>#?components.skip-link.to-*</code>)
— the component owns the words because they are contract, not copy. <strong>adom</strong>
resolves the document the target lives in (<code>uix.dom.getDocument(node)</code>, never the
global <code>document</code>: 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.
</p>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">to</td>
<td class="type">string (required)</td>
<td>
The target's <code>id</code> — the value, not a <code>#</code> fragment. The target does
not need to be focusable: the component makes it focusable for the jump and puts it back
on blur.
</td>
</tr>
<tr>
<td class="name">region</td>
<td class="type">main | navigation | complementary | search | banner | contentinfo</td
>
<td>
Which landmark the target is. Picks the sentence; <strong>not</strong> stamped on
the DOM. Default <code>main</code>.
</td>
</tr>
<tr>
<td class="name">children</td>
<td class="type">Snippet</td>
<td>
Custom words, for a region the role vocabulary cannot name (two
<code>complementary</code> panels). Overrides <code>region</code>'s sentence — and
then the app owns that string.
</td>
</tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>
<strong>Emits</strong> <code>data-skip-link</code> on the anchor, plus the
<code>href="#{'{to}'}"</code> fallback. Everything else passes through to the
<code>&lt;a&gt;</code>.
</p>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{skipLinkMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{skipLinkMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{skipLinkMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>{events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th><th>marker</th><th>element</th><th>archetype</th><th>optional</th>
</tr>
</thead>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.archetype ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Texts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>key</th><th>idlangref</th><th>resolved</th></tr></thead>
<tbody>
{#each Object.entries(skipLinkMorfo.texts) as [key, ref] (key)}
<tr>
<td class="name">{key}</td>
<td class="type"><code>{ref}</code></td>
<td>{uix.langs.t(ref)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
One part and no data-attrs beyond the identity. <code>region</code> is deliberately NOT
declared: it selects a text and no layer reads it, and morfo is the contract BETWEEN layers
— the same correction <code>Affix</code> made on 2026-08-15 after shipping its placement knobs
here for a day.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
<code>src/uix/eidos/components/skip-link/skip-link.css</code> — two states and nothing in between.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-skip-link]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>
sr-only: in the tab order and in the accessibility tree, out of the picture. Not
<code>display:none</code>/<code>visibility:hidden</code>/negative
<code>tabindex</code> — all three would remove the one thing it exists to be in.
</td>
</tr>
<tr>
<td class="name"><code>[data-skip-link]:focus</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>
The pill: <code>position: fixed</code> on logical insets (RTL mirrors for free),
<code>--skip-link-z-index</code> (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).
</td>
</tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>
Tokens: <code>--skip-link-z-index</code>, <code>--skip-link-offset</code>,
<code>--skip-link-padding-block</code>, <code>--skip-link-padding-inline</code>,
<code>--skip-link-radius</code>, <code>--skip-link-bg</code>,
<code>--skip-link-fg</code>, <code>--skip-link-shadow</code>.
</p>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Success criterion</td>
<td>
<strong>WCAG 2.4.1 Bypass Blocks (A)</strong>. One link to the main content is
technique <strong>G1</strong>; one per area is <strong>G124</strong>. Both are
sufficient — which one you get is what the page composes, not a prop.
</td>
</tr>
<tr>
<td class="name">Order</td>
<td>
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.
</td>
</tr>
<tr>
<td class="name">Focus, not scroll</td>
<td>
A bare <code>#fragment</code> 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
<code>tabindex="-1"</code> on blur.
</td>
</tr>
<tr>
<td class="name">Name</td>
<td>
The visible sentence IS the accessible name — no <code>aria-label</code>, nothing to
go out of sync. Localised through langs by landmark role.
</td>
</tr>
<tr>
<td class="name">Visible focus</td>
<td>
<code>:focus</code>, not only <code>:focus-visible</code>: 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).
</td>
</tr>
<tr>
<td class="name">Never covered</td>
<td>
Its own z rung (<code>--z-index-skip-link: 950</code>), above every piece of page
chrome. It cannot share the <code>affix</code> 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.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.