uix(chat-message): temable — 84 % → 100 %, y las TREINTA muertas eran ejes que la demo no enciende

Protocolo PLAN-theming §7 sobre `chat-message`, el mayor de la familia chat.
Censo 84 % → **100 %**, contrato 75 → **81** claves, centinela **77/81** con 4
adjudicadas. El default NO se mueve: diff de computed VACÍO (4.192 valores · 8
estados · 16 nodos) y capturas 2x antes/después **idénticas byte a byte**, en
reposo y con la barra tapback abierta.

## Otra vez, lo caro fue VER

El guard leía **45/75** antes de tocar una línea de código. Una fila de chat es
una PILA de superficies opt-in (reply citado, read-by, estado de entrega,
mención) y de ejes de PRESENTACIÓN (`run`, `direction`, `emphasized`) que la
demo arranca en su posición más pobre: `solo` + `in`, sin ninguna de las cuatro.
Con las superficies encendidas por `prepareWith` y los cinco ejes barridos como
PRODUCTO: **71/75 antes de acuñar nada**. La sonda pasó de 12 nodos a 16 en
reposo, y a 24 con el panel abierto.

## Lo que entra (6 claves, la COSTURA de los seis globales)

`focus-radius` · `mentioned-fg` · `reply-fg` · `reaction-fg` ·
`reaction-inner-gap` · `quick-react-radius`. El valor sigue siendo el del
sistema; el knob pasa a ser del componente.

**Los nombres salen del CATÁLOGO, no del generador.** La ficha proponía `radius`
(colisiona con `emphasize-radius`, que ES la esquina en reposo de la MISMA
fila), `bubble-fg` (el knob es de la MENCIÓN: `mentioned-bg` y
`mentioned-accent` ya existían), `reaction-width` + `reaction-height` para el
glifo (`reaction-height` ya existe y es la altura del CHIP) y
`quick-reactions-width` para un `max-content` que es identidad.

## El caso que obligó a decidir: `reaction-gap` ya existía con otro sentido

La receta la pinta en la fila `reactions` — el hueco ENTRE chips — mientras el
knob nuevo es el ritmo INTERNO del chip, el emoji y su cuenta. Mismo valor
(`--space-1`), dos papeles: un chip más denso no es una fila más apretada.
Reusar la clave habría cambiado en silencio lo que mueve un tema que ya la usa,
así que entra como `reaction-inner-gap`. El resto del bloque `{parte}-gap` de
esta receta (`meta-gap`, `read-by-gap`, `header-gap`, `actions-gap`) significa
«dentro de la parte»: la anómala es la vieja, y renombrarla es churn con riesgo.

## Lo que SALE: una regla que no ha pintado nunca

`[data-chat-message-reaction] svg, [data-chat-message-reaction-add] svg` fijaba
`1.1em` en los dos ejes «porque un svg de sólo viewBox computa 0x0». El glifo
por defecto es el `Icon` compuesto, que emite `style="width:
var(--icon-size-sm); height: ..."` EN LÍNEA, y un estilo en línea gana a todo
selector. Medido: **16 px** (el paso `sm` del icono), no los 15,4 px que `1.1em`
daría sobre los 14 px heredados. La otra mitad del selector no tiene nodo: los
chips llevan un emoji de texto. **Retirada: 0 diffs.** Tercera vez en la familia
(chat-log y chat-composer, ayer).

## Identidades firmadas (4, fuera del ratio)

`inline-size: max-content` de la barra tapback (la barra ES su fila de emoji),
`line-height: 1` de la celda (la celda es la caja del glifo) y los dos
`opacity: 1` de la pastilla de acciones (el final del fundido 0 → 1).

## Adjudicadas (4) — las cuatro son límites del INSTRUMENTO, medidas a mano

- `bubble-tail-radius` — el guard fotografía UNA esquina (`borderTopLeftRadius`)
  y la cola es la de ABAJO. Medido en las 8 combinaciones dirección x run:
  4 px → 1234 px en `solo` y `last`, en las dos direcciones. Su hermana
  `bubble-radius-run` lee viva porque `middle` aplana la esquina START, que sí
  está en la lista.
- `emphasize-duration` — ES la transición que el guard congela para medir todo
  lo demás. Sin congelar: 0,24 s → 4,321 s.
- `focus-radius` — la fila sólo es tabulable compuesta DENTRO de un Feed (soma
  fusiona la identidad de `Feed.Article` cuando hay Feed ancestro) y su propia
  demo la monta suelta: `:focus-visible` no tiene nodo ahí. Medido en la demo de
  `chat-log`, donde las filas SÍ son artículos de feed (`tabindex=0`): reposo
  6 px, enfocada 4 px → 1234 px, por foco programático y por recorrido con Tab.
- `quick-react-hover-scale` — `transform` no está en la lista de propiedades del
  guard (punto ciego ya adjudicado en background / rating-group / card) y la
  celda es un botón PORTALADO con regla `:hover`. Con puntero real:
  `matrix(1.18…)` → `matrix(7.77…)`.

## Un defecto real medido y NO corregido aquí (mueve píxel)

El chip de añadir reacción lleva también `data-popover-trigger`, y
`[data-popover-trigger]:not([data-archetype='field-trigger'])` pesa (0,2,0)
contra los (0,1,0) de `[data-chat-message-reaction-add]`: su fondo, borde,
radio, tinta, altura, padding y tamaño de letra los pinta `popover.css`. Se ve
en la captura — los dos chips de reacción son píldoras redondas y el de añadir
es un cuadrado gris. Los `reaction-*` no mienten (alcanzan sobre los chips
normales), así que no hay nada que retirar: es una decisión de diseño. Clase
`gradient-picker` / `emoji-picker`. Queda REGISTRADO, no tocado (D-TH.5).

## Guards

censo `--only chat-message` 100 % (global 68 %, no baja) · `component-audit`
PASS · `eidos-lint` 0 invalid (37 morfo-backed / 41 eidos-only) · `vitest run
src/uix/eidos` sin rojos nuevos (el único vivo es el conocido
`skin-media-player`) · `rtl:check` 0 · `docs:check` 0 · `check` COMPLETED sin un
solo error del componente · prettier limpio sobre el contenido normalizado a LF.

README con su sección «Talla y tema», demo con pestaña `Tokens` (81 claves,
verificada en navegador) y ficha con su veredicto §5.

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

@ -5,47 +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**: **84%** — 65 de 77 knobs por token público
- **Knobs de apariencia**: 84 — público 65 · privado 0 · global 6 · literal 6 · sistema 7 · excepción 0 _(los dos últimos, fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 75 pública(s) — `gap`, `row-gap-run`, `row-gap-group`, `gutter-inline-size`, `bubble-padding-inline-sm`, `bubble-padding-inline-md`, `bubble-padding-inline-lg`, `bubble-padding-block-sm`, `bubble-padding-block-md`, `bubble-padding-block-lg`, `bubble-radius`, `bubble-radius-run`, `bubble-tail-radius`, `bubble-max-inline-size`, `bubble-font-size-sm`, `bubble-font-size-md`, `bubble-font-size-lg`, `bubble-line-height`, `bubble-bg-in`, `bubble-fg-in`, `bubble-bg-out`, `bubble-fg-out`, `bubble-shadow`, `header-gap`, `header-font-size`, `header-font-weight`, `header-fg`, `meta-gap`, `meta-font-size`, `meta-fg`, `status-size`, `status-fg`, `read-status-fg`, `failed-status-fg`, `read-by-gap`, `read-by-font-size`, `read-by-fg`, `read-by-margin`, `emphasize-bg`, `emphasize-radius`, `emphasize-duration`, `reply-padding-inline`, `reply-padding-block`, `reply-accent-size`, `reply-radius`, `reply-bg`, `reply-accent`, `reply-font-size`, `reaction-gap`, `reaction-height`, `reaction-padding-inline`, `reaction-radius`, `reaction-font-size`, `reaction-bg`, `reaction-border`, `reaction-shadow`, `reaction-overlap`, `reaction-pressed-bg`, `reaction-pressed-border`, `reaction-pressed-fg`, `quick-react-gap`, `quick-react-pad`, `quick-react-size`, `quick-react-emoji-size`, `quick-react-hover-bg`, `quick-react-hover-scale`, `actions-bg`, `actions-border`, `actions-shadow`, `actions-radius`, `actions-padding`, `mentioned-bg`, `mentioned-accent`, `actions-gap`, `actions-offset`
- **Medido**: 2026-08-24 · **Alcance**: **100%** — 71 de 71 knobs por token público
- **Knobs de apariencia**: 78 — público 71 · privado 0 · global 0 · literal 0 · sistema 7 · excepción 4 _(los dos últimos, fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 81 pública(s) — `gap`, `row-gap-run`, `row-gap-group`, `focus-radius`, `gutter-inline-size`, `bubble-padding-inline-sm`, `bubble-padding-inline-md`, `bubble-padding-inline-lg`, `bubble-padding-block-sm`, `bubble-padding-block-md`, `bubble-padding-block-lg`, `bubble-radius`, `bubble-radius-run`, `bubble-tail-radius`, `bubble-max-inline-size`, `bubble-font-size-sm`, `bubble-font-size-md`, `bubble-font-size-lg`, `bubble-line-height`, `bubble-bg-in`, `bubble-fg-in`, `bubble-bg-out`, `bubble-fg-out`, `bubble-shadow`, `header-gap`, `header-font-size`, `header-font-weight`, `header-fg`, `meta-gap`, `meta-font-size`, `meta-fg`, `status-size`, `status-fg`, `read-status-fg`, `failed-status-fg`, `read-by-gap`, `read-by-font-size`, `read-by-fg`, `read-by-margin`, `emphasize-bg`, `emphasize-radius`, `emphasize-duration`, `reply-padding-inline`, `reply-padding-block`, `reply-accent-size`, `reply-radius`, `reply-bg`, `reply-fg`, `reply-accent`, `reply-font-size`, `reaction-gap`, `reaction-inner-gap`, `reaction-height`, `reaction-padding-inline`, `reaction-radius`, `reaction-font-size`, `reaction-bg`, `reaction-fg`, `reaction-border`, `reaction-shadow`, `reaction-overlap`, `reaction-pressed-bg`, `reaction-pressed-border`, `reaction-pressed-fg`, `quick-react-gap`, `quick-react-pad`, `quick-react-size`, `quick-react-radius`, `quick-react-emoji-size`, `quick-react-hover-bg`, `quick-react-hover-scale`, `actions-bg`, `actions-border`, `actions-shadow`, `actions-radius`, `actions-padding`, `mentioned-bg`, `mentioned-fg`, `mentioned-accent`, `actions-gap`, `actions-offset`
- **Eje `size`**: sí · **ficheros**: `chat-message.css`
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (6)
### 1.1 Directo a primitivo global (0)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `chat-message.css:38` | `[data-chat-message]:focus-visible` | `border-radius` | `var(--radius-sm)` |
| 2 | `chat-message.css:226` | `[data-chat-message][data-mentioned] [data-chat-message-bubble]` | `color` | `var(--color-content-primary)` |
| 3 | `chat-message.css:289` | `[data-chat-message-reply]` | `color` | `var(--color-content-secondary)` |
| 4 | `chat-message.css:328` | `[data-chat-message-reaction], [data-chat-message-reaction-add]` | `gap` | `var(--space-1)` |
| 5 | `chat-message.css:334` | `[data-chat-message-reaction], [data-chat-message-reaction-add]` | `color` | `var(--color-content-primary)` |
| 6 | `chat-message.css:407` | `[data-chat-message-quick-react]` | `border-radius` | `var(--radius-full)` |
_Ninguno._
### 1.2 A través de un privado (0)
_Ninguno._
### 1.3 Literales (6)
### 1.3 Literales (0)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `chat-message.css:355` | `[data-chat-message-reaction] svg, [data-chat-message-reaction-add] svg` | `inline-size` | `1.1em` |
| 2 | `chat-message.css:356` | `[data-chat-message-reaction] svg, [data-chat-message-reaction-add] svg` | `block-size` | `1.1em` |
| 3 | `chat-message.css:379` | `[data-popover-content][data-chat-message-quick-reactions]` | `inline-size` | `max-content` |
| 4 | `chat-message.css:409` | `[data-chat-message-quick-react]` | `line-height` | `1` |
| 5 | `chat-message.css:478` | `[data-chat-message]:hover [data-chat-message-actions], [data-chat-message]:focus-within [data-chat-message-actions]` | `opacity` | `1` |
| 6 | `chat-message.css:484` | `[data-chat-message-actions]` | `opacity` | `1` |
_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 | `chat-message.css:371` | `[data-popover-content][data-chat-message-quick-reactions]` | `inline-size` | `max-content` |
| 2 | `chat-message.css:401` | `[data-chat-message-quick-react]` | `line-height` | `1` |
| 3 | `chat-message.css:470` | `[data-chat-message]:hover [data-chat-message-actions], [data-chat-message]:focus-within [data-chat-message-actions]` | `opacity` | `1` |
| 4 | `chat-message.css:476` | `[data-chat-message-actions]` | `opacity` | `1` |
## 2. Sistema transversal (7) — informativo, fuera del ratio
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
@ -58,7 +49,7 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
| 4 | `chat-message.css:300` | `[data-chat-message-reply]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 5 | `chat-message.css:342` | `[data-chat-message-reaction]:hover, [data-chat-message-reaction-add]:hover` | `background-image` | `linear-gradient(var(--state-hover), var(--state-hover))` |
| 6 | `chat-message.css:347` | `[data-chat-message-reaction]:focus-visible, [data-chat-message-reaction-add]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 7 | `chat-message.css:424` | `[data-chat-message-quick-react]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 7 | `chat-message.css:416` | `[data-chat-message-quick-react]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
## 3. Privados de la receta — ¿de dónde sale su valor?
@ -71,7 +62,7 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
- **Tiene eje `size`**: los tokens dimensionales van por talla (`{part}-{eje}-{k}`) apuntando al bundle `--size-{k}-*`, nunca al primitivo crudo (theming §5; el guard `recipe-css-contract` prohíbe el primitivo).
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (9)
### 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
@ -81,15 +72,7 @@ no distingue lo que debería — se marca `⚠`.
| token (`--chat-message-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `radius` | `root` | `var(--radius-sm)` | 1 |
| `bubble-fg` | `root` | `var(--color-content-primary)` | 1 |
| `reply-fg` | `root` | `var(--color-content-secondary)` | 1 |
| `reaction-gap` _(ya existe)_ | `root` | `var(--space-1)` | 1 |
| `reaction-fg` | `root` | `var(--color-content-primary)` | 1 |
| `reaction-width` | `root` | `1.1em` | 1 |
| `reaction-height` _(ya existe)_ | `root` | `1.1em` | 1 |
| `quick-reactions-width` | `root` | `max-content` | 1 |
| `quick-react-radius` | `root` | `var(--radius-full)` | 1 |
### 4.2 Sin nombre mecánico (3)
@ -107,6 +90,96 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(pendiente — lo escribe el autor; se conserva al regenerar)_
**EJECUTADO 2026-08-24 — 84 % → 100 %.** Contrato 75 → **81** claves; censo
`público 71 · global 0 · literal 0 · sistema 7 · excepción 4`. Diff de computed
**VACÍO** (4.192 valores, 8 estados, 16 nodos) y capturas 2× **idénticas byte a
byte**, en reposo y con la barra tapback abierta. Centinela **77/81**, cuatro
adjudicadas. `component-audit` PASS · `eidos-lint` 0 invalid · `rtl:check` 0 ·
`docs:check` 0 · `check` sin errores del componente.
**Otra vez, lo caro fue VER.** El guard daba **45/75** antes de tocar una línea
y las treinta «muertas» estaban las treinta VIVAS: una fila de chat es una PILA
de superficies opt-in (reply citado, read-by, estado de entrega, mención) y de
ejes de PRESENTACIÓN (`run`, `direction`, `emphasized`) que la demo arranca en
su posición más pobre. Con las cuatro superficies encendidas por `prepareWith`
y los cinco ejes barridos como producto: **71/75 antes de acuñar nada**. La
sonda pasó de 12 a 16 nodos en reposo y a 24 con la barra abierta.
**Lo que entra (6 claves, la COSTURA de los seis globales)**: `focus-radius`,
`mentioned-fg`, `reply-fg`, `reaction-fg`, `reaction-inner-gap` y
`quick-react-radius`. El valor sigue siendo el del sistema; el knob pasa a ser
del componente.
**Los nombres salen del CATÁLOGO.** La ficha proponía `radius` (colisiona con
`emphasize-radius`, que ES la esquina en reposo de la MISMA fila: la de foco
lleva su modificador delante, `focus-radius`), `bubble-fg` (el knob es de la
MENCIÓN — `mentioned-bg` / `mentioned-accent` ya existen, faltaba
`mentioned-fg`), `reaction-width` + `reaction-height` para el glifo (dos
literales de una regla muerta, y `reaction-height` ya existe con otro sentido:
la altura del CHIP) y `quick-reactions-width` para un `max-content` que es
identidad.
**Y el caso que obligó a decidir: `reaction-gap` ya existía con otro sentido.**
La receta la pinta en la fila `reactions` (el hueco ENTRE chips) mientras el
knob nuevo es el ritmo INTERNO del chip — el emoji y su cuenta. Mismo valor
(`--space-1`), dos papeles: un chip más denso no es una fila más apretada.
Reusar la clave habría cambiado en silencio lo que mueve un tema que ya la usa,
así que entra como `reaction-inner-gap` y `reaction-gap` se queda como está. El
resto del bloque `{parte}-gap` de esta receta (`meta-gap`, `read-by-gap`,
`header-gap`, `actions-gap`) significa «dentro de la parte»: la anómala es la
vieja, y renombrarla es churn con riesgo.
**Lo que SALE: una regla que no ha pintado nunca.**
`[data-chat-message-reaction] svg, [data-chat-message-reaction-add] svg` fijaba
`1.1em` en los dos ejes «porque un svg de sólo viewBox computa 0×0». El glifo
por defecto es el `Icon` compuesto, que emite
`style="width: var(--icon-size-sm); height: …"` INLINE — y un estilo en línea
gana a todo selector. Medido: **16 px** (el paso `sm` del icono), no los 15,4 px
que `1.1em` daría sobre los 14 px heredados. La otra mitad del selector no tiene
nodo: los chips llevan un emoji de texto. **Retirada: 0 diffs.** Tercera vez en
la familia (chat-log, chat-composer, y aquí).
**Identidades firmadas (4, fuera del ratio)**: `inline-size: max-content` de la
barra tapback (la barra ES su fila de emoji), `line-height: 1` de la celda (la
celda es la caja del glifo) y los dos `opacity: 1` de la pastilla de acciones
(el final del fundido 0 → 1).
**Adjudicadas (4), todas límites del INSTRUMENTO y medidas a mano**:
1. `bubble-tail-radius` — el guard fotografía UNA esquina
(`borderTopLeftRadius`) y la cola es la de ABAJO (`border-end-start` en `in`,
`border-end-end` en `out`). Medido en las 8 combinaciones dirección × run:
4 px → 1234 px en `solo` y `last` en las dos direcciones. Su hermana
`bubble-radius-run` lee viva porque `middle` aplana la esquina START, que sí
está en la lista.
2. `emphasize-duration` — ES la transición que el guard congela para poder medir
todo lo demás. Sin congelar: 0,24 s → 4,321 s.
3. `focus-radius` — la fila sólo es tabulable compuesta DENTRO de un Feed (soma
fusiona la identidad de `Feed.Article` cuando hay un Feed ancestro) y su
propia demo la monta suelta, así que `:focus-visible` no tiene nodo ahí.
Medido en `/uix/components/chat-log`, donde las filas SÍ son artículos de
feed (`tabindex=0`): reposo 6 px (la esquina de `emphasize`), enfocada 4 px →
1234 px, por foco programático y por recorrido real con Tab.
4. `quick-react-hover-scale` — dos límites a la vez: `transform` no está en la
lista de propiedades del guard (el punto ciego ya adjudicado en `background`,
`rating-group` y `card`) y la celda es un botón PORTALADO con regla `:hover`.
Con puntero real y transiciones congeladas: `matrix(1.18…)` →
`matrix(7.77…)`.
**Defecto real encontrado y NO corregido aquí (mueve píxel)**: el chip de añadir
reacción lleva también `data-popover-trigger`, y
`[data-popover-trigger]:not([data-archetype='field-trigger'])` pesa (0,2,0)
contra los (0,1,0) de `[data-chat-message-reaction-add]` — así que su fondo, su
borde, su radio, su tinta, su altura, su padding y su tamaño de letra los pinta
`popover.css`. Se ve en la captura: los dos chips de reacción son píldoras
redondas y el de añadir es un cuadrado gris. Es la clase `gradient-picker` (36
de 45) / `emoji-picker` (3). Los `reaction-*` NO mienten —alcanzan sobre los
chips normales—, así que no hay nada que retirar; lo que hay es una decisión de
diseño pendiente. Registrado, no tocado (D-TH.5).
**Lo que queda fuera y por qué**: cuatro identidades firmadas (§1.4) y siete
knobs de sistema transversal (§2, anillo de foco × 4, velo de estado × 2,
`--opacity-muted` del `sending`). No hay deuda: el componente está CERRADO en su
número.
<!-- veredicto:end -->

@ -105,7 +105,12 @@ const EXTRA_NODES: Record<string, string> = {
// painted surface is the composed Button's icon slot, which the recipe sizes
// by descendant selector (`[data-fab] [data-button-icon]`) and which carries
// the Button's attr, not the FAB's.
fab: '[data-fab] [data-button-icon]'
fab: '[data-fab] [data-button-icon]',
// Its quick-reaction panel is PORTALED (the bar rides on `Popover.Content`
// and the gap it paints sits on the composed `[data-popover-viewport]`), and
// the add-reaction glyph is the composed `Icon` — a bare svg with `data-icon`.
'chat-message':
'[data-chat-message-quick-reactions] [data-popover-viewport], [data-chat-message-reaction-add] > svg'
};
/**
@ -122,7 +127,10 @@ const OPEN_TRIGGER: Record<string, string> = {
// demo, whose stage mounts exactly ONE swatch (the trigger chip). Every other
// swatch — presets and saved swatches, a dozen of them — lives in the
// PORTALED panel, so the open pass is where the node count stops being 1.
'color-swatch': '[data-color-picker-trigger]'
'color-swatch': '[data-color-picker-trigger]',
// Its only openable surface is the quick-reaction tapback bar, hanging off
// the add-reaction chip — the default `[data-{c}-trigger]` matches nothing.
'chat-message': '[data-chat-message-reaction-add]'
};
const DEMO_VARIANTS: Record<string, string[]> = {
@ -163,6 +171,16 @@ const DEMO_VARIANTS: Record<string, string[]> = {
'chat-composer': [
'[data-uix-control]:has([data-uix-control-label]:text-is("context")) [data-uix-chip]:text-is("reply")',
'[data-uix-control]:has([data-uix-control-label]:text-is("simulate")) button:has-text("attach file")'
],
// Four of its twelve parts are OPT-IN and boot off: the quoted reply, the
// read-receipt row, the delivery status (only rendered when `delivery` is
// set) and the mention accent. Without them the probe saw 12 nodes and none
// of the four surfaces the recipe paints there.
'chat-message': [
'[data-uix-control]:has([data-uix-control-label]:text-is("reply (quote)")) input[type=checkbox]',
'[data-uix-control]:has([data-uix-control-label]:text-is("read-by")) input[type=checkbox]',
'[data-uix-control]:has([data-uix-control-label]:text-is("mentioned")) input[type=checkbox]',
'[data-uix-control]:has([data-uix-control-label]:text-is("delivery")) [data-uix-chip]:text-is("read")'
]
};

@ -124,6 +124,18 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'disabled-opacity':
'file-upload sweeps DESCENDANTS: `[data-file-upload] [data-disabled]` is (0,2,0), the same weight as `[data-chat-composer-send][data-disabled]`, and its recipe is emitted LATER, so it wins over every disabled node inside a FileUpload — which is where the demo (and the documented composition) puts the composer; measured on the REAL nodes with the ancestor attr removed -> reaches (0.4 -> 0.123 on the send AND on the shell). Both defaults are --opacity-disabled, so the collision is invisible until a theme moves one'
},
'chat-message': {
// 2026-08-24. Four, and every one of them is a limit of the INSTRUMENT —
// measured on the real nodes, one by one.
'bubble-tail-radius':
'the guard snapshots exactly ONE corner (`borderTopLeftRadius`) and the tail is the BOTTOM one — `border-end-start-radius` incoming, `border-end-end-radius` outgoing. Measured on the real bubble across the 8 direction x run combinations: 4px -> 1234px on solo and last in BOTH directions (10px/10px/10px/4px -> 10px/10px/10px/1234px in, 10px/10px/4px/10px -> 10px/10px/1234px/10px out), while the corner the guard reads never moves. Its sibling `bubble-radius-run` reads alive because `middle` flattens the START corner, which IS in the read set',
'emphasize-duration':
'the guard freezes transitions and this token IS the transition (the jump-to-message flash is a two-state toggle + a transition, R-4.5 — there are no keyframes); measured unfrozen on the real row -> reaches (0.24s -> 4.321s)',
'focus-radius':
'the row is tabbable ONLY when composed into a Feed — soma merges `Feed.Article`s identity (tabindex, keyboard) into the provider props when a Feed ancestor exists — and its own demo mounts it standalone, so `:focus-visible` has no node there. Measured on /uix/components/chat-log, where the rows ARE feed articles (tabindex=0), with transitions frozen: rest 6px (the emphasize corner), focused 4px -> 1234px, both by programmatic focus and by a real Tab walk',
'quick-react-hover-scale':
'two limits at once: the guard does not snapshot `transform` (the same blind spot adjudicated for background / rating-group / card) and the cell is a PORTALED tapback button whose rule is `:hover, :focus-visible`. Measured with a real pointer on the open bar, transitions frozen -> reaches (matrix(1.18, 0, 0, 1.18, 0, 0) -> matrix(7.77, 0, 0, 7.77, 0, 0))'
},
'chat-log': {
// 2026-08-23. ONE key, and only because the guard blurs after opening: the
// event row is a tabbable feed article whose corner exists only under the

@ -459,6 +459,34 @@ const COMPONENT_OVERRIDES: Record<
'[data-uix-control]:has([data-uix-control-label]:text-is("simulate")) button:has-text("attach file")'
]
},
// A conversation row is a pile of OPT-IN surfaces and PRESENTATION axes, and
// the demo boots the plainest of them: no quoted reply, no read receipts, no
// delivery status, no mention accent, `run='solo'` and `direction='in'`.
// Thirty of its seventy-five tokens read dead on that stage — every one of
// them alive on a row that carries the axis. The four surfaces come from the
// demo controls; the five axes are swept as their product (the run corners,
// the outgoing palette, the two delivery tints, the mention and the
// jump-to-message flash). The quick-reaction tapback bar is PORTALED off the
// add-reaction chip, and the gap it paints sits on the composed
// `[data-popover-viewport]` (measured 2026-08-24).
'chat-message': {
prepareWith: [
'[data-uix-control]:has([data-uix-control-label]:text-is("reply (quote)")) input[type=checkbox]',
'[data-uix-control]:has([data-uix-control-label]:text-is("read-by")) input[type=checkbox]',
'[data-uix-control]:has([data-uix-control-label]:text-is("delivery")) [data-uix-chip]:text-is("read")'
],
openWith: ['[data-chat-message-reaction-add]'],
openMarker: '[data-chat-message-quick-reactions]',
extraNodes:
'[data-chat-message-quick-reactions] [data-popover-viewport], [data-chat-message-reaction-add] > svg',
sweepAttr: [
{ attr: 'data-run', values: ['solo', 'middle', 'last'] },
{ attr: 'data-direction', values: ['in', 'out'] },
{ attr: 'data-delivery', values: ['delivered', 'read', 'failed'] },
{ attr: 'data-mentioned', values: [null, ''] },
{ attr: 'data-emphasized', values: [null, ''] }
]
},
// The FIFTH canon component with no route of its own
// (`/uix/components/color-swatch` is a 404, like picker-shell / mockup /
// surface / audio-player): it is measured where it actually renders — the

@ -57,17 +57,76 @@ never hand-rolled SVG paths.
theme retints. No `intent` prop: the row is a frame; the evaluative load
lives in the events (CANON §5).
## Talla y tema
**81 claves públicas** en `lib/recipes/base.ts`, con eje `size` (`sm · md · lg`)
en las dos coordenadas que densifican la burbuja — su tipografía y sus dos
paddings lógicos. La pestaña **Tokens** de su demo las resuelve en vivo.
| grupo | claves |
| --- | --- |
| fila | `gap`, `row-gap-run`, `row-gap-group`, `focus-radius`, `gutter-inline-size` |
| burbuja | `bubble-padding-inline-{sm,md,lg}`, `bubble-padding-block-{sm,md,lg}`, `bubble-font-size-{sm,md,lg}`, `bubble-line-height`, `bubble-radius`, `bubble-radius-run`, `bubble-tail-radius`, `bubble-max-inline-size`, `bubble-bg-in`, `bubble-fg-in`, `bubble-bg-out`, `bubble-fg-out`, `bubble-shadow` |
| cabecera y meta | `header-gap`, `header-font-size`, `header-font-weight`, `header-fg`, `meta-gap`, `meta-font-size`, `meta-fg` |
| entrega | `status-size`, `status-fg`, `read-status-fg`, `failed-status-fg` |
| reply citado | `reply-padding-inline`, `reply-padding-block`, `reply-accent-size`, `reply-accent`, `reply-radius`, `reply-bg`, `reply-fg`, `reply-font-size` |
| chips de reacción | `reaction-gap`, `reaction-inner-gap`, `reaction-height`, `reaction-padding-inline`, `reaction-radius`, `reaction-font-size`, `reaction-bg`, `reaction-fg`, `reaction-border`, `reaction-shadow`, `reaction-overlap`, `reaction-pressed-bg`, `reaction-pressed-border`, `reaction-pressed-fg` |
| barra tapback | `quick-react-gap`, `quick-react-pad`, `quick-react-size`, `quick-react-radius`, `quick-react-emoji-size`, `quick-react-hover-bg`, `quick-react-hover-scale` |
| pastilla de acciones | `actions-bg`, `actions-border`, `actions-shadow`, `actions-radius`, `actions-padding`, `actions-gap`, `actions-offset` |
| mención y salto | `mentioned-bg`, `mentioned-fg`, `mentioned-accent`, `emphasize-bg`, `emphasize-radius`, `emphasize-duration` |
| read-by | `read-by-gap`, `read-by-font-size`, `read-by-fg`, `read-by-margin` |
Lo que entró el **2026-08-24** (84 % → 100 %): la COSTURA de los seis knobs que
leían el sistema a pelo — `focus-radius` (la esquina que sigue el anillo de
foco; NO es la de `emphasize`, que va un paso más ancha), `mentioned-fg`,
`reply-fg`, `reaction-fg`, `reaction-inner-gap` y `quick-react-radius`.
**`reaction-gap` y `reaction-inner-gap` son dos knobs, no uno**: el primero
separa los chips (se pinta en la fila `reactions`), el segundo es el ritmo
INTERNO del chip — el emoji y su cuenta. Comparten valor por defecto y no
comparten papel: un chip más denso no es una fila más apretada.
**Y una regla RETIRADA por muerta**: `[data-chat-message-reaction] svg,
[data-chat-message-reaction-add] svg` fijaba `1.1em` en los dos ejes, pero el
glifo por defecto es el `Icon` compuesto, que escribe
`style="width: var(--icon-size-sm); height: …"` **en línea** — y ningún selector
gana a un estilo en línea. Medido: el glifo computa 16 px (el paso `sm` del
icono), nunca los 15,4 px que la regla creía pintar. Retirarla da **0 diffs**.
Es la misma lección que `chat-log` y `chat-composer` (2026-08-23).
**Identidades firmadas** (fuera del ratio, con su anotación `literal:`): el
`inline-size: max-content` de la barra tapback (la barra ES su fila de emoji),
el `line-height: 1` de la celda (la celda es la caja del glifo) y los dos
`opacity: 1` de la pastilla de acciones (el final del fundido 0 → 1).
**Lo que el guard da por silencioso** (4, todas medidas a mano y en el ledger):
`bubble-tail-radius` — el centinela fotografía UNA esquina (`borderTopLeftRadius`)
y la cola es la de abajo; `emphasize-duration` — ES la transición que el guard
congela para poder medir lo demás; `focus-radius` — la fila sólo es tabulable
compuesta DENTRO de un Feed, y su propia demo la monta suelta (medida en la de
`chat-log`); y `quick-react-hover-scale` — `transform` no está en la lista de
propiedades del guard y la celda es un botón portalado con regla `:hover`.
**Defecto conocido, NO tocado aquí**: el chip de añadir reacción lleva también
`data-popover-trigger`, y `[data-popover-trigger]:not([data-archetype='field-trigger'])`
pesa (0,2,0) contra los (0,1,0) de esta receta — así que su fondo, su borde, su
radio y su tinta los pinta `popover.css`, no `reaction-*`. Se ve en la demo: los
dos chips de reacción son píldoras redondas y el de añadir, un cuadrado gris. Es
la clase `gradient-picker` / `emoji-picker`; arreglarlo mueve píxel, así que
queda registrado, no corregido.
## Tokens (`--chat-message-*`)
Declared in `lib/recipes/base.ts`: row layout (`gap`, `row-gap-run`,
`row-gap-group`, `gutter-inline-size`), bubble (`bubble-bg/fg-in|out`,
`bubble-shadow`, paddings per size, `bubble-radius[-run]`,
`bubble-tail-radius`, `bubble-max-inline-size`, typography), header/meta,
status (`status-color[-read|-failed]`, `status-size`), reply (`reply-*`
incl. logical accent bar), reaction chips (`reaction-*` incl. pressed
palette, `reaction-shadow`, `reaction-overlap`), `mentioned-bg/accent`,
status (`status-fg`, `read-status-fg`, `failed-status-fg`, `status-size`),
reply (`reply-*` incl. logical accent bar and its ink), reaction chips
(`reaction-*` incl. the chip's own `reaction-inner-gap`, its ink, the pressed
palette, `reaction-shadow`, `reaction-overlap`), `mentioned-bg/fg/accent`,
actions pill (`actions-bg/border/shadow/radius/padding` + `gap/offset`),
read-by row (`read-by-gap/font-size/color/margin`), jump-highlight
read-by row (`read-by-gap/font-size/fg/margin`), jump-highlight
(`emphasize-bg/radius/duration`). Recipe-contract compliant: logical axes,
size-bundle coordinates, state-layer hovers, `--opacity-muted` while
sending, outline focus, no local keyframes.

@ -35,7 +35,7 @@
[data-chat-message]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
border-radius: var(--radius-sm);
border-radius: var(--chat-message-focus-radius);
}
/* The double-registered Feed.Article stamps archetype 'item', whose global
@ -223,7 +223,7 @@
/* Mention highlight — logical accent bar + soft tint. */
[data-chat-message][data-mentioned] [data-chat-message-bubble] {
background: var(--chat-message-mentioned-bg);
color: var(--color-content-primary);
color: var(--chat-message-mentioned-fg);
border-inline-start: var(--chat-message-reply-accent-size) solid
var(--chat-message-mentioned-accent);
}
@ -286,7 +286,7 @@
var(--chat-message-reply-accent);
border-radius: var(--chat-message-reply-radius);
background: var(--chat-message-reply-bg);
color: var(--color-content-secondary);
color: var(--chat-message-reply-fg);
font-size: var(--chat-message-reply-font-size);
margin-block-end: var(--space-0-5);
cursor: pointer;
@ -325,13 +325,13 @@
font: inherit;
display: inline-flex;
align-items: center;
gap: var(--space-1);
gap: var(--chat-message-reaction-inner-gap);
block-size: var(--chat-message-reaction-height);
padding-inline: var(--chat-message-reaction-padding-inline);
border: var(--border-width) solid var(--chat-message-reaction-border);
border-radius: var(--chat-message-reaction-radius);
background: var(--chat-message-reaction-bg);
color: var(--color-content-primary);
color: var(--chat-message-reaction-fg);
font-size: var(--chat-message-reaction-font-size);
box-shadow: var(--chat-message-reaction-shadow);
cursor: pointer;
@ -348,14 +348,6 @@
outline-offset: var(--focus-ring-offset);
}
/* Icon children (the default smiley) need explicit box — a viewBox-only
inline svg computes to 0×0 inside the flex chip. */
[data-chat-message-reaction] svg,
[data-chat-message-reaction-add] svg {
inline-size: 1.1em;
block-size: 1.1em;
}
[data-chat-message-reaction][data-pressed] {
background: var(--chat-message-reaction-pressed-bg);
border-color: var(--chat-message-reaction-pressed-border);
@ -376,7 +368,7 @@
horizontal flex row. `inline-size: max-content` stands alone (not inside
`min()`, where an intrinsic keyword fails to compute). */
[data-popover-content][data-chat-message-quick-reactions] {
inline-size: max-content;
inline-size: max-content; /* literal: intrinsic sizing — the bar IS its emoji row; identity, not a knob */
min-inline-size: 0;
max-inline-size: min(var(--popover-max-width), calc(100vw - var(--space-4)));
--_popover-padding-block: var(--chat-message-quick-react-pad);
@ -404,9 +396,9 @@
flex: none;
inline-size: var(--chat-message-quick-react-size);
block-size: var(--chat-message-quick-react-size);
border-radius: var(--radius-full);
border-radius: var(--chat-message-quick-react-radius);
font-size: var(--chat-message-quick-react-emoji-size);
line-height: 1;
line-height: 1; /* literal: the cell is the glyph's box — identity, not a knob */
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-out),
@ -475,13 +467,13 @@
[data-chat-message]:hover [data-chat-message-actions],
[data-chat-message]:focus-within [data-chat-message-actions] {
opacity: 1;
opacity: 1; /* literal: fully revealed — the end of the 0 → 1 fade, identity */
}
/* Touch: hover does not exist — keep the cluster reachable. */
@media (pointer: coarse) {
[data-chat-message-actions] {
opacity: 1;
opacity: 1; /* literal: fully revealed — no hover to reveal it, identity */
}
}

@ -984,6 +984,7 @@
--chat-message-gap: var(--space-2);
--chat-message-row-gap-run: var(--space-0-5);
--chat-message-row-gap-group: var(--space-3);
--chat-message-focus-radius: var(--radius-sm);
--chat-message-gutter-inline-size: var(--avatar-size-md, var(--space-8));
--chat-message-bubble-padding-inline-sm: var(--space-2-5);
--chat-message-bubble-padding-inline-md: var(--space-3);
@ -1027,14 +1028,17 @@
--chat-message-reply-accent-size: var(--space-0-5);
--chat-message-reply-radius: var(--radius-sm);
--chat-message-reply-bg: var(--color-neutral-track);
--chat-message-reply-fg: var(--color-content-secondary);
--chat-message-reply-accent: var(--color-primary-solid);
--chat-message-reply-font-size: var(--size-xs-font-size);
--chat-message-reaction-gap: var(--space-1);
--chat-message-reaction-inner-gap: var(--space-1);
--chat-message-reaction-height: var(--size-xs-control-height);
--chat-message-reaction-padding-inline: var(--space-2);
--chat-message-reaction-radius: var(--radius-full);
--chat-message-reaction-font-size: var(--size-xs-font-size);
--chat-message-reaction-bg: var(--color-surface-default);
--chat-message-reaction-fg: var(--color-content-primary);
--chat-message-reaction-border: var(--color-neutral-separator);
--chat-message-reaction-shadow: var(--shadow-1);
--chat-message-reaction-overlap: var(--space-1);
@ -1044,6 +1048,7 @@
--chat-message-quick-react-gap: var(--space-0-5);
--chat-message-quick-react-pad: var(--space-1);
--chat-message-quick-react-size: var(--size-lg-control-height);
--chat-message-quick-react-radius: var(--radius-full);
--chat-message-quick-react-emoji-size: var(--size-xl-font-size);
--chat-message-quick-react-hover-bg: var(--color-neutral-hover);
--chat-message-quick-react-hover-scale: 1.18;
@ -1053,6 +1058,7 @@
--chat-message-actions-radius: var(--radius-full);
--chat-message-actions-padding: var(--space-0-5);
--chat-message-mentioned-bg: var(--color-primary-surface);
--chat-message-mentioned-fg: var(--color-content-primary);
--chat-message-mentioned-accent: var(--color-primary-solid);
--chat-message-actions-gap: var(--space-0-5);
--chat-message-actions-offset: var(--space-1);

@ -909,6 +909,10 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
gap: 'var(--space-2)',
'row-gap-run': 'var(--space-0-5)',
'row-gap-group': 'var(--space-3)',
// The corner the focus ring follows. It is NOT the emphasize corner: the
// jump-highlight wash rides one step wider, and the two coexist on the
// same node (the focus rule out-ranks the resting one while focused).
'focus-radius': 'var(--radius-sm)',
// Avatar column reserve — follows the composed Avatar's md diameter.
'gutter-inline-size': 'var(--avatar-size-md, var(--space-8))',
// ── Bubble ────────────────────────────────────────────────────────
@ -972,15 +976,21 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'reply-accent-size': 'var(--space-0-5)',
'reply-radius': 'var(--radius-sm)',
'reply-bg': 'var(--color-neutral-track)',
'reply-fg': 'var(--color-content-secondary)',
'reply-accent': 'var(--color-primary-solid)',
'reply-font-size': 'var(--size-xs-font-size)',
// ── Reaction chips — floating pills lapping the bubble edge ──────
// `reaction-gap` separates the chips (it is painted on the `reactions`
// row); `reaction-inner-gap` is the chip's OWN rhythm — the emoji and its
// count. Two roles, two knobs: a denser chip is not a tighter row.
'reaction-gap': 'var(--space-1)',
'reaction-inner-gap': 'var(--space-1)',
'reaction-height': 'var(--size-xs-control-height)',
'reaction-padding-inline': 'var(--space-2)',
'reaction-radius': 'var(--radius-full)',
'reaction-font-size': 'var(--size-xs-font-size)',
'reaction-bg': 'var(--color-surface-default)',
'reaction-fg': 'var(--color-content-primary)',
'reaction-border': 'var(--color-neutral-separator)',
'reaction-shadow': 'var(--shadow-1)',
'reaction-overlap': 'var(--space-1)',
@ -992,6 +1002,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'quick-react-gap': 'var(--space-0-5)',
'quick-react-pad': 'var(--space-1)',
'quick-react-size': 'var(--size-lg-control-height)',
'quick-react-radius': 'var(--radius-full)',
'quick-react-emoji-size': 'var(--size-xl-font-size)',
'quick-react-hover-bg': 'var(--color-neutral-hover)',
'quick-react-hover-scale': '1.18',
@ -1003,6 +1014,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'actions-padding': 'var(--space-0-5)',
// ── Mention highlight ────────────────────────────────────────────
'mentioned-bg': 'var(--color-primary-surface)',
'mentioned-fg': 'var(--color-content-primary)',
'mentioned-accent': 'var(--color-primary-solid)',
// ── Actions cluster ──────────────────────────────────────────────
'actions-gap': 'var(--space-0-5)',

@ -21,11 +21,22 @@
import SystemAxes from '../../lib/SystemAxes.svelte';
import MotionPanel from '../../lib/MotionPanel.svelte';
import SemaPanel from '../../lib/SemaPanel.svelte';
import TokensPanel from '../../lib/TokensPanel.svelte';
import { DemoTrace } from '../../lib/harness.svelte';
const uix = getActiveUix();
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
type Tab =
| 'live'
| 'system'
| 'motion'
| 'sema'
| 'services'
| 'api'
| 'morfo'
| 'recipe'
| 'tokens'
| 'a11y';
let tab = $state<Tab>('live');
const directions: ChatMessageDirection[] = ['in', 'out'];
@ -314,6 +325,9 @@
>
</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>81</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -725,6 +739,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="chat-message" 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.