From c56d6544e6c6e8873f1819c9664c6c557a757664 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 21 Aug 2026 21:55:54 +0200 Subject: [PATCH] =?UTF-8?q?uix(card-group):=20temable=20=E2=80=94=200=20%?= =?UTF-8?q?=20=E2=86=92=2090=20%,=20y=20dos=20privados=20abreviados=20que?= =?UTF-8?q?=20mueren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20 claves en una entrada NUEVA de `recipes/base.ts`. Censo 0 % → 90 %, cero globales y cero privados. Global 47 %, sin contrato 45 → 44. Es un CASCO de composición y su propia cabecera ya lo decía: compone ToggleGroup, Collapsible, Card y Button, y la receta posee «ONLY the genuinely CardGroup-level visual». Eso acota el contrato antes de nombrar nada — el cromo de las tarjetas es de Card, el del título de disclosure es de Button. 1. **`--_cg-pad` y `--_cg-card-radius` eran privados ABREVIADOS** (§6 r5 los prohíbe: `cg` por `card-group`), el mismo defecto que los `--_mp-*` de media-player. Pasar a públicos con el nombre completo es a la vez el renombrado que la regla pedía y la tokenización. La §3 de la ficha decía «la receta no declara privados propios»: los declaraba en su bloque raíz (`card-group.css:15-16`), y el escáner no los veía por buscarlos con el prefijo completo del componente. 2. **`--shape-outer-radius` NO se acuña: se CALCULA.** La receta lo compone como `card-radius + padding` para que las tarjetas aniden concéntricamente (§30). Un tema mueve las dos coordenadas y el radio exterior se recompone solo; el censo ya lo clasifica como sistema. 3. **El eje `size` es sm|md|lg, no las cinco tallas** (`Extract`): seis coordenadas por talla, no diez. Escribir xs/xl habría sido vocabulario muerto. 4. Los tres `padding` son shorthand con una parte fija y otra variable; lo que varía y no es el inset del grupo es el hueco inferior → `title-padding-block-end` y `description-padding-block-end`. 5. El chevron es geometría propia (dos bordes girados 45°): `chevron-size` cubre las dos dimensiones y `chevron-width` el trazo. Su `currentColor` hereda del título a propósito y no es knob. 6. Los `font-size` van al BUNDLE (`--size-{k}-font-size`, alias 1:1) y no al primitivo crudo — misma trampa que cayó en code-block: la propuesta copia el CSS verbatim y hereda el incumplimiento cuando el valor de partida ya violaba `recipe-css-contract`. Aquí se aplicó de entrada. Artefactos y gates: - Sonda antes/después = 0 diffs en 2.581 valores × 7 estados. Determinismo verificado: dos corridas, 0 diffs. - R-5.4: 15/20. Los cinco mudos son los del encabezado ESTÁTICO, que la demo no monta (0 nodos `[data-static]` contra 1 `[data-button]`, CONTADOS); forzado `data-static` alcanzan los cinco, y cada uno lleva su medición en el ledger. El hueco de demo queda registrado en next-features §13, misma clase que el `kind='month'|'year'` de date-range-picker. - censo --only 90 % · component:audit PASS · eidos-lint invalid 0, class-hooks 0 · suite eidos sin rojos nuevos (el conocido skin-media-player) · rtl:check 0 · docs:check 0 · check por fichero limpio. - README con su «Talla y tema» y tab `Tokens` en la demo (20 claves, en vivo). Co-Authored-By: Claude Fable 5 --- docs/audit/theming/README.md | 12 +- docs/audit/theming/card-group.md | 116 +++++++++--------- docs/next-features.md | 7 ++ scripts/theming-sentinel-exceptions.ts | 12 ++ src/uix/eidos/components/card-group/README.md | 38 ++++++ .../components/card-group/card-group.css | 62 ++++------ src/uix/eidos/generated/base.css | 33 +++++ src/uix/eidos/lib/recipes/base.ts | 53 ++++++++ .../uix/components/card-group/+page.svelte | 10 +- 9 files changed, 246 insertions(+), 97 deletions(-) diff --git a/docs/audit/theming/README.md b/docs/audit/theming/README.md index 576b7b288..b626b1d7d 100644 --- a/docs/audit/theming/README.md +++ b/docs/audit/theming/README.md @@ -10,9 +10,9 @@ - **Medido**: 2026-08-21 · **162 recetas** con CSS + **8 componentes sin receta** = 170 fichas, el árbol entero de `eidos/components/` - **La pregunta**: ¿cuánto de la apariencia de cada componente puede cambiar un tema **sin tocar el sistema ni la receta**? -- **Alcance global**: **47%** — 2195 de 4680 knobs pasan por un token público del componente -- **Reparto**: público 2195 · privado 654 · global 1251 · literal 580 · sistema transversal 509 _(fuera del ratio)_ -- **Sin token público propio**: 45 · **alcance < 20 %**: 39 · **alcance 100 %**: 7 · **con eje `size`**: 65 +- **Alcance global**: **47%** — 2213 de 4678 knobs pasan por un token público del componente +- **Reparto**: público 2213 · privado 654 · global 1233 · literal 578 · sistema transversal 509 _(fuera del ratio)_ +- **Sin token público propio**: 44 · **alcance < 20 %**: 38 · **alcance 100 %**: 7 · **con eje `size`**: 64 ## Cómo se lee @@ -91,12 +91,12 @@ theming §6.7 lo pone delante (`hover-bg`). Las fichas proponen la forma | 19 | [timeline](./timeline.md) | 24 | 51% | 49 | 45 | | 20 | [avatar](./avatar.md) | 23 | 36% | 36 | 0 | -## Sin ningún token público propio (45) +## Sin ningún token público propio (44) 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) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`field-langs`](./field-langs.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) · [`virtual-grid`](./virtual-grid.md) · [`virtual-list`](./virtual-list.md) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md) +[`alert-dialog`](./alert-dialog.md) · [`announce`](./announce.md) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`field-langs`](./field-langs.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`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) · [`virtual-grid`](./virtual-grid.md) · [`virtual-list`](./virtual-list.md) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md) ## Tabla completa (162 recetas, por alcance ascendente) @@ -108,7 +108,6 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component | [month-grid](./month-grid.md) | 0% | 61 | 0 | 7 | 0 | 5 | 49 | 0 | – | | [year-grid](./year-grid.md) | 0% | 61 | 0 | 7 | 0 | 5 | 49 | 0 | – | | [field-langs](./field-langs.md) | 0% | 41 | 0 | 0 | 31 | 8 | 2 | 0 | – | -| [card-group](./card-group.md) | 0% | 24 | 0 | 0 | 18 | 4 | 2 | 0 | y | | [spinner](./spinner.md) | 0% | 23 | 0 | 15 | 4 | 4 | 0 | 0 | y | | [picker-shell](./picker-shell.md) | 0% | 21 | 0 | 4 | 17 | 0 | 0 | 0 | y | | [link-preview](./link-preview.md) | 0% | 17 | 0 | 4 | 9 | 1 | 3 | 0 | y | @@ -238,6 +237,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component | [gradient-picker](./gradient-picker.md) | 88% | 27 | 23 | 0 | 2 | 1 | 1 | 25 | – | | [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 | +| [card-group](./card-group.md) | 90% | 22 | 18 | 0 | 0 | 2 | 2 | 20 | – | | [collapsible](./collapsible.md) | 90% | 12 | 9 | 0 | 1 | 0 | 2 | 11 | – | | [tree-view](./tree-view.md) | 92% | 28 | 24 | 1 | 0 | 1 | 2 | 46 | – | | [feed](./feed.md) | 94% | 33 | 30 | 0 | 1 | 1 | 1 | 50 | – | diff --git a/docs/audit/theming/card-group.md b/docs/audit/theming/card-group.md index b275c93cf..4f76d6109 100644 --- a/docs/audit/theming/card-group.md +++ b/docs/audit/theming/card-group.md @@ -5,48 +5,27 @@ > 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 22 knobs por token público -- **Knobs de apariencia**: 24 — público 0 · privado 0 · global 18 · literal 4 · sistema 2 _(fuera del ratio)_ -- **Contrato hoy** (`lib/recipes/base.ts`): **sin entrada en `base.ts`** -- **Eje `size`**: sí · **ficheros**: `card-group.css` +- **Medido**: 2026-08-21 · **Alcance**: **90%** — 18 de 20 knobs por token público +- **Knobs de apariencia**: 22 — público 18 · privado 0 · global 0 · literal 2 · sistema 2 _(fuera del ratio)_ +- **Contrato hoy** (`lib/recipes/base.ts`): 20 pública(s) — `padding-sm`, `padding-md`, `padding-lg`, `grid-gap-sm`, `grid-gap-md`, `grid-gap-lg`, `padding`, `grid-gap`, `card-radius`, `title-font-weight`, `title-fg`, `title-font-family`, `title-font-size`, `title-line-height`, `title-padding-block-end`, `chevron-size`, `chevron-width`, `description-fg`, `description-font-size`, `description-padding-block-end` +- **Eje `size`**: no · **ficheros**: `card-group.css` ## 1. Knobs fuera de alcance -### 1.1 Directo a primitivo global (18) +### 1.1 Directo a primitivo global (0) -| # | fichero:línea | selector | propiedad | valor | -| ---: | --- | --- | --- | --- | -| 1 | `card-group.css:38` | `[data-card-group-title][data-button]` | `padding-inline` | `var(--_cg-pad)` | -| 2 | `card-group.css:39` | `[data-card-group-title][data-button]` | `font-weight` | `var(--font-weight-semibold)` | -| 3 | `card-group.css:46` | `[data-card-group-title][data-static]` | `padding` | `var(--_cg-pad) var(--_cg-pad) var(--space-2)` | -| 4 | `card-group.css:47` | `[data-card-group-title][data-static]` | `color` | `var(--color-content-primary)` | -| 5 | `card-group.css:48` | `[data-card-group-title][data-static]` | `font-family` | `var(--style-label-font-family)` | -| 6 | `card-group.css:49` | `[data-card-group-title][data-static]` | `font-size` | `var(--font-size-md)` | -| 7 | `card-group.css:50` | `[data-card-group-title][data-static]` | `font-weight` | `var(--font-weight-semibold)` | -| 8 | `card-group.css:51` | `[data-card-group-title][data-static]` | `line-height` | `var(--leading-ui)` | -| 9 | `card-group.css:65` | `[data-card-group-title-chevron]` | `border-inline-end` | `var(--border-width-medium) solid currentColor` | -| 10 | `card-group.css:66` | `[data-card-group-title-chevron]` | `border-block-end` | `var(--border-width-medium) solid currentColor` | -| 11 | `card-group.css:79` | `[data-card-group-description]` | `padding` | `0 var(--_cg-pad) var(--space-2)` | -| 12 | `card-group.css:80` | `[data-card-group-description]` | `color` | `var(--color-content-muted)` | -| 13 | `card-group.css:81` | `[data-card-group-description]` | `font-size` | `var(--font-size-sm)` | -| 14 | `card-group.css:93` | `[data-card-group-grid]` | `gap` | `var(--space-3)` | -| 15 | `card-group.css:94` | `[data-card-group-grid]` | `padding` | `0 var(--_cg-pad) var(--_cg-pad)` | -| 16 | `card-group.css:100` | `[data-card-group] [data-card]` | `border-radius` | `var(--_cg-card-radius)` | -| 17 | `card-group.css:131` | `[data-card-group][data-size='sm'] [data-card-group-grid]` | `gap` | `var(--space-2)` | -| 18 | `card-group.css:138` | `[data-card-group][data-size='lg'] [data-card-group-grid]` | `gap` | `var(--space-4)` | +_Ninguno._ ### 1.2 A través de un privado (0) _Ninguno._ -### 1.3 Literales (4) +### 1.3 Literales (2) | # | fichero:línea | selector | propiedad | valor | | ---: | --- | --- | --- | --- | -| 1 | `card-group.css:20` | `[data-card-group]` | `inline-size` | `100%` | -| 2 | `card-group.css:61` | `[data-card-group-title-chevron]` | `inline-size` | `0.5em` | -| 3 | `card-group.css:62` | `[data-card-group-title-chevron]` | `block-size` | `0.5em` | -| 4 | `card-group.css:105` | `[data-card-group-item]` | `block-size` | `100%` | +| 1 | `card-group.css:21` | `[data-card-group]` | `inline-size` | `100%` | +| 2 | `card-group.css:107` | `[data-card-group-item]` | `block-size` | `100%` | ## 2. Sistema transversal (2) — informativo, fuera del ratio @@ -54,8 +33,8 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2). | # | fichero:línea | selector | propiedad | valor | | ---: | --- | --- | --- | --- | -| 1 | `card-group.css:21` | `[data-card-group]` | `border-radius` | `var(--shape-outer-radius)` | -| 2 | `card-group.css:69` | `[data-card-group-title-chevron]` | `opacity` | `var(--opacity-muted)` | +| 1 | `card-group.css:22` | `[data-card-group]` | `border-radius` | `var(--shape-outer-radius)` | +| 2 | `card-group.css:71` | `[data-card-group-title-chevron]` | `opacity` | `var(--opacity-muted)` | ## 3. Privados de la receta — ¿de dónde sale su valor? @@ -63,9 +42,7 @@ _La receta no declara privados propios en su CSS._ ## 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` (14) +### 4.1 Tokens a declarar en `lib/recipes/base.ts` (0) Valor **verbatim** del CSS de hoy: el default no se mueve, sólo cambia quién puede moverlo. Nombres derivados de recipe-contract §1 (ejes lógicos, talla @@ -75,27 +52,10 @@ no distingue lo que debería — se marca `⚠`. | token (`--card-group-…`) | scope TSC | valor propuesto | usos | | --- | --- | --- | ---: | -| `title-font-weight` | `root` | `var(--font-weight-semibold)` | 2 | -| `title-padding-inline` | `root` | `var(--_cg-pad)` | 1 | -| `title-fg` | `root` | `var(--color-content-primary)` | 1 | -| `title-font-family` | `root` | `var(--style-label-font-family)` | 1 | -| `title-font-size` | `root` | `var(--font-size-md)` | 1 | -| `title-line-height` | `root` | `var(--leading-ui)` | 1 | -| `title-chevron-width` | `root` | `0.5em` | 1 | -| `title-chevron-height` | `root` | `0.5em` | 1 | -| `description-fg` | `root` | `var(--color-content-muted)` | 1 | -| `description-font-size` | `root` | `var(--font-size-sm)` | 1 | -| `grid-gap` | `root` | `var(--space-3)` | 1 | -| `radius` | `root` | `var(--_cg-card-radius)` | 1 | -| `grid-gap-sm` | `root` | `var(--space-2)` | 1 | -| `grid-gap-lg` | `root` | `var(--space-4)` | 1 | - -### 4.2 Sin nombre mecánico (7) + +### 4.2 Sin nombre mecánico (2) - **⚠ 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** — 2: `inline-size`, `block-size`. -- **⚠ decisión: `padding` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 3: `padding`. -- **⚠ decisión: `border-inline-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-inline-end`. -- **⚠ 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`. ### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4) @@ -109,6 +69,52 @@ no distingue lo que debería — se marca `⚠`. -_(pendiente — lo escribe el autor; se conserva al regenerar)_ +**Medido 2026-08-21** (sonda ×2 sobre el mismo código = 0 diffs en 2.581 +valores · 7 estados). Es un CASCO de composición —compone ToggleGroup, +Collapsible, Card y Button— y su propia cabecera lo dice: la receta posee +«ONLY the genuinely CardGroup-level visual». Eso acota el contrato antes de +nombrar nada: el cromo de las tarjetas es de `Card`, el del título es de +`Button`, y aquí sólo entra lo del grupo. + +1. **`--_cg-pad` y `--_cg-card-radius` son privados ABREVIADOS** (§6 r5 los + prohíbe: `cg` por `card-group`) — el mismo defecto que los `--_mp-*` de + media-player y los `--gp-*` de gradient-picker. Los dos pasan a públicos con + el nombre completo (`padding`, `card-radius`), que es a la vez el renombrado + que la regla pide y la tokenización. **La §3 de esta ficha se equivoca** al + decir «la receta no declara privados propios»: los declara en su bloque raíz + (`card-group.css:15-16`); el escáner no los vio porque los busca con el + prefijo completo del componente. +2. **`--shape-outer-radius` NO se acuña.** Es un token del sistema de forma que + esta receta CALCULA (`calc(card-radius + padding)`) para que las tarjetas + aniden concéntricamente (§30). El censo ya lo clasifica como `system`, y así + se queda: un tema mueve las dos coordenadas y el radio exterior se recompone + solo. +3. **El eje `size` es sm/md/lg, no las cinco tallas** + (`CardGroupSize = Extract`), así que las + coordenadas por talla son SEIS, no diez. Escribir xs/xl habría sido + vocabulario muerto. +4. **Los tres `padding` son shorthand con una parte fija y otra variable**: + `var(--_cg-pad) var(--_cg-pad) var(--space-2)` (título estático), + `0 var(--_cg-pad) var(--space-2)` (descripción) y + `0 var(--_cg-pad) var(--_cg-pad)` (grid). Lo que varía y no es el padding + del grupo es el hueco inferior → `title-padding-block-end` y + `description-padding-block-end`; el resto lo cubre `padding`. +5. **El chevron es geometría propia, no un icono compuesto**: dos bordes + girados 45°. `inline-size` y `block-size` son UN knob (`chevron-size`, + `0.5em`) y la anchura del trazo otro (`chevron-width`). El `currentColor` no + es knob: hereda del título a propósito. +6. **`title-font-weight` lo comparten las dos formas del título** (el botón de + disclosure y el encabezado estático), con el mismo valor. Un solo knob. +7. **Los `font-size` van al BUNDLE, no al primitivo** — + `var(--size-md-font-size)` y `var(--size-sm-font-size)`, alias 1:1— porque + `recipe-css-contract` lo exige y la propuesta generada copia el CSS + verbatim. Es la misma trampa que acaba de caer en `code-block`: cuando el + valor de partida ya incumple el contrato, la propuesta hereda el + incumplimiento. +8. **Fuera del contrato**: los dos `100%` (identidad de layout, D-TH.2) y el + `opacity: var(--opacity-muted)` del chevron (sistema). + +Quedan **20 claves** y el alcance esperado ronda el 90 %: lo que no llega son +los dos `100%`. diff --git a/docs/next-features.md b/docs/next-features.md index 5168cade8..035b6e69c 100644 --- a/docs/next-features.md +++ b/docs/next-features.md @@ -659,6 +659,13 @@ Lo que `textarea` añadió (2026-08-21): los diez componentes con ledger: ningún otro cambia, un único STALE (`command.input-placeholder-fg`, por el arreglo del `::placeholder`). +- **Hueco de demo: `card-group` no monta su encabezado ESTÁTICO.** La página + renderiza el título sólo en su forma de disclosure (`[data-button]`), así que + la mitad `[data-static]` del cromo del título —tinta, familia, tamaño, + interlineado y el hueco inferior— no se ve ni se mide: cinco tokens + adjudicados por eso. Misma clase que `date-range-picker` con + `kind='month'|'year'` (norma N-6). + Lo que `code-block` añadió (2026-08-21): - **⚠ La demo de `code-block` NO ENSEÑA el `code-block`: el harness del sitio diff --git a/scripts/theming-sentinel-exceptions.ts b/scripts/theming-sentinel-exceptions.ts index f6a57b77d..a2a4e01e4 100644 --- a/scripts/theming-sentinel-exceptions.ts +++ b/scripts/theming-sentinel-exceptions.ts @@ -114,6 +114,18 @@ export const SENTINEL_EXCEPTIONS: Record> = { 'sentinel-spinner-reduced-duration': 'behind prefers-reduced-motion; emulated reduce context → reaches (4s → 11.5s)' }, + 'card-group': { + 'title-fg': + 'the STATIC heading form is not mounted in the demo (0 [data-static] nodes vs 1 [data-button], counted); forced → reaches (rgb(1,2,3))', + 'title-font-family': + 'static heading not mounted in the demo; forced → reaches (Instrument Sans → Zapfino)', + 'title-font-size': + 'static heading not mounted in the demo; forced → reaches (16 → 1234px)', + 'title-line-height': + 'static heading not mounted in the demo; forced → reaches (20px → 3.77em)', + 'title-padding-block-end': + 'static heading not mounted in the demo; forced → reaches (8 → 1234px)' + }, 'code-block': { 'pre-padding': 'the DEMO HARNESS outweighs the component: `[data-uix-docs] pre` (uix.css:801, 0-1-1) beats `[data-code-block-pre]` (0-1-0) and paints padding/border/radius/bg/font on the
. Forced with !important → reaches (16px 20px → 12px). Seven properties measured, registered in next-features §13'
diff --git a/src/uix/eidos/components/card-group/README.md b/src/uix/eidos/components/card-group/README.md
index 3601e68fc..43600f2fd 100644
--- a/src/uix/eidos/components/card-group/README.md
+++ b/src/uix/eidos/components/card-group/README.md
@@ -169,6 +169,44 @@ Item (ToggleGroup.Item button when selectable).
   `mode` enum + min/max trio. `whenFull` governs the at-cap strategy; the mode
   is derived from the ceiling.
 
+## Talla y tema
+
+**Guard R-5.4** (`npm run theming:sentinel -- card-group `): 15/20 tokens
+mueven un computed en vivo; los cinco que callan son los del encabezado
+ESTÁTICO, que la demo no monta (0 nodos `[data-static]` contra 1
+`[data-button]`, contados), y están adjudicados uno a uno con su medición en
+`scripts/theming-sentinel-exceptions.ts`. Corrido 2026-08-21.
+
+Contrato en `lib/recipes/base.ts > card-group` (20 claves, entrada NUEVA).
+Alcance del censo: **90 %** (era 0 %), con cero globales y cero privados.
+
+**Es un CASCO de composición**, y eso acota el contrato antes de nombrar nada:
+compone ToggleGroup, Collapsible, Card y Button, así que el cromo de las
+tarjetas es de `Card`, el del título de disclosure es de `Button`, y aquí sólo
+entra lo del grupo.
+
+**El eje `size` es sm|md|lg**, no las cinco tallas
+(`CardGroupSize = Extract`): seis coordenadas, no
+diez. Escribir xs/xl habría sido vocabulario muerto.
+
+**`--shape-outer-radius` no es un knob: se CALCULA.** La receta lo compone como
+`card-radius + padding` para que las tarjetas aniden concéntricamente (§30), y
+el censo ya lo clasifica como sistema. Un tema mueve las dos coordenadas y el
+radio exterior se recompone solo — por eso `card-radius` y `padding` son
+públicos y el resultado no.
+
+**Dos privados abreviados murieron aquí**: `--_cg-pad` y `--_cg-card-radius`
+violaban §6 r5 (`cg` por `card-group`), el mismo defecto que los `--_mp-*` de
+media-player. Pasar a públicos con el nombre completo es a la vez el renombrado
+que la regla pedía y la tokenización. (La §3 de la ficha generada decía que la
+receta no declaraba privados: los declaraba en su bloque raíz, y el escáner no
+los vio por buscarlos con el prefijo completo.)
+
+El chevron es geometría propia — dos bordes girados 45° —, así que
+`chevron-size` cubre las dos dimensiones y `chevron-width` el trazo; su
+`currentColor` hereda del título a propósito y no es knob. Los dos `100%` son
+identidad de layout (D-TH.2) y el `opacity` del chevron es sistema.
+
 ## Gaps
 
 - **`disabled` doesn't reach the toggle.** The props table promises "selection
diff --git a/src/uix/eidos/components/card-group/card-group.css b/src/uix/eidos/components/card-group/card-group.css
index 1ca878da3..c387c5dc7 100644
--- a/src/uix/eidos/components/card-group/card-group.css
+++ b/src/uix/eidos/components/card-group/card-group.css
@@ -11,9 +11,10 @@
  */
 
 [data-card-group] {
-	--_cg-pad: var(--space-4);
-	--_cg-card-radius: var(--radius-md);
-	--shape-outer-radius: calc(var(--_cg-card-radius) + var(--_cg-pad));
+	/* The outer radius is COMPUTED, not a knob: a theme moves `card-radius` and
+	   `padding` and the concentric nesting (§30) recomposes itself. The two
+	   per-size coordinates are emitted by the TSC on this host. */
+	--shape-outer-radius: calc(var(--card-group-card-radius) + var(--card-group-padding));
 
 	display: block;
 	box-sizing: border-box;
@@ -30,25 +31,26 @@
 /* ── Title (disclosure trigger = system Button, ghost) ───────────────────────
  * Button owns ALL chrome (bg / border / vertical padding / font / cursor /
  * hover / focus ring / press). The recipe keeps ONLY the header-specific
- * layout: full-bleed horizontal padding aligned to the grid (`--_cg-pad`) and
+ * layout: full-bleed horizontal padding aligned to the grid (`--card-group-padding`) and
  * the chevron pushed to the trailing edge via space-between. */
 
 [data-card-group-title][data-button] {
 	justify-content: space-between;
-	padding-inline: var(--_cg-pad);
-	font-weight: var(--font-weight-semibold);
+	padding-inline: var(--card-group-padding);
+	font-weight: var(--card-group-title-font-weight);
 	text-align: start;
 }
 
 /* Static (non-toggleable) heading — no button, plain header type. */
 [data-card-group-title][data-static] {
 	display: block;
-	padding: var(--_cg-pad) var(--_cg-pad) var(--space-2);
-	color: var(--color-content-primary);
-	font-family: var(--style-label-font-family);
-	font-size: var(--font-size-md);
-	font-weight: var(--font-weight-semibold);
-	line-height: var(--leading-ui);
+	padding: var(--card-group-padding) var(--card-group-padding)
+		var(--card-group-title-padding-block-end);
+	color: var(--card-group-title-fg);
+	font-family: var(--card-group-title-font-family);
+	font-size: var(--card-group-title-font-size);
+	font-weight: var(--card-group-title-font-weight);
+	line-height: var(--card-group-title-line-height);
 }
 
 [data-card-group-title] [data-card-group-title-text] {
@@ -58,12 +60,12 @@
 
 /* Chevron — down at rest, up when the Collapsible is open. */
 [data-card-group-title-chevron] {
-	inline-size: 0.5em;
-	block-size: 0.5em;
+	inline-size: var(--card-group-chevron-size);
+	block-size: var(--card-group-chevron-size);
 	flex: 0 0 auto;
 	margin-block-start: -0.15em;
-	border-inline-end: var(--border-width-medium) solid currentColor;
-	border-block-end: var(--border-width-medium) solid currentColor;
+	border-inline-end: var(--card-group-chevron-width) solid currentColor;
+	border-block-end: var(--card-group-chevron-width) solid currentColor;
 	transform: rotate(45deg);
 	transition: transform var(--duration-fast) var(--ease-out);
 	opacity: var(--opacity-muted);
@@ -76,9 +78,9 @@
 
 [data-card-group-description] {
 	margin: 0;
-	padding: 0 var(--_cg-pad) var(--space-2);
-	color: var(--color-content-muted);
-	font-size: var(--font-size-sm);
+	padding: 0 var(--card-group-padding) var(--card-group-description-padding-block-end);
+	color: var(--card-group-description-fg);
+	font-size: var(--card-group-description-font-size);
 }
 
 /* ── Content region + grid ─────────────────────────────────────────────────── */
@@ -90,14 +92,14 @@
 [data-card-group-grid] {
 	display: grid;
 	grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
-	gap: var(--space-3);
-	padding: 0 var(--_cg-pad) var(--_cg-pad);
+	gap: var(--card-group-grid-gap);
+	padding: 0 var(--card-group-padding) var(--card-group-padding);
 }
 
 /* ── Concentric cards (§30: inner = outer − inset) ─────────────────────────── */
 
 [data-card-group] [data-card] {
-	border-radius: var(--_cg-card-radius);
+	border-radius: var(--card-group-card-radius);
 }
 
 /* The item stretches to fill its grid cell so cards in a row match height. */
@@ -124,16 +126,6 @@
 
 /* ── Size scale ────────────────────────────────────────────────────────────── */
 
-[data-card-group][data-size='sm'] {
-	--_cg-pad: var(--space-3);
-}
-[data-card-group][data-size='sm'] [data-card-group-grid] {
-	gap: var(--space-2);
-}
-
-[data-card-group][data-size='lg'] {
-	--_cg-pad: var(--space-5);
-}
-[data-card-group][data-size='lg'] [data-card-group-grid] {
-	gap: var(--space-4);
-}
+/* Size coordinates (`padding`, `grid-gap`) are EMITTED by the TSC on the host
+ * from the recipe contract — sm | md | lg, the component's whole axis. Keeping
+ * the old `[data-size]` blocks here would shadow the new tokens. */
diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css
index de089d280..957dcaca0 100644
--- a/src/uix/eidos/generated/base.css
+++ b/src/uix/eidos/generated/base.css
@@ -4000,6 +4000,24 @@
 	--splitter-grip-cross: 2px;
 	--splitter-transition-duration: var(--duration-fast);
 	--splitter-transition-ease: var(--ease-default);
+	--card-group-padding-sm: var(--space-3);
+	--card-group-padding-md: var(--space-4);
+	--card-group-padding-lg: var(--space-5);
+	--card-group-grid-gap-sm: var(--space-2);
+	--card-group-grid-gap-md: var(--space-3);
+	--card-group-grid-gap-lg: var(--space-4);
+	--card-group-card-radius: var(--radius-md);
+	--card-group-title-font-weight: var(--font-weight-semibold);
+	--card-group-title-fg: var(--color-content-primary);
+	--card-group-title-font-family: var(--style-label-font-family);
+	--card-group-title-font-size: var(--size-md-font-size);
+	--card-group-title-line-height: var(--leading-ui);
+	--card-group-title-padding-block-end: var(--space-2);
+	--card-group-chevron-size: 0.5em;
+	--card-group-chevron-width: var(--border-width-medium);
+	--card-group-description-fg: var(--color-content-muted);
+	--card-group-description-font-size: var(--size-sm-font-size);
+	--card-group-description-padding-block-end: var(--space-2);
 	--code-block-radius: var(--radius-md);
 	--code-block-fg: var(--color-content-primary);
 	--code-block-border-width: var(--border-width);
@@ -6773,6 +6791,21 @@
 	--_callout-palette-text: var(--palette-text, var(--callout-primary-text));
 }
 
+[data-card-group] {
+	--card-group-padding: var(--card-group-padding-md);
+	--card-group-grid-gap: var(--card-group-grid-gap-md);
+}
+
+[data-card-group][data-size='sm'] {
+	--card-group-padding: var(--card-group-padding-sm);
+	--card-group-grid-gap: var(--card-group-grid-gap-sm);
+}
+
+[data-card-group][data-size='lg'] {
+	--card-group-padding: var(--card-group-padding-lg);
+	--card-group-grid-gap: var(--card-group-grid-gap-lg);
+}
+
 [data-combobox-control], [data-combobox-input] {
 	--combobox-control-height: var(--combobox-control-height-md);
 	--combobox-control-font-size: var(--combobox-control-font-size-md);
diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts
index 60a4b8219..7cc5a1bd1 100644
--- a/src/uix/eidos/lib/recipes/base.ts
+++ b/src/uix/eidos/lib/recipes/base.ts
@@ -6378,6 +6378,59 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
 	// floating layer mirrors this computed z onto the positioner wrapper). The
 	// band is deliberately NOT the global `--z-index-*` ladder — see
 	// STATIC_Z_INDEX_OVERLAY.
+	// CardGroup is a COMPOSITION SHELL (ToggleGroup + Collapsible + Card +
+	// Button): the cards' chrome belongs to Card's recipe and the title's to
+	// Button's. Only group-level visual lives here. The size axis is sm|md|lg —
+	// `CardGroupSize = Extract` — so there are no
+	// xs/xl coordinates to write.
+	'card-group': {
+		'padding-sm': 'var(--space-3)',
+		'padding-md': 'var(--space-4)',
+		'padding-lg': 'var(--space-5)',
+		'grid-gap-sm': 'var(--space-2)',
+		'grid-gap-md': 'var(--space-3)',
+		'grid-gap-lg': 'var(--space-4)',
+		padding: {
+			declarations: [
+				{ value: 'var(--card-group-padding-md)', scope: 'host' },
+				{ value: 'var(--card-group-padding-sm)', scope: 'size:sm' },
+				{ value: 'var(--card-group-padding-lg)', scope: 'size:lg' }
+			]
+		},
+		'grid-gap': {
+			declarations: [
+				{ value: 'var(--card-group-grid-gap-md)', scope: 'host' },
+				{ value: 'var(--card-group-grid-gap-sm)', scope: 'size:sm' },
+				{ value: 'var(--card-group-grid-gap-lg)', scope: 'size:lg' }
+			]
+		},
+
+		// The card corner the group nests concentrically against. The recipe
+		// COMPUTES `--shape-outer-radius` from this plus `padding` (§30), so a
+		// theme moves the two coordinates and the outer radius recomposes itself —
+		// that system token is not re-coined here.
+		'card-radius': 'var(--radius-md)',
+
+		// Title — both forms (the disclosure Button and the static heading) share
+		// the weight. Everything else about the button is Button's recipe.
+		'title-font-weight': 'var(--font-weight-semibold)',
+		'title-fg': 'var(--color-content-primary)',
+		'title-font-family': 'var(--style-label-font-family)',
+		'title-font-size': 'var(--size-md-font-size)',
+		'title-line-height': 'var(--leading-ui)',
+		// The gap under the header, the one part of its padding shorthand that is
+		// not the group's own inset.
+		'title-padding-block-end': 'var(--space-2)',
+
+		// Chevron — own geometry (two borders rotated 45°), not a composed icon.
+		// Its colour is `currentColor` on purpose: it inherits from the title.
+		'chevron-size': '0.5em',
+		'chevron-width': 'var(--border-width-medium)',
+
+		'description-fg': 'var(--color-content-muted)',
+		'description-font-size': 'var(--size-sm-font-size)',
+		'description-padding-block-end': 'var(--space-2)'
+	},
 	// CodeBlock. The `[data-code-block-code]` node is deliberately ABSENT from
 	// this contract: it consumes the `--style-code-*` typographic layer (with the
 	// wrapper's per-instance escape hatch `--_code-block-font-size` in front),
diff --git a/web/routes/uix/components/card-group/+page.svelte b/web/routes/uix/components/card-group/+page.svelte
index 1c8510e14..0ec6466a7 100644
--- a/web/routes/uix/components/card-group/+page.svelte
+++ b/web/routes/uix/components/card-group/+page.svelte
@@ -6,8 +6,9 @@
 	} from '$uix/eidos/components/card-group';
 	import { Card } from '$uix/eidos/components/card';
 	import { UNLIMITED, isSelectable, type SelectionLimit } from '$libs/selection';
+	import TokensPanel from '../../lib/TokensPanel.svelte';
 
-	type Tab = 'live' | 'api' | 'composition' | 'a11y';
+	type Tab = 'live' | 'api' | 'composition' | 'tokens' | 'a11y';
 	let tab = $state('live');
 
 	// ── Live state ──────────────────────────────────────────────────────────
@@ -246,6 +247,9 @@
 		
 		
 		
+		
 		
 	
 
@@ -432,6 +436,10 @@
 		
 	{/if}
 
+	{#if tab === 'tokens'}
+		
+	{/if}
+
 	{#if tab === 'a11y'}
 		

Accessibility