refactor(toggle-group): item is structurally a Toggle (zero derivation duplication)

Closes the deuda left explicit in hand-off 2026-05-27 #5: the inlined
derivation expressions in toggle-group.css duplicated Toggle's recipe
because [data-toggle-group-item] wasn't a descendant of [data-toggle].

Resolution is structural, not a drift test:

- morfo: each item declares { attr: 'data-toggle', value: v.literal(''),
  severity: 'required' }. Captures structural identity — an item IS a
  toggle in every observable sense (same press, same variant/color/size,
  same on/off machine).
- eidos: new context.ts propagates the root's variant + size via
  reactive getters; the item wrapper writes them as data-attrs on its
  button. Combined with data-toggle, the item is DOM-equivalent to a
  standalone <Toggle>.
- eidos css: ~150 lines deleted (base derived tokens, variant cascades,
  size cascade, focus/disabled/icon-only duplicates). Toggle's recipe
  now paints the item end-to-end. CSS keeps only the grouping concerns:
  flex, orientation, attached, block, focus z-index, group disabled.
- composition (TSC v2.2) stays — palette overrides land on the item
  under [data-toggle-group][data-color='X'] [data-toggle-group-item],
  which Toggle's --toggle-palette-* chain reads at item scope.

Verification:
- DOM probe (12 combinations: 4 colors × 3 variants × 2 states) on
  /uix/components/toggle-group. Computed values match bit-a-bit with
  baseline pre-refactor (e.g. affirm/solid on = rgb(18,165,148),
  risk/outline on = srgb(0.2,0.118,0.043), threat/ghost on =
  rgb(25,17,17)).
- vitest src/uix/{morfo,eidos}: 163/163 pass.
- vitest src/uix/soma/components/toggle-group: 4/4 pass.
- npm run check: 12 errors (all pre-existing in active Words sprint;
  zero added by this migration).

Drive-by: the same npm-check pass surfaced 4 stale 'orientation' refs
in words-toolbar.svelte + words-toolbar-group.svelte from this session's
earlier toolbar refactor (orientation was removed from types but not
from these soma components). Removed; brings check from 16 → 12 errors.

CLAUDE.md hand-off 2026-05-28 documents the new doctrine: when a
wrapper visually reuses another, declare structural identity in the
wrapper's morfo. Don't duplicate the cascade, don't extend TSC for one
case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 949cc88d75
commit d2f184da44

@ -532,6 +532,34 @@ Pregunta arquitectónica del usuario: "si activeUIX quiere ser referencia como f
**Tests**: 101/101 pass en `src/uix/eidos`. `npm run check`: los mismos 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active).
## Session hand-off — 2026-05-28 (toggle-group structural identity)
Cierra la deuda dejada explícitamente abierta en el hand-off 2026-05-27 #5 ("Los `inlined derivation expressions` en `toggle-group.css` son la única duplicación entre recipes/base.ts y CSS. Si Toggle's derivations cambian, hay que actualizar ambos."). Ahora son cero.
**Pregunta arquitectónica del usuario** tras verificar el fix EXT-FIX (color cascade): "¿qué es lo recomendado en referencia al ecosistema y su diseño y arquitectura?". Respuesta firme: **resolver la duplicación estructuralmente, no con un test de drift**. El item de toggle-group ES un toggle (mismo press, mismo variant/color/size, misma máquina on/off) — la doctrina "morfo declara DNA" pide declararlo.
**Cambios**:
- `src/uix/morfo/components/toggle-group.ts`: el part `Item` declara `{ attr: 'data-toggle', value: v.literal(''), severity: 'required' }`. Cada item proyecta `data-toggle=""` como atributo de presencia. Cero overhead, captura la identidad estructural.
- `src/uix/eidos/components/toggle-group/context.ts` (nuevo): contexto Svelte tipado (`ToggleGroupEidosCtx` con getters reactivos para `variant` y `size`) — propaga las dos perillas eidos-only del root a cada item.
- `src/uix/eidos/components/toggle-group/toggle-group.svelte`: setea el contexto en el root. Getters mantienen reactividad cuando el prop cambia.
- `src/uix/eidos/components/toggle-group/toggle-group-item.svelte`: lee el contexto y escribe `data-variant={ctx?.variant}` + `data-size={ctx?.size}` en el button. Combinado con `data-toggle` del morfo, el elemento es DOM-equivalente a un `<Toggle>` standalone.
- `src/uix/eidos/components/toggle-group/toggle-group.css`: borradas ~150 líneas (todas las derivaciones base + variant cascade + size cascade + focus-visible / disabled / icon-only duplicados). El CSS conserva SOLO grouping concerns (flex layout, orientation, attached con first/last/border-radius, block, focus z-index, group-level disabled). El header comment se rescribe documentando la nueva división de responsabilidades.
- `src/uix/eidos/components/toggle-group/README.md`: sección "Recipe" + "Decisiones" actualizadas — la entrada "Structural identity" documenta el patrón.
**Por qué TSC v2.2 composition se queda en la mezcla**: la composition sigue siendo necesaria para overridear `--toggle-palette-*` en el item bajo `[data-toggle-group][data-color='X'] [data-toggle-group-item]`. El color NO se propaga via contexto porque la composition ya hace el trabajo a nivel CSS, sin overhead reactivo. Variant/size sí se propagan porque tienen muchos derivados (height, padding, gap, font, radius × 5 sizes; bg, fg, border, hover, on, on-hover × 3 variants) que solo Toggle's recipe ya emite — no había ningún beneficio en mantener cascadas paralelas.
**Verificación**: probe DOM en `/uix/components/toggle-group` capturando computed values en las 12 combinaciones (4 colores × 3 variants) × 2 estados (on/off) — match bit-a-bit con baseline pre-refactor. Ejemplos:
- `affirm/solid` on: `rgb(18,165,148)` = `#12a594` (palette-solid affirm)
- `risk/outline` on: `color(srgb 0.2 0.118 0.043)` ≈ `#331e0b` (color-mix outline on-bg)
- `threat/ghost` on: `rgb(25,17,17)` = `#191111` (palette-track threat)
**Tests**: 163/163 pass en `src/uix/morfo` + `src/uix/eidos`. 4/4 pass en `src/uix/soma/components/toggle-group`. `npm run check`: 16 errores pre-existentes (los mismos del sprint Words), CERO añadidos por esta migración.
**Coste real vs estimado**: el hand-off #5 estimó el cambio como "too big" — incorrecto. Total = 1 entrada en morfo + 1 archivo de contexto (~30 líneas) + 2 ediciones puntuales en wrappers (~5 líneas cada) + 1 rewrite de CSS reduciendo ~150 líneas a ~85. La parte engañosa era pensar que requería tocar Toggle's CSS — no lo hace.
**Doctrina reforzada**: cuando un wrapper componente reusa visualmente otro, la respuesta canónica NO es duplicar el cascade ni extender TSC con un tercer feature. Es declarar la identidad estructural en el morfo del wrapper. Patrón aplicable si emerge button-group, link-group, etc.
## Session hand-off — 2026-05-09 (selector discipline + lint reframing)
- **Typed selector builder applied** in

@ -61,11 +61,21 @@ ships a sound payload — no per-component cascade needed.
## Recipe
`toggle-group.css`. Consumes the Toggle token vocabulary
(`--toggle-*`) so item visuals stay coherent with standalone
`<Toggle>` buttons. `data-attached`, `data-block`, `data-orientation`,
`data-size`, `data-variant`, `data-color` cascade from the root to
the items via descendant selectors.
`toggle-group.css` owns only what's unique to grouping: flex layout,
orientation, `data-attached` (segmented control), `data-block`
(stretch), focus-visible z-index, and a group-level disabled
cascade.
The painting itself comes from Toggle's recipe — each item declares
`data-toggle` in its morfo (structural identity) and the eidos
wrapper propagates `data-variant` + `data-size` from the group via
context, so the item element carries exactly what a standalone
`<Toggle>` would. `data-color` stays on the group; TSC v2.2
`composition` (see `lib/recipes/base.ts > toggle-group.composition`)
emits `--toggle-palette-*` overrides on every item under
`[data-toggle-group][data-color='X'] [data-toggle-group-item]`.
Zero derivation duplication between toggle-group.css and Toggle's
recipe.
## Baseline
@ -100,6 +110,14 @@ missing.
next to a `<ToggleGroup>` looks visually identical at the same
`variant` × `color` × `size`. Single source of truth for the
"toggle button" vocabulary.
- **Structural identity**: each item declares `data-toggle` in its
morfo and the eidos wrapper propagates the group's `variant`/`size`
to it via context. The Toggle recipe paints the item end-to-end —
base, variant cascades, size cascades, on/off state, hover,
focus-visible, disabled, icon-only. toggle-group.css owns only the
grouping concerns. Prior approach inlined the derivation
expressions, which duplicated Toggle's logic; the structural
identity removes the duplication entirely.
- No re-bindable `pressed` per item — the value array on the group
is the source of truth (Radix-style).

@ -0,0 +1,35 @@
import { getContext, setContext } from 'svelte';
import type { ToggleGroupVariant, ToggleGroupSize } from './types';
/**
* Eidos context for `<ToggleGroup>` → `<ToggleGroup.Item>` — propagates
* the visual knobs (`variant`, resolved `size`) the root receives so
* each item renders with the same data-* attrs a standalone `<Toggle>`
* would carry.
*
* Why context instead of CSS descendant selectors: the item declares
* `data-toggle` in its morfo (structural identity — an item IS a
* toggle). The Toggle recipe owns the entire `--_toggle-*` derivation
* chain on `[data-toggle][data-variant='X']` and `[data-toggle][data-
* size='Y']`. For those rules to fire on an item, the item element
* itself needs `data-variant` and `data-size` — not its ancestor.
*
* The context value uses property getters so reads stay reactive: when
* the root's `variant` or `size` prop changes, items observing the
* getter see the new value on the next read.
*/
const KEY = Symbol('toggle-group-eidos-ctx');
export interface ToggleGroupEidosCtx {
readonly variant: ToggleGroupVariant;
readonly size: ToggleGroupSize;
}
export function setToggleGroupEidosCtx(ctx: ToggleGroupEidosCtx): void {
setContext(KEY, ctx);
}
export function getToggleGroupEidosCtx(): ToggleGroupEidosCtx | undefined {
return getContext<ToggleGroupEidosCtx | undefined>(KEY);
}

@ -10,10 +10,22 @@
*/
import * as ToggleGroup from '$soma/components/toggle-group';
import type { ToggleGroupItemProps } from './types';
import { getToggleGroupEidosCtx } from './context';
let { iconOnly = false, children, ...rest }: ToggleGroupItemProps = $props();
// Inherit variant/size from the root <ToggleGroup>. The item has
// `data-toggle` (declared in morfo) and the Toggle recipe needs
// these attrs ON the item itself — not on the ancestor — to resolve
// the variant/size cascades.
const ctx = getToggleGroupEidosCtx();
</script>
<ToggleGroup.Item {...rest} data-icon-only={iconOnly ? '' : undefined}>
<ToggleGroup.Item
{...rest}
data-icon-only={iconOnly ? '' : undefined}
data-variant={ctx?.variant}
data-size={ctx?.size}
>
{@render children?.()}
</ToggleGroup.Item>

@ -1,25 +1,18 @@
/*
* ToggleGroup recipe.
*
* Cross-recipe color composition lives in the recipe via TSC v2.2
* `composition: { toggle: { targetSelector: '[data-toggle-group-item]',
* tokens: { palette-*: ... } } }` (see `lib/recipes/base.ts >
* toggle-group.composition`). The generator emits the per-color
* descendant overrides for Toggle's palette tokens automatically.
*
* Visually each item IS a Toggle — same height / padding / radius
* tokens, same variant + palette vocabulary. The group adds:
*
* - Layout: flex container, orientation-aware direction, gap
* - `data-attached`: items collapse spacing + share borders so the
* group reads as a single segmented control
* - `data-block`: items grow equally to fill the inline-size
* - Disabled cascade: a single `disabled` on the group disables all
* items (the morfo already projects `data-disabled` on each item).
* - Per-color cascade on items via TSC v2.2 composition (above).
* Structural identity: each item declares `data-toggle` in its morfo
* and the eidos wrapper propagates `data-variant` + `data-size` from
* the group to every item. Result: an item is, observably, a Toggle —
* the entire Toggle recipe (base + variant/size cascades + on-state +
* hover / disabled / icon-only) paints it. This file owns ONLY what's
* unique to grouping: flex layout, orientation, attached (segmented
* control), block (stretch), and the per-color palette overrides
* generated via TSC v2.2 `composition` (see `lib/recipes/base.ts >
* toggle-group.composition`).
*
* [data-toggle-group] → flex container
* [data-toggle-group-item] → button (Toggle-shaped)
* [data-toggle-group-item] → button, also carrier of `[data-toggle]`
*/
[data-toggle-group] {
@ -95,214 +88,22 @@
z-index: 1;
}
/* ── Item ────────────────────────────────────────────────────────────
*
* Items consume the Toggle palette tokens (one source of truth for the
* "toggle button" visual vocabulary). `data-variant` / `data-color` /
* `data-size` are read from the ancestor group via descendant
* selectors below.
*/
[data-toggle-group-item] {
--_toggle-height: var(--toggle-height-md);
--_toggle-padding-inline: var(--toggle-px-md);
--_toggle-gap: var(--toggle-gap-md);
--_toggle-font-size: var(--toggle-font-size-md);
--_toggle-font-weight: var(--toggle-font-weight-md);
--_toggle-radius: var(--toggle-radius-md);
--toggle-palette-track: var(--toggle-neutral-track);
--toggle-palette-element: var(--toggle-neutral-element);
--toggle-palette-hover: var(--toggle-neutral-hover);
--toggle-palette-border: var(--toggle-neutral-border);
--toggle-palette-solid: var(--toggle-neutral-solid);
--toggle-palette-solid-hover: var(--toggle-neutral-solid-hover);
--toggle-palette-text: var(--toggle-neutral-text);
--toggle-palette-contrast: var(--toggle-neutral-contrast);
/*
* Derived tokens are inlined here (rather than read via
* `var(--toggle-solid-*)` etc.) because Toggle's recipe declares
* those at `[data-toggle]` scope — which the toggle-group-item is
* NOT a descendant of. Reading them via var() would resolve to
* undefined and collapse the cascade. Inlining keeps the var()
* substitution local to the item, so the TSC v2.2 composition
* override of `--toggle-palette-*` (in `recipes/base.ts >
* toggle-group.composition`) propagates through.
*
* The literal expressions mirror `recipes/base.ts > toggle.solid-*`.
* If those change, update both sides.
*/
--_toggle-bg: var(--toggle-solid-bg);
--_toggle-fg: var(--toggle-solid-fg);
--_toggle-border: var(--toggle-solid-border);
--_toggle-hover-bg: color-mix(
in srgb,
var(--color-surface-overlay) 68%,
var(--toggle-palette-track)
);
--_toggle-hover-border: color-mix(
in srgb,
var(--toggle-palette-border) 45%,
var(--color-border-strong)
);
--_toggle-on-bg: var(--toggle-palette-solid);
--_toggle-on-fg: var(--toggle-palette-contrast);
--_toggle-on-border: var(--toggle-palette-solid);
--_toggle-on-hover-bg: var(--toggle-palette-solid-hover);
--_toggle-on-hover-border: var(--toggle-palette-solid-hover);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--_toggle-gap);
min-height: var(--_toggle-height);
padding-inline: var(--_toggle-padding-inline);
border: var(--toggle-border-width) solid var(--_toggle-border);
border-radius: var(--_toggle-radius);
background: var(--_toggle-bg);
color: var(--_toggle-fg);
box-shadow: var(--toggle-shadow);
font-family: var(--toggle-font-family);
font-size: var(--_toggle-font-size);
font-weight: var(--_toggle-font-weight);
line-height: var(--toggle-line-height);
letter-spacing: var(--toggle-letter-spacing);
white-space: nowrap;
cursor: pointer;
transition:
background var(--toggle-transition-duration) var(--toggle-transition-ease),
border-color var(--toggle-transition-duration) var(--toggle-transition-ease),
color var(--toggle-transition-duration) var(--toggle-transition-ease),
box-shadow var(--toggle-transition-duration) var(--toggle-transition-ease);
}
[data-toggle-group-item]:hover:not([data-disabled]) {
background: var(--_toggle-hover-bg);
border-color: var(--_toggle-hover-border);
}
[data-toggle-group-item][data-state='on'] {
background: var(--_toggle-on-bg);
color: var(--_toggle-on-fg);
border-color: var(--_toggle-on-border);
}
[data-toggle-group-item][data-state='on']:hover:not([data-disabled]) {
background: var(--_toggle-on-hover-bg);
border-color: var(--_toggle-on-hover-border);
}
/* Focused item floats above neighbors so the focus ring isn't clipped
* either. Toggle's recipe owns the ring itself (outline + box-shadow). */
[data-toggle-group-item]:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
z-index: 2;
}
[data-toggle-group-item][data-disabled] {
cursor: default;
opacity: var(--toggle-disabled-opacity, 0.55);
}
[data-toggle-group-item][data-icon-only] {
padding-inline: 0;
inline-size: var(--_toggle-height);
}
/* ── Size cascade — items inherit data-size from the group ───────── */
[data-toggle-group][data-size='xs'] [data-toggle-group-item] {
--_toggle-height: var(--toggle-height-xs);
--_toggle-padding-inline: var(--toggle-px-xs);
--_toggle-gap: var(--toggle-gap-xs);
--_toggle-font-size: var(--toggle-font-size-xs);
--_toggle-font-weight: var(--toggle-font-weight-xs);
--_toggle-radius: var(--toggle-radius-xs);
}
[data-toggle-group][data-size='sm'] [data-toggle-group-item] {
--_toggle-height: var(--toggle-height-sm);
--_toggle-padding-inline: var(--toggle-px-sm);
--_toggle-gap: var(--toggle-gap-sm);
--_toggle-font-size: var(--toggle-font-size-sm);
--_toggle-font-weight: var(--toggle-font-weight-sm);
--_toggle-radius: var(--toggle-radius-sm);
}
[data-toggle-group][data-size='lg'] [data-toggle-group-item] {
--_toggle-height: var(--toggle-height-lg);
--_toggle-padding-inline: var(--toggle-px-lg);
--_toggle-gap: var(--toggle-gap-lg);
--_toggle-font-size: var(--toggle-font-size-lg);
--_toggle-font-weight: var(--toggle-font-weight-lg);
--_toggle-radius: var(--toggle-radius-lg);
}
[data-toggle-group][data-size='xl'] [data-toggle-group-item] {
--_toggle-height: var(--toggle-height-xl);
--_toggle-padding-inline: var(--toggle-px-xl);
--_toggle-gap: var(--toggle-gap-xl);
--_toggle-font-size: var(--toggle-font-size-xl);
--_toggle-font-weight: var(--toggle-font-weight-xl);
--_toggle-radius: var(--toggle-radius-xl);
}
/* ── Variant cascade ───────────────────────────────────────────────
*
* Inlined for the same reason as the item base block above: Toggle's
* `--toggle-{outline,ghost}-*` tokens live at `[data-toggle]` scope,
* which the toggle-group-item is not a descendant of. Reading them
* via var() would resolve to undefined. The expressions mirror
* `recipes/base.ts > toggle.{outline,ghost}-*`.
*/
[data-toggle-group][data-variant='outline'] [data-toggle-group-item] {
--_toggle-bg: transparent;
--_toggle-fg: var(--toggle-palette-text);
--_toggle-border: color-mix(
in srgb,
var(--toggle-palette-border) 88%,
var(--color-border-default)
);
--_toggle-hover-bg: var(--toggle-palette-track);
--_toggle-hover-border: color-mix(
in srgb,
var(--toggle-palette-border) 100%,
var(--color-border-default)
);
--_toggle-on-bg: color-mix(
in srgb,
var(--toggle-palette-element) 100%,
var(--color-surface-raised)
);
--_toggle-on-fg: var(--toggle-palette-text);
--_toggle-on-border: var(--toggle-palette-border);
--_toggle-on-hover-bg: var(--toggle-palette-hover);
--_toggle-on-hover-border: var(--toggle-palette-border);
}
[data-toggle-group][data-variant='ghost'] [data-toggle-group-item] {
--_toggle-bg: transparent;
--_toggle-fg: var(--toggle-palette-text);
--_toggle-border: transparent;
--_toggle-hover-bg: color-mix(in srgb, var(--toggle-palette-track) 72%, transparent);
--_toggle-hover-border: transparent;
--_toggle-on-bg: var(--toggle-palette-track);
--_toggle-on-fg: var(--toggle-palette-text);
--_toggle-on-border: color-mix(
in srgb,
var(--toggle-palette-border) 72%,
transparent
);
--_toggle-on-hover-bg: var(--toggle-palette-element);
--_toggle-on-hover-border: color-mix(
in srgb,
var(--toggle-palette-border) 88%,
transparent
);
}
/* Color cascade lives in the recipe (TSC v2.2 composition). See file
* header comment for details. */
/* Group-level disabled cascade — the morfo also projects `data-disabled`
* onto each item, so Toggle's `[data-toggle][data-disabled]` rule paints
* each one; this rule disables interaction at the container. */
[data-toggle-group][data-disabled] {
opacity: var(--toggle-disabled-opacity, 0.55);
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. */

@ -17,6 +17,7 @@
import { ActiveEidos } from '$uix/eidos';
import * as ToggleGroup from '$soma/components/toggle-group';
import type { ToggleGroupProps } from './types';
import { setToggleGroupEidosCtx } from './context';
let {
variant = 'solid',
@ -31,6 +32,18 @@
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// Propagate variant/size to every <ToggleGroup.Item> so each item's
// own `[data-toggle]` element carries the attrs the Toggle recipe
// reads. Getters keep the bridge reactive.
setToggleGroupEidosCtx({
get variant() {
return variant;
},
get size() {
return resolvedSize;
}
});
</script>
<ToggleGroup.Provider

@ -64,6 +64,17 @@ export const toggleGroupMorfo = {
optional: false,
states: ['on', 'off'],
data: [
/**
* Structural identity: a ToggleGroup item IS a Toggle in
* every observable sense — same press semantics, same
* variant/color/size vocabulary, same on/off state
* machine. Declaring `data-toggle` here lets Eidos reuse
* the entire Toggle recipe (base + variant/size/color
* cascades) instead of inlining the derivation
* expressions in `toggle-group.css`. Single source of
* truth: Toggle's recipe.
*/
{ attr: 'data-toggle', value: v.literal(''), severity: 'required' },
{ attr: 'data-state', values: ['on', 'off'], value: v.stateRef('on') },
{ attr: 'data-value' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },

@ -10,7 +10,6 @@
let {
ref = $bindable(null),
id = createId(uid, 'words-toolbar-group'),
orientation = 'horizontal',
children,
child,
...restProps
@ -21,8 +20,7 @@
ref: writableActive(
() => ref,
(v) => (ref = v)
),
orientation: readableActive(() => orientation)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -10,7 +10,6 @@
let {
ref = $bindable(null),
id = createId(uid, 'words-toolbar'),
orientation = 'horizontal',
children,
child,
...restProps
@ -21,8 +20,7 @@
ref: writableActive(
() => ref,
(v) => (ref = v)
),
orientation: readableActive(() => orientation)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

Loading…
Cancel
Save

Powered by TurnKey Linux.