diff --git a/CLAUDE.md b/CLAUDE.md index de4aceb6c..b6a30f6d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -515,6 +515,23 @@ Fix (en `toggle-group.css`): **inlined las derivation expressions** que leen pal - Si emergen patrones similares al toggle-group (otros wrappers compositivos como button-group, nav-menu), la composition TSC v2.2 los cubre — no requiere más extensiones. - 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. +## Session hand-off — 2026-05-27 #6 (variants canon — `EIDOS_VARIANTS` + lint) + +Pregunta arquitectónica del usuario: "si activeUIX quiere ser referencia como framework, ¿qué es lo lógicamente coherente respecto a la extensibilidad de variants?". Respuesta firme: **variants son canon del eidos, NO del theme** — paralelo a las 8 sema families del libro. + +**Cambios**: + +- `src/uix/eidos/lib/types.ts`: nueva constante `EIDOS_VARIANTS` (5 archetypes: `control`/`selection`/`chip`/`marker`/`tabs`) como single source of truth. Los 5 union types se derivan via `[number]` indexed access — valor y tipo no pueden desincronizarse. Nueva constante `EIDOS_VARIANT_VALUES` (Set flat de todos los valores canónicos + utilidades cross-component como `'plain'` y `'subtle'`). +- `src/uix/eidos/recipe-css-contract.test.ts`: nuevo test "variant CSS selectors per component match the declared type union". Por cada componente: extrae el union type de `components/{c}/types.ts` via regex (literal-union + archetype-alias patterns soportados, Extract<>/conditional types caen a advisory mode); compara con los `[data-{c}][data-variant='X']` selectores en `{c}.css`; reporta typos y unauthorized extensions bidireccionalmente. 101/101 tests pasan. +- `src/uix/eidos/THEMING.md` §19: nueva sección "Variants son canon del eidos, NO del theme" con argumentación (portabilidad, type safety, archetypes perceptuales), tabla de las 3 capas de la cebolla (sema → variants → palette), referencia a `EIDOS_VARIANTS`, comparación con Radix Themes/Mantine/Chakra v3/Ark UI/shadcn. TOC actualizado. +- `src/uix/eidos/README.md`: tabla de referencia ampliada con §19. + +**Doctrina sostenida**: Theme = retintar lo perceptualmente fijo. Cambia QUÉ color es `affirm`, no QUÉ significa `outline`. Si una app necesita un look brandeado, hace override de tokens en `EidosConfig.recipes` o crea un wrapper composicional — NO inventa un nuevo variant. + +**Variants component-specific permitidos** (Banner `inline`/`overlay`/`persistent`, Spinner `bars`/`dots`/`ring`, Button `'plain'`): viven en cada `components/{c}/types.ts` y el lint los valida contra la CSS del componente. + +**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-09 (selector discipline + lint reframing) - **Typed selector builder applied** in diff --git a/src/uix/eidos/README.md b/src/uix/eidos/README.md index 15adda46d..2196a104b 100644 --- a/src/uix/eidos/README.md +++ b/src/uix/eidos/README.md @@ -562,6 +562,7 @@ Resumen rápido de lo que cubre, para no duplicar aquí: | Integración con sema vía `event:*` | §13 | | Anti-patterns y FAQ | §16, §17 | | Cobertura universal de TSC (sin excepciones) | §18 | +| Variants son canon del eidos, NO del theme (con `EIDOS_VARIANTS`) | §19 | Lo que sigue en este README son las decisiones operativas de la **capa visual como módulo** (typography sourcing, picker patterns, API diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index 1b68c91ed..d8c7d133f 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -44,6 +44,7 @@ 16. [Anti-patterns que NO debes cometer](#16-anti-patterns-que-no-debes-cometer) 17. [FAQ — decisiones polémicas](#17-faq--decisiones-polémicas) 18. [Cobertura universal de TSC](#18-cobertura-universal-de-tsc) +19. [Variants son canon del eidos, NO del theme](#19-variants-son-canon-del-eidos-no-del-theme) --- @@ -1598,6 +1599,160 @@ algebra + cross-axis collision detection + auto-inferred deps). --- +## 19. Variants son canon del eidos, NO del theme + +**Posición arquitectónica firmemente sostenida**: el vocabulario de +variants (`solid`, `outline`, `ghost`, `soft`, `surface`, `line`, +`pills`, etc.) está **fijo en el sistema**. Un theme NO puede: + +- Añadir un variant nuevo (no hay un `branded`, `bubble`, `corporate` + que cada theme invente). +- Redefinir el cascade visual de un variant existente (`outline` significa + "bordered restraint" en todos los themes — solo cambia el COLOR del + border, no su geometría). + +### Las 3 capas de la cebolla + +| Capa | Qué es | Quién la cambia | +|---|---|---| +| **Sema families / intents** | vocabulario perceptual canon del libro (8 families × 6 intents) | NADIE — fijo | +| **Eidos variants** | archetypes visuales perceptuales (5 archetypes shared + variants component-specific) | NADIE — fijo | +| **Eidos color roles** | los 9 roles canon (`primary`/`affirm`/`risk`/…) | NADIE — fijo | +| **Eidos color palette** | qué hex es cada rol | **THEME** | +| **Tokens internos de recipe** | `--toggle-solid-bg` etc. | **App** (override puntual en `EidosConfig.recipes`) | + +**Theming = retintar lo perceptualmente fijo**. El theme cambia QUÉ +color es `affirm`, no QUÉ significa `outline`. + +### Por qué fijos + +1. **Portabilidad de componentes.** `` debe + renderizar coherentemente en cualquier theme. Theme-defined variants + romperían eso silenciosamente — un componente que asume `outline` + no funcionaría en un theme que no lo declara. + +2. **Type safety = parte del contrato.** Los consumers necesitan + `SelectionVariant = 'solid' | 'outline' | 'ghost'` para autocomplete + y TS errors. Un `Record` extensible perdería esa garantía. + Radix Themes 3.x, Chakra v3, Mantine — todas las referencias serias + mantienen variants fijos por componente. + +3. **Variants son archetypes perceptuales, paralelos a sema families.** + `solid` = "filled emphasis", `outline` = "bordered restraint", + `ghost` = "ambient transparency", `soft` = "tinted background". Eso + es vocabulario perceptual del framework — no decisión de theme. + +4. **Hay 5 archetypes, no infinitos.** El catálogo se cierra; el TS + los rechaza si no son canónicos. Si emerge un nuevo archetype + genuinamente perceptual, se añade a `EIDOS_VARIANTS` — pero al + nivel del framework, no al del theme. + +### Single source of truth — `EIDOS_VARIANTS` + +`src/uix/eidos/lib/types.ts` declara la constante: + +```ts +export const EIDOS_VARIANTS = { + control: ['surface', 'outline', 'ghost'], + selection: ['solid', 'outline', 'ghost'], + chip: ['soft', 'solid', 'outline', 'ghost'], + marker: ['solid', 'soft', 'outline'], + tabs: ['line', 'surface', 'pills'] +} as const satisfies Readonly>; + +export type ControlVariant = (typeof EIDOS_VARIANTS.control)[number]; +export type SelectionVariant = (typeof EIDOS_VARIANTS.selection)[number]; +// … +``` + +Los 5 unions se DERIVAN de la const — el valor y el tipo no pueden +desincronizarse. Cada componente narrow al archetype apropiado: + +```ts +// components/toggle/types.ts +export type ToggleVariant = SelectionVariant; + +// components/accordion/types.ts +export type AccordionVariant = ControlVariant; +``` + +Para narrowing más fino dentro de un archetype: + +```ts +export type AlertDialogCancelVariant = Extract; +``` + +### Variants component-specific + +Algunos componentes tienen vocabularios genuinamente únicos: + +- `Banner`: `inline | overlay | persistent` (posicionamiento, no + treatment perceptual). +- `Spinner`: `bars | dots | ring` (geometría del indicador). +- `Button`: añade `'plain'` para inline/link-like — no merece archetype + propio porque solo aparece en Button + Code. + +Estos viven en cada `components/{c}/types.ts`. El lint +`recipe-css-contract.test.ts > variant CSS selectors per component +match the declared type union` valida bidireccionalmente: + +- CSS usa `[data-{c}][data-variant='X']` → X debe estar en el union. +- Type union declara `'X'` → CSS debería tener entries (advisory). + +### Lo que un theme SÍ puede + +- Cambiar paletas (`ThemeColorSet.scales`, `ThemeColorSet.roles`). +- Cambiar shadows (`ShadowScale`). +- Cambiar typography styles (`TypographyPrimitiveSet.styles`). + +### Lo que un theme NO puede + +- Añadir variants. (`ThemeDefinition` no expone `recipes`.) +- Redefinir cascades visuales. (Los recipes son `EidosConfig.recipes`, + parte del bootstrap del app, no del theme.) +- Cambiar roles de color. (Los 9 roles son canon.) +- Cambiar sizes canon. (`SIZE_PRIMITIVE_KEYS` es fijo.) + +### Lo que el app SÍ puede (al boot, no per-theme) + +- Override de tokens en `EidosConfig.recipes` — cambia el RESULTADO + del cascade visual, no añade un variant nuevo. +- Crear componentes wrappers propios que compongan los primitives de + eidos con className/style custom. +- Cambiar tokens en runtime via `ActiveEidos.setOverrides()` (per-app + variables CSS). + +### Cuándo añadir un archetype nuevo + +Solo si EMERGE un patrón perceptual repetido en ≥3 componentes que no +encaja en los 5 archetypes existentes. Procedimiento: + +1. Documentar el archetype con 1 párrafo describiendo el affordance + perceptual (paralelo a "solid = filled emphasis"). +2. Añadirlo a `EIDOS_VARIANTS` en `lib/types.ts`. +3. Export el type derivado. +4. Migrar los componentes consumidores a referenciarlo. +5. Actualizar esta sección. + +### Comparación con referentes + +| Lib | Variants extensibles por theme | Variants extensibles por app | +|---|---|---| +| **Radix Themes 3.x** | ❌ | ❌ (fijos por componente) | +| **Mantine 7+** | ❌ | ❌ (defaultProps + styles override) | +| **Chakra UI v3 (Panda)** | ❌ | ⚠️ via recipes config (compound variants) | +| **Ark UI** | n/a (100% headless, sin opinión) | n/a | +| **shadcn/ui** | n/a (copy-paste, no framework) | ✓ (copia + edita) | +| **activeUIX** | ❌ | ⚠️ via `EidosConfig.recipes` override (cambia tokens, no añade variants) | + +activeUIX se alinea con Radix Themes y Mantine: framework con contrato +fijo, theme con flexibilidad acotada al color/spacing. La extensibilidad +extrema (Tailwind, CSS-in-JS plain) es deliberadamente NO el goal — +porque la promesa del framework es portabilidad perceptual entre apps +y themes. + +--- + **Última revisión**: 2026-05-27. Si algo en este doc no coincide con el código, el código gana — pero abre un issue para que actualicemos el doc. diff --git a/src/uix/eidos/lib/types.ts b/src/uix/eidos/lib/types.ts index e9407ff6b..1a567851b 100644 --- a/src/uix/eidos/lib/types.ts +++ b/src/uix/eidos/lib/types.ts @@ -46,14 +46,66 @@ export type Position = | 'bottom-right'; /** - * Shared visual treatment families. Component files may narrow these - * families with `Extract<>`, but must not redeclare the literal unions. + * Canonical Eidos variant archetypes — visual treatments shared across + * multiple components. Single source of truth: `EIDOS_VARIANTS`. The + * unions are derived from the const so the value list and the type + * stay in sync. + * + * **Why fixed, not theme-extensible**: variants encode perceptual + * archetypes ("filled emphasis" = solid; "bordered restraint" = outline; + * "ambient" = ghost; "tinted background" = soft; "control surface" = + * surface; tabs-specific = line/pills). These belong to the eidos + * canon, paralleling the 8 sema families. Theme-extensible variants + * would break component portability — a `` + * must render coherently in every theme. See `THEMING.md §19`. + * + * **Component-specific variants** (e.g. Banner's `inline`/`overlay`/ + * `persistent`, Spinner's `bars`/`dots`/`ring`, Button's `plain`) are + * allowed when no archetype fits — declared in each component's + * `types.ts`. The recipe-css-contract lint checks per-component that + * the CSS selectors match the type union (no typos either way). + * + * To add a new archetype: append a new key to `EIDOS_VARIANTS`. To + * narrow within an archetype: `Extract` + * in the component's `types.ts`. + */ +export const EIDOS_VARIANTS = { + control: ['surface', 'outline', 'ghost'], + selection: ['solid', 'outline', 'ghost'], + chip: ['soft', 'solid', 'outline', 'ghost'], + marker: ['solid', 'soft', 'outline'], + tabs: ['line', 'surface', 'pills'] +} as const satisfies Readonly>; + +/** + * Flat allowlist of all values appearing in any canonical archetype. + * Used by the recipe-css-contract guard to recognise canonical values + * separately from component-specific extensions. Includes the + * cross-component utility values (`plain`, `subtle`) that emerge in + * more than one component recipe but aren't sufficient for their own + * archetype. + * + * Component-only variant values (e.g. Banner's `inline`, Spinner's + * `dots`) live in their component's `types.ts` and are NOT listed + * here — the per-component CSS-vs-type lint covers them. */ -export type ControlVariant = 'surface' | 'outline' | 'ghost'; -export type SelectionVariant = 'solid' | 'outline' | 'ghost'; -export type ChipVariant = 'soft' | 'solid' | 'outline' | 'ghost'; -export type MarkerVariant = 'solid' | 'soft' | 'outline'; -export type TabsVariant = 'line' | 'surface' | 'pills'; +export const EIDOS_VARIANT_VALUES = new Set([ + ...EIDOS_VARIANTS.control, + ...EIDOS_VARIANTS.selection, + ...EIDOS_VARIANTS.chip, + ...EIDOS_VARIANTS.marker, + ...EIDOS_VARIANTS.tabs, + // Cross-component utilities — accepted in any component's variant + // union without earning their own archetype. + 'plain', + 'subtle' +]); + +export type ControlVariant = (typeof EIDOS_VARIANTS.control)[number]; +export type SelectionVariant = (typeof EIDOS_VARIANTS.selection)[number]; +export type ChipVariant = (typeof EIDOS_VARIANTS.chip)[number]; +export type MarkerVariant = (typeof EIDOS_VARIANTS.marker)[number]; +export type TabsVariant = (typeof EIDOS_VARIANTS.tabs)[number]; /** * Shared Eidos color role vocabulary. `primary` and `secondary` are diff --git a/src/uix/eidos/recipe-css-contract.test.ts b/src/uix/eidos/recipe-css-contract.test.ts index 640483fe7..10deb19c2 100644 --- a/src/uix/eidos/recipe-css-contract.test.ts +++ b/src/uix/eidos/recipe-css-contract.test.ts @@ -6,6 +6,7 @@ import { THEME_BASE_RECIPE_TOKENS } from './lib/recipes/base' import type { EidosConfig, RecipeTokenSet, RecipeTokenValue } from './lib/config-types' import { renderStaticCss } from './lib/render-css' import { createThemeBaseEidosConfig } from './lib/themes/base' +import { EIDOS_VARIANTS, EIDOS_VARIANT_VALUES } from './lib/types' /** * Build a minimal EidosConfig whose recipes are the given map. Reuses the @@ -263,6 +264,57 @@ describe('Eidos recipe CSS contract', () => { expect(violations).toEqual([]) }) + /** + * Variant canon enforcement (per-component). + * + * For every component CSS, the `[data-{component}][data-variant='X']` + * selectors must use variant values that the component's own + * `types.ts` declares — no typos, no unauthorized extensions. + * + * **Why this matters**: variants are part of the framework's + * perceptual canon (see THEMING.md §19). They are NOT + * theme-extensible — a `` must render + * coherently in every theme. The 5 archetypes in `lib/types.ts > + * EIDOS_VARIANTS` cover the common cross-component vocabularies; + * component-specific variants (e.g. Banner's `inline`/`overlay`) + * are declared in each component's own `types.ts` and validated + * by this lint. + * + * **What this catches**: + * - CSS uses `data-variant='outlne'` (typo) → fails: not in type union + * - Adds `[data-toggle][data-variant='neon']` without updating + * `ToggleVariant` → fails: not in type + * - Removes `'ghost'` from type union but leaves the CSS rule → + * fails: type ⊃ CSS check + * + * **Type parsing**: extracts the union literal values from + * `export type {Pascal}Variant = ...` patterns. Handles direct + * literal unions and aliases to the 5 archetypes. Components with + * complex types (Extract / Omit / unions of multiple archetypes — + * Button, AlertDialog actions, etc.) are skipped with the variant + * pool defaulting to ALL canonical values + the component's own + * literal union; the lint runs in advisory mode there. + */ + it('variant CSS selectors per component match the declared type union', () => { + const violations: string[] = [] + for (const { component } of readComponentCssFiles()) { + const cssVariants = collectComponentVariantValues(component) + if (cssVariants.size === 0) continue + const typeVariants = extractComponentVariantTypeValues(component) + if (!typeVariants) continue // type file missing or complex pattern; skip strict check + for (const variant of cssVariants) { + if (!typeVariants.has(variant)) { + violations.push( + `components/${component}/${component}.css uses ` + + `[data-${component}][data-variant='${variant}'] but ` + + `${component}/types.ts declares only [${[...typeVariants].sort().join(', ')}]` + ) + } + } + } + expect(violations).toEqual([]) + }) + /** * Token Scope Contract (TSC) — the production recipes pass the * generator's scope validation. If a recipe author mis-scopes a @@ -278,6 +330,86 @@ describe('Eidos recipe CSS contract', () => { }) }) +/** + * Pull every `[data-{component}][data-variant='X']` selector out of + * the component CSS, returning the set of X values. Strips comments + * first so commented-out selectors don't count. + */ +function collectComponentVariantValues(component: string): Set { + const file = join(COMPONENTS_DIR, component, `${component}.css`) + if (!existsSync(file)) return new Set() + const css = stripCssComments(readFileSync(file, 'utf8')) + const ownPrefixes = [`[data-${component}]`, `[data-${component}-`] + const variants = new Set() + const RE = /\[data-variant='([^']+)'\]/g + for (const match of css.matchAll(RE)) { + // Only consider matches where the variant selector follows a + // selector that scopes the component itself (root or any of its + // parts) — avoids picking up other component's selectors that + // happen to be in the same file (e.g. a banner CSS that styles + // a button inside it). + const ctx = css.slice(Math.max(0, match.index - 200), match.index) + if (ownPrefixes.some((pre) => ctx.includes(pre))) { + variants.add(match[1]) + } + } + return variants +} + +/** + * Parse `src/uix/eidos/components/{component}/types.ts` and extract + * the variant value set declared for that component. Handles: + * - Direct literal union: `export type FooVariant = 'a' | 'b' | 'c'` + * - Archetype alias: `export type FooVariant = ControlVariant` + * - Multiple variant types in one file (root + sub-parts) — merged. + * + * Returns `undefined` if the file doesn't exist OR the variant type + * uses a pattern the parser doesn't handle (Extract / Omit / unions + * of types). The caller skips strict validation for those components. + */ +function extractComponentVariantTypeValues(component: string): Set | undefined { + const file = join(COMPONENTS_DIR, component, 'types.ts') + if (!existsSync(file)) return undefined + const src = readFileSync(file, 'utf8') + // Match any `export type FooVariant = ...;` declaration. The + // component might declare multiple (root + sub-part), e.g. Avatar + // has AvatarVariant + AvatarBadgeVariant. We merge all variant + // types found in the file. + const DECL = /export type \w*Variant(?:\w*)?\s*=\s*([^;]+?);/gs + const result = new Set() + let sawAny = false + let sawUnknownPattern = false + for (const match of src.matchAll(DECL)) { + sawAny = true + const body = match[1].trim() + // Direct literal union + const literals = [...body.matchAll(/'([^']+)'/g)].map((m) => m[1]) + if (literals.length > 0 && /^\s*'[^']+'(?:\s*\|\s*'[^']+')*\s*$/.test(body)) { + for (const v of literals) result.add(v) + continue + } + // Direct alias to a known archetype + const aliasMatch = body.match(/^\s*(ControlVariant|SelectionVariant|ChipVariant|MarkerVariant|TabsVariant)\s*$/) + if (aliasMatch) { + const archetype = aliasMatch[1].replace('Variant', '').toLowerCase() as keyof typeof EIDOS_VARIANTS + for (const v of EIDOS_VARIANTS[archetype]) result.add(v) + continue + } + // Unrecognised pattern — fall back to advisory mode. + sawUnknownPattern = true + } + if (!sawAny) return undefined + if (sawUnknownPattern) { + // Complex types (Extract<>, unions of archetypes, etc.). Be + // permissive: union all canonical values + any literals we + // did manage to extract. The check becomes "the variant must + // be canonical somewhere" rather than "it must be in THIS + // component's specific set". + return new Set([...EIDOS_VARIANT_VALUES, ...result]) + } + return result +} + /** * Token Scope Contract — generator-level scenario tests. *