uix(link): temable — 0 % → 36 %, y ese techo bajo es la respuesta correcta

5 claves fusionadas en el bloque `link` de `recipes/base.ts` (donde ya vivían
los dos forwards de paleta). Censo 0 % → 36 %.

**Un enlace en línea hereda casi todo del texto que lo rodea**, así que su
superficie de tema es pequeña por diseño. Sus dos escotillas por instancia
(`--_link-font-size`, `--_link-line-height`) tienen como fallback `inherit`, y
ese `inherit` es SEMÁNTICO: el enlace toma la tipografía del párrafo en el que
vive. Convertirlo en token exigiría un público con valor `inherit`, que en
`:root` no significa lo mismo — se quedan privadas. Con los dos forwards de
paleta, ésos son los cuatro privados que forman el grueso del 64 % restante.

Lo tokenizable es la GEOMETRÍA: radio del anillo, hueco del glifo externo,
tamaño del glifo (`0.85em` en las dos dimensiones → UN knob) y el grosor y
desplazamiento del subrayado, cada uno declarado en las DOS reglas que lo
dibujan (`always` y `hover`) con el mismo valor. Las tres opacidades son
sistema; el `fill: currentColor` del glifo es identidad, sigue a la tinta del
enlace a propósito.

**La cascada no se toca.** El CSS lleva dos correcciones de especificidad ya
medidas y explicadas en su sitio (el `inherit` condicional, para que un `color`
pedido por el consumidor no se descarte en silencio; y el `:active` al final,
porque una pulsación es siempre también un hover). El diff de computed confirma
que tokenizar no las altera.

Artefactos y gates:
- Sonda antes/después = 0 diffs en 203 valores × 7 estados. Determinismo
  verificado.
- R-5.4: 2/5, los tres mudos adjudicados con su medición. Ninguno es deuda: el
  glifo externo no se monta en la demo (0 nodos, contados) y el enlace arranca
  en `data-underline='hover'` sin puntero encima, así que NO HAY subrayado que
  mover (`text-decoration-line: none`, `thickness: auto`). Forzando el estado,
  los tres alcanzan.
- En el mismo pase, `text-decoration-thickness` y `text-underline-offset`
  entran en la lista de propiedades del guard, que no las miraba —
  verificado que los otros DIECISÉIS componentes con ledger no cambian.
- censo --only 36 % · component:audit PASS · eidos-lint invalid 0, class-hooks
  0 · suite eidos sin rojos nuevos · rtl:check 0 · docs:check 0 · check por
  fichero limpio · README con «Talla y tema» y tab `Tokens` (5 claves).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent adb77863cd
commit d3089e880c

@ -10,9 +10,9 @@
- **Medido**: 2026-08-21 · **162 recetas** con CSS + **8 componentes sin receta** = 170 fichas, el árbol entero de `eidos/components/`
- **La pregunta**: ¿cuánto de la apariencia de cada componente puede cambiar un tema **sin tocar el sistema ni la receta**?
- **Alcance global**: **49%** — 2277 de 4676 knobs pasan por un token público del componente
- **Reparto**: público 2277 · privado 635 · global 1190 · literal 574 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 40 · **alcance < 20 %**: 33 · **alcance 100 %**: 8 · **con eje `size`**: 62
- **Alcance global**: **49%** — 2281 de 4676 knobs pasan por un token público del componente
- **Reparto**: público 2281 · privado 635 · global 1189 · literal 571 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 39 · **alcance < 20 %**: 32 · **alcance 100 %**: 8 · **con eje `size`**: 62
## Cómo se lee
@ -91,12 +91,12 @@ theming §6.7 lo pone delante (`hover-bg`). Las fichas proponen la forma
| 19 | [timeline](./timeline.md) | 24 | 51% | 49 | 45 |
| 20 | [avatar](./avatar.md) | 23 | 36% | 36 | 0 |
## Sin ningún token público propio (40)
## Sin ningún token público propio (39)
Ni una clave pública en `lib/recipes/base.ts` — alguno tiene privados forward
(`_palette-*`), que no son contrato: un tema no puede nombrarlos.
[`alert-dialog`](./alert-dialog.md) · [`announce`](./announce.md) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`field-langs`](./field-langs.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`knob`](./knob.md) · [`label`](./label.md) · [`link`](./link.md) · [`mark`](./mark.md) · [`mask-field`](./mask-field.md) · [`menu-dial`](./menu-dial.md) · [`mockup`](./mockup.md) · [`month-grid`](./month-grid.md) · [`motion`](./motion.md) · [`number-field`](./number-field.md) · [`palabras`](./palabras.md) · [`range-calendar`](./range-calendar.md) · [`scroll-frames`](./scroll-frames.md) · [`skeleton`](./skeleton.md) · [`skin-media-player`](./skin-media-player.md) · [`stack`](./stack.md) · [`text`](./text.md) · [`text-blur`](./text-blur.md) · [`text-circular`](./text-circular.md) · [`text-focus`](./text-focus.md) · [`text-gradient`](./text-gradient.md) · [`text-scramble`](./text-scramble.md) · [`virtual-grid`](./virtual-grid.md) · [`virtual-list`](./virtual-list.md) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md)
[`alert-dialog`](./alert-dialog.md) · [`announce`](./announce.md) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`field-langs`](./field-langs.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`knob`](./knob.md) · [`label`](./label.md) · [`mark`](./mark.md) · [`mask-field`](./mask-field.md) · [`menu-dial`](./menu-dial.md) · [`mockup`](./mockup.md) · [`month-grid`](./month-grid.md) · [`motion`](./motion.md) · [`number-field`](./number-field.md) · [`palabras`](./palabras.md) · [`range-calendar`](./range-calendar.md) · [`scroll-frames`](./scroll-frames.md) · [`skeleton`](./skeleton.md) · [`skin-media-player`](./skin-media-player.md) · [`stack`](./stack.md) · [`text`](./text.md) · [`text-blur`](./text-blur.md) · [`text-circular`](./text-circular.md) · [`text-focus`](./text-focus.md) · [`text-gradient`](./text-gradient.md) · [`text-scramble`](./text-scramble.md) · [`virtual-grid`](./virtual-grid.md) · [`virtual-list`](./virtual-list.md) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md)
## Tabla completa (162 recetas, por alcance ascendente)
@ -108,7 +108,6 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [month-grid](./month-grid.md) | 0% | 61 | 0 | 7 | 0 | 5 | 49 | 0 | – |
| [year-grid](./year-grid.md) | 0% | 61 | 0 | 7 | 0 | 5 | 49 | 0 | – |
| [field-langs](./field-langs.md) | 0% | 41 | 0 | 0 | 31 | 8 | 2 | 0 | – |
| [link](./link.md) | 0% | 14 | 0 | 4 | 2 | 5 | 3 | 0 | – |
| [code](./code.md) | 0% | 13 | 0 | 0 | 3 | 4 | 6 | 0 | – |
| [virtual-list](./virtual-list.md) | 0% | 13 | 0 | 2 | 6 | 4 | 1 | 0 | y |
| [virtual-grid](./virtual-grid.md) | 0% | 12 | 0 | 2 | 6 | 3 | 1 | 0 | y |
@ -156,6 +155,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [toggle-group](./toggle-group.md) | 33% | 3 | 1 | 0 | 1 | 1 | 0 | 1 | – |
| [background](./background.md) | 35% | 26 | 8 | 6 | 6 | 3 | 3 | 31 | – |
| [avatar](./avatar.md) | 36% | 36 | 13 | 17 | 0 | 6 | 0 | 0 | y |
| [link](./link.md) | 36% | 14 | 4 | 4 | 1 | 2 | 3 | 5 | – |
| [audio-player](./audio-player.md) | 37% | 19 | 7 | 0 | 8 | 4 | 0 | 5 | – |
| [tooltip](./tooltip.md) | 38% | 22 | 8 | 9 | 3 | 1 | 1 | 20 | y |
| [date-range-picker](./date-range-picker.md) | 40% | 53 | 20 | 9 | 19 | 2 | 3 | 22 | y |

@ -5,19 +5,18 @@
> Vista de conjunto: [README](./README.md) · método y protocolo:
> [`PLAN-theming.md`](../../process/PLAN-theming.md) §1, §2, §7.
- **Medido**: 2026-08-20 · **Alcance**: **0%** — 0 de 11 knobs por token público
- **Knobs de apariencia**: 14 — público 0 · privado 4 · global 2 · literal 5 · sistema 3 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 0 pública(s) · 2 privada(s) forward — `_palette-text`, `_palette-solid-hover`
- **Medido**: 2026-08-21 · **Alcance**: **36%** — 4 de 11 knobs por token público
- **Knobs de apariencia**: 14 — público 4 · privado 4 · global 1 · literal 2 · sistema 3 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 5 pública(s) — `radius`, `gap`, `external-icon-size`, `underline-width`, `underline-offset` · 2 privada(s) forward — `_palette-text`, `_palette-solid-hover`
- **Eje `size`**: no · **ficheros**: `link.css`
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (2)
### 1.1 Directo a primitivo global (1)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `link.css:23` | `[data-link]` | `border-radius` | `var(--radius-sm)` |
| 2 | `link.css:93` | `[data-link]:active, [data-link][data-variant]:active` | `opacity` | `var(--opacity-70)` |
| 1 | `link.css:93` | `[data-link]:active, [data-link][data-variant]:active` | `opacity` | `var(--opacity-70)` |
### 1.2 A través de un privado (4)
@ -28,15 +27,12 @@
| 3 | `link.css:21` | `[data-link]` | `color` | `var(--_link-palette-text)` |
| 4 | `link.css:76` | `[data-link]:hover` | `color` | `var(--_link-palette-solid-hover)` |
### 1.3 Literales (5)
### 1.3 Literales (2)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `link.css:26` | `[data-link]` | `gap` | `0.25em` |
| 2 | `link.css:108` | `[data-link-external-icon]` | `width` | `0.85em` |
| 3 | `link.css:109` | `[data-link-external-icon]` | `height` | `0.85em` |
| 4 | `link.css:116` | `[data-link-sr-only]` | `width` | `1px` |
| 5 | `link.css:117` | `[data-link-sr-only]` | `height` | `1px` |
| 1 | `link.css:116` | `[data-link-sr-only]` | `width` | `1px` |
| 2 | `link.css:117` | `[data-link-sr-only]` | `height` | `1px` |
## 2. Sistema transversal (3) — informativo, fuera del ratio
@ -56,7 +52,7 @@ Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inl
## 4. Propuesta de corrección
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (3)
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (1)
Valor **verbatim** del CSS de hoy: el default no se mueve, sólo cambia quién
puede moverlo. Nombres derivados de recipe-contract §1 (ejes lógicos, talla
@ -66,15 +62,13 @@ no distingue lo que debería — se marca `⚠`.
| token (`--link-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `radius` | `root` | `var(--radius-sm)` | 1 |
| `gap` | `root` | `0.25em` | 1 |
| `press-opacity` | `root` | `var(--opacity-70)` | 1 |
### 4.2 Sin nombre mecánico (8)
### 4.2 Sin nombre mecánico (6)
- **⚠ decisión: el privado que alimenta este knob no se declara en el CSS (viene de `base.ts` o de un estilo inline) — hay que resolverlo antes de nombrarlo** — 4: `font-size`, `line-height`, `color`.
- **⚠ decisión: `width` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 2: `width`.
- **⚠ decisión: `height` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 2: `height`.
- **⚠ decisión: `width` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `width`.
- **⚠ decisión: `height` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `height`.
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)
@ -88,6 +82,31 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(pendiente — lo escribe el autor; se conserva al regenerar)_
**Medido 2026-08-21** (sonda ×2 = 0 diffs en 203 valores · 7 estados). Un
enlace en línea hereda casi todo del texto que lo rodea, así que su superficie
de tema es pequeña por diseño — y esa es la respuesta correcta, no una carencia.
1. **`--_link-font-size` y `--_link-line-height` NO se tokenizan.** Son
escotillas por instancia cuyo fallback es `inherit`, y ese `inherit` es
SEMÁNTICO: el enlace toma la tipografía del párrafo en el que vive («inherits
ambient typography», dice la receta). Convertirlo en token exigiría un
público con valor `inherit`, que en `:root` no significa lo mismo. Se quedan
privadas y son parte del 36 % que no alcanza — honestamente.
2. **Los dos forwards de paleta también** (THM-2): la tinta y su hover leen
`--_link-palette-text` / `-solid-hover` a pelo, sin sitio donde meter un
público sin duplicar la regla.
3. **Lo tokenizable es la geometría**: el radio del anillo, el hueco del icono
externo, el grosor y el desplazamiento del subrayado (cada uno en DOS reglas
— `always` y `hover` — con el mismo valor, así que un knob cada uno), y el
tamaño del icono externo, que es `0.85em` en las dos dimensiones: **un**
knob, no dos.
4. **Las tres opacidades son sistema** (`--opacity-subtle`, `--opacity-70`,
`--opacity-disabled`) y no entran al ratio. El `fill: currentColor` del
icono es identidad: sigue a la tinta del enlace a propósito.
5. **No se toca la cascada.** El CSS lleva dos correcciones de especificidad ya
medidas y explicadas en su sitio (el `inherit` condicional para que un
`color` pedido no se descarte en silencio, y el `:active` declarado al final
porque una pulsación es siempre también un hover). Tokenizar no las altera:
el diff de computed lo confirma.
<!-- veredicto:end -->

@ -136,6 +136,14 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'preview-z':
'the drag PREVIEW is created only while a drag is in flight (0 nodes at rest, counted); forced → reaches (100 → 4321)'
},
link: {
'external-icon-size':
'the demo mounts no EXTERNAL link, so the glyph never renders (0 nodes, counted); forced → reaches (11.89 → 77px on both axes)',
'underline-width':
"the demo link sits at data-underline='hover' with no pointer on it, so there IS no underline to move (text-decoration-line: none, thickness: auto). Forced 'always' → reaches (1 → 9px). textDecorationThickness was missing from the guard prop list and was added in the same pass",
'underline-offset':
"same: no underline at rest under data-underline='hover'; forced → reaches (2 → 11px). textUnderlineOffset added to the guard prop list too"
},
'link-preview': {
'trigger-fg':
'the panel opens on HOVER, so measuring it REQUIRES the pointer on the trigger — and hover is exactly the state whose rule overrides the resting ink. Structural, not a demo gap: the hover token (`hover-trigger-fg`) reads live in the same run',

@ -74,6 +74,8 @@ const PROPS = [
'boxShadow',
'opacity',
'textDecorationColor',
'textDecorationThickness',
'textUnderlineOffset',
'outlineColor',
'outlineWidth',
'outlineOffset',

@ -85,6 +85,44 @@ the surrounding component). Si Link se usa como affordance que disparara
un commit (form submit, dialog action), el consumer compone un
`<Toolbar.Button>` u otro componente con sema — Link no es ese surface.
## Talla y tema
**Guard R-5.4** (`npm run theming:sentinel -- link <url>`): 2/5 en vivo; los
tres que callan están adjudicados con su medición en
`scripts/theming-sentinel-exceptions.ts`. Corrido 2026-08-21.
Contrato en `lib/recipes/base.ts > link` (5 claves, fusionadas con los dos
forwards de paleta). Alcance del censo: **36 %** (era 0 %) — y ese techo bajo
es la respuesta correcta, no una carencia.
**Un enlace en línea hereda casi todo del texto que lo rodea.** Sus dos
escotillas por instancia (`--_link-font-size`, `--_link-line-height`) tienen
como fallback `inherit`, y ese `inherit` es SEMÁNTICO: el enlace toma la
tipografía del párrafo en el que vive. Convertirlo en token exigiría un público
con valor `inherit`, que en `:root` no significa lo mismo, así que se quedan
privadas. Con los dos forwards de paleta (THM-2), ésos son los cuatro privados
que forman el grueso del 64 % restante.
**Lo tokenizable es la geometría**: el radio del anillo, el hueco del glifo
externo, el tamaño del glifo (`0.85em` en las dos dimensiones — UN knob) y el
grosor y desplazamiento del subrayado, cada uno declarado en las dos reglas que
lo dibujan (`always` y `hover`) con el mismo valor. Las tres opacidades son
sistema y el `fill: currentColor` del glifo es identidad: sigue a la tinta del
enlace a propósito.
**La cascada no se toca.** El CSS lleva dos correcciones de especificidad ya
medidas y explicadas en su sitio — el `inherit` condicional, para que un
`color` pedido por el consumidor no se descarte en silencio, y el `:active`
declarado al final porque una pulsación es siempre también un hover. El diff de
computed confirma que tokenizar no las altera.
Los tres tokens mudos no son deuda: el glifo externo no se monta en la demo (0
nodos) y el enlace arranca en `data-underline='hover'` sin puntero encima, así
que **no hay subrayado que mover** (`text-decoration-line: none`). Forzando el
estado, los tres alcanzan. En el mismo pase se añadieron
`text-decoration-thickness` y `text-underline-offset` a la lista de propiedades
del guard, que no las miraba.
## Gaps
| Gap | Disposición | Detalle |

@ -20,10 +20,10 @@
line-height: var(--_link-line-height, inherit);
color: var(--_link-palette-text);
background: transparent;
border-radius: var(--radius-sm);
border-radius: var(--link-radius);
display: inline-flex;
align-items: center;
gap: 0.25em;
gap: var(--link-gap);
cursor: pointer;
text-decoration: none;
transition:
@ -47,15 +47,15 @@
/* Underline behaviour. `data-underline` overrides the variant default. */
[data-link][data-underline='always'] {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
text-decoration-thickness: var(--link-underline-width);
text-underline-offset: var(--link-underline-offset);
}
[data-link][data-underline='hover']:hover,
[data-link][data-underline='hover']:focus-visible {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
text-decoration-thickness: var(--link-underline-width);
text-underline-offset: var(--link-underline-offset);
}
[data-link][data-underline='none'] {
@ -105,8 +105,8 @@
}
[data-link-external-icon] {
width: 0.85em;
height: 0.85em;
width: var(--link-external-icon-size);
height: var(--link-external-icon-size);
fill: currentColor;
flex-shrink: 0;
}

@ -2116,6 +2116,11 @@
--listbox-group-label-font-size: var(--size-xs-font-size);
--listbox-group-label-font-weight: var(--font-weight-medium);
--listbox-group-label-tracking: var(--tracking-caps);
--link-radius: var(--radius-sm);
--link-gap: 0.25em;
--link-external-icon-size: 0.85em;
--link-underline-width: 1px;
--link-underline-offset: 2px;
--proof-of-human-min-height-sm: calc(var(--size-sm-control-height) * 3);
--proof-of-human-min-height-md: calc(var(--size-md-control-height) * 3);
--proof-of-human-min-height-lg: calc(var(--size-lg-control-height) * 3);

@ -3051,6 +3051,15 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
// it was pinned to `--color-primary-solid-hover`, so a coloured link went
// purple under the pointer.
link: {
radius: 'var(--radius-sm)',
// Gap to the external-link glyph, and the glyph itself — em-relative so
// both track the link's inherited type.
gap: '0.25em',
'external-icon-size': '0.85em',
// The underline. Both rules that draw it (`always` and `hover`) share these
// two values, so it is one knob each.
'underline-width': '1px',
'underline-offset': '2px',
'_palette-text': { value: 'var(--color-primary-text)', scope: 'host' },
'_palette-solid-hover': { value: 'var(--color-primary-solid-hover)', scope: 'host' }
},

@ -8,8 +8,9 @@
} from '$uix/eidos/components/link';
import { compileMorfo } from '$uix/morfo';
import { linkMorfo } from '@/uix/morfo/components/link';
import TokensPanel from '../../lib/TokensPanel.svelte';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'tokens' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
@ -171,6 +172,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>5</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -399,6 +403,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="link" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>

Loading…
Cancel
Save

Powered by TurnKey Linux.