feat(theming+soma): media-player Batch-4 (SOM-4) + THM-2 shared-palette rollout to 14 surfaces

media-player Batch-4 (closes SOM-4): the 10 sub-parts migrate to
renderProps() (35 attr-dup hits -> 0). Register the sources the morfo
reads (label propRef, on stateRef, direction/type) so renderProps emits
type/role/aria-*/data-state; keep only contract-only soma-owned attrs.
Pin the soma-attr-audit ceiling to 0.

THM-2 rollout - collapse each per-component palette cascade into the
shared per-instance layer (size win, behavior-preserving for roles,
adds the 33 scales):
- Rename palette tokens to canonical _palette-{slot} + route via the
  presence-guarded forward: tags-input, stepper, badge, card, select
  (parts-aware trigger+content), file-upload, editable, tag-group.
- Extend the shared vocabulary with alpha surface(a2)/surface-hover(a3)
  slots (soft variant of variant-aware recipes) and hover(4) - the
  latter also fixes Toggle's latent scale-hover.
- tag-group: variant-aware migration (solid/outline -> canonical,
  soft -> alpha surface; merge outline-fg into text, prune orphans).
- toggle-group: replace the 4-role cross-recipe composition with a
  hand-authored presence-scoped route to the shared layer (the
  composition DSL can't presence-scope); covers roles + 33 scales.
- Widen color to ComponentColor (roles + 33 scales) where arbitrary
  color is meaningful: badge, tag-group, tags-input, toggle-group, and
  add the scale picker to their demos. Semantic controls stay restricted.

Verified end-to-end with the REAL components (drive the demo color
control -> the component emits data-color -> the shared layer resolves
the exact scale). Gates: recipe-css-contract/config/contracts/
soma-attr-audit/media-player suites green; 0 orphan tokens.

avatar remains for a focused pass: its variant-resolved matrix and badge
sub-part don't fit a mechanical migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 4fae7e243d
commit 05f0031cad

@ -106,6 +106,41 @@ channel consume it; `sema-map.ts`'s per-family hold column died). The chapter
holds in ~34 places. **Blocked**: the file is the author's WIP (uncommitted
edits) — sync it when the author's pass lands, not before.
## 6. THM-2 rollout — per-instance palette to the remaining surfaces — 2026-07-13
**Origin**: clean-room THM-2 (`docs/process/continue-cleanroom-fixes-2026-07.md`
§THM-2 tracker). The universal `--palette-*` shared layer + presence-guarded
forward shipped 2026-07-12 (`0e75890f`, 5/17 surfaces: button/toggle/checkbox/
radio-group/switch). The remaining **12/17** each need their recipe to expose a
`palette-*` (public) or `_palette-*` (private) indirection with **canonical
slots** (`track·element·border·solid·solid-hover·text·contrast`) so the forward
routes them to the shared 33-scale cascade.
**Census (2026-07-13)** of the "forward-ready privates" subgroup revealed it
splits by slot vocabulary, not by "public vs private":
- **Clean rename** (`_accent-*`→`_palette-*`, canonical slots): **tags-input**
(track/border/text), **stepper** (solid/track/border/text). Rename the token
key in `recipes/base.ts` + the `--_{c}-accent-*` consumers in the `.css`; the
forward then auto-emits. *(Both executed 2026-07-13 — see the plan's
media-player/THM-2 blocks and the changelog when committed.)*
- **Needs mini-design** (non-canonical vocabulary): **tag-group** — its
`_selected-*` palette is variant-aware (`soft-bg`=surface, `soft-bg-hover`=
surface-hover), tones the canonical 7 palette slots don't carry, so the soft
variant can't route through the shared layer without a slot-mapping decision.
**toggle-group** — has no private palette at all; it drives color via
cross-recipe composition into Toggle's PUBLIC `--toggle-palette-*` (only 4
roles hardcoded today), so per-instance `color=teal` needs the composition to
read the shared `--palette-*` on the group and route it to the items — a
composition change, not a forward.
- **Rol-directo, mini-diseño** (from the plan): badge, card, avatar,
avatar-badge, editable, file-upload, select-trigger, select-content — map
chrome→slot, add the indirection, route CSS. badge/card = best first pair.
**Sequence**: clean renames first (cheap, behavior-preserving for roles +
additive for the 33 scales); tag-group/toggle-group + the rol-directo set are
per-component dossiers (lote estilo S4, live `color=teal` + nesting check).
Detail + running tracker: the plan's §THM-2. Remove this entry when 17/17 ship.
## 5. Book editorial note — "el hold es suelo, no tijera" — 2026-07-06
**Origin**: A.4/A.5 verdict. The mechanism (hold = floor for the expression,

@ -227,59 +227,114 @@
### Tracker de rollout THM-2 (sucesor de `pendiente_color_demos.md`)
**DONE — resuelven per-instance (5/17):** button, toggle, checkbox,
radio-group, switch.
**BATCH pendiente (12/17)** — cada una: declarar tokens `palette-*`/
`_palette-*` (host default) + consumir `--{c}-palette-*` en su receta. Split:
- **Paleta-privada lista-para-forward** (verificar slots, casi gratis):
toggle-group, tag-group, tags-input, stepper (revisar cuáles ya tienen
`_palette-*` y solo les falta alinear slots — el forward las coge solas).
- **Rol-directo, necesitan mini-diseño** (mapear cromo→slot, añadir
indirección + rutar CSS): badge, card, avatar, avatar-badge, editable,
file-upload, select-trigger, select-content.
(badge/card se investigaron: consumen roles directos sin indirección de
paleta — de ahí el mini-diseño; son el mejor primer par del lote.)
Lote estilo S4 (dossier por componente, verificación en vivo de `color=teal`
+ anidamiento). Registrar en `docs/next-features.md` (registro de iniciativas
del track paralelo) al arrancar.
## ⏭️ ARRANQUE PRÓXIMA SESIÓN → media-player Batch-4 (SCOPEADO, listo para ejecutar)
Estado al cierre 2026-07-12: **F0 · F1 · F2 · F3 · F4-C · SEM-4 · THM-2 ✅**
commiteadas y pusheadas (`4addfd7d` · `29e621b1` · `b55ca6ee` · `082b6623` ·
`0e75890f`). La tabla §9 de la clean-room está prácticamente entera; queda
cola de rollout/scoping.
### 1º — media-player Batch-4 (cierra SOM-4; el último batch de attr-dedup)
**Diagnóstico ya hecho** (`npx tsx scripts/soma-attr-audit.ts --component
media-player` → 35 hits / 10 partes): el provider
(`soma/components/media-player/media-player-provider.svelte.ts`) registra en
el runtime **SOLO la parte `provider`** (`this.runtimePart = this.runtime.part('provider', …)`);
las 10 sub-partes declaran sus props **a mano** (role/type/aria-*/data-*) que
el morfo YA declara. Hits por parte: media(data-media) · poster/buffering(aria-hidden)
· play/seek/mute/fullscreen/pip/caption-button(type,role,aria-label,aria-keyshortcuts,
aria-pressed,data-state,data-direction) · time(data-type).
**Patrón de migración** (idéntico a batches 1–3, plantilla en
`carousel-provider.svelte.ts:444` y `table`): por CADA sub-parte —
(a) registrarla en el runtime `this.runtime.part('play-button', { ref, … })`;
(b) en su props getter, `...this.runtimePart('play-button').renderProps()`;
(c) dejar SOLO el override computado (el `aria-label` localizado vía
`this.label`; `data-state`/`aria-pressed` los emite renderProps desde
`stateRef`). El morfo declara: buttons con `role:'button'` +
`type:literal('button')` + `aria-label:propRef('label')` + `aria-keyshortcuts:literal` +
`data-state:stateRef` + `aria-pressed:stateRef` — todo eso lo cubre renderProps.
**Cierre**: actualizar el ceiling de media-player en
`soma-attr-audit.test.ts` (hoy en el comentario "único batch restante", sin
pin — ponerle su techo real de overrides legítimos). Verificar: baseline 57 ·
contracts 38/38 · soma-attr-audit verde · **navegador** (media-player sigue
reproduciendo/mute/fullscreen; los botones conservan aria-label localizado).
### Después (cola, sin orden fijo)
**El rollout tiene DOS ejes (descubrimiento 2026-07-12 por crítica del usuario
"usa componentes reales, no divs"):** (1) el **token** en la capa compartida
(colapso de peso + resolución CSS); (2) el **tipo de la prop `color`** del
componente, que GATEA qué colores son alcanzables. El rename del token hace que
la cascada resuelva CUALQUIER `data-color` que el componente emita — pero si la
prop está tipada `ColorRole`, el componente NUNCA emite una escala, así que
`color="teal"` es error de tipo y las 33 escalas quedan LATENTES. Verificación
correcta = componente REAL + control REAL del demo (el picker de 33 escalas),
nunca `<div data-color>` fabricado ([[feedback_verify_with_real_components_not_synthetic_dom]]).
**Capa compartida (eje 1) — DONE (13):** button, toggle, checkbox, radio-group,
switch · tags-input, stepper, badge, card, select, file-upload, editable ·
**tag-group** (2026-07-12, variant-aware: solid/outline→slots canónicos,
**soft→slots ALPHA `surface`(a2)/`surface-hover`(a3)** que AÑADÍ a
`PALETTE_SLOT_STEP` + capa compartida + forward — extensión aditiva del
vocabulario, aprobada por el usuario; merge outline-fg→text + poda de 8
intermedios huérfanos).
**Prop `color` ampliada a `ComponentColor` (eje 2) — DONE + verificado real
(3):** **badge, tag-group, tags-input** (2026-07-12, `ColorRole`→`ComponentColor`
+ picker de 33 escalas en el demo + **verificado con el componente real**: click
en el chip "teal" del demo → el componente emite `data-color="teal"` → resuelve
escala teal exacta, incl. surface alpha translúcido). button/toggle/card ya la
tenían. **Semánticos, se quedan restringidos** (decisión usuario): checkbox/
radio-group (`AffirmativeColorRole`) · stepper (`ProgressiveColorRole`) ·
editable (`EditableColorRole`) · select/file-upload (`ColorRole`, accents de
form-control). Para éstos, THM-2 = colapso de peso behavior-preserving (roles).
**toggle-group ✅ HECHO + verificado real (2026-07-12):** colcoreaba vía
composición cross-recipe en la paleta pública de Toggle (4 roles). Migrado a
**ruta a la capa compartida**: (1) añadí el slot `hover`(4) a `PALETTE_SLOT_STEP`
— behavior-preserving para Toggle (`--toggle-{role}-hover`=`--color-{role}-hover`)
y **de paso arregla el hover-en-escalas de Toggle** (estaba latente-roto); (2)
quité el bloque `composition` del recipe (era su ÚNICO consumidor — el DSL de
composición no puede presence-scope `[data-color]` sin valor) y **hand-authoré
UNA regla** en `toggle-group.css`: `[data-toggle-group][data-color]
[data-toggle-group-item] { --toggle-palette-{slot}: var(--palette-{slot}) }`
(presence-guard = anidamiento seguro; cubre roles + 33 escalas); (3) amplié
`ToggleGroupColor` a `ComponentColor` (+ sentinels absent/unknown) + picker.
Test `active-eidos-config` que pinneaba la composición → actualizado (el DSL
`emitComposition` queda como infra sin consumidor actual). **Verificado real**:
chip "teal" → grupo emite `data-color="teal"` → item (un Toggle) obtiene
`--toggle-palette-solid`=teal-9, `-hover`=teal-4, `-border`=teal-7, `-text`=teal-11;
Toggle sin regresión (affirm-hover preservado, teal-hover ahora funciona).
**Pendiente (1) — avatar: NO es migración mecánica, tiene 2 arrugas reales:**
- El `matrix()` resuelve **color×variante juntos**, y un avatar **sin color**
con variant solid/soft tiene default sutil (neutral-*soft*, no neutral-solid,
porque las decls del matrix exigen color): un split a `_palette-*` variant-only
cambiaría ese default silenciosamente (regresión).
- La **sub-parte `badge`** tiene su PROPIO default (affirm-solid) y sigue el
color del avatar por herencia — no encaja en el modelo presence-guard del
forward (que rutea por el `data-color` PROPIO del elemento, y el badge no
tiene el suyo). Migrar el root dejando el badge per-component es posible pero
parcial.
→ Es una reescritura real de un componente visual shipped, con riesgo de
regresión difícil de verificar sin screenshots. **Pase enfocado** con
verificación visual real, no exprimido al final de sesión.
Rename limpio (patrón, para los que tenían slots canónicos): renombrar la clave
del token en `recipes/base.ts` a `_palette-{slot}` + los consumidores
`--_{c}-{slot}`/`--_{c}-accent-{slot}` en el `.css`; el forward auto-emite
`[data-{c}][data-color]{--_{c}-palette-slot: var(--palette-slot, host)}`.
Baseline estable = 9 errores AJENOS en `src/` (4 connection + 1 nav-menu
`sideOffset` + 4 file-upload-soma `props['aria-label']`, todos pre-existentes/
track paralelo, probado con git stash). Registrado en `docs/next-features.md §6`.
## media-player Batch-4 ✅ EJECUTADA 2026-07-13 — SOM-4 CERRADO (attr-dedup completo)
> Gates: **soma-attr-audit media-player = 0 hits** (35→0) · provider test 9/9 ·
> guard soma-attr-audit con `media-player: 0` verde · contracts **38/38** ·
> baseline propio **57** restaurado · **navegador (dev real)**:
> DOM de cada botón con sus attrs de morfo emitidos por renderProps + labels
> LOCALIZADOS (`Reproducir`/`Silenciar`/`Avanzar`/`Pantalla completa`), y el
> camino reactivo verificado en vivo (mute click → `data-state` off→on +
> `aria-pressed` false→true + `aria-label` "Silenciar"→"Activar sonido"; play
> click → paused→playing + "Reproducir"→"Pausar").
>
> **Hallazgo de reconciliación**: el ceiling REAL era **0**, no un residuo. El
> handoff asumía "dejar el `aria-label` como override" (patrón carousel), pero
> carousel guarda su label porque su morfo declara un `translationRef` DEFAULT y
> soma añade la lógica prop-or-translation encima. El morfo de media-player
> declara `aria-label: propRef('label')` a secas — el label ES un prop, así que
> lo correcto es ALIMENTARLO como source (`props: { label: () => this.label }`)
> y dejar que renderProps lo emita. Cero override.
>
> **Patrón aplicado** a las 10 sub-partes (`media-player-provider.svelte.ts`):
> por parte, registrar en `runtime.part(...)` los sources que el morfo lee —
> `props: { label }` (buttons), `states: { on }` (mute/fullscreen/pip/caption,
> para el par `data-state`+`aria-pressed` vía `stateRef('on')`),
> `props: { direction }` (seek), `props: { type }` (time) — y en el `.assert()`
> cambiar `...runtimePart.props` por `...runtimePart.renderProps()`, dejando SOLO
> lo contract-only que soma posee (play-button `data-state` sin `value`;
> `data-disabled` de presencia; `data-media` de media/`aria-hidden` de
> poster/buffering salen de renderProps). El `data-media` de la parte `media` lo
> resuelve renderProps contra el source `media` de nivel-componente.
>
> **Ajuste de test**: en `media-player-provider.svelte.test.ts` los accesos
> indexados `props['aria-label']`/`['aria-pressed']`/`['data-state']` dejaron de
> tipar (el `as const` sobre el bag ya no expone esas claves — vienen del spread
> `renderProps()`: `Record<string,unknown>`) → convertidos a `toMatchObject`
> (mismo aserto de runtime, type-safe; estilo del test de carousel). Valores en
> runtime idénticos byte-a-byte (los 9 tests siguen verdes).
>
> **Cierre en el guard**: `soma-attr-audit.test.ts` gana `'media-player': 0`
> con dossier de por qué es 0; el comentario "Still UNMIGRATED" retirado.
### Cola pendiente (sin orden fijo — algunas requieren decisión de usuario)
- **THM-2 rollout** — 12 superficies restantes (tracker en §THM-2 arriba):
privadas-forward-ready [toggle-group/tag-group/tags-input/stepper] vs
rol-directo-mini-diseño [badge/card/avatar/…]. Lote estilo S4.

@ -1586,7 +1586,7 @@ describe('ActiveEidos config', () => {
)
})
it('emits TSC v2.2 multi-part and cross-recipe composition selectors', () => {
it('emits TSC v2.2 multi-part selectors', () => {
const css = createThemeBaseEidos().renderStaticCss()
// Multi-part scope: a `parts: ['trigger','content']` token emits a
@ -1595,9 +1595,12 @@ describe('ActiveEidos config', () => {
expect(css).toContain(
"[data-select-trigger][data-color='affirm'], [data-select-content][data-color='affirm']"
)
// Cross-recipe composition: toggle-group overrides toggle's palette tokens,
// scoped to its own cascade + a descendant target selector.
expect(css).toContain("[data-toggle-group][data-color='primary'] [data-toggle-group-item]")
// NOTE: cross-recipe `composition` had a single consumer, toggle-group.
// THM-2 (2026-07-12) superseded its per-color enumeration with a
// presence-guarded route to the shared `--palette-*` layer — ONE rule for
// all roles + 33 scales, hand-authored in `toggle-group.css` (the
// composition DSL can't presence-scope). `emitComposition` remains as
// infrastructure for a future per-color cross-recipe need.
})
it('renders raw theme CSS with a selector derived only from the theme id', () => {

@ -26,8 +26,8 @@
--_badge-radius: var(--badge-radius-full);
/* Default variant = soft; overridden by data-variant below. */
--_badge-bg: var(--_badge-track);
--_badge-fg: var(--_badge-text);
--_badge-bg: var(--_badge-palette-track);
--_badge-fg: var(--_badge-palette-text);
--_badge-border-color: transparent;
display: inline-flex;
@ -83,26 +83,26 @@
/* ── Variant slices ──────────────────────────────────────────────────── */
[data-badge][data-variant='soft'] {
--_badge-bg: var(--_badge-track);
--_badge-fg: var(--_badge-text);
--_badge-bg: var(--_badge-palette-track);
--_badge-fg: var(--_badge-palette-text);
--_badge-border-color: transparent;
}
[data-badge][data-variant='solid'] {
--_badge-bg: var(--_badge-solid);
--_badge-fg: var(--_badge-contrast);
--_badge-bg: var(--_badge-palette-solid);
--_badge-fg: var(--_badge-palette-contrast);
--_badge-border-color: transparent;
}
[data-badge][data-variant='outline'] {
--_badge-bg: transparent;
--_badge-fg: var(--_badge-text);
--_badge-border-color: var(--_badge-border);
--_badge-fg: var(--_badge-palette-text);
--_badge-border-color: var(--_badge-palette-border);
}
[data-badge][data-variant='ghost'] {
--_badge-bg: transparent;
--_badge-fg: var(--_badge-text);
--_badge-fg: var(--_badge-palette-text);
--_badge-border-color: transparent;
}
@ -118,13 +118,13 @@
inline-size: var(--badge-dot-size);
block-size: var(--badge-dot-size);
border-radius: var(--radius-full);
background: var(--_badge-solid);
background: var(--_badge-palette-solid);
}
[data-badge][data-variant='solid'] [data-badge-dot] {
/* On a solid background, switch the dot to the contrast tone so it
* still pops against the filled chip. */
background: var(--_badge-contrast);
background: var(--_badge-palette-contrast);
}
/* ── Icon slot ───────────────────────────────────────────────────────── */

@ -1,5 +1,5 @@
import type { Snippet } from 'svelte';
import type { ChipVariant, ColorRole, ResponsiveProp, ShapeFamily, Size } from '$uix/eidos/lib/types';
import type { ChipVariant, ComponentColor, ResponsiveProp, ShapeFamily, Size } from '$uix/eidos/lib/types';
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
import type { BoxProps } from '../box/types';
@ -18,11 +18,11 @@ export type BadgeVariant = ChipVariant;
export type BadgeSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg'>;
/**
* Badge color. Full `ColorRole` (primary/secondary/neutral + 5 intents)
* so consumers can map status semantics directly (success → affirm,
* warning → risk, error → threat, …).
* Badge color. A role (primary/secondary/neutral + 5 intents, so consumers map
* status semantics directly: success → affirm, warning → risk, error → threat),
* OR any of the 33 palette scales for a brand/arbitrary chip (THM-2 · §25.5).
*/
export type BadgeColor = ColorRole;
export type BadgeColor = ComponentColor;
/**
* Border radius scale. Independent from `size` so consumers can choose

@ -39,13 +39,14 @@
--_card-body-font-size: var(--card-body-font-size-md);
--_card-radius: var(--card-radius-md);
/* `--_card-track/border/solid/text/contrast` are TSC-emitted from
* `lib/recipes/base.ts > card._track/_border/_solid/_text/_contrast`
* with default = neutral + 7 per-color overrides. */
/* `--_card-palette-{track,border,solid,text,contrast}` are TSC-emitted from
* `lib/recipes/base.ts > card._palette-*` with default = neutral + 7 per-color
* overrides; the THM-2 shared-layer forward routes them to `color="teal"` +
* the 33 scales. */
/* Default variant = soft; overridden by data-variant below. */
--_card-bg: var(--_card-track);
--_card-fg: var(--_card-text);
--_card-bg: var(--_card-palette-track);
--_card-fg: var(--_card-palette-text);
--_card-border-color: transparent;
display: flex;
@ -144,36 +145,36 @@
* brand / per-tag / event colors where the consumer accepts that.
*/
[data-card][data-color-custom] {
--_card-track: color-mix(in srgb, var(--card-color-custom) 14%, transparent);
--_card-border: color-mix(in srgb, var(--card-color-custom) 50%, transparent);
--_card-solid: var(--card-color-custom);
--_card-text: var(--card-color-custom);
--_card-contrast: var(--card-color-custom-contrast, white);
--_card-palette-track: color-mix(in srgb, var(--card-color-custom) 14%, transparent);
--_card-palette-border: color-mix(in srgb, var(--card-color-custom) 50%, transparent);
--_card-palette-solid: var(--card-color-custom);
--_card-palette-text: var(--card-color-custom);
--_card-palette-contrast: var(--card-color-custom-contrast, white);
}
/* ── Variant slices ──────────────────────────────────────────────────── */
[data-card][data-variant='soft'] {
--_card-bg: var(--_card-track);
--_card-fg: var(--_card-text);
--_card-bg: var(--_card-palette-track);
--_card-fg: var(--_card-palette-text);
--_card-border-color: transparent;
}
[data-card][data-variant='solid'] {
--_card-bg: var(--_card-solid);
--_card-fg: var(--_card-contrast);
--_card-bg: var(--_card-palette-solid);
--_card-fg: var(--_card-palette-contrast);
--_card-border-color: transparent;
}
[data-card][data-variant='outline'] {
--_card-bg: var(--color-surface-default);
--_card-fg: var(--_card-text);
--_card-border-color: var(--_card-border);
--_card-fg: var(--_card-palette-text);
--_card-border-color: var(--_card-palette-border);
}
[data-card][data-variant='ghost'] {
--_card-bg: transparent;
--_card-fg: var(--_card-text);
--_card-fg: var(--_card-palette-text);
--_card-border-color: transparent;
}
@ -195,11 +196,11 @@
}
[data-card][data-interactive][data-variant='ghost']:hover:not([data-disabled]) {
background: var(--_card-track);
background: var(--_card-palette-track);
}
[data-card][data-interactive][data-variant='outline']:hover:not([data-disabled]) {
background: var(--_card-track);
background: var(--_card-palette-track);
}
/* `:not(:has(…:active))` — when an INTERACTIVE descendant is being pressed (a
@ -221,13 +222,13 @@
/* ── Selected state ──────────────────────────────────────────────────── */
[data-card][data-selected] {
box-shadow: 0 0 0 var(--card-selected-ring-width) var(--_card-solid);
box-shadow: 0 0 0 var(--card-selected-ring-width) var(--_card-palette-solid);
}
[data-card][data-selected][data-variant='solid'] {
/* On solid bg the ring would clash with the fill — use the contrast
* tone for a visible outline. */
box-shadow: 0 0 0 var(--card-selected-ring-width) var(--_card-contrast);
box-shadow: 0 0 0 var(--card-selected-ring-width) var(--_card-palette-contrast);
}
/* Selected + hovered: `box-shadow` is a single property, so the interactive
@ -235,12 +236,12 @@
* on hover). Compose both — ring first, hover shadow second. */
[data-card][data-selected][data-interactive]:hover:not([data-disabled]) {
box-shadow:
0 0 0 var(--card-selected-ring-width) var(--_card-solid),
0 0 0 var(--card-selected-ring-width) var(--_card-palette-solid),
var(--card-hover-shadow);
}
[data-card][data-selected][data-variant='solid'][data-interactive]:hover:not([data-disabled]) {
box-shadow:
0 0 0 var(--card-selected-ring-width) var(--_card-contrast),
0 0 0 var(--card-selected-ring-width) var(--_card-palette-contrast),
var(--card-hover-shadow);
}
@ -288,7 +289,7 @@
/* Solid variant pulls the description toward the contrast tone with a
* slight transparency so it reads as secondary on the saturated bg. */
[data-card][data-variant='solid'] [data-card-description] {
color: color-mix(in srgb, var(--_card-contrast) 80%, transparent);
color: color-mix(in srgb, var(--_card-palette-contrast) 80%, transparent);
}
/* ── Body slot ───────────────────────────────────────────────────────── */
@ -312,7 +313,7 @@
/* Footer border for solid variant — use a translucent contrast line. */
[data-card][data-variant='solid'] [data-card-footer] {
border-block-start-color: color-mix(in srgb, var(--_card-contrast) 18%, transparent);
border-block-start-color: color-mix(in srgb, var(--_card-palette-contrast) 18%, transparent);
}
/* ── Reduced motion ──────────────────────────────────────────────────── */

@ -3,7 +3,9 @@
--_editable-control-height: var(--editable-control-height-md);
--_editable-control-padding-inline: var(--editable-control-padding-inline-md);
--_editable-font-size: var(--editable-font-size-md);
/* `--_editable-accent-*` is TSC-emitted from `lib/recipes/base.ts > editable._accent-*`. */
/* `--_editable-palette-*` is TSC-emitted from `lib/recipes/base.ts >
* editable._palette-*`; the THM-2 shared-layer forward routes them to
* `color="teal"` + the 33 scales. */
display: inline-flex;
align-items: center;
@ -91,7 +93,7 @@
[data-editable-input] {
inline-size: 100%;
border-color: var(--_editable-accent-border);
border-color: var(--_editable-palette-border);
background: var(--editable-control-bg);
box-shadow: var(--editable-control-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
@ -124,12 +126,12 @@
}
[data-editable-input]:hover:not([data-disabled]):not([data-readonly]) {
border-color: var(--_editable-accent-border);
border-color: var(--_editable-palette-border);
}
[data-editable-preview]:focus-visible,
[data-editable-input]:focus-visible {
border-color: var(--_editable-accent-border);
border-color: var(--_editable-palette-border);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
@ -180,8 +182,8 @@
}
[data-editable-submit-trigger] {
border-color: var(--_editable-accent-border);
background: var(--_editable-accent-solid);
border-color: var(--_editable-palette-border);
background: var(--_editable-palette-solid);
color: var(--editable-submit-color);
}
@ -193,8 +195,8 @@
}
[data-editable-submit-trigger]:hover:not(:disabled) {
background: var(--_editable-accent-solid-hover);
border-color: var(--_editable-accent-solid-hover);
background: var(--_editable-palette-solid-hover);
border-color: var(--_editable-palette-solid-hover);
}
[data-editable-edit-trigger]:focus-visible,

@ -66,9 +66,9 @@
[data-file-upload-dropzone]:hover:not([data-disabled]),
[data-file-upload-dropzone][data-dragging] {
border-color: var(--_file-upload-accent-border);
background: var(--_file-upload-accent-track);
color: var(--_file-upload-accent-text);
border-color: var(--_file-upload-palette-border);
background: var(--_file-upload-palette-track);
color: var(--_file-upload-palette-text);
}
[data-file-upload-dropzone]:focus-visible,
@ -105,14 +105,14 @@
}
[data-file-upload-trigger] {
border-color: var(--_file-upload-accent-solid);
background: var(--_file-upload-accent-solid);
border-color: var(--_file-upload-palette-solid);
background: var(--_file-upload-palette-solid);
color: var(--file-upload-button-solid-color);
}
[data-file-upload-trigger]:hover:not([data-disabled]) {
border-color: var(--_file-upload-accent-solid-hover);
background: var(--_file-upload-accent-solid-hover);
border-color: var(--_file-upload-palette-solid-hover);
background: var(--_file-upload-palette-solid-hover);
}
[data-file-upload-item-remove],
@ -194,7 +194,7 @@
inset-inline-start: 0;
inline-size: calc(var(--_file-upload-progress-value, 0) * 1%);
border-radius: inherit;
background: var(--_file-upload-accent-solid);
background: var(--_file-upload-palette-solid);
transition: inline-size var(--file-upload-transition-duration) var(--file-upload-transition-ease);
}

@ -4,8 +4,9 @@
* `data-color` lives on the trigger AND content parts (NOT on the root
* `[data-select]`) because content portals out of the document tree and
* must carry the color independently. The per-color cascade for the 4
* private `_accent-*` slots is declared in the recipe (`lib/recipes/base.ts >
* select._accent-*`) via TSC v2.2 `parts: ['trigger', 'content']`. The
* private `_palette-*` slots is declared in the recipe (`lib/recipes/base.ts >
* select._palette-*`) via TSC v2.2 `parts: ['trigger', 'content']`; the THM-2
* shared-layer forward (parts-aware) routes them to `color="teal"` + 33 scales. The
* generator emits one block per `data-color` value that targets both
* parts as a comma-separated selector — same cascade discipline as a
* single-host TSC component, just with two target roots.
@ -97,13 +98,13 @@
}
[data-select-trigger][data-state='open'] {
border-color: var(--_select-accent-border);
border-color: var(--_select-palette-border);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-select-trigger]:focus-visible {
border-color: var(--_select-accent-border);
border-color: var(--_select-palette-border);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
@ -159,7 +160,7 @@
[data-select-trigger][data-state='open'] [data-select-indicator] {
transform: rotate(180deg);
color: var(--_select-accent-text);
color: var(--_select-palette-text);
}
[data-floating-wrapper]:has(> [data-select-content]) {
@ -309,8 +310,8 @@
/* Hover + keyboard highlight come from the canonical archetype layer
(archetypes.css). Only the chosen value's accent stays component-local. */
[data-select-item][data-state='checked'] {
background: var(--_select-accent-track);
color: var(--_select-accent-text);
background: var(--_select-palette-track);
color: var(--_select-palette-text);
}
[data-select-item][data-disabled] {

@ -2,8 +2,9 @@
--_stepper-gap: var(--stepper-gap-md);
--_stepper-indicator-size: var(--stepper-indicator-size-md);
--_stepper-font-size: var(--stepper-font-size-md);
/* `--_stepper-accent-*` private tokens are TSC-emitted from
* `lib/recipes/base.ts > stepper._accent-*` with the per-color cascade. */
/* `--_stepper-palette-*` private tokens are TSC-emitted from
* `lib/recipes/base.ts > stepper._palette-*` with the per-color cascade;
* the THM-2 shared-layer forward routes them to `color="teal"` + 33 scales. */
display: grid;
gap: var(--_stepper-gap);
@ -101,15 +102,15 @@
[data-stepper-trigger][data-state='current'] [data-stepper-indicator],
[data-stepper-indicator][data-state='current'] {
border-color: var(--_stepper-accent-border);
background: var(--_stepper-accent-track);
color: var(--_stepper-accent-text);
border-color: var(--_stepper-palette-border);
background: var(--_stepper-palette-track);
color: var(--_stepper-palette-text);
}
[data-stepper-trigger][data-state='complete'] [data-stepper-indicator],
[data-stepper-indicator][data-state='complete'] {
border-color: var(--_stepper-accent-solid);
background: var(--_stepper-accent-solid);
border-color: var(--_stepper-palette-solid);
background: var(--_stepper-palette-solid);
color: var(--stepper-complete-color);
}
@ -118,15 +119,15 @@
[data-stepper-trigger][data-state='current']
[data-stepper-indicator] {
background: transparent;
color: var(--_stepper-accent-text);
color: var(--_stepper-palette-text);
}
[data-stepper][data-variant='solid'] [data-stepper-indicator][data-state='current'],
[data-stepper][data-variant='solid']
[data-stepper-trigger][data-state='current']
[data-stepper-indicator] {
border-color: var(--_stepper-accent-solid);
background: var(--_stepper-accent-solid);
border-color: var(--_stepper-palette-solid);
background: var(--_stepper-palette-solid);
color: var(--stepper-complete-color);
}
@ -146,7 +147,7 @@
}
[data-stepper-separator][data-complete] {
background: var(--_stepper-accent-solid);
background: var(--_stepper-palette-solid);
}
[data-stepper-content],
@ -188,8 +189,8 @@
}
[data-stepper-next-trigger] {
border-color: var(--_stepper-accent-border);
background: var(--_stepper-accent-solid);
border-color: var(--_stepper-palette-border);
background: var(--_stepper-palette-solid);
color: var(--stepper-complete-color);
}

@ -4,7 +4,10 @@
--_tag-group-item-padding-inline: var(--tag-group-item-padding-inline-md);
--_tag-group-item-gap: var(--tag-group-item-gap-md);
--_tag-group-font-size: var(--tag-group-font-size-md);
/* `--_tag-group-selected-*` are TSC-emitted (lib/recipes/base.ts > tag-group._selected-*). */
/* `--_tag-group-palette-*` are TSC-emitted (lib/recipes/base.ts >
* tag-group._palette-*); the THM-2 shared-layer forward routes them to
* `color="teal"` + the 33 scales. The soft variant reads the alpha
* `surface`/`surface-hover` slots; the outline fg reads `text` (merged). */
display: inline-flex;
flex-wrap: wrap;
@ -99,22 +102,22 @@
[data-tag-group-item][data-state='selected'],
[data-tag-group-link][data-state='selected'] {
border-color: var(--_tag-group-selected-soft-bg);
background: var(--_tag-group-selected-soft-bg);
color: var(--_tag-group-selected-soft-fg);
border-color: var(--_tag-group-palette-surface);
background: var(--_tag-group-palette-surface);
color: var(--_tag-group-palette-text);
}
[data-tag-group-item][data-state='selected']:hover:not([data-disabled]),
[data-tag-group-link][data-state='selected']:hover:not([data-disabled]) {
border-color: var(--_tag-group-selected-soft-bg-hover);
background: var(--_tag-group-selected-soft-bg-hover);
color: var(--_tag-group-selected-soft-fg);
border-color: var(--_tag-group-palette-surface-hover);
background: var(--_tag-group-palette-surface-hover);
color: var(--_tag-group-palette-text);
}
[data-tag-group][data-variant='solid'] [data-tag-group-item][data-state='selected'],
[data-tag-group][data-variant='solid'] [data-tag-group-link][data-state='selected'] {
border-color: var(--_tag-group-selected-solid-bg);
background: var(--_tag-group-selected-solid-bg);
border-color: var(--_tag-group-palette-solid);
background: var(--_tag-group-palette-solid);
color: var(--tag-group-solid-fg);
}
@ -122,15 +125,15 @@
[data-tag-group-item][data-state='selected']:hover:not([data-disabled]),
[data-tag-group][data-variant='solid']
[data-tag-group-link][data-state='selected']:hover:not([data-disabled]) {
border-color: var(--_tag-group-selected-solid-bg-hover);
background: var(--_tag-group-selected-solid-bg-hover);
border-color: var(--_tag-group-palette-solid-hover);
background: var(--_tag-group-palette-solid-hover);
}
[data-tag-group][data-variant='outline'] [data-tag-group-item][data-state='selected'],
[data-tag-group][data-variant='outline'] [data-tag-group-link][data-state='selected'] {
border-color: var(--_tag-group-selected-outline-border);
border-color: var(--_tag-group-palette-border);
background: transparent;
color: var(--_tag-group-selected-outline-fg);
color: var(--_tag-group-palette-text);
}
[data-tag-group][data-variant='ghost'] [data-tag-group-item],
@ -142,8 +145,8 @@
[data-tag-group][data-variant='ghost'] [data-tag-group-item][data-state='selected'],
[data-tag-group][data-variant='ghost'] [data-tag-group-link][data-state='selected'] {
background: var(--_tag-group-selected-soft-bg);
color: var(--_tag-group-selected-soft-fg);
background: var(--_tag-group-palette-surface);
color: var(--_tag-group-palette-text);
}
[data-tag-group-item][data-disabled],

@ -8,11 +8,12 @@ import type {
TagGroupSelectionMode,
ItemSnippetProps as TagGroupItemSnippetProps
} from '$soma/components/tag-group';
import type { ChipVariant, ColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { ChipVariant, ComponentColor, ResponsiveProp, Size } from '$uix/eidos/lib/types';
export type TagGroupSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg'>;
export type TagGroupVariant = ChipVariant;
export type TagGroupColor = ColorRole;
/** Hierarchy role, intent, OR any of the 33 palette scales (THM-2 · §25.5). */
export type TagGroupColor = ComponentColor;
/**
* Props for the eidos `<TagGroup>`.
@ -25,7 +26,7 @@ export type TagGroupProps = ProviderProps & {
size?: ResponsiveProp<TagGroupSize>;
/** Chip treatment. @default 'soft' */
variant?: TagGroupVariant;
/** Doctrinal palette key for selected tags. @default 'primary' */
/** Selected-tag palette: a role, intent, or any of the 33 palette scales. @default 'primary' */
color?: TagGroupColor;
};

@ -6,8 +6,9 @@
--_tags-input-item-height: var(--tags-input-item-height-md);
--_tags-input-item-padding-inline: var(--tags-input-item-padding-inline-md);
--_tags-input-font-size: var(--tags-input-font-size-md);
/* `--_tags-input-accent-*` private tokens are TSC-emitted (per-color
* cascade in lib/recipes/base.ts > tags-input._accent-*). */
/* `--_tags-input-palette-*` private tokens are TSC-emitted (per-color
* cascade in lib/recipes/base.ts > tags-input._palette-*); the THM-2
* shared-layer forward routes them to `color="teal"` + the 33 scales. */
display: grid;
gap: var(--_tags-input-gap);
@ -94,7 +95,7 @@
}
[data-tags-input-control][data-focus] {
border-color: var(--_tags-input-accent-border);
border-color: var(--_tags-input-palette-border);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
@ -142,9 +143,9 @@
[data-tags-input-item][data-state='active'],
[data-tags-input-item]:focus-visible {
border-color: var(--_tags-input-accent-border);
background: var(--_tags-input-accent-track);
color: var(--_tags-input-accent-text);
border-color: var(--_tags-input-palette-border);
background: var(--_tags-input-palette-track);
color: var(--_tags-input-palette-text);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}

@ -7,11 +7,12 @@ import type {
ItemTextProps,
ProviderProps
} from '$soma/components/tags-input';
import type { ColorRole, ControlVariant, ResponsiveProp, ShapeFamily, Size } from '$uix/eidos/lib/types';
import type { ComponentColor, ControlVariant, ResponsiveProp, ShapeFamily, Size } from '$uix/eidos/lib/types';
export type TagsInputSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
export type TagsInputVariant = ControlVariant;
export type TagsInputColor = ColorRole;
/** A role, intent, or any of the 33 palette scales (THM-2 · §25.5). */
export type TagsInputColor = ComponentColor;
export type TagsInputProps = ProviderProps & {
/** Visual density for the control, tags and actions. @default 'md' */

@ -102,8 +102,20 @@
pointer-events: none;
}
/* Per-color cascade: TSC v2.2 `composition` in `lib/recipes/base.ts >
* toggle-group.composition` emits the palette overrides on every item
* as `[data-toggle-group][data-color='X'] [data-toggle-group-item]`.
* That feeds Toggle's `--toggle-palette-*` chain on each item. No CSS
* needed here. */
/* Per-color palette (THM-2). The group's `data-color` sets the shared
* `--palette-*` layer on the group; it inherits to each item, and this rule
* routes it into Toggle's `--toggle-palette-*` chain — so an item paints in
* the group's instance color. Presence-guarded (`[data-color]` with no value)
* so a group WITHOUT a color leaves items on Toggle's own default (no leak).
* Covers all roles + the 33 scales in ONE rule (was a 4-role composition; the
* composition DSL can't presence-scope, so this is hand-authored). */
[data-toggle-group][data-color] [data-toggle-group-item] {
--toggle-palette-track: var(--palette-track);
--toggle-palette-element: var(--palette-element);
--toggle-palette-hover: var(--palette-hover);
--toggle-palette-border: var(--palette-border);
--toggle-palette-solid: var(--palette-solid);
--toggle-palette-solid-hover: var(--palette-solid-hover);
--toggle-palette-text: var(--palette-text);
--toggle-palette-contrast: var(--palette-contrast);
}

@ -1,5 +1,5 @@
import type { ProviderProps, ItemProps, ToggleGroupSelectionMode } from '$soma/components/toggle-group';
import type { ResponsiveProp, SelectionVariant, Size } from '$uix/eidos/lib/types';
import type { ComponentColor, ResponsiveProp, SelectionVariant, Size } from '$uix/eidos/lib/types';
/**
* Eidos `<ToggleGroup>` — visual wrapper over the headless soma
@ -20,15 +20,13 @@ import type { ResponsiveProp, SelectionVariant, Size } from '$uix/eidos/lib/type
/** Toggle group visual variants. Mirrors {@link ToggleVariant}. */
export type ToggleGroupVariant = SelectionVariant;
/** Color tone the group's recipe propagates to its items via `data-color`. */
export type ToggleGroupColor =
| 'primary'
| 'neutral'
| 'affirm'
| 'risk'
| 'threat'
| 'absent'
| 'unknown';
/**
* Color tone the group propagates to its items via `data-color` — a role,
* intent, or any of the 33 palette scales (THM-2 · §25.5; routed to each item's
* Toggle palette through the shared per-instance layer). `absent`/`unknown` are
* the pre-existing non-evaluative sentinels.
*/
export type ToggleGroupColor = ComponentColor | 'absent' | 'unknown';
/**
* Sizing subset toggle-group exposes. Matches {@link ToggleSize}, narrowed

File diff suppressed because it is too large Load Diff

@ -378,8 +378,10 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'risk-border': 'var(--color-risk-border)',
'threat-track': 'var(--color-threat-track)',
'threat-border': 'var(--color-threat-border)',
// Private palette (TSC v2.1). 8 colors, 5 slots.
_track: {
// Private palette (TSC v2.1 · canonical `_palette-*` slots so the THM-2
// shared-layer forward routes color="teal" + the 33 scales; renamed from
// the bare `_{slot}` prefix 2026-07-13). 8 colors, 5 slots.
'_palette-track': {
declarations: [
{ value: 'var(--badge-primary-track)', scope: 'host' },
{ value: 'var(--badge-secondary-track)', scope: 'color:secondary' },
@ -391,7 +393,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--badge-loss-track)', scope: 'color:loss' }
]
},
_border: {
'_palette-border': {
declarations: [
{ value: 'var(--badge-primary-border)', scope: 'host' },
{ value: 'var(--badge-secondary-border)', scope: 'color:secondary' },
@ -403,7 +405,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--badge-loss-border)', scope: 'color:loss' }
]
},
_solid: {
'_palette-solid': {
declarations: [
{ value: 'var(--badge-primary-solid)', scope: 'host' },
{ value: 'var(--badge-secondary-solid)', scope: 'color:secondary' },
@ -415,7 +417,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--badge-loss-solid)', scope: 'color:loss' }
]
},
_text: {
'_palette-text': {
declarations: [
{ value: 'var(--badge-primary-text)', scope: 'host' },
{ value: 'var(--badge-secondary-text)', scope: 'color:secondary' },
@ -427,7 +429,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--badge-loss-text)', scope: 'color:loss' }
]
},
_contrast: {
'_palette-contrast': {
declarations: [
{ value: 'var(--badge-primary-contrast)', scope: 'host' },
{ value: 'var(--badge-secondary-contrast)', scope: 'color:secondary' },
@ -582,8 +584,10 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'risk-border': 'var(--color-risk-border)',
'threat-track': 'var(--color-threat-track)',
'threat-border': 'var(--color-threat-border)',
// Private palette (TSC v2.1). 8 colors, 5 slots. Default = neutral.
_track: {
// Private palette (TSC v2.1 · canonical `_palette-*` slots so the THM-2
// shared-layer forward routes color="teal" + the 33 scales; renamed from
// the bare `_{slot}` prefix 2026-07-13). 8 colors, 5 slots. Default = neutral.
'_palette-track': {
declarations: [
{ value: 'var(--card-neutral-track)', scope: 'host' },
{ value: 'var(--card-primary-track)', scope: 'color:primary' },
@ -595,7 +599,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--card-loss-track)', scope: 'color:loss' }
]
},
_border: {
'_palette-border': {
declarations: [
{ value: 'var(--card-neutral-border)', scope: 'host' },
{ value: 'var(--card-primary-border)', scope: 'color:primary' },
@ -607,7 +611,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--card-loss-border)', scope: 'color:loss' }
]
},
_solid: {
'_palette-solid': {
declarations: [
{ value: 'var(--card-neutral-solid)', scope: 'host' },
{ value: 'var(--card-primary-solid)', scope: 'color:primary' },
@ -619,7 +623,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--card-loss-solid)', scope: 'color:loss' }
]
},
_text: {
'_palette-text': {
declarations: [
{ value: 'var(--card-neutral-text)', scope: 'host' },
{ value: 'var(--card-primary-text)', scope: 'color:primary' },
@ -631,7 +635,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--card-loss-text)', scope: 'color:loss' }
]
},
_contrast: {
'_palette-contrast': {
declarations: [
{ value: 'var(--card-neutral-contrast)', scope: 'host' },
{ value: 'var(--card-primary-contrast)', scope: 'color:primary' },
@ -2144,7 +2148,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
// `text`). The legacy CSS also declared `--_select-accent-solid`
// but nothing in select.css read it; that orphan stayed out of
// the TSC migration on purpose.
'_accent-track': {
'_palette-track': {
parts: ['trigger', 'content'],
declarations: [
{ value: 'var(--select-primary-track)', scope: 'host' },
@ -2157,7 +2161,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--select-loss-track)', scope: 'color:loss' }
]
},
'_accent-border': {
'_palette-border': {
parts: ['trigger', 'content'],
declarations: [
{ value: 'var(--select-primary-border)', scope: 'host' },
@ -2170,7 +2174,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--select-loss-border)', scope: 'color:loss' }
]
},
'_accent-text': {
'_palette-text': {
parts: ['trigger', 'content'],
declarations: [
{ value: 'var(--select-primary-text)', scope: 'host' },
@ -2340,8 +2344,10 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'risk-border': 'var(--color-risk-border)',
'threat-track': 'var(--color-threat-track)',
'threat-border': 'var(--color-threat-border)',
// Private accent palette (TSC v2.1). 8 colors, 5 slots.
'_accent-solid': {
// Private palette (TSC v2.1 · canonical `_palette-*` slots for the THM-2
// shared-layer forward — color="teal" + the 33 scales; renamed from
// `_accent-*` 2026-07-13). 8 colors, 5 slots.
'_palette-solid': {
declarations: [
{ value: 'var(--file-upload-primary-solid)', scope: 'host' },
{ value: 'var(--file-upload-secondary-solid)', scope: 'color:secondary' },
@ -2353,7 +2359,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--file-upload-loss-solid)', scope: 'color:loss' }
]
},
'_accent-solid-hover': {
'_palette-solid-hover': {
declarations: [
{ value: 'var(--file-upload-primary-solid-hover)', scope: 'host' },
{ value: 'var(--file-upload-secondary-solid-hover)', scope: 'color:secondary' },
@ -2365,7 +2371,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--file-upload-loss-solid-hover)', scope: 'color:loss' }
]
},
'_accent-track': {
'_palette-track': {
declarations: [
{ value: 'var(--file-upload-primary-track)', scope: 'host' },
{ value: 'var(--file-upload-secondary-track)', scope: 'color:secondary' },
@ -2377,7 +2383,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--file-upload-loss-track)', scope: 'color:loss' }
]
},
'_accent-border': {
'_palette-border': {
declarations: [
{ value: 'var(--file-upload-primary-border)', scope: 'host' },
{ value: 'var(--file-upload-secondary-border)', scope: 'color:secondary' },
@ -2389,7 +2395,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--file-upload-loss-border)', scope: 'color:loss' }
]
},
'_accent-text': {
'_palette-text': {
declarations: [
{ value: 'var(--file-upload-primary-text)', scope: 'host' },
{ value: 'var(--file-upload-secondary-text)', scope: 'color:secondary' },
@ -2451,63 +2457,50 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'primary-soft-bg': 'var(--color-primary-surface)',
'primary-soft-bg-hover': 'var(--color-primary-surface-hover)',
'primary-soft-fg': 'var(--color-primary-text)',
'primary-outline-border': 'var(--color-primary-border)',
'primary-outline-fg': 'var(--color-primary-text)',
'secondary-solid-bg': 'var(--color-secondary-solid)',
'primary-outline-border': 'var(--color-primary-border)', 'secondary-solid-bg': 'var(--color-secondary-solid)',
'secondary-solid-bg-hover': 'var(--color-secondary-solid-hover)',
'secondary-soft-bg': 'var(--color-secondary-surface)',
'secondary-soft-bg-hover': 'var(--color-secondary-surface-hover)',
'secondary-soft-fg': 'var(--color-secondary-text)',
'secondary-outline-border': 'var(--color-secondary-border)',
'secondary-outline-fg': 'var(--color-secondary-text)',
'neutral-solid-bg': 'var(--color-neutral-solid)',
'secondary-outline-border': 'var(--color-secondary-border)', 'neutral-solid-bg': 'var(--color-neutral-solid)',
'neutral-solid-bg-hover': 'var(--color-neutral-solid-hover)',
'neutral-soft-bg': 'var(--color-neutral-surface)',
'neutral-soft-bg-hover': 'var(--color-neutral-surface-hover)',
'neutral-soft-fg': 'var(--color-neutral-text)',
'neutral-outline-border': 'var(--color-neutral-border)',
'neutral-outline-fg': 'var(--color-neutral-text)',
'affirm-solid-bg': 'var(--color-affirm-solid)',
'neutral-outline-border': 'var(--color-neutral-border)', 'affirm-solid-bg': 'var(--color-affirm-solid)',
'affirm-solid-bg-hover': 'var(--color-affirm-solid-hover)',
'affirm-soft-bg': 'var(--color-affirm-surface)',
'affirm-soft-bg-hover': 'var(--color-affirm-surface-hover)',
'affirm-soft-fg': 'var(--color-affirm-text)',
'affirm-outline-border': 'var(--color-affirm-border)',
'affirm-outline-fg': 'var(--color-affirm-text)',
'fulfill-solid-bg': 'var(--color-fulfill-solid)',
'affirm-outline-border': 'var(--color-affirm-border)', 'fulfill-solid-bg': 'var(--color-fulfill-solid)',
'fulfill-solid-bg-hover': 'var(--color-fulfill-solid-hover)',
'fulfill-soft-bg': 'var(--color-fulfill-surface)',
'fulfill-soft-bg-hover': 'var(--color-fulfill-surface-hover)',
'fulfill-soft-fg': 'var(--color-fulfill-text)',
'fulfill-outline-border': 'var(--color-fulfill-border)',
'fulfill-outline-fg': 'var(--color-fulfill-text)',
'risk-solid-bg': 'var(--color-risk-solid)',
'fulfill-outline-border': 'var(--color-fulfill-border)', 'risk-solid-bg': 'var(--color-risk-solid)',
'risk-solid-bg-hover': 'var(--color-risk-solid-hover)',
'risk-soft-bg': 'var(--color-risk-surface)',
'risk-soft-bg-hover': 'var(--color-risk-surface-hover)',
'risk-soft-fg': 'var(--color-risk-text)',
'risk-outline-border': 'var(--color-risk-border)',
'risk-outline-fg': 'var(--color-risk-text)',
'threat-solid-bg': 'var(--color-threat-solid)',
'risk-outline-border': 'var(--color-risk-border)', 'threat-solid-bg': 'var(--color-threat-solid)',
'threat-solid-bg-hover': 'var(--color-threat-solid-hover)',
'threat-soft-bg': 'var(--color-threat-surface)',
'threat-soft-bg-hover': 'var(--color-threat-surface-hover)',
'threat-soft-fg': 'var(--color-threat-text)',
'threat-outline-border': 'var(--color-threat-border)',
'threat-outline-fg': 'var(--color-threat-text)',
'loss-solid-bg': 'var(--color-loss-solid)',
'threat-outline-border': 'var(--color-threat-border)', 'loss-solid-bg': 'var(--color-loss-solid)',
'loss-solid-bg-hover': 'var(--color-loss-solid-hover)',
'loss-soft-bg': 'var(--color-loss-surface)',
'loss-soft-bg-hover': 'var(--color-loss-surface-hover)',
'loss-soft-fg': 'var(--color-loss-text)',
'loss-outline-border': 'var(--color-loss-border)',
'loss-outline-fg': 'var(--color-loss-text)',
// Private selected palette (TSC v2.1). 8 colors, 7 slots
// (solid-bg + solid-bg-hover for solid variant, soft-bg +
// soft-bg-hover + soft-fg for soft variant, outline-border +
// outline-fg for outline variant). All 3 variants share the
// same per-color cascade keyed on `data-color`.
'_selected-solid-bg': {
// Private palette (TSC v2.1 · canonical `_palette-*` slots so the THM-2
// shared-layer forward routes color="teal" + the 33 scales, incl. the
// alpha `surface`/`surface-hover` slots for the soft variant; renamed
// from `_selected-*` 2026-07-12). solid + solid-hover (solid variant) ·
// surface + surface-hover + text (soft variant) · border + text (outline
// variant — its fg merged into `text`, same value). Cascade keyed on
// `data-color`.
'_palette-solid': {
declarations: [
{ value: 'var(--tag-group-primary-solid-bg)', scope: 'host' },
{ value: 'var(--tag-group-secondary-solid-bg)', scope: 'color:secondary' },
@ -2519,7 +2512,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--tag-group-loss-solid-bg)', scope: 'color:loss' }
]
},
'_selected-solid-bg-hover': {
'_palette-solid-hover': {
declarations: [
{ value: 'var(--tag-group-primary-solid-bg-hover)', scope: 'host' },
{ value: 'var(--tag-group-secondary-solid-bg-hover)', scope: 'color:secondary' },
@ -2531,7 +2524,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--tag-group-loss-solid-bg-hover)', scope: 'color:loss' }
]
},
'_selected-soft-bg': {
'_palette-surface': {
declarations: [
{ value: 'var(--tag-group-primary-soft-bg)', scope: 'host' },
{ value: 'var(--tag-group-secondary-soft-bg)', scope: 'color:secondary' },
@ -2543,7 +2536,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--tag-group-loss-soft-bg)', scope: 'color:loss' }
]
},
'_selected-soft-bg-hover': {
'_palette-surface-hover': {
declarations: [
{ value: 'var(--tag-group-primary-soft-bg-hover)', scope: 'host' },
{ value: 'var(--tag-group-secondary-soft-bg-hover)', scope: 'color:secondary' },
@ -2555,7 +2548,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--tag-group-loss-soft-bg-hover)', scope: 'color:loss' }
]
},
'_selected-soft-fg': {
'_palette-text': {
declarations: [
{ value: 'var(--tag-group-primary-soft-fg)', scope: 'host' },
{ value: 'var(--tag-group-secondary-soft-fg)', scope: 'color:secondary' },
@ -2567,7 +2560,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--tag-group-loss-soft-fg)', scope: 'color:loss' }
]
},
'_selected-outline-border': {
'_palette-border': {
declarations: [
{ value: 'var(--tag-group-primary-outline-border)', scope: 'host' },
{ value: 'var(--tag-group-secondary-outline-border)', scope: 'color:secondary' },
@ -2578,18 +2571,6 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--tag-group-threat-outline-border)', scope: 'color:threat' },
{ value: 'var(--tag-group-loss-outline-border)', scope: 'color:loss' }
]
},
'_selected-outline-fg': {
declarations: [
{ value: 'var(--tag-group-primary-outline-fg)', scope: 'host' },
{ value: 'var(--tag-group-secondary-outline-fg)', scope: 'color:secondary' },
{ value: 'var(--tag-group-neutral-outline-fg)', scope: 'color:neutral' },
{ value: 'var(--tag-group-affirm-outline-fg)', scope: 'color:affirm' },
{ value: 'var(--tag-group-fulfill-outline-fg)', scope: 'color:fulfill' },
{ value: 'var(--tag-group-risk-outline-fg)', scope: 'color:risk' },
{ value: 'var(--tag-group-threat-outline-fg)', scope: 'color:threat' },
{ value: 'var(--tag-group-loss-outline-fg)', scope: 'color:loss' }
]
}
},
editable: {
@ -2650,9 +2631,10 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'affirm-border': 'var(--color-affirm-border)',
'risk-border': 'var(--color-risk-border)',
'threat-border': 'var(--color-threat-border)',
// Private accent palette (TSC v2.1). Only solid + border +
// solid-hover are consumed; track/text declared pre-TSC but unused.
'_accent-solid': {
// Private palette (TSC v2.1 · canonical `_palette-*` slots for the THM-2
// shared-layer forward — color="teal" + the 33 scales; renamed from
// `_accent-*` 2026-07-13). solid + border + solid-hover consumed.
'_palette-solid': {
declarations: [
{ value: 'var(--editable-primary-solid)', scope: 'host' },
{ value: 'var(--editable-secondary-solid)', scope: 'color:secondary' },
@ -2662,7 +2644,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--editable-threat-solid)', scope: 'color:threat' }
]
},
'_accent-border': {
'_palette-border': {
declarations: [
{ value: 'var(--editable-primary-border)', scope: 'host' },
{ value: 'var(--editable-secondary-border)', scope: 'color:secondary' },
@ -2672,7 +2654,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--editable-threat-border)', scope: 'color:threat' }
]
},
'_accent-solid-hover': {
'_palette-solid-hover': {
declarations: [
{ value: 'var(--color-primary-solid-hover)', scope: 'host' },
{ value: 'var(--color-secondary-solid-hover)', scope: 'color:secondary' },
@ -2888,9 +2870,11 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'risk-border': 'var(--color-risk-border)',
'threat-track': 'var(--color-threat-track)',
'threat-border': 'var(--color-threat-border)',
// Private accent palette (TSC v2.1). 8 colors, 3 slots (track,
// Private palette (TSC v2.1 · canonical `_palette-*` slots so the
// THM-2 shared-layer forward routes `color="teal"` + the 33 scales;
// renamed from `_accent-*` 2026-07-13). 8 colors, 3 slots (track,
// border, text). `solid` was declared pre-TSC but never consumed.
'_accent-track': {
'_palette-track': {
declarations: [
{ value: 'var(--tags-input-primary-track)', scope: 'host' },
{ value: 'var(--tags-input-secondary-track)', scope: 'color:secondary' },
@ -2902,7 +2886,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--tags-input-loss-track)', scope: 'color:loss' }
]
},
'_accent-border': {
'_palette-border': {
declarations: [
{ value: 'var(--tags-input-primary-border)', scope: 'host' },
{ value: 'var(--tags-input-secondary-border)', scope: 'color:secondary' },
@ -2914,7 +2898,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--tags-input-loss-border)', scope: 'color:loss' }
]
},
'_accent-text': {
'_palette-text': {
declarations: [
{ value: 'var(--tags-input-primary-text)', scope: 'host' },
{ value: 'var(--tags-input-secondary-text)', scope: 'color:secondary' },
@ -3088,8 +3072,10 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'neutral-border': 'var(--color-neutral-border)',
'affirm-track': 'var(--color-affirm-track)',
'affirm-border': 'var(--color-affirm-border)',
// Private accent palette (TSC v2.1). Per-color cascade in config.
'_accent-solid': {
// Private palette (TSC v2.1 · canonical `_palette-*` slots for the
// THM-2 shared-layer forward — color="teal" + the 33 scales; renamed
// from `_accent-*` 2026-07-13). Per-color cascade in config.
'_palette-solid': {
declarations: [
{ value: 'var(--stepper-primary-solid)', scope: 'host' },
{ value: 'var(--stepper-secondary-solid)', scope: 'color:secondary' },
@ -3098,7 +3084,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--stepper-fulfill-solid)', scope: 'color:fulfill' }
]
},
'_accent-track': {
'_palette-track': {
declarations: [
{ value: 'var(--stepper-primary-track)', scope: 'host' },
{ value: 'var(--stepper-secondary-track)', scope: 'color:secondary' },
@ -3107,7 +3093,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--stepper-fulfill-track)', scope: 'color:fulfill' }
]
},
'_accent-border': {
'_palette-border': {
declarations: [
{ value: 'var(--stepper-primary-border)', scope: 'host' },
{ value: 'var(--stepper-secondary-border)', scope: 'color:secondary' },
@ -3116,7 +3102,7 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
{ value: 'var(--stepper-fulfill-border)', scope: 'color:fulfill' }
]
},
'_accent-text': {
'_palette-text': {
declarations: [
{ value: 'var(--stepper-primary-text)', scope: 'host' },
{ value: 'var(--stepper-secondary-text)', scope: 'color:secondary' },
@ -4006,90 +3992,11 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
// ─────────────────────────────────────────────────────────────────────
'toggle-group': {
gap: 'var(--space-1)',
// TSC v2.2 — cross-recipe composition. Items inside a toggle-group
// ARE toggles, so the group's `data-color` cascade must override
// Toggle's PUBLIC palette tokens (owned by Toggle's recipe — not by
// toggle-group) ONLY when reached through a toggle-group ancestor.
//
// `targetSelector: '[data-toggle-group-item]'` reaches the foreign
// part inside the host's cascade. The generator emits:
// [data-toggle-group][data-color='X'] [data-toggle-group-item] {
// --toggle-palette-{slot}: var(--toggle-X-{slot});
// }
//
// Replaces the 4 hand-written CSS blocks that used to live in
// `toggle-group.css` (lines 264-308 in the legacy file).
composition: {
toggle: {
targetSelector: '[data-toggle-group-item]',
tokens: {
'palette-track': {
declarations: [
{ value: 'var(--toggle-primary-track)', scope: 'color:primary' },
{ value: 'var(--toggle-affirm-track)', scope: 'color:affirm' },
{ value: 'var(--toggle-risk-track)', scope: 'color:risk' },
{ value: 'var(--toggle-threat-track)', scope: 'color:threat' }
]
},
'palette-element': {
declarations: [
{ value: 'var(--toggle-primary-element)', scope: 'color:primary' },
{ value: 'var(--toggle-affirm-element)', scope: 'color:affirm' },
{ value: 'var(--toggle-risk-element)', scope: 'color:risk' },
{ value: 'var(--toggle-threat-element)', scope: 'color:threat' }
]
},
'palette-hover': {
declarations: [
{ value: 'var(--toggle-primary-hover)', scope: 'color:primary' },
{ value: 'var(--toggle-affirm-hover)', scope: 'color:affirm' },
{ value: 'var(--toggle-risk-hover)', scope: 'color:risk' },
{ value: 'var(--toggle-threat-hover)', scope: 'color:threat' }
]
},
'palette-border': {
declarations: [
{ value: 'var(--toggle-primary-border)', scope: 'color:primary' },
{ value: 'var(--toggle-affirm-border)', scope: 'color:affirm' },
{ value: 'var(--toggle-risk-border)', scope: 'color:risk' },
{ value: 'var(--toggle-threat-border)', scope: 'color:threat' }
]
},
'palette-solid': {
declarations: [
{ value: 'var(--toggle-primary-solid)', scope: 'color:primary' },
{ value: 'var(--toggle-affirm-solid)', scope: 'color:affirm' },
{ value: 'var(--toggle-risk-solid)', scope: 'color:risk' },
{ value: 'var(--toggle-threat-solid)', scope: 'color:threat' }
]
},
'palette-solid-hover': {
declarations: [
{ value: 'var(--toggle-primary-solid-hover)', scope: 'color:primary' },
{ value: 'var(--toggle-affirm-solid-hover)', scope: 'color:affirm' },
{ value: 'var(--toggle-risk-solid-hover)', scope: 'color:risk' },
{ value: 'var(--toggle-threat-solid-hover)', scope: 'color:threat' }
]
},
'palette-text': {
declarations: [
{ value: 'var(--toggle-primary-text)', scope: 'color:primary' },
{ value: 'var(--toggle-affirm-text)', scope: 'color:affirm' },
{ value: 'var(--toggle-risk-text)', scope: 'color:risk' },
{ value: 'var(--toggle-threat-text)', scope: 'color:threat' }
]
},
'palette-contrast': {
declarations: [
{ value: 'var(--toggle-primary-contrast)', scope: 'color:primary' },
{ value: 'var(--toggle-affirm-contrast)', scope: 'color:affirm' },
{ value: 'var(--toggle-risk-contrast)', scope: 'color:risk' },
{ value: 'var(--toggle-threat-contrast)', scope: 'color:threat' }
]
}
}
}
}
// Per-color palette: the group's `data-color` routes to every item's Toggle
// palette via the shared per-instance layer (THM-2). The composition DSL
// can't presence-scope (`[data-color]` with no value), so the routing is
// hand-authored in `toggle-group.css` — ONE rule covering all roles + the
// 33 scales, replacing the old 4-role composition enumeration.
},
// ─────────────────────────────────────────────────────────────────────

@ -1746,18 +1746,33 @@ interface NormalizedDeclaration {
// ── Universal per-instance palette cascade (THEMING §25.5) ────────────────
//
// `palette-{slot}` is RESERVED recipe vocabulary: any recipe token named
// `palette-track` / `-element` / `-border` / `-solid` / `-solid-hover` /
// `-text` / `-contrast` automatically receives the full `color:{scale}`
// cascade for every donor scale in `PALETTE_SCALES` — the generator appends
// the missing scales, so `<X color="teal">` resolves on EVERY recipe that
// exposes the indirection, never on a hand-maintained subset (the 31-vs-33
// drift class). Author-declared `color:*` declarations win (they are kept;
// only absent scales are appended). The step map mirrors
// `palette-track` / `-surface` / `-surface-hover` / `-element` / `-border` /
// `-solid` / `-solid-hover` / `-text` / `-contrast` automatically receives the
// full `color:{scale}` cascade for every donor scale in `PALETTE_SCALES` — the
// generator appends the missing scales, so `<X color="teal">` resolves on EVERY
// recipe that exposes the indirection, never on a hand-maintained subset (the
// 31-vs-33 drift class). Author-declared `color:*` declarations win (they are
// kept; only absent scales are appended). The solid step map mirrors
// `DEFAULT_COLOR_ROLE_SLOT_STEPS`; the fallback keeps a purged app on the
// primary role instead of broken chrome.
//
// `surface` / `surface-hover` are the TRANSLUCENT (alpha) background tints
// (steps `a2`/`a3`), added 2026-07-12 so the soft variant of variant-aware
// recipes (tag-group, avatar) routes its tinted background through the same
// per-instance palette — the canonical solid slots can't express a translucent
// tone. They are alpha, not solid: `--scale-{scale}-a{2,3}` (fallback
// `--color-primary-surface{,-hover}`), NOT a role-slot mirror.
const PALETTE_SLOT_STEP: Readonly<Record<string, string>> = {
track: '1',
surface: 'a2',
'surface-hover': 'a3',
element: '3',
// `hover` (step 4) — the element-background hover tone. Added 2026-07-12 so
// a grouped Toggle (toggle-group routes the group's `data-color` to its items)
// hovers in the instance color; also un-strands Toggle's own `palette-hover`
// on the 33 scales (`--toggle-{role}-hover` = `--color-{role}-hover`, so roles
// are behavior-preserving). Solid step, not alpha.
hover: '4',
border: '7',
solid: '9',
'solid-hover': '10',

@ -374,11 +374,15 @@ describe('MediaPlayerProvider', () => {
fake.emit('play', { paused: false });
fake.emit('volumechange', { muted: true });
expect(result.play.props['data-state']).toBe('playing');
expect(result.play.props['aria-label']).toBe('Pause');
expect(result.mute.props['data-state']).toBe('on');
expect(result.mute.props['aria-pressed']).toBe('true');
expect(result.mute.props['aria-label']).toBe('Unmute');
// These attrs are now resolved by renderProps (from the registered label
// prop + `on` state sources), so they aren't statically-typed keys on the
// props bag — assert via toMatchObject like the initial snapshot above.
expect(result.play.props).toMatchObject({ 'data-state': 'playing', 'aria-label': 'Pause' });
expect(result.mute.props).toMatchObject({
'data-state': 'on',
'aria-pressed': 'true',
'aria-label': 'Unmute'
});
cleanup();
dom.dispose();

@ -526,8 +526,9 @@ export class MediaPlayerMediaProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-media': this.provider.opts.media.current,
// data-media is morfo-declared (propRef('media')), resolved by renderProps
// against the component-level `media` source.
...this.runtimePart.renderProps(),
// Tap-to-toggle-play gesture on the video surface (YouTube convention);
// the audio element is 0-height, so this only fires for video.
onclick: () => this.provider.togglePlay()
@ -559,8 +560,9 @@ export class MediaPlayerPosterProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'aria-hidden': 'true',
// aria-hidden is morfo-declared (literal), resolved by renderProps.
...this.runtimePart.renderProps(),
// data-hidden is contract-only in the morfo (presence flag) — soma owns it.
'data-hidden': boolToEmptyStrOrUndef(this.provider.started)
} as const)
);
@ -590,8 +592,8 @@ export class MediaPlayerBufferingIndicatorProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'aria-hidden': 'true'
// aria-hidden is morfo-declared (literal), resolved by renderProps.
...this.runtimePart.renderProps()
} as const)
);
}
@ -644,7 +646,9 @@ export class MediaPlayerPlayButtonProvider {
this.runtimePart = this.provider.runtime.part('play-button', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Source so renderProps resolves the morfo's aria-label (propRef('label')).
props: { label: () => this.label }
});
}
@ -654,11 +658,9 @@ export class MediaPlayerPlayButtonProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
role: 'button',
'aria-label': this.label,
'aria-keyshortcuts': 'k',
// type/role/aria-label/aria-keyshortcuts are morfo-declared, resolved by renderProps.
...this.runtimePart.renderProps(),
// data-state is contract-only in the morfo (no resolvable value) — soma owns it.
'data-state': this.provider.paused ? 'paused' : 'playing',
onclick: () => this.provider.togglePlay()
} as const)
@ -684,7 +686,13 @@ export class MediaPlayerSeekButtonProvider {
this.runtimePart = this.provider.runtime.part('seek-button', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Sources so renderProps resolves aria-label (propRef('label')) and
// data-direction (propRef('direction')).
props: {
label: () => this.label,
direction: () => this.opts.direction.current
}
});
}
@ -701,11 +709,8 @@ export class MediaPlayerSeekButtonProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
role: 'button',
'aria-label': this.label,
'data-direction': this.opts.direction.current,
// type/role/aria-label/data-direction are morfo-declared, resolved by renderProps.
...this.runtimePart.renderProps(),
onclick: this.onclick
} as const)
);
@ -729,7 +734,11 @@ export class MediaPlayerMuteButtonProvider {
this.runtimePart = this.provider.runtime.part('mute-button', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Sources so renderProps resolves aria-label (propRef('label')) and the
// on-state pair (data-state + aria-pressed via stateRef('on')).
props: { label: () => this.label },
states: { on: () => this.provider.muted }
});
}
@ -739,13 +748,9 @@ export class MediaPlayerMuteButtonProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
role: 'button',
'aria-label': this.label,
'aria-pressed': this.provider.muted ? 'true' : 'false',
'aria-keyshortcuts': 'm',
'data-state': this.provider.muted ? 'on' : 'off',
// type/role/aria-label/aria-keyshortcuts/aria-pressed/data-state are
// morfo-declared, resolved by renderProps.
...this.runtimePart.renderProps(),
onclick: () => this.provider.toggleMute()
} as const)
);
@ -769,7 +774,11 @@ export class MediaPlayerFullscreenButtonProvider {
this.runtimePart = this.provider.runtime.part('fullscreen-button', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Sources so renderProps resolves aria-label (propRef('label')) and the
// on-state pair (data-state + aria-pressed via stateRef('on')).
props: { label: () => this.label },
states: { on: () => this.provider.fullscreen }
});
}
@ -781,13 +790,9 @@ export class MediaPlayerFullscreenButtonProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
role: 'button',
'aria-label': this.label,
'aria-pressed': this.provider.fullscreen ? 'true' : 'false',
'aria-keyshortcuts': 'f',
'data-state': this.provider.fullscreen ? 'on' : 'off',
// type/role/aria-label/aria-keyshortcuts/aria-pressed/data-state are
// morfo-declared, resolved by renderProps.
...this.runtimePart.renderProps(),
onclick: () => void this.provider.toggleFullscreen()
} as const)
);
@ -812,7 +817,9 @@ export class MediaPlayerTimeProvider {
this.runtimePart = this.provider.runtime.part('time', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Source so renderProps resolves the morfo's data-type (propRef('type')).
props: { type: () => this.opts.type.current }
});
}
@ -827,8 +834,8 @@ export class MediaPlayerTimeProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-type': this.opts.type.current
// data-type is morfo-declared (propRef('type')), resolved by renderProps.
...this.runtimePart.renderProps()
} as const)
);
}
@ -912,7 +919,11 @@ export class MediaPlayerPipButtonProvider {
this.runtimePart = this.provider.runtime.part('pip-button', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Sources so renderProps resolves aria-label (propRef('label')) and the
// on-state pair (data-state + aria-pressed via stateRef('on')).
props: { label: () => this.label },
states: { on: () => this.provider.pip }
});
}
@ -922,12 +933,9 @@ export class MediaPlayerPipButtonProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
role: 'button',
'aria-label': this.label,
'aria-pressed': this.provider.pip ? 'true' : 'false',
'data-state': this.provider.pip ? 'on' : 'off',
// type/role/aria-label/aria-pressed/data-state are morfo-declared,
// resolved by renderProps.
...this.runtimePart.renderProps(),
onclick: () => void this.provider.togglePip()
} as const)
);
@ -951,7 +959,11 @@ export class MediaPlayerCaptionButtonProvider {
this.runtimePart = this.provider.runtime.part('caption-button', {
id: opts.id,
ref: opts.ref,
owner: this
owner: this,
// Sources so renderProps resolves aria-label (propRef('label')) and the
// on-state pair (data-state + aria-pressed via stateRef('on')).
props: { label: () => this.label },
states: { on: () => this.provider.captionsOn }
});
}
@ -961,14 +973,12 @@ export class MediaPlayerCaptionButtonProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
role: 'button',
'aria-label': this.label,
'aria-pressed': this.provider.captionsOn ? 'true' : 'false',
'aria-keyshortcuts': 'c',
'data-state': this.provider.captionsOn ? 'on' : 'off',
// Disabled until the media exposes at least one caption/subtitle track.
// type/role/aria-label/aria-keyshortcuts/aria-pressed/data-state are
// morfo-declared, resolved by renderProps.
...this.runtimePart.renderProps(),
// data-disabled is contract-only in the morfo (presence flag) — soma owns
// it; `disabled` is the native attr. Disabled until the media exposes at
// least one caption/subtitle track.
'data-disabled': boolToEmptyStrOrUndef(!this.provider.captionsAvailable),
disabled: this.provider.captionsAvailable ? undefined : true,
onclick: () => this.provider.toggleCaptions()

@ -78,11 +78,15 @@ const MIGRATED_CEILINGS: Record<string, number> = {
// each residue is a conscious value override documented AT the call site.
knob: 1, // control aria-valuetext — "soma owns the *value* (formatting)"
'mask-field': 1, // input aria-label — computed, yields to Field.Label's labelledby
timeline: 2 // provider aria-label (prop-or-localized) + item aria-current (derived from root `active`)
// Still UNMIGRATED (the whole old shape, zero renderProps wiring):
// media-player (35 hits / 10 parts) — the next migration batch, sized and
// registered in docs/process/continue-cleanroom-fixes-2026-07.md; add it
// here with its ceiling when that batch lands. `words` is ACTIVE_DEV_TRACK.
timeline: 2, // provider aria-label (prop-or-localized) + item aria-current (derived from root `active`)
// Batch 4 (SOM-4 close, 2026-07-12): media-player fully migrated. Every button
// sources its label (propRef) + on-state (stateRef) so renderProps emits
// type/role/aria-label/aria-keyshortcuts/aria-pressed/data-state; media/poster/
// buffering/time resolve their data-*/aria-hidden the same way. 0 residue —
// contract-only attrs (play-button data-state, *-disabled presence, slider
// data-orientation) stay soma-owned and are excluded from the hit count.
'media-player': 0
// `words` is ACTIVE_DEV_TRACK (excluded).
};
describe('soma↔morfo attr de-dup guard', () => {

@ -12,6 +12,7 @@
import { Motion } from '$uix/eidos/components/motion';
import { compileMorfo } from '$uix/morfo';
import { badgeMorfo } from '@/uix/morfo/components/badge';
import { PALETTE_SCALES } from '../../lib/harness.svelte';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
@ -38,6 +39,7 @@
const variants: BadgeVariant[] = ['soft', 'solid', 'outline', 'ghost'];
const sizes: BadgeSize[] = ['xs', 'sm', 'md', 'lg'];
// Roles + the 33 palette scales — `color` accepts any (ComponentColor, THM-2 §25.5).
const colors: BadgeColor[] = [
'primary',
'secondary',
@ -46,7 +48,8 @@
'fulfill',
'risk',
'threat',
'loss'
'loss',
...PALETTE_SCALES
];
const roundeds: BadgeRounded[] = ['sm', 'md', 'lg', 'full'];

@ -11,6 +11,20 @@
import type { Intent } from '$uix/intent';
import { getActiveUix } from '$active-uix';
import { page } from '$app/state';
import { PALETTE_SCALES } from '../../lib/harness.svelte';
// Roles + the 33 palette scales — `color` accepts any (ComponentColor, THM-2 §25.5).
const COLOR_OPTIONS = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss',
...PALETTE_SCALES
] as const;
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TagModel = {
@ -395,7 +409,7 @@
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as c}
{#each COLOR_OPTIONS as c}
<button
data-uix-chip
data-active={color === c}

@ -11,6 +11,20 @@
import type { Intent } from '$uix/intent';
import { getActiveUix } from '$active-uix';
import { page } from '$app/state';
import { PALETTE_SCALES } from '../../lib/harness.svelte';
// Roles + the 33 palette scales — `color` accepts any (ComponentColor, THM-2 §25.5).
const COLOR_OPTIONS = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss',
...PALETTE_SCALES
] as const;
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type BlurBehavior = 'add' | 'clear';
@ -353,7 +367,7 @@
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as c}
{#each COLOR_OPTIONS as c}
<button data-uix-chip data-active={color === c} onclick={() => (color = c as TagsInputColor)}>
{c}
</button>

@ -11,6 +11,7 @@
import { toggleGroupMorfo } from '@/uix/morfo/components/toggle-group';
import { getActiveUix } from '$active-uix';
import { page } from '$app/state';
import { PALETTE_SCALES } from '../../lib/harness.svelte';
const uix = getActiveUix();
const params = page.url.searchParams;
@ -21,6 +22,7 @@
// ── Live state ──────────────────────────────────────────────────
const selectionModes: ToggleGroupSelectionMode[] = ['single', 'multiple'];
const variants: ToggleGroupVariant[] = ['solid', 'outline', 'ghost'];
// Roles/sentinels + the 33 palette scales — `color` accepts any (ComponentColor, THM-2 §25.5).
const colors: ToggleGroupColor[] = [
'primary',
'neutral',
@ -28,7 +30,8 @@
'risk',
'threat',
'absent',
'unknown'
'unknown',
...PALETTE_SCALES
];
const sizes: ToggleGroupSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
const orientations: Orientation[] = ['horizontal', 'vertical'];

Loading…
Cancel
Save

Powered by TurnKey Linux.