diff --git a/docs/audit/theming/README.md b/docs/audit/theming/README.md index 174d31742..904ee794c 100644 --- a/docs/audit/theming/README.md +++ b/docs/audit/theming/README.md @@ -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 | diff --git a/docs/audit/theming/link.md b/docs/audit/theming/link.md index 1acaaff4a..1c0332eb3 100644 --- a/docs/audit/theming/link.md +++ b/docs/audit/theming/link.md @@ -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 `⚠`. -_(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. diff --git a/scripts/theming-sentinel-exceptions.ts b/scripts/theming-sentinel-exceptions.ts index 82bd902cb..a64460249 100644 --- a/scripts/theming-sentinel-exceptions.ts +++ b/scripts/theming-sentinel-exceptions.ts @@ -136,6 +136,14 @@ export const SENTINEL_EXCEPTIONS: Record> = { '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', diff --git a/scripts/theming-sentinel.ts b/scripts/theming-sentinel.ts index f9d9efb1e..b79b550b2 100644 --- a/scripts/theming-sentinel.ts +++ b/scripts/theming-sentinel.ts @@ -74,6 +74,8 @@ const PROPS = [ 'boxShadow', 'opacity', 'textDecorationColor', + 'textDecorationThickness', + 'textUnderlineOffset', 'outlineColor', 'outlineWidth', 'outlineOffset', diff --git a/src/uix/eidos/components/link/README.md b/src/uix/eidos/components/link/README.md index b5910be14..bbe5d99db 100644 --- a/src/uix/eidos/components/link/README.md +++ b/src/uix/eidos/components/link/README.md @@ -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 `` u otro componente con sema — Link no es ese surface. +## Talla y tema + +**Guard R-5.4** (`npm run theming:sentinel -- link `): 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 | diff --git a/src/uix/eidos/components/link/link.css b/src/uix/eidos/components/link/link.css index 7996f1812..bd64165a7 100644 --- a/src/uix/eidos/components/link/link.css +++ b/src/uix/eidos/components/link/link.css @@ -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; } diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index affb2ebd0..9e7e8802c 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -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); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index f396dc433..6eacee158 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -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' } }, diff --git a/web/routes/uix/components/link/+page.svelte b/web/routes/uix/components/link/+page.svelte index 59cee019a..7cd94d6ee 100644 --- a/web/routes/uix/components/link/+page.svelte +++ b/web/routes/uix/components/link/+page.svelte @@ -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('live'); let trace = $state([]); @@ -171,6 +172,9 @@ + @@ -399,6 +403,10 @@ {/if} + {#if tab === 'tokens'} + + {/if} + {#if tab === 'a11y'}

Accessibility