uix(color): cierra 2 fugas transitivas (card-group-item · s-text) + guard runtime

Verificacion adversarial multi-agente (17 componentes de la sesion de cierre,
23 agentes) cazo 2 fugas que la verificacion individual en Chrome no cubrio —
componentes abiertos TRANSITIVAMENTE (heredan un alias abierto) y nunca
tocados ni verificados:

- **card-group-item**: estampaba `data-color={color}` CRUDO en ambas ramas
  (button + div) sin `data-color-custom` ni seed → un valor CSS crudo (que el
  tipo abierto `CardColor=ComponentColorProp` permite) caia a neutral y se
  descartaba en silencio. Ahora pasa por `resolveComponentColor` (ruta
  GENERICA `--color-custom`, que el forward compartido `[data-card][data-color-
  custom]` consume). NOTA: NO usa el split bespoke de Card (`--card-color-
  custom`) a proposito — esa ruta esta ENSOMBRECIDA por el forward de THM-2 y
  no pinta (bug pre-existente de Card/Avatar, flageado aparte). Verificado en
  Chrome: teal=escala exacta, #3b82f6=palette-solid exacto, neutral=sin estampa.

- **s-text**: `STextColor = TextColor` se abrio gratis al ensanchar TextColor
  en Fase 4, pero su .svelte seguia en el puente pre-Fase-4
  (`var(--color-content-${colorKey})` para TODO valor) → escalas/intents/crudo
  producian vars inexistentes. Migrado a la rama eje-primero de Fase 4 (espejo
  de Text): ink axis inline sin estampa; resto por el forward. Recipe
  `_palette-text` + regla colored-only + stamp. Verificado en Chrome
  (risk/teal exactos, custom aplicado, default sin estampa).

- **split-button**: revisado, SIN cambios — delega `{color}` crudo en
  ButtonGroup→Button (ya verificado); no re-estampa.

Guard runtime NUEVO en recipe-css-contract.test.ts — "pairs a dynamic
`data-color` stamp with `data-color-custom`": el guard de tipos es ciego al
runtime (solo lee types.ts), asi que un stamp dinamico sin el par custom pasa
inadvertido (justo la clase de card-group-item). Este falla si algun .svelte
estampa `data-color={…}` dinamico sin `data-color-custom` en el mismo fichero;
literal estatico (`data-color="neutral"` del badge +N de avatar-group) exento;
WIP excluido. 30/30 verde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 10118f532a
commit 6cdba0182f

@ -18,6 +18,8 @@
*/
import * as ToggleGroup from '$soma/components/toggle-group';
import { ToggleGroupProvider } from '$soma/components/toggle-group/toggle-group-provider.svelte';
import { resolveComponentColor } from '$uix/eidos/lib/component-color';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import { getCardGroupVisualContext } from './context';
import type { CardGroupItemProps } from './types';
@ -27,6 +29,7 @@
color = 'neutral',
rounded = 'md',
disabled = false,
style,
children,
...rest
}: CardGroupItemProps = $props();
@ -38,6 +41,16 @@
// Pressed state from the ToggleGroup provider (reactive `pressedSet`).
const tg = ToggleGroupProvider.get();
const selected = $derived(!!tg && tg.isItemPressed(value));
// The item reuses the `card` recipe via `data-card`, so it must open `color`
// too. It routes through the SHARED helper (canonical → `data-color`; raw CSS
// → `data-color-custom` + the `--color-custom` seed), which the card recipe's
// `[data-card][data-color-custom]` forward consumes — NOT Card's own bespoke
// `--card-color-custom` split (that one is shadowed by the shared forward at
// runtime; see the flagged Card/Avatar follow-up). `'neutral'` stamps nothing
// and keeps the card's neutral host default.
const colorAttrs = $derived(resolveComponentColor(color === 'neutral' ? undefined : color));
const mergedStyle = $derived(composeInlineStyle(style, colorAttrs.customStyle));
</script>
{#if selectable}
@ -51,12 +64,14 @@
data-interactive=""
data-variant={variant}
data-size={ctx?.cardSize}
data-color={color}
data-color={colorAttrs.dataColor}
data-color-custom={colorAttrs.isCustom ? '' : undefined}
data-rounded={rounded}
data-selected={selected ? '' : undefined}
data-disabled={disabled ? '' : undefined}
data-animation-style={cascade}
data-no-emerge={cascade ? '' : undefined}
style={mergedStyle}
>
{@render children?.()}
</button>
@ -69,11 +84,13 @@
data-card=""
data-variant={variant}
data-size={ctx?.cardSize}
data-color={color}
data-color={colorAttrs.dataColor}
data-color-custom={colorAttrs.isCustom ? '' : undefined}
data-rounded={rounded}
data-disabled={disabled ? '' : undefined}
data-animation-style={cascade}
data-no-emerge={cascade ? '' : undefined}
style={mergedStyle}
>
{@render children?.()}
</div>

@ -48,6 +48,15 @@
color: var(--_s-text-color, var(--style-caption-color));
}
/* Colour beyond the content-ink axis comes from the shared palette forward
(`--_s-text-palette-text`): any role / intent / 33 scale / custom value routes
through. The ink axis (primary…on-solid) never stamps `data-color` — it keeps
the inline `--color-content-*` var. Mirrors <Text>. */
[data-s-text][data-color],
[data-s-text][data-color-custom] {
--_s-text-color: var(--_s-text-palette-text);
}
[data-s-text][data-italic] {
font-style: italic;
}

@ -23,6 +23,7 @@
// `$app/environment` (couples the visual layer to a specific framework).
const isBrowser = typeof window !== 'undefined';
import { ActiveEidos } from '$uix/eidos';
import { resolveComponentColor } from '$uix/eidos/lib/component-color';
import {
useContainerWidth,
useTextLayout,
@ -54,6 +55,15 @@
const eidos = ActiveEidos.require();
// Content-ink axis — these keep the direct `--color-content-*` var (here
// `primary`/`secondary` mean INK levels, not the hierarchy roles). Anything
// else routes through the shared palette forward: canonical → `data-color`,
// raw CSS → `data-color-custom` + the `--color-custom` seed. Mirrors <Text>.
const CONTENT_INK = new Set(['primary', 'secondary', 'muted', 'disabled', 'on-solid']);
const colorKey = $derived(eidos.resolve(color));
const inkKey = $derived(colorKey && CONTENT_INK.has(colorKey) ? colorKey : undefined);
const colorAttrs = $derived(resolveComponentColor(inkKey ? undefined : colorKey));
function pushVar(decls: string[], name: string, value: string | number | undefined): void {
if (value === undefined || value === null || value === '') return;
decls.push(`${name}: ${value};`);
@ -65,7 +75,6 @@
const sizeKey = eidos.resolve(size);
const familyKey = eidos.resolve(family);
const weightKey = eidos.resolve(weight);
const colorKey = eidos.resolve(color);
const alignKey = eidos.resolve(align);
if (sizeKey) pushVar(decls, '--_s-text-font-size', `var(--font-size-${sizeKey})`);
@ -74,7 +83,8 @@
pushVar(decls, '--_s-text-letter-spacing', `var(--font-letter-spacing-${sizeKey})`);
if (familyKey) pushVar(decls, '--_s-text-font-family', `var(--font-family-${familyKey})`);
if (weightKey) pushVar(decls, '--_s-text-font-weight', `var(--font-weight-${weightKey})`);
if (colorKey) pushVar(decls, '--_s-text-color', `var(--color-content-${colorKey})`);
if (inkKey) pushVar(decls, '--_s-text-color', `var(--color-content-${inkKey})`);
if (colorAttrs.customStyle) decls.push(colorAttrs.customStyle);
if (alignKey) pushVar(decls, '--_s-text-align', alignKey);
if (typeof clamp === 'number' && clamp > 0)
pushVar(decls, '--_s-text-clamp', String(clamp));
@ -161,6 +171,8 @@
class={className}
data-s-text=""
data-style={textStyle}
data-color={colorAttrs.dataColor}
data-color-custom={colorAttrs.isCustom ? '' : undefined}
data-italic={italic ? '' : undefined}
data-underline={underline ? '' : undefined}
data-truncate={truncate ? '' : undefined}

@ -5191,6 +5191,14 @@
--_switch-palette-solid-hover: var(--palette-solid-hover, var(--switch-neutral-solid-hover));
}
[data-s-text] {
--_s-text-palette-text: var(--color-primary-text);
}
[data-s-text][data-color], [data-s-text][data-color-custom] {
--_s-text-palette-text: var(--palette-text, var(--color-primary-text));
}
[data-toggle] {
--toggle-palette-track: var(--toggle-neutral-track);
--toggle-palette-element: var(--toggle-neutral-element);

@ -3753,7 +3753,12 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
's-text': {
'hint-color': 'var(--color-content-secondary)',
'hint-opacity': '0.8',
'hint-margin-top': 'var(--space-1)'
'hint-margin-top': 'var(--space-1)',
// Content-ink tint (Fase 4, mirrors <Text>) — `color` beyond the ink axis
// routes through the shared palette forward. Colored-only: the ink axis
// (primary…on-solid) never stamps `data-color`, so this host is the
// forward fallback only.
'_palette-text': { value: 'var(--color-primary-text)', scope: 'host' }
},
's-text-virtual-list': {
'line-height': 'var(--leading-prose)',

@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
@ -282,6 +282,34 @@ describe('Eidos recipe CSS contract', () => {
expect(violations).toEqual([])
})
it('pairs a dynamic `data-color` stamp with `data-color-custom` (runtime openness)', () => {
// The type guard above proves the *type* stays open, but is blind to the
// RUNTIME: a wrapper that stamps `data-color={value}` without also stamping
// `data-color-custom` silently drops raw CSS colours (they match no role /
// scale selector and fall to the host default) — the class of defect that
// slipped past on card-group-item. This guard fails when any component
// `.svelte` stamps a DYNAMIC `data-color={…}` yet never stamps
// `data-color-custom` in the same file. Note the pairing is what opens the
// value-by-CSS path via `resolveComponentColor` (or the isCanonical split).
// A STATIC `data-color="neutral"` literal (e.g. avatar-group's overflow
// badge) is a fixed value, not an open prop, and is intentionally exempt.
const violations: string[] = []
for (const component of readdirSync(COMPONENTS_DIR)) {
if (WIP_TRACKS.has(component)) continue
const dir = join(COMPONENTS_DIR, component)
if (!statSync(dir).isDirectory()) continue
for (const file of readdirSync(dir)) {
if (!file.endsWith('.svelte')) continue
const src = readFileSync(join(dir, file), 'utf8')
if (/data-color=\{/.test(src) && !src.includes('data-color-custom')) {
violations.push(`${component}/${file}: dynamic data-color={…} without data-color-custom`)
}
}
}
expect(violations).toEqual([])
})
// Phantom-theme-token guard (STUMBLES #7, soma→eidos CSS-var drift). A recipe
// that consumes a theme token which the foundation never declares fails
// SILENTLY: the `var(--x)` resolves to nothing (empty / transparent), only

Loading…
Cancel
Save

Powered by TurnKey Linux.