uix(image-picker): temable — 71 % → 100 %, y el instrumento medía UN nodo

Una clave nueva (13 → 14), cuatro literales firmados y el interruptor que hace
medible el componente. Diff de computed 0 sobre 1.344 valores en 7 estados y 6
nodos; R-5.4 de 13/13 a 14/14 SIN una sola adjudicación; capturas 2×
antes/después idénticas.

EL PICKER ARRANCA VACÍO Y LA SONDA VEÍA UN NODO

Todo lo que la receta pinta —preview, canvas, toolbar y los dos botones de
icono— sólo existe en el estado ready, al que no llega ningún trigger: hay que
cargar un fichero. Con el chip «Load sample image» de la demo la sonda pasa de 1
a 6 nodos. La sonda lo enciende por DEMO_VARIANTS y el centinela por
prepareWith; éste necesita además openMarker, porque su re-apertura por token
volvería a pulsar el chip y remontaría la vista previa a mitad de corrida.

LA CLAVE ES hover-button-bg, Y EL NOMBRE LO DECIDIÓ EL CATÁLOGO

Era el único knob que iba a un primitivo global a pelo. La §4 lo nombraba
hover-rotate-bg por el PRIMER selector de la regla, y miente dos veces: pinta
rotate Y remove, y el contrato de este componente ya llama button-* a ese par
desde que nació. hover-{parte}-{slot} es la forma de las 60 claves hover-* de
base.ts.

NO ES EL VELO DEL SISTEMA — MEDIDO DESDE EL PÍXEL HACIA ARRIBA

Los dos botones flotan SOBRE la foto: su relleno de reposo es translúcido
(color-mix 88 %) y el hover lo vuelve OPACO. Es un cambio de RELLENO, no una
capa encima. Con las transiciones congeladas, elementsFromPoint sobre el centro
del botón: el cambio cae en el <button> —el nodo con forma, radio 6 px, de
srgb .988/.88 a oklch(.9911 0 0)— y NINGÚN ancestro recibe background-image,
porque el arquetipo action no trae velo (archetypes.css sólo vela trigger, item
y option). No se bendice un duplicado, que es lo que paró el hover-* de listbox.

LOS CUATRO LITERALES SE FIRMAN, NO SE ACUÑAN

El 100 % doble del hijo del canvas es identidad (la <Image> ES la caja del
canvas, ya con inset: 0), el line-height: 1 es un botón de un solo glifo, y el
Highlight de forced-colors es la paleta del sistema operativo: un valor de tema
ahí lo sustituiría el UA. Válvula de recipe-contract §3 — salen del ratio como
ausencia ESCRITA.

DEFECTO REAL, ANOTADO, NO ARREGLADO

Ese hover neutro es una invención por componente y §38 / R-4.3 mandan que el
hover neutro sea la capa --state-*. Migrarlo cambia el default (velo translúcido
en vez de relleno opaco) y D-TH.5 lo prohíbe aquí. Y no basta con cambiar la
receta: el arquetipo del par es action, que HOY no recibe velo — o action gana
velo en archetypes.css (mueve píxel en todo el catálogo) o el par cambia de
arquetipo, que es morfo. Escrito en el veredicto §5 y en el README.

CIFRAS Y GUARDS

Censo 71 % → 100 % (20 knobs → 16: público 12 → 13, global 1 → 0, literal 4 → 0,
excepción firmada 0 → 4; sistema 3, fuera del ratio). Contrato 13 → 14 claves.
component-audit PASS · eidos-lint invalid 0 (12 morfo-backed, 5 eidos-only) ·
vitest src/uix/eidos 35/36 (único rojo skin-media-player, preexistente) ·
rtl:check 0 · docs:check 0 · npm run check sin errores en ficheros de este
componente. README con su sección «Talla y tema» y pestaña Tokens en la demo
(14 claves, verificada con clic de Playwright tras networkidle).

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

@ -5,40 +5,38 @@
> Vista de conjunto: [README](./README.md) · método y protocolo:
> [`PLAN-theming.md`](../../process/PLAN-theming.md) §1, §2, §7.
- **Medido**: 2026-08-23 · **Alcance**: **71%** — 12 de 17 knobs por token público
- **Knobs de apariencia**: 20 — público 12 · privado 0 · global 1 · literal 4 · sistema 3 · excepción 0 _(los dos últimos, fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 13 pública(s) — `gap`, `preview-radius`, `preview-bg`, `preview-border`, `preview-aspect`, `toolbar-gap`, `toolbar-inset`, `button-size`, `button-bg`, `button-fg`, `button-border`, `button-radius`, `button-font-size`
- **Medido**: 2026-08-23 · **Alcance**: **100%** — 13 de 13 knobs por token público
- **Knobs de apariencia**: 16 — público 13 · privado 0 · global 0 · literal 0 · sistema 3 · excepción 4 _(los dos últimos, fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 14 pública(s) — `gap`, `preview-radius`, `preview-bg`, `preview-border`, `preview-aspect`, `toolbar-gap`, `toolbar-inset`, `button-size`, `button-bg`, `button-fg`, `button-border`, `button-radius`, `button-font-size`, `hover-button-bg`
- **Eje `size`**: no · **ficheros**: `image-picker.css`
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (1)
### 1.1 Directo a primitivo global (0)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `image-picker.css:85` | `[data-image-picker-rotate]:hover:not([disabled]), [data-image-picker-remove]:hover:not([disabled])` | `background` | `var(--color-surface-default)` |
_Ninguno._
### 1.2 A través de un privado (0)
_Ninguno._
### 1.3 Literales (4)
### 1.3 Literales (0)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `image-picker.css:45` | `[data-image-picker-canvas] > *` | `inline-size` | `100%` |
| 2 | `image-picker.css:46` | `[data-image-picker-canvas] > *` | `block-size` | `100%` |
| 3 | `image-picker.css:79` | `[data-image-picker-rotate], [data-image-picker-remove]` | `line-height` | `1` |
| 4 | `image-picker.css:109` | `[data-image-picker-rotate]:focus-visible, [data-image-picker-remove]:focus-visible` | `outline-color` | `Highlight` |
_Ninguno._
### 1.4 Excepciones firmadas (0) — fuera del ratio
### 1.4 Excepciones firmadas (4) — fuera del ratio
Literales que llevan su anotación `/* literal: <razón> */` en la propia
declaración: la válvula de recipe-contract §3, la misma que honra
`component-audit`. **Una desviación firmada no es deuda** — se listan para que la
razón se lea, no para acuñarlas.
_Ninguno._
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `image-picker.css:45` | `[data-image-picker-canvas] > *` | `inline-size` | `100%` |
| 2 | `image-picker.css:46` | `[data-image-picker-canvas] > *` | `block-size` | `100%` |
| 3 | `image-picker.css:79` | `[data-image-picker-rotate], [data-image-picker-remove]` | `line-height` | `1` |
| 4 | `image-picker.css:109` | `[data-image-picker-rotate]:focus-visible, [data-image-picker-remove]:focus-visible` | `outline-color` | `Highlight` |
## 2. Sistema transversal (3) — informativo, fuera del ratio
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
@ -55,7 +53,7 @@ _La receta no declara privados propios en su CSS._
## 4. Propuesta de corrección
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (1)
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (0)
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
@ -65,7 +63,6 @@ no distingue lo que debería — se marca `⚠`.
| token (`--image-picker-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `hover-rotate-bg` | `root` | `var(--color-surface-default)` | 1 |
### 4.2 Sin nombre mecánico (4)
@ -85,6 +82,55 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(pendiente — lo escribe el autor; se conserva al regenerar)_
**Ejecutado 2026-08-23 — 71 % → 100 %, una clave nueva y cuatro literales
firmados.** Diff de computed 0 sobre 1.344 valores en 7 estados (6 nodos);
R-5.4 de 13/13 a **14/14 sin una sola adjudicación**; capturas 2× antes/después
idénticas.
**El instrumento medía UN nodo.** El picker arranca VACÍO —un dropzone de
FileUpload— y todo lo que la receta pinta (preview, canvas, toolbar y los dos
botones de icono) sólo existe en el estado `ready`, al que no llega ningún
trigger: hay que cargar un fichero. Con el chip «Load sample image» de la demo
la sonda pasa de 1 a **6 nodos**. Sonda (`DEMO_VARIANTS`) y centinela
(`prepareWith`) llevan ya el interruptor; el centinela además necesita
`openMarker` para que su re-apertura por token no vuelva a pulsar el chip y
remonte la vista previa a mitad de corrida.
**La única clave: `hover-button-bg`.** Era el único knob que iba a un primitivo
global a pelo (`--color-surface-default`). La §4 lo nombraba `hover-rotate-bg`
por el PRIMER selector de la regla, y miente dos veces: pinta rotate **y**
remove, y el contrato de este componente ya llama `button-*` a ese par desde que
nació. El catálogo decide — `hover-{parte}-{slot}` es la forma de las 60 claves
`hover-*` de `base.ts`.
**No es el velo del sistema, y por eso se acuña.** Los dos botones flotan SOBRE
la foto: su relleno de reposo es translúcido (`color-mix(… 88%, transparent)`) y
el hover lo vuelve OPACO — un cambio de RELLENO, no una capa encima. Medido
desde el píxel hacia arriba con las transiciones congeladas: el cambio cae en el
`<button>` (el nodo con forma, radio 6 px, `srgb .988/.88` → `oklch(.9911 0 0)`)
y **ningún ancestro recibe `background-image`**, porque el arquetipo `action` no
trae velo — `archetypes.css` sólo vela `trigger`, `item` y `option`. No se está
bendiciendo un duplicado, que es lo que paró el `hover-*` de `listbox`.
**Los cuatro literales se firman, no se acuñan** (válvula de recipe-contract §3):
el `100%` doble del hijo del canvas es identidad (la `<Image>` ES la caja del
canvas, que ya está en `inset: 0`), el `line-height: 1` es un botón de un solo
glifo, y el `Highlight` de `forced-colors` es la paleta del sistema operativo —
un valor de tema ahí lo sustituiría el UA, que es justo lo que ese modo hace.
**Fuera del ratio por doctrina**: `--opacity-disabled` (×2) y el par
`--focus-ring-*` son de la capa de sistema (theming §32).
**Sin eje `size` y es correcto**: la prop `size` del wrapper viaja al
`ImageAdjustments` embebido; este cromo de orquestación no escala.
⚠ **Un defecto real, ANOTADO, no arreglado**: ese hover neutro es una invención
por componente, y §38 / R-4.3 mandan que el hover neutro sea la capa
`--state-*`. Migrarlo cambia el default (velo translúcido en vez de relleno
opaco) y D-TH.5 lo prohíbe dentro de este eje. Queda escrito aquí y en el README
del componente. Nota para quien lo firme: el arquetipo del par es `action`, que
HOY no recibe velo del sistema, así que la migración no es sólo cambiar la
receta — o `action` gana velo en `archetypes.css` (mueve píxel en todos los
`action` del catálogo) o el par cambia de arquetipo, que es morfo.
<!-- veredicto:end -->

@ -126,7 +126,11 @@ const DEMO_VARIANTS: Record<string, string[]> = {
'[data-uix-control]:has([data-uix-control-label]:text-is("dot")) input[type=checkbox]',
'[data-uix-control]:has([data-uix-control-label]:text-is("icon")) input[type=checkbox]',
'[data-uix-control]:has([data-uix-control-label]:text-is("removable")) input[type=checkbox]'
]
],
// It boots EMPTY (a FileUpload dropzone): the preview, the canvas, the
// toolbar and the two icon buttons — every part the recipe paints — only
// exist in the `ready` state. Without the sample chip the probe saw 1 node.
'image-picker': ['[data-uix-chip]:text-is("Load sample image")']
};
async function main() {

@ -392,6 +392,15 @@ const COMPONENT_OVERRIDES: Record<
openWith: [
'[data-uix-control]:has([data-uix-control-label]:text-is("showBorder")) [data-uix-chip]:text-is("true")'
]
},
// It boots EMPTY — a FileUpload dropzone — and every part the recipe paints
// (preview, canvas, toolbar, the two icon buttons) exists ONLY in the `ready`
// state, which no trigger reaches: the demo's sample chip is the only way in.
// The marker keeps the re-open guard from clicking that chip before every
// token, which would rebuild the File and remount the preview mid-run.
'image-picker': {
prepareWith: ['[data-uix-chip]:text-is("Load sample image")'],
openMarker: '[data-image-picker-preview]'
}
};

@ -80,20 +80,41 @@ cropper-ready value.
sibling of the canvas inside the Preview, so it stays upright while the image
rotates.
## Recipe tokens
Public, themeable — `--image-picker-*` (the composed components bring their own):
| Token | Default |
| --- | --- |
| `gap` | space-3 (preview → adjustments) |
| `preview-radius` / `preview-bg` / `preview-border` / `preview-aspect` | rounded box, 4/3 |
| `toolbar-gap` / `toolbar-inset` | floating top-right |
| `button-*` | icon-button chrome (rotate / remove) |
`eidos-lint image-picker` → invalid 0 (15 morfo-backed selectors). CSS is imported
from the wrapper (code-split chunk) — intentionally NOT in `eidos/index.css`.
## Talla y tema
**14 claves públicas** en `lib/recipes/base.ts` — `--image-picker-*`, y sólo el
cromo de ORQUESTACIÓN (los tres componentes que compone traen el suyo): la pila
(`gap`), la caja de vista previa (`preview-{radius,bg,border,aspect}`), la barra
flotante (`toolbar-{gap,inset}`) y los dos botones de icono
(`button-{size,bg,fg,border,radius,font-size}` + `hover-button-bg`). **Sin eje
`size`**: la prop `size` viaja al `ImageAdjustments` embebido, no a este cromo.
La pestaña **Tokens** de su demo las resuelve en vivo.
Lo que entró el **2026-08-23** (71 % → 100 %):
- **`hover-button-bg`** — el único knob que iba a un primitivo global a pelo
(`--color-surface-default`). No es el velo del sistema: los dos botones flotan
SOBRE la foto, así que su relleno de reposo es translúcido
(`color-mix(… 88%, transparent)`) y el hover lo vuelve OPACO. Medido desde el
píxel hacia arriba: el cambio cae en el `<button>` —el nodo con forma, radio
6 px— y ningún ancestro recibe velo; el arquetipo `action` no trae ninguno
(`archetypes.css` sólo vela `trigger` / `item` / `option`).
- **Los cuatro literales quedan ANOTADOS**, no acuñados: el `100%` doble del hijo
del canvas (la `<Image>` ES la caja del canvas), el `line-height: 1` de un
botón de un solo glifo, y el `Highlight` de `forced-colors`, que es la paleta
del sistema operativo — un valor de tema ahí lo sustituiría el UA.
**Lo que queda fuera por doctrina**: la opacidad de deshabilitado y el anillo de
foco son de la capa de sistema (`--opacity-disabled`, `--focus-ring-*`).
⚠ **Deuda anotada, no arreglada**: ese hover neutro es una invención por
componente — §38 / R-4.3 mandan que el hover sea la capa `--state-*`. Migrarlo
cambia el default (velo translúcido en vez de relleno opaco), que es justo lo que
D-TH.5 prohíbe; queda escrito aquí y en el veredicto de su ficha.
`eidos-lint image-picker` → invalid 0 (12 morfo-backed selectors, 5 eidos-only).
CSS is imported from the wrapper (code-split chunk) — intentionally NOT in
`eidos/index.css`.
## Gaps
| Gap | Disposition | Notes |

@ -42,8 +42,8 @@
[data-image-picker-canvas] > * {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
inline-size: 100%; /* literal: identity — the composed <Image> IS the canvas box */
block-size: 100%; /* literal: identity — the composed <Image> IS the canvas box */
}
[data-image-picker-preview][data-rotation='90'] [data-image-picker-canvas] {
@ -76,13 +76,13 @@
background: var(--image-picker-button-bg);
color: var(--image-picker-button-fg);
font-size: var(--image-picker-button-font-size);
line-height: 1;
line-height: 1; /* literal: identity — a glyph-only button box, the type sets no leading */
cursor: pointer;
}
[data-image-picker-rotate]:hover:not([disabled]),
[data-image-picker-remove]:hover:not([disabled]) {
background: var(--color-surface-default);
background: var(--image-picker-hover-button-bg);
}
[data-image-picker-rotate]:focus-visible,
@ -106,6 +106,6 @@
@media (forced-colors: active) {
[data-image-picker-rotate]:focus-visible,
[data-image-picker-remove]:focus-visible {
outline-color: Highlight;
outline-color: Highlight; /* literal: the forced-colors system keyword IS the OS palette — a theme value here would be substituted away */
}
}

@ -1498,6 +1498,7 @@
--image-picker-button-border: var(--color-border-default);
--image-picker-button-radius: var(--radius-md);
--image-picker-button-font-size: var(--size-md-font-size);
--image-picker-hover-button-bg: var(--color-surface-default);
--cropper-viewport-bg: var(--color-surface-muted);
--cropper-viewport-radius: var(--radius-lg);
--cropper-mask-bg: rgb(0 0 0 / 0.55);

@ -1681,7 +1681,11 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'button-fg': 'var(--color-content-primary)',
'button-border': 'var(--color-border-default)',
'button-radius': 'var(--radius-md)',
'button-font-size': 'var(--size-md-font-size)'
'button-font-size': 'var(--size-md-font-size)',
// The icon buttons float over the picture, so their rest fill is
// translucent and hover makes it OPAQUE — a fill swap, not the system's
// translucent veil (the `action` archetype carries none).
'hover-button-bg': 'var(--color-surface-default)'
},
cropper: {
'viewport-bg': 'var(--color-surface-muted)',

@ -7,10 +7,11 @@
import { compileMorfo } from '$uix/morfo';
import { imagePickerMorfo } from '@/uix/morfo/components/image-picker';
import { getActiveUix } from '$active-uix';
import TokensPanel from '../../lib/TokensPanel.svelte';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'tokens' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ────────────────────────────────────────────────────────
@ -205,6 +206,9 @@
<span data-uix-tab-count>{events.length}</span>
</button>
<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>14</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -490,6 +494,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="image-picker" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>

Loading…
Cancel
Save

Powered by TurnKey Linux.