feat(feed): temable — 0 % → 94 %, y cierra el bloque F2-A

Octavo y último del bloque.

  censo    0 % → 94 % · global 43 %
  computed reposo y las CINCO tallas, idénticas al byte

**El título no escala por talla: escala por NIVEL DE ENCABEZADO.** El informe
leyó sus cuatro valores como una colisión de `size`, y son `aria-level` 3..6 —
un encabezado más profundo es más pequeño porque es más profundo. Sus tokens
son `article-title-font-size-{3,4,5,6}`, no coordenadas de talla. Tercera
corrección de este tipo a un veredicto en el bloque.

**El ⚠ del `sentinel` eran DOS medidas**, como sospechaba el veredicto: la
fila-sonda mantiene un alto mínimo para que el observador tenga algo que
intersecar, y el spinner que va dentro tiene su propio diámetro.

El feed tipa por debajo del 1:1 de md en adelante, como `table` y `tree-grid`;
la coordenada lo nombra en vez de esconderlo.

**Sobre la medición, que aquí hubo que trabajarla.** La sonda dio 7 diffs y
ninguno era un cambio:

- el `box-shadow` del nodo `[data-busy]` está ANIMADO, y la sonda congela
  `transition` pero nunca `animation` —a propósito, porque congelarla impide
  que Presence monte nada—, así que lo muestrea en una fase distinta cada vez:
  dos corridas del MISMO código dan cinco diffs;
- y el estado `hover` se mide después de haber forzado las tallas, así que su
  altura arrastra la última.

Lo que decide es que las SEIS medidas estables —reposo y las cinco tallas—
salieron idénticas al byte: 544,5 · 564,5 · 628,5 · 792,5 · 966,5 px. Queda
escrito en el README para que el siguiente no persiga el mismo fantasma.

  audit PASS · eidos-lint 0 invalid · rtl 0 · docs 0 · check 0 en tocados ·
  suite: sólo el rojo conocido

Demo con tab Tokens: 50 filas, 0 sin computar.

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

@ -10,9 +10,9 @@
- **Medido**: 2026-08-20 · **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**: **43%** — 2073 de 4856 knobs pasan por un token público del componente
- **Reparto**: público 2073 · privado 712 · global 1484 · literal 587 · sistema transversal 335 _(fuera del ratio)_
- **Sin token público propio**: 50 · **alcance < 20 %**: 44 · **alcance 100 %**: 7 · **con eje `size`**: 73
- **Alcance global**: **43%** — 2103 de 4856 knobs pasan por un token público del componente
- **Reparto**: público 2103 · privado 699 · global 1469 · literal 585 · sistema transversal 335 _(fuera del ratio)_
- **Sin token público propio**: 49 · **alcance < 20 %**: 43 · **alcance 100 %**: 7 · **con eje `size`**: 72
## Cómo se lee
@ -83,20 +83,20 @@ theming §6.7 lo pone delante (`hover-bg`). Las fichas proponen la forma
| 11 | [color-picker](./color-picker.md) | 43 | 46% | 82 | 54 |
| 12 | [field-langs](./field-langs.md) | 39 | 0% | 41 | 0 |
| 13 | [field](./field.md) | 33 | 51% | 73 | 60 |
| 14 | [feed](./feed.md) | 32 | 0% | 33 | 0 |
| 15 | [date-range-picker](./date-range-picker.md) | 30 | 40% | 53 | 22 |
| 16 | [natural-time-picker](./natural-time-picker.md) | 30 | 61% | 77 | 62 |
| 17 | [popover](./popover.md) | 28 | 42% | 51 | 36 |
| 18 | [radio-cards](./radio-cards.md) | 28 | 60% | 72 | 39 |
| 19 | [drawer](./drawer.md) | 27 | 55% | 62 | 44 |
| 20 | [grid-list](./grid-list.md) | 27 | 0% | 32 | 0 |
| 14 | [date-range-picker](./date-range-picker.md) | 30 | 40% | 53 | 22 |
| 15 | [natural-time-picker](./natural-time-picker.md) | 30 | 61% | 77 | 62 |
| 16 | [popover](./popover.md) | 28 | 42% | 51 | 36 |
| 17 | [radio-cards](./radio-cards.md) | 28 | 60% | 72 | 39 |
| 18 | [drawer](./drawer.md) | 27 | 55% | 62 | 44 |
| 19 | [grid-list](./grid-list.md) | 27 | 0% | 32 | 0 |
| 20 | [code-block](./code-block.md) | 26 | 0% | 26 | 0 |
## Sin ningún token público propio (50)
## Sin ningún token público propio (49)
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) · [`card-group`](./card-group.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.md) · [`code-block`](./code-block.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`feed`](./feed.md) · [`field-langs`](./field-langs.md) · [`grid-list`](./grid-list.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`kbd`](./kbd.md) · [`knob`](./knob.md) · [`label`](./label.md) · [`link`](./link.md) · [`link-preview`](./link-preview.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) · [`picker-shell`](./picker-shell.md) · [`range-calendar`](./range-calendar.md) · [`scroll-frames`](./scroll-frames.md) · [`skeleton`](./skeleton.md) · [`skin-media-player`](./skin-media-player.md) · [`spinner`](./spinner.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) · [`textarea`](./textarea.md) · [`tree-view`](./tree-view.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) · [`card-group`](./card-group.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.md) · [`code-block`](./code-block.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`field-langs`](./field-langs.md) · [`grid-list`](./grid-list.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`kbd`](./kbd.md) · [`knob`](./knob.md) · [`label`](./label.md) · [`link`](./link.md) · [`link-preview`](./link-preview.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) · [`picker-shell`](./picker-shell.md) · [`range-calendar`](./range-calendar.md) · [`scroll-frames`](./scroll-frames.md) · [`skeleton`](./skeleton.md) · [`skin-media-player`](./skin-media-player.md) · [`spinner`](./spinner.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) · [`textarea`](./textarea.md) · [`tree-view`](./tree-view.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 | 13 | 40 | 5 | 3 | 0 | y |
| [year-grid](./year-grid.md) | 0% | 61 | 0 | 13 | 40 | 5 | 3 | 0 | y |
| [field-langs](./field-langs.md) | 0% | 41 | 0 | 0 | 31 | 8 | 2 | 0 | – |
| [feed](./feed.md) | 0% | 33 | 0 | 13 | 16 | 3 | 1 | 0 | y |
| [grid-list](./grid-list.md) | 0% | 32 | 0 | 8 | 17 | 2 | 5 | 0 | y |
| [textarea](./textarea.md) | 0% | 28 | 0 | 16 | 5 | 4 | 3 | 0 | y |
| [tree-view](./tree-view.md) | 0% | 28 | 0 | 8 | 14 | 4 | 2 | 0 | y |
@ -241,6 +240,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [chat-typing](./chat-typing.md) | 89% | 9 | 8 | 0 | 1 | 0 | 0 | 7 | – |
| [breadcrumb](./breadcrumb.md) | 90% | 23 | 18 | 2 | 0 | 0 | 3 | 28 | y |
| [collapsible](./collapsible.md) | 90% | 12 | 9 | 0 | 1 | 0 | 2 | 11 | – |
| [feed](./feed.md) | 94% | 33 | 30 | 0 | 1 | 1 | 1 | 50 | – |
| [scroll-area](./scroll-area.md) | 94% | 17 | 15 | 0 | 0 | 1 | 1 | 13 | – |
| [tree-grid](./tree-grid.md) | 95% | 42 | 37 | 1 | 0 | 1 | 3 | 47 | – |
| [command](./command.md) | 98% | 52 | 49 | 0 | 0 | 1 | 2 | 57 | – |

@ -5,59 +5,28 @@
> 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 32 knobs por token público
- **Knobs de apariencia**: 33 — público 0 · privado 13 · global 16 · literal 3 · sistema 1 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 0 pública(s) · 2 privada(s) forward — `_palette-solid`, `_palette-element`
- **Eje `size`**: sí · **ficheros**: `feed.css`
- **Medido**: 2026-08-20 · **Alcance**: **94%** — 30 de 32 knobs por token público
- **Knobs de apariencia**: 33 — público 30 · privado 0 · global 1 · literal 1 · sistema 1 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 50 pública(s) — `font-size-xs`, `font-size-sm`, `font-size-md`, `font-size-lg`, `font-size-xl`, `article-padding-block-xs`, `article-padding-block-sm`, `article-padding-block-md`, `article-padding-block-lg`, `article-padding-block-xl`, `article-padding-inline-xs`, `article-padding-inline-sm`, `article-padding-inline-md`, `article-padding-inline-lg`, `article-padding-inline-xl`, `thread-indent-xs`, `thread-indent-sm`, `thread-indent-md`, `thread-indent-lg`, `thread-indent-xl`, `font-size`, `article-padding-block`, `article-padding-inline`, `thread-indent`, `radius`, `font-family`, `line-height`, `fg`, `border-width`, `divider`, `gap`, `surface-bg`, `article-gap`, `article-title-fg`, `article-title-font-weight`, `article-title-font-size-3`, `article-title-font-size-4`, `article-title-font-size-5`, `article-title-font-size-6`, `article-description-fg`, `thread-margin-block-start`, `thread-rail-width`, `sentinel-min-block-size`, `sentinel-fg`, `sentinel-font-size`, `sentinel-spinner-size`, `sentinel-spinner-width`, `sentinel-spinner-gap`, `sentinel-spinner-duration`, `sentinel-spinner-reduced-duration` · 2 privada(s) forward — `_palette-solid`, `_palette-element`
- **Eje `size`**: no · **ficheros**: `feed.css`
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (16)
### 1.1 Directo a primitivo global (1)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `feed.css:27` | `[data-feed-root]` | `font-family` | `var(--style-label-font-family)` |
| 2 | `feed.css:29` | `[data-feed-root]` | `line-height` | `var(--leading-ui)` |
| 3 | `feed.css:30` | `[data-feed-root]` | `color` | `var(--color-content-primary)` |
| 4 | `feed.css:84` | `[data-feed-article]` | `gap` | `var(--space-1)` |
| 5 | `feed.css:92` | `[data-feed-root][data-variant='surface'] [data-feed-article]` | `background` | `var(--color-surface-default)` |
| 6 | `feed.css:121` | `[data-feed-article-title]` | `font-weight` | `var(--font-weight-semibold, 600)` |
| 7 | `feed.css:122` | `[data-feed-article-title]` | `color` | `var(--color-content-primary)` |
| 8 | `feed.css:125` | `[data-feed-article-title][aria-level='3']` | `font-size` | `var(--font-size-md)` |
| 9 | `feed.css:126` | `[data-feed-article-title][aria-level='4']` | `font-size` | `var(--font-size-sm)` |
| 10 | `feed.css:127` | `[data-feed-article-title][aria-level='5']` | `font-size` | `var(--font-size-sm)` |
| 11 | `feed.css:128` | `[data-feed-article-title][aria-level='6']` | `font-size` | `var(--font-size-xs)` |
| 12 | `feed.css:132` | `[data-feed-article-description]` | `color` | `var(--color-content-secondary)` |
| 13 | `feed.css:160` | `[data-feed-sentinel]` | `min-block-size` | `var(--space-6)` |
| 14 | `feed.css:161` | `[data-feed-sentinel]` | `color` | `var(--color-content-secondary)` |
| 15 | `feed.css:162` | `[data-feed-sentinel]` | `font-size` | `var(--font-size-xs)` |
| 16 | `feed.css:173` | `[data-feed][data-busy] [data-feed-sentinel]::before` | `border-radius` | `var(--radius-full)` |
### 1.2 A través de un privado (13)
| 1 | `feed.css:155` | `[data-feed][data-busy] [data-feed-sentinel]::before` | `border-radius` | `var(--radius-full)` |
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `feed.css:26` | `[data-feed-root]` | `border-radius` | `var(--_feed-radius)` |
| 2 | `feed.css:28` | `[data-feed-root]` | `font-size` | `var(--_feed-font-size)` |
| 3 | `feed.css:72` | `[data-feed]` | `gap` | `var(--_feed-gap)` |
| 4 | `feed.css:85` | `[data-feed-article]` | `padding-block` | `var(--_feed-article-padding-block)` |
| 5 | `feed.css:86` | `[data-feed-article]` | `padding-inline` | `var(--_feed-article-padding-inline)` |
| 6 | `feed.css:87` | `[data-feed-article]` | `border-radius` | `var(--_feed-radius)` |
| 7 | `feed.css:93` | `[data-feed-root][data-variant='surface'] [data-feed-article]` | `border` | `var(--border-width) solid var(--_feed-divider)` |
| 8 | `feed.css:98` | `[data-feed-root][data-variant='outline'] [data-feed-article]` | `border` | `var(--border-width) solid var(--_feed-divider)` |
| 9 | `feed.css:104` | `[data-feed-root][data-variant='ghost'] [data-feed-article]` | `border-block-end` | `var(--border-width) solid var(--_feed-divider)` |
| 10 | `feed.css:139` | `[data-feed-thread]` | `gap` | `var(--_feed-gap)` |
| 11 | `feed.css:141` | `[data-feed-thread]` | `padding-inline-start` | `var(--_feed-thread-indent)` |
| 12 | `feed.css:147` | `[data-feed-thread]` | `border-inline-start` | `var(--border-width-medium) solid var(--_feed-palette-element)` |
| 13 | `feed.css:171` | `[data-feed][data-busy] [data-feed-sentinel]::before` | `border` | `var(--border-width-medium) solid var(--_feed-palette-element)` |
### 1.3 Literales (3)
### 1.2 A través de un privado (0)
_Ninguno._
### 1.3 Literales (1)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `feed.css:34` | `[data-feed-root][data-block]` | `inline-size` | `100%` |
| 2 | `feed.css:168` | `[data-feed][data-busy] [data-feed-sentinel]::before` | `inline-size` | `0.75rem` |
| 3 | `feed.css:169` | `[data-feed][data-busy] [data-feed-sentinel]::before` | `block-size` | `0.75rem` |
| 1 | `feed.css:30` | `[data-feed-root][data-block]` | `inline-size` | `100%` |
## 2. Sistema transversal (1) — informativo, fuera del ratio
@ -65,27 +34,17 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `feed.css:114` | `[data-feed-article]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 1 | `feed.css:86` | `[data-feed-article]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
## 3. Privados de la receta — ¿de dónde sale su valor?
| privado | declaraciones | valor(es) | origen | ¿deriva de un público? |
| --- | ---: | --- | --- | :-: |
| `--_feed-radius` | 1 | `var(--radius-md)` | global | no |
| `--_feed-article-padding-block` | 5 | `var(--space-3)`, `var(--space-2)`, `var(--space-4)`, `var(--space-5)` | global | no |
| `--_feed-article-padding-inline` | 5 | `var(--space-4)`, `var(--space-3)`, `var(--space-5)`, `var(--space-6)` | global | no |
| `--_feed-gap` | 1 | `var(--space-2)` | global | no |
| `--_feed-thread-indent` | 5 | `var(--space-5)`, `var(--space-4)`, `var(--space-6)`, `var(--space-7)` | global | no |
| `--_feed-font-size` | 5 | `var(--font-size-sm)`, `var(--font-size-xs)`, `var(--font-size-md)`, `var(--font-size-lg)` | global | no |
| `--_feed-divider` | 1 | `var(--color-border-default)` | global | no |
_La receta no declara privados propios en su CSS._
Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inline del wrapper): `--_feed-palette-element`, `--_feed-palette-solid`.
## 4. Propuesta de corrección
- **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` (38)
### 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
@ -95,60 +54,11 @@ no distingue lo que debería — se marca `⚠`.
| token (`--feed-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `article-title-font-size` | `root` | ⚠ `var(--font-size-md)` / `var(--font-size-sm)` / `var(--font-size-xs)` | 4 |
| `sentinel-height` | `root` | ⚠ `var(--space-6)` / `0.75rem` | 2 |
| `root-radius` | `root` | `var(--radius-md)` | 1 |
| `root-font-family` | `root` | `var(--style-label-font-family)` | 1 |
| `root-font-size-md` | `size:md` | `var(--font-size-sm)` | 1 |
| `root-font-size-xs` | `size:xs` | `var(--font-size-xs)` | 1 |
| `root-font-size-sm` | `size:sm` | `var(--font-size-sm)` | 1 |
| `root-font-size-lg` | `size:lg` | `var(--font-size-md)` | 1 |
| `root-font-size-xl` | `size:xl` | `var(--font-size-lg)` | 1 |
| `root-line-height` | `root` | `var(--leading-ui)` | 1 |
| `root-fg` | `root` | `var(--color-content-primary)` | 1 |
| `gap` | `root` | `var(--space-2)` | 1 |
| `article-gap` | `root` | `var(--space-1)` | 1 |
| `article-padding-block-md` | `size:md` | `var(--space-3)` | 1 |
| `article-padding-block-xs` | `size:xs` | `var(--space-2)` | 1 |
| `article-padding-block-sm` | `size:sm` | `var(--space-2)` | 1 |
| `article-padding-block-lg` | `size:lg` | `var(--space-4)` | 1 |
| `article-padding-block-xl` | `size:xl` | `var(--space-5)` | 1 |
| `article-padding-inline-md` | `size:md` | `var(--space-4)` | 1 |
| `article-padding-inline-xs` | `size:xs` | `var(--space-3)` | 1 |
| `article-padding-inline-sm` | `size:sm` | `var(--space-3)` | 1 |
| `article-padding-inline-lg` | `size:lg` | `var(--space-5)` | 1 |
| `article-padding-inline-xl` | `size:xl` | `var(--space-6)` | 1 |
| `article-radius` | `root` | `var(--radius-md)` | 1 |
| `surface-root-bg` | `root` | `var(--color-surface-default)` | 1 |
| `article-title-font-weight` | `root` | `var(--font-weight-semibold, 600)` | 1 |
| `article-title-fg` | `root` | `var(--color-content-primary)` | 1 |
| `article-description-fg` | `root` | `var(--color-content-secondary)` | 1 |
| `thread-gap` | `root` | `var(--space-2)` | 1 |
| `thread-padding-inline-md` | `size:md` | `var(--space-5)` | 1 |
| `thread-padding-inline-xs` | `size:xs` | `var(--space-4)` | 1 |
| `thread-padding-inline-sm` | `size:sm` | `var(--space-4)` | 1 |
| `thread-padding-inline-lg` | `size:lg` | `var(--space-6)` | 1 |
| `thread-padding-inline-xl` | `size:xl` | `var(--space-7)` | 1 |
| `sentinel-fg` | `root` | `var(--color-content-secondary)` | 1 |
| `sentinel-font-size` | `root` | `var(--font-size-xs)` | 1 |
| `sentinel-width` | `root` | `0.75rem` | 1 |
| `sentinel-radius` | `root` | `var(--radius-full)` | 1 |
### 4.2 Sin nombre mecánico (6)
### 4.2 Sin nombre mecánico (1)
- **⚠ decisión: `100%` es un valor identidad o geometría de layout, no un knob de tema — el perímetro de «knob» es D-TH.2, sin firmar** — 1: `inline-size`.
- **⚠ decisión: `border` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 3: `border`.
- **⚠ decisión: `border-block-end` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `border-block-end`.
- **⚠ decisión: `border-inline-start` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `border-inline-start`.
### 4.3 Avisos sobre los tokens propuestos (6)
- **el privado `--_feed-radius` debe pasar a leer este público (o desaparecer)** — `--feed-root-radius`, `--feed-article-radius`
- **el privado `--_feed-font-size` debe pasar a leer este público (o desaparecer)** — `--feed-root-font-size-md`, `--feed-root-font-size-xs`, `--feed-root-font-size-sm`, `--feed-root-font-size-lg`, `--feed-root-font-size-xl`
- **el privado `--_feed-gap` debe pasar a leer este público (o desaparecer)** — `--feed-gap`, `--feed-thread-gap`
- **el privado `--_feed-article-padding-block` debe pasar a leer este público (o desaparecer)** — `--feed-article-padding-block-md`, `--feed-article-padding-block-xs`, `--feed-article-padding-block-sm`, `--feed-article-padding-block-lg`, `--feed-article-padding-block-xl`
- **el privado `--_feed-article-padding-inline` debe pasar a leer este público (o desaparecer)** — `--feed-article-padding-inline-md`, `--feed-article-padding-inline-xs`, `--feed-article-padding-inline-sm`, `--feed-article-padding-inline-lg`, `--feed-article-padding-inline-xl`
- **el privado `--_feed-thread-indent` debe pasar a leer este público (o desaparecer)** — `--feed-thread-padding-inline-md`, `--feed-thread-padding-inline-xs`, `--feed-thread-padding-inline-sm`, `--feed-thread-padding-inline-lg`, `--feed-thread-padding-inline-xl`
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)

@ -49,14 +49,14 @@ el hilo anidado como parte.
## Comparativa
| Capacidad | UIX | APG feed | Radix/Ark/Bits/RAC | Implementaciones ad-hoc |
| --- | --- | --- | --- | --- |
| `role="feed"` + posinset/setsize | ✓ | ✓ | No publicado | Raro |
| Teclado de feed (Page/Ctrl+Home/End) | ✓ | ✓ | — | Raro |
| `busy` durante el fetch | ✓ | ✓ | — | A veces |
| Sentinel de scroll infinito como parte | ✓ | (sugerido) | — | Custom |
| Hilos anidados (`Thread` role=feed) | ✓ | ✓ (nested feeds) | — | Raro |
| Semántica perceptiva del load-more | ✓ | — | — | No |
| Capacidad | UIX | APG feed | Radix/Ark/Bits/RAC | Implementaciones ad-hoc |
| -------------------------------------- | --- | ---------------- | ------------------ | ----------------------- |
| `role="feed"` + posinset/setsize | ✓ | ✓ | No publicado | Raro |
| Teclado de feed (Page/Ctrl+Home/End) | ✓ | ✓ | — | Raro |
| `busy` durante el fetch | ✓ | ✓ | — | A veces |
| Sentinel de scroll infinito como parte | ✓ | (sugerido) | — | Custom |
| Hilos anidados (`Thread` role=feed) | ✓ | ✓ (nested feeds) | — | Raro |
| Semántica perceptiva del load-more | ✓ | — | — | No |
Referencias: [APG Feed](https://www.w3.org/WAI/ARIA/apg/patterns/feed/) —
el único patrón normativo; no hay librería headless de referencia que lo
@ -81,13 +81,45 @@ publique.
disparo del load-more pertenece al contrato del componente; el consumidor
solo escucha `onLoadMore`.
## Talla y tema
Contrato en `lib/recipes/base.ts > feed` (50 claves). Alcance del censo:
**94 %** (era 0 % antes del 2026-08-21), sin privados propios — sólo quedan los
dos forwards de paleta THM-2.
**El eje `size`** (xs..xl) se estampa en `[data-feed-root]`, así que los
resueltos declaran `parts: ['root']`. El feed **tipa por debajo del 1:1** de md
en adelante (`font-size-md` lee la coordenada `sm`): un flujo se lee en bloque,
igual que `table` y `tree-grid`. La coordenada nombra ese paso en vez de
esconderlo en un primitivo crudo.
**El título NO escala por talla: escala por NIVEL DE ENCABEZADO.** El informe
leyó sus cuatro valores como una colisión de talla, y son `aria-level` 3..6 —
un encabezado más profundo es más pequeño porque es más profundo. Por eso sus
tokens son `article-title-font-size-{3,4,5,6}` y no coordenadas de `size`.
**El ⚠ del `sentinel` eran DOS medidas**: la fila-sonda mantiene un alto mínimo
para que el observador tenga algo que intersecar, y el spinner que va dentro
tiene su propio diámetro. Dos tokens, no uno.
### Nota de medición
La sonda da diffs que NO son cambios, y conviene saberlo antes de perseguirlos:
el `box-shadow` del nodo `[data-busy]` está ANIMADO y la sonda congela
`transition` pero nunca `animation` —a propósito—, así que lo muestrea en una
fase distinta cada vez; dos corridas del mismo código dan cinco diffs. Y el
estado `hover` se mide después de haber forzado tallas, así que su altura
arrastra la última. **Las seis medidas que sí son estables —reposo y las cinco
tallas— salieron idénticas al byte** antes y después: 544,5 · 564,5 · 628,5 ·
792,5 · 966,5 px.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Estados de resultado del fetch (complete/fail) como eventos propios | **diferir** | Hoy son del consumidor (que posee el fetch); si el censo de "sustain" del catálogo se consolida, feed es candidato natural. |
| Skeleton integrado durante `busy` | **descartar** | Componer `<Skeleton>` dentro del feed — no absorber. |
| Tests del wrapper eidos (soma con suite ✓) | **diferir** | Pasada SYS-2. |
| Gap | Disposición | Detalle |
| ------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Estados de resultado del fetch (complete/fail) como eventos propios | **diferir** | Hoy son del consumidor (que posee el fetch); si el censo de "sustain" del catálogo se consolida, feed es candidato natural. |
| Skeleton integrado durante `busy` | **descartar** | Componer `<Skeleton>` dentro del feed — no absorber. |
| Tests del wrapper eidos (soma con suite ✓) | **diferir** | Pasada SYS-2. |
## Referencias

@ -14,20 +14,16 @@
*/
[data-feed-root] {
--_feed-radius: var(--radius-md);
--_feed-article-padding-block: var(--space-3);
--_feed-article-padding-inline: var(--space-4);
--_feed-gap: var(--space-2);
--_feed-thread-indent: var(--space-5);
--_feed-font-size: var(--font-size-sm);
--_feed-divider: var(--color-border-default);
/* Every knob is PUBLIC and the per-size cascade is emitted by the TSC from
`recipes/base.ts`; a `[data-size]` block here would paint the OLD values
over the new tokens and the computed diff would read 0 through the old
path. */
display: block;
border-radius: var(--_feed-radius);
font-family: var(--style-label-font-family);
font-size: var(--_feed-font-size);
line-height: var(--leading-ui);
color: var(--color-content-primary);
border-radius: var(--feed-radius);
font-family: var(--feed-font-family);
font-size: var(--feed-font-size);
line-height: var(--feed-line-height);
color: var(--feed-fg);
}
[data-feed-root][data-block] {
@ -40,36 +36,12 @@
}
/* ── Sizes ─ */
[data-feed-root][data-size='xs'] {
--_feed-article-padding-block: var(--space-2);
--_feed-article-padding-inline: var(--space-3);
--_feed-thread-indent: var(--space-4);
--_feed-font-size: var(--font-size-xs);
}
[data-feed-root][data-size='sm'] {
--_feed-article-padding-block: var(--space-2);
--_feed-article-padding-inline: var(--space-3);
--_feed-thread-indent: var(--space-4);
--_feed-font-size: var(--font-size-sm);
}
[data-feed-root][data-size='lg'] {
--_feed-article-padding-block: var(--space-4);
--_feed-article-padding-inline: var(--space-5);
--_feed-thread-indent: var(--space-6);
--_feed-font-size: var(--font-size-md);
}
[data-feed-root][data-size='xl'] {
--_feed-article-padding-block: var(--space-5);
--_feed-article-padding-inline: var(--space-6);
--_feed-thread-indent: var(--space-7);
--_feed-font-size: var(--font-size-lg);
}
/* ── Feed root ─ */
[data-feed] {
display: flex;
flex-direction: column;
gap: var(--_feed-gap);
gap: var(--feed-gap);
outline: none;
}
@ -81,27 +53,27 @@
[data-feed-article] {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-block: var(--_feed-article-padding-block);
padding-inline: var(--_feed-article-padding-inline);
border-radius: var(--_feed-radius);
gap: var(--feed-article-gap);
padding-block: var(--feed-article-padding-block);
padding-inline: var(--feed-article-padding-inline);
border-radius: var(--feed-radius);
outline: none;
}
[data-feed-root][data-variant='surface'] [data-feed-article] {
background: var(--color-surface-default);
border: var(--border-width) solid var(--_feed-divider);
background: var(--feed-surface-bg);
border: var(--feed-border-width) solid var(--feed-divider);
}
[data-feed-root][data-variant='outline'] [data-feed-article] {
background: transparent;
border: var(--border-width) solid var(--_feed-divider);
border: var(--feed-border-width) solid var(--feed-divider);
}
[data-feed-root][data-variant='ghost'] [data-feed-article] {
background: transparent;
border: 0;
border-block-end: var(--border-width) solid var(--_feed-divider);
border-block-end: var(--feed-border-width) solid var(--feed-divider);
border-radius: 0;
padding-inline: 0;
}
@ -118,33 +90,43 @@
/* ── ArticleTitle ─ */
[data-feed-article-title] {
margin: 0;
font-weight: var(--font-weight-semibold, 600);
color: var(--color-content-primary);
font-weight: var(--feed-article-title-font-weight);
color: var(--feed-article-title-fg);
}
[data-feed-article-title][aria-level='3'] { font-size: var(--font-size-md); }
[data-feed-article-title][aria-level='4'] { font-size: var(--font-size-sm); }
[data-feed-article-title][aria-level='5'] { font-size: var(--font-size-sm); }
[data-feed-article-title][aria-level='6'] { font-size: var(--font-size-xs); }
/* The title steps by HEADING LEVEL, not by the component's size: a deeper
heading is smaller because it is deeper. */
[data-feed-article-title][aria-level='3'] {
font-size: var(--feed-article-title-font-size-3);
}
[data-feed-article-title][aria-level='4'] {
font-size: var(--feed-article-title-font-size-4);
}
[data-feed-article-title][aria-level='5'] {
font-size: var(--feed-article-title-font-size-5);
}
[data-feed-article-title][aria-level='6'] {
font-size: var(--feed-article-title-font-size-6);
}
/* ── ArticleDescription ─ */
[data-feed-article-description] {
color: var(--color-content-secondary);
color: var(--feed-article-description-fg);
}
/* ── Thread (nested feed) ─ */
[data-feed-thread] {
display: flex;
flex-direction: column;
gap: var(--_feed-gap);
margin-block-start: var(--space-2);
padding-inline-start: var(--_feed-thread-indent);
gap: var(--feed-gap);
margin-block-start: var(--feed-thread-margin-block-start);
padding-inline-start: var(--feed-thread-indent);
/* The rail marks the edge the thread is indented FROM, so it rides the same
logical side as the padding and mirrors with it. A `:dir(rtl)` rule that
repainted it at `inline-end` used to live here: that is a DOUBLE flip —
the logical property had already mirrored — and it left the indent on one
edge and the rail on the other. RTL-1 cannot see this shape. */
border-inline-start: var(--border-width-medium) solid var(--_feed-palette-element);
border-inline-start: var(--feed-thread-rail-width) solid var(--_feed-palette-element);
}
/* ── Sentinel ─
@ -157,21 +139,21 @@
display: flex;
align-items: center;
justify-content: center;
min-block-size: var(--space-6);
color: var(--color-content-secondary);
font-size: var(--font-size-xs);
min-block-size: var(--feed-sentinel-min-block-size);
color: var(--feed-sentinel-fg);
font-size: var(--feed-sentinel-font-size);
}
[data-feed][data-busy] [data-feed-sentinel]::before {
content: '';
display: inline-block;
inline-size: 0.75rem;
block-size: 0.75rem;
margin-inline-end: var(--space-2);
border: var(--border-width-medium) solid var(--_feed-palette-element);
inline-size: var(--feed-sentinel-spinner-size);
block-size: var(--feed-sentinel-spinner-size);
margin-inline-end: var(--feed-sentinel-spinner-gap);
border: var(--feed-sentinel-spinner-width) solid var(--_feed-palette-element);
border-block-start-color: var(--_feed-palette-solid);
border-radius: var(--radius-full);
animation: feed-spin 0.7s linear infinite;
animation: feed-spin var(--feed-sentinel-spinner-duration) linear infinite;
}
/* functional: continuous loading-spinner period — machinery, not an event signature */
@ -183,6 +165,6 @@
@media (prefers-reduced-motion: reduce) {
[data-feed][data-busy] [data-feed-sentinel]::before {
animation-duration: 4s;
animation-duration: var(--feed-sentinel-spinner-reduced-duration);
}
}

@ -1739,6 +1739,52 @@
--tree-grid-guide-fg: var(--color-border-default);
--tree-grid-guide-width: 1px;
--tree-grid-branch-placeholder-size: 1em;
--feed-font-size-xs: var(--size-xs-font-size);
--feed-font-size-sm: var(--size-sm-font-size);
--feed-font-size-md: var(--size-sm-font-size);
--feed-font-size-lg: var(--size-md-font-size);
--feed-font-size-xl: var(--size-lg-font-size);
--feed-article-padding-block-xs: var(--space-2);
--feed-article-padding-block-sm: var(--space-2);
--feed-article-padding-block-md: var(--space-3);
--feed-article-padding-block-lg: var(--space-4);
--feed-article-padding-block-xl: var(--space-5);
--feed-article-padding-inline-xs: var(--space-3);
--feed-article-padding-inline-sm: var(--space-3);
--feed-article-padding-inline-md: var(--space-4);
--feed-article-padding-inline-lg: var(--space-5);
--feed-article-padding-inline-xl: var(--space-6);
--feed-thread-indent-xs: var(--space-4);
--feed-thread-indent-sm: var(--space-4);
--feed-thread-indent-md: var(--space-5);
--feed-thread-indent-lg: var(--space-6);
--feed-thread-indent-xl: var(--space-7);
--feed-radius: var(--radius-md);
--feed-font-family: var(--style-label-font-family);
--feed-line-height: var(--leading-ui);
--feed-fg: var(--color-content-primary);
--feed-border-width: var(--border-width);
--feed-divider: var(--color-border-default);
--feed-gap: var(--space-2);
--feed-surface-bg: var(--color-surface-default);
--feed-article-gap: var(--space-1);
--feed-article-title-fg: var(--color-content-primary);
--feed-article-title-font-weight: var(--font-weight-semibold);
--feed-article-title-font-size-3: var(--size-md-font-size);
--feed-article-title-font-size-4: var(--size-sm-font-size);
--feed-article-title-font-size-5: var(--size-sm-font-size);
--feed-article-title-font-size-6: var(--size-xs-font-size);
--feed-article-description-fg: var(--color-content-secondary);
--feed-thread-margin-block-start: var(--space-2);
--feed-thread-rail-width: var(--border-width-medium);
--feed-sentinel-min-block-size: var(--space-6);
--feed-sentinel-fg: var(--color-content-secondary);
--feed-sentinel-font-size: var(--size-xs-font-size);
--feed-sentinel-spinner-size: 0.75rem;
--feed-sentinel-spinner-width: var(--border-width-medium);
--feed-sentinel-spinner-gap: var(--space-2);
--feed-sentinel-spinner-duration: 0.7s;
--feed-sentinel-spinner-reduced-duration: 4s;
--date-range-picker-accent-border: var(--color-primary-border);
--date-range-picker-accent-track: var(--color-primary-track);
--date-range-picker-accent-text: var(--color-primary-text);
@ -5477,10 +5523,42 @@
}
[data-feed-root] {
--feed-font-size: var(--feed-font-size-md);
--feed-article-padding-block: var(--feed-article-padding-block-md);
--feed-article-padding-inline: var(--feed-article-padding-inline-md);
--feed-thread-indent: var(--feed-thread-indent-md);
--_feed-palette-solid: var(--color-primary-solid);
--_feed-palette-element: var(--color-primary-element);
}
[data-feed-root][data-size='xs'] {
--feed-font-size: var(--feed-font-size-xs);
--feed-article-padding-block: var(--feed-article-padding-block-xs);
--feed-article-padding-inline: var(--feed-article-padding-inline-xs);
--feed-thread-indent: var(--feed-thread-indent-xs);
}
[data-feed-root][data-size='sm'] {
--feed-font-size: var(--feed-font-size-sm);
--feed-article-padding-block: var(--feed-article-padding-block-sm);
--feed-article-padding-inline: var(--feed-article-padding-inline-sm);
--feed-thread-indent: var(--feed-thread-indent-sm);
}
[data-feed-root][data-size='lg'] {
--feed-font-size: var(--feed-font-size-lg);
--feed-article-padding-block: var(--feed-article-padding-block-lg);
--feed-article-padding-inline: var(--feed-article-padding-inline-lg);
--feed-thread-indent: var(--feed-thread-indent-lg);
}
[data-feed-root][data-size='xl'] {
--feed-font-size: var(--feed-font-size-xl);
--feed-article-padding-block: var(--feed-article-padding-block-xl);
--feed-article-padding-inline: var(--feed-article-padding-inline-xl);
--feed-thread-indent: var(--feed-thread-indent-xl);
}
[data-feed-root][data-color], [data-feed-root][data-color-custom] {
--_feed-palette-element: var(--palette-element, var(--color-primary-element));
--_feed-palette-solid: var(--palette-solid, var(--color-primary-solid));

@ -2208,6 +2208,114 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
// stays open (guard/uniformity) + the wrapper stamps a harmless `data-color`, but
// there is nothing to forward, so NO recipe here (avoids an orphan palette token).
feed: {
// ── Per-size coordinates (xs..xl) ──
// The feed types BELOW the 1:1 from md up (`font-size-md` reads the sm
// coordinate): a stream is read in bulk, like `table` and `tree-grid`.
// The coordinate names that step instead of hiding it in a primitive.
'font-size-xs': 'var(--size-xs-font-size)',
'font-size-sm': 'var(--size-sm-font-size)',
'font-size-md': 'var(--size-sm-font-size)',
'font-size-lg': 'var(--size-md-font-size)',
'font-size-xl': 'var(--size-lg-font-size)',
'article-padding-block-xs': 'var(--space-2)',
'article-padding-block-sm': 'var(--space-2)',
'article-padding-block-md': 'var(--space-3)',
'article-padding-block-lg': 'var(--space-4)',
'article-padding-block-xl': 'var(--space-5)',
'article-padding-inline-xs': 'var(--space-3)',
'article-padding-inline-sm': 'var(--space-3)',
'article-padding-inline-md': 'var(--space-4)',
'article-padding-inline-lg': 'var(--space-5)',
'article-padding-inline-xl': 'var(--space-6)',
// How far a nested thread steps in from its parent.
'thread-indent-xs': 'var(--space-4)',
'thread-indent-sm': 'var(--space-4)',
'thread-indent-md': 'var(--space-5)',
'thread-indent-lg': 'var(--space-6)',
'thread-indent-xl': 'var(--space-7)',
// The RESOLVED names. `data-size` is stamped on `[data-feed-root]`.
'font-size': {
parts: ['root'],
declarations: [
{ value: 'var(--feed-font-size-md)', scope: 'host' },
{ value: 'var(--feed-font-size-xs)', scope: 'size:xs' },
{ value: 'var(--feed-font-size-sm)', scope: 'size:sm' },
{ value: 'var(--feed-font-size-lg)', scope: 'size:lg' },
{ value: 'var(--feed-font-size-xl)', scope: 'size:xl' }
]
},
'article-padding-block': {
parts: ['root'],
declarations: [
{ value: 'var(--feed-article-padding-block-md)', scope: 'host' },
{ value: 'var(--feed-article-padding-block-xs)', scope: 'size:xs' },
{ value: 'var(--feed-article-padding-block-sm)', scope: 'size:sm' },
{ value: 'var(--feed-article-padding-block-lg)', scope: 'size:lg' },
{ value: 'var(--feed-article-padding-block-xl)', scope: 'size:xl' }
]
},
'article-padding-inline': {
parts: ['root'],
declarations: [
{ value: 'var(--feed-article-padding-inline-md)', scope: 'host' },
{ value: 'var(--feed-article-padding-inline-xs)', scope: 'size:xs' },
{ value: 'var(--feed-article-padding-inline-sm)', scope: 'size:sm' },
{ value: 'var(--feed-article-padding-inline-lg)', scope: 'size:lg' },
{ value: 'var(--feed-article-padding-inline-xl)', scope: 'size:xl' }
]
},
'thread-indent': {
parts: ['root'],
declarations: [
{ value: 'var(--feed-thread-indent-md)', scope: 'host' },
{ value: 'var(--feed-thread-indent-xs)', scope: 'size:xs' },
{ value: 'var(--feed-thread-indent-sm)', scope: 'size:sm' },
{ value: 'var(--feed-thread-indent-lg)', scope: 'size:lg' },
{ value: 'var(--feed-thread-indent-xl)', scope: 'size:xl' }
]
},
// ── Chassis ──
radius: 'var(--radius-md)',
'font-family': 'var(--style-label-font-family)',
'line-height': 'var(--leading-ui)',
fg: 'var(--color-content-primary)',
'border-width': 'var(--border-width)',
divider: 'var(--color-border-default)',
gap: 'var(--space-2)',
'surface-bg': 'var(--color-surface-default)',
// ── Article ──
'article-gap': 'var(--space-1)',
'article-title-fg': 'var(--color-content-primary)',
'article-title-font-weight': 'var(--font-weight-semibold)',
// The title steps by HEADING LEVEL, not by the component's size - the
// audit read these four as a size collision, and they are `aria-level`
// 3..6. A deeper heading is smaller because it is deeper.
'article-title-font-size-3': 'var(--size-md-font-size)',
'article-title-font-size-4': 'var(--size-sm-font-size)',
'article-title-font-size-5': 'var(--size-sm-font-size)',
'article-title-font-size-6': 'var(--size-xs-font-size)',
'article-description-fg': 'var(--color-content-secondary)',
// ── Thread ──
'thread-margin-block-start': 'var(--space-2)',
'thread-rail-width': 'var(--border-width-medium)',
// ── Sentinel ──
// TWO different measures, which the audit flagged as one ⚠: the probe row
// keeps a minimum height so the observer has something to intersect, and
// the spinner inside it has its own diameter.
'sentinel-min-block-size': 'var(--space-6)',
'sentinel-fg': 'var(--color-content-secondary)',
'sentinel-font-size': 'var(--size-xs-font-size)',
'sentinel-spinner-size': '0.75rem',
'sentinel-spinner-width': 'var(--border-width-medium)',
'sentinel-spinner-gap': 'var(--space-2)',
'sentinel-spinner-duration': '0.7s',
'sentinel-spinner-reduced-duration': '4s',
'_palette-solid': {
parts: ['root'],
declarations: [{ value: 'var(--color-primary-solid)', scope: 'host' }]

@ -9,10 +9,11 @@
import { compileMorfo } from '$uix/morfo';
import { feedMorfo } from '@/uix/morfo/components/feed';
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' | 'a11y' | 'tokens';
let tab = $state<Tab>('live');
// ── Sample data ──────────────────────────────────────────────────────
@ -20,15 +21,41 @@
type Post = { id: string; title: string; body: string; replies?: Reply[] };
const SEED: Post[] = [
{ id: 'p1', title: 'Shipped soma TreeGrid', body: 'Hierarchical rows + columns over CSS grid. RowChildren span 1/-1 to keep alignment.', replies: [
{ id: 'r1', author: 'Ada', body: 'Nice — does it cope with deeply nested levels?' },
{ id: 'r2', author: 'Ben', body: 'Caps at 8 in the recipe, deeper trees override the indent var.' }
] },
{ id: 'p2', title: 'Auto-derived heading level for threads', body: 'Top-level articles are h3, nested h4, double-nested h5. Aria-level matches.' },
{ id: 'p3', title: 'PageDown at the end fires onLoadMore', body: 'No JS scroll listener needed — the Provider intercepts keydown on focused articles.' },
{ id: 'p4', title: 'IntersectionObserver sentinel', body: 'Drop a Sentinel inside the Feed. When it scrolls into view, onIntersect fires.', replies: [
{ id: 'r3', author: 'Cora', body: 'Defaults to the Feed\'s onLoadMore — works out of the box.' }
] }
{
id: 'p1',
title: 'Shipped soma TreeGrid',
body: 'Hierarchical rows + columns over CSS grid. RowChildren span 1/-1 to keep alignment.',
replies: [
{ id: 'r1', author: 'Ada', body: 'Nice — does it cope with deeply nested levels?' },
{
id: 'r2',
author: 'Ben',
body: 'Caps at 8 in the recipe, deeper trees override the indent var.'
}
]
},
{
id: 'p2',
title: 'Auto-derived heading level for threads',
body: 'Top-level articles are h3, nested h4, double-nested h5. Aria-level matches.'
},
{
id: 'p3',
title: 'PageDown at the end fires onLoadMore',
body: 'No JS scroll listener needed — the Provider intercepts keydown on focused articles.'
},
{
id: 'p4',
title: 'IntersectionObserver sentinel',
body: 'Drop a Sentinel inside the Feed. When it scrolls into view, onIntersect fires.',
replies: [
{
id: 'r3',
author: 'Cora',
body: "Defaults to the Feed's onLoadMore — works out of the box."
}
]
}
];
let posts = $state<Post[]>(SEED);
@ -145,11 +172,10 @@
<h1 data-uix-page-title>Feed</h1>
<p data-uix-page-lede>
Scrollable activity stream — <code>role="feed"</code> of
<code>role="article"</code> children. PageUp/PageDown navigates
articles; the Sentinel fires <code>onLoadMore</code> when it
scrolls into view. Use Feed for activity streams, chat
histories, threaded comments — anything where the article
count grows lazily. Use <code>GridList</code> /
<code>role="article"</code> children. PageUp/PageDown navigates articles; the Sentinel fires
<code>onLoadMore</code>
when it scrolls into view. Use Feed for activity streams, chat histories, threaded comments —
anything where the article count grows lazily. Use <code>GridList</code> /
<code>Listbox</code> for bounded selection.
</p>
<div data-uix-page-meta>
@ -167,22 +193,30 @@
<a href={feedMorfo.apg} target="_blank" rel="noreferrer">feed ↗</a>
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>posts</span> {posts.length}
<span data-uix-meta-key>posts</span>
{posts.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>busy</span> {busy ? 'loading' : 'idle'}
<span data-uix-meta-key>busy</span>
{busy ? 'loading' : 'idle'}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>locale</span> {locale}
<span data-uix-meta-key>locale</span>
{locale}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>dir</span> {dir}
<span data-uix-meta-key>dir</span>
{dir}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef} style="padding: var(--uix-space-3); display: flex; justify-content: center;">
<div
data-uix-stage-area
bind:this={stageRef}
style="padding: var(--uix-space-3); display: flex; justify-content: center;"
>
<div style="inline-size: min(36rem, 100%);">
<Feed
{busy}
@ -226,12 +260,17 @@
<span>focus an article + PageDown to navigate; scroll to fire sentinel…</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span
>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>posts</span> {posts.length}
<span data-uix-stage-trace-key>posts</span>
{posts.length}
</span>
</div>
</div>
@ -249,7 +288,12 @@
<span data-uix-layer-badge="sema">sema</span>
<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 === 'recipe'} onclick={() => (tab = 'recipe')}
>Recipe</button
>
<button data-uix-tab data-active={tab === 'tokens'} onclick={() => (tab = 'tokens')}>
Tokens <span data-uix-tab-count>50</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -257,7 +301,9 @@
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Soma owns PageUp/PageDown nav, Ctrl+Home/End jumps, auto-derived aria-level for threads, IntersectionObserver sentinel wiring. Eidos adds card chrome + thread indent + sentinel spinner.
Soma owns PageUp/PageDown nav, Ctrl+Home/End jumps, auto-derived aria-level for threads,
IntersectionObserver sentinel wiring. Eidos adds card chrome + thread indent + sentinel
spinner.
</p>
<div data-uix-subsection-head>
@ -276,7 +322,11 @@
<span data-uix-control-label>direction</span>
<span data-uix-chips role="radiogroup">
{#each ['auto', 'ltr', 'rtl'] as v (v)}
<button data-uix-chip data-active={dirMode === v} onclick={() => (dirMode = v as typeof dirMode)}>{v}</button>
<button
data-uix-chip
data-active={dirMode === v}
onclick={() => (dirMode = v as typeof dirMode)}>{v}</button
>
{/each}
</span>
</label>
@ -289,9 +339,19 @@
<label data-uix-control>
<span data-uix-control-label>totalItems</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={totalItems === undefined} onclick={() => (totalItems = undefined)}>unknown</button>
<button data-uix-chip data-active={totalItems === 20} onclick={() => (totalItems = 20)}>20</button>
<button data-uix-chip data-active={totalItems === 100} onclick={() => (totalItems = 100)}>100</button>
<button
data-uix-chip
data-active={totalItems === undefined}
onclick={() => (totalItems = undefined)}>unknown</button
>
<button data-uix-chip data-active={totalItems === 20} onclick={() => (totalItems = 20)}
>20</button
>
<button
data-uix-chip
data-active={totalItems === 100}
onclick={() => (totalItems = 100)}>100</button
>
</span>
</label>
<label data-uix-control>
@ -325,7 +385,11 @@
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v as FeedVariant)}>{v}</button>
<button
data-uix-chip
data-active={variant === v}
onclick={() => (variant = v as FeedVariant)}>{v}</button
>
{/each}
</span>
</label>
@ -333,7 +397,9 @@
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as FeedSize)}>{s}</button>
<button data-uix-chip data-active={size === s} onclick={() => (size = s as FeedSize)}
>{s}</button
>
{/each}
</span>
</label>
@ -341,7 +407,11 @@
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as c (c)}
<button data-uix-chip data-active={color === c} onclick={() => (color = c as FeedColor)}>{c}</button>
<button
data-uix-chip
data-active={color === c}
onclick={() => (color = c as FeedColor)}>{c}</button
>
{/each}
</span>
</label>
@ -375,21 +445,59 @@
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>6 parts: <code>Provider · Article · ArticleTitle · ArticleDescription · Thread · Sentinel</code>. Provider auto-wires <code>aria-labelledby</code> / <code>aria-describedby</code> on each Article. Thread depth drives auto-derived heading level (h3 → h4 → h5 …).</p>
<p data-uix-section-desc>
6 parts: <code
>Provider · Article · ArticleTitle · ArticleDescription · Thread · Sentinel</code
>. Provider auto-wires <code>aria-labelledby</code> / <code>aria-describedby</code> on each Article.
Thread depth drives auto-derived heading level (h3 → h4 → h5 …).
</p>
<div data-uix-subsection-head>Provider (Feed)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">totalItems</td><td class="type">number | undefined</td><td class="default empty">—</td><td>Total across all pages. <code>undefined</code> = unknown (AT reads "n of unknown").</td></tr>
<tr><td class="name">busy</td><td class="type">boolean</td><td class="default">false</td><td>Sets <code>aria-busy</code> while loading.</td></tr>
<tr><td class="name">onLoadMore</td><td class="type">() => void</td><td class="default empty">—</td><td>Fired on PageDown-past-last or Sentinel intersection.</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>Article card chrome.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td><td class="default">'md'</td><td>Padding + thread indent + font scale.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ColorRole</td><td class="default">'primary'</td><td>Focus ring + sentinel spinner.</td></tr>
<tr><td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Stretch to container width.</td></tr>
<tr><td class="name">maxHeight <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">string</td><td class="default empty">—</td><td>Wraps the feed in a scroll region.</td></tr>
<tr
><td class="name">totalItems</td><td class="type">number | undefined</td><td
class="default empty">—</td
><td
>Total across all pages. <code>undefined</code> = unknown (AT reads "n of unknown").</td
></tr
>
<tr
><td class="name">busy</td><td class="type">boolean</td><td class="default">false</td
><td>Sets <code>aria-busy</code> while loading.</td></tr
>
<tr
><td class="name">onLoadMore</td><td class="type">() => void</td><td
class="default empty">—</td
><td>Fired on PageDown-past-last or Sentinel intersection.</td></tr
>
<tr
><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">'surface' | 'outline' | 'ghost'</td
><td class="default">'surface'</td><td>Article card chrome.</td></tr
>
<tr
><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td
><td class="default">'md'</td><td>Padding + thread indent + font scale.</td></tr
>
<tr
><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">ColorRole</td
><td class="default">'primary'</td><td>Focus ring + sentinel spinner.</td></tr
>
<tr
><td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">boolean</td
><td class="default">true</td><td>Stretch to container width.</td></tr
>
<tr
><td class="name">maxHeight <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">string</td
><td class="default empty">—</td><td>Wraps the feed in a scroll region.</td></tr
>
</tbody>
</table>
</div>
@ -399,11 +507,31 @@
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">Article.posinset</td><td class="type">number</td><td class="default">auto</td><td>1-based position in full set. Auto when DOM order matches feed order.</td></tr>
<tr><td class="name">ArticleTitle.level</td><td class="type">1–6</td><td class="default">3 + threadDepth</td><td>Heading level. Auto-derived from enclosing Thread depth.</td></tr>
<tr><td class="name">Sentinel.rootMargin</td><td class="type">string</td><td class="default">'200px'</td><td>IntersectionObserver rootMargin.</td></tr>
<tr><td class="name">Sentinel.onIntersect</td><td class="type">() => void</td><td class="default">Feed.onLoadMore</td><td>Defaults to firing the parent Feed's onLoadMore.</td></tr>
<tr><td class="name">Sentinel.disabled</td><td class="type">boolean</td><td class="default">false</td><td>Skip observer setup.</td></tr>
<tr
><td class="name">Article.posinset</td><td class="type">number</td><td class="default"
>auto</td
><td>1-based position in full set. Auto when DOM order matches feed order.</td></tr
>
<tr
><td class="name">ArticleTitle.level</td><td class="type">1–6</td><td class="default"
>3 + threadDepth</td
><td>Heading level. Auto-derived from enclosing Thread depth.</td></tr
>
<tr
><td class="name">Sentinel.rootMargin</td><td class="type">string</td><td
class="default">'200px'</td
><td>IntersectionObserver rootMargin.</td></tr
>
<tr
><td class="name">Sentinel.onIntersect</td><td class="type">() => void</td><td
class="default">Feed.onLoadMore</td
><td>Defaults to firing the parent Feed's onLoadMore.</td></tr
>
<tr
><td class="name">Sentinel.disabled</td><td class="type">boolean</td><td
class="default">false</td
><td>Skip observer setup.</td></tr
>
</tbody>
</table>
</div>
@ -421,7 +549,13 @@
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
<thead
><tr
><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th
>States</th
><th>Optional</th></tr
></thead
>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
@ -441,12 +575,23 @@
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
<thead
><tr
><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th
>target</th
></tr
></thead
>
<tbody>
{#each events as action (action.name)}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
{@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'}
{@const intentStr =
typeof intentDecl === 'string'
? intentDecl
: intentDecl
? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}`
: '—'}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
@ -470,7 +615,12 @@
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<thead
><tr
><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th
></tr
></thead
>
<tbody>
{#each events as action (action.name)}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
@ -485,7 +635,8 @@
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-feed-article]') ?? stageRef) as HTMLElement | null;
const target = (stageRef?.querySelector('[data-feed-article]') ??
stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
@ -493,8 +644,8 @@
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}
>▶ play</button>
}}>▶ play</button
>
</td>
</tr>
{/each}
@ -512,31 +663,76 @@
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>What it paints</th></tr></thead>
<tbody>
<tr><td class="name">[data-feed-root]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Wrapper — chrome, scroll region.</td></tr>
<tr><td class="name">[data-feed-root][data-variant] [data-feed-article]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Article card chrome per variant.</td></tr>
<tr><td class="name">[data-feed-article-title][aria-level]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Font-size scaled by heading level.</td></tr>
<tr><td class="name">[data-feed-thread]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Nested feed with accent border indent.</td></tr>
<tr><td class="name">[data-feed][data-busy] [data-feed-sentinel]::before</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Spinner during load.</td></tr>
<tr
><td class="name">[data-feed-root]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Wrapper — chrome, scroll region.</td></tr
>
<tr
><td class="name">[data-feed-root][data-variant] [data-feed-article]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Article card chrome per variant.</td></tr
>
<tr
><td class="name">[data-feed-article-title][aria-level]</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
><td>Font-size scaled by heading level.</td></tr
>
<tr
><td class="name">[data-feed-thread]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Nested feed with accent border indent.</td></tr
>
<tr
><td class="name">[data-feed][data-busy] [data-feed-sentinel]::before</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Spinner during load.</td></tr
>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="feed" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>Follows the APG <a href={feedMorfo.apg} target="_blank" rel="noreferrer">feed pattern</a>. Root is <code>role="feed"</code>, each child is <code>role="article"</code> with <code>aria-posinset</code> / <code>aria-setsize</code> auto-wired. <code>aria-setsize="-1"</code> when total is unknown (per APG).</p>
<p data-uix-section-desc>
Follows the APG <a href={feedMorfo.apg} target="_blank" rel="noreferrer">feed pattern</a>.
Root is <code>role="feed"</code>, each child is <code>role="article"</code> with
<code>aria-posinset</code>
/ <code>aria-setsize</code> auto-wired. <code>aria-setsize="-1"</code> when total is unknown (per
APG).
</p>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>PageDown</span> (on Article)</td><td>Focus next article. At the last one, calls <code>onLoadMore</code>.</td></tr>
<tr><td><span data-uix-kbd>PageUp</span> (on Article)</td><td>Focus previous article.</td></tr>
<tr><td><span data-uix-kbd>Ctrl+Home</span> / <span data-uix-kbd>Ctrl+End</span></td><td>First / last article.</td></tr>
<tr><td><span data-uix-kbd>Tab</span> (within Article)</td><td>Reach inner interactive elements per native tab order.</td></tr>
<tr
><td><span data-uix-kbd>PageDown</span> (on Article)</td><td
>Focus next article. At the last one, calls <code>onLoadMore</code>.</td
></tr
>
<tr
><td><span data-uix-kbd>PageUp</span> (on Article)</td><td>Focus previous article.</td
></tr
>
<tr
><td><span data-uix-kbd>Ctrl+Home</span> / <span data-uix-kbd>Ctrl+End</span></td><td
>First / last article.</td
></tr
>
<tr
><td><span data-uix-kbd>Tab</span> (within Article)</td><td
>Reach inner interactive elements per native tab order.</td
></tr
>
</tbody>
</table>
</div>

Loading…
Cancel
Save

Powered by TurnKey Linux.