feat(eidos): variants canon — EIDOS_VARIANTS + per-component lint

Variants son canon del eidos, NO del theme. Decisión arquitectónica
firmemente sostenida: el vocabulario de variants (solid/outline/ghost/
soft/surface/line/pills) está fijo a nivel del framework — paralelo
a las 8 sema families del libro. Theme = retintar lo perceptualmente
fijo; cambia QUÉ color es `affirm`, no QUÉ significa `outline`.

Cambios:

- lib/types.ts: nueva constante `EIDOS_VARIANTS` con los 5 archetypes
  canónicos (control / selection / chip / marker / tabs). Los 5 union
  types se derivan via `[number]` indexed access — valor y tipo no
  pueden desincronizarse. Nueva `EIDOS_VARIANT_VALUES` Set flat con
  todos los valores canónicos + utilidades cross-component (`plain`,
  `subtle`).

- 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` (soporta literal
  unions + archetype aliases; cae a advisory mode en Extract<> y
  conditional types); compara con `[data-{c}][data-variant='X']`
  selectores en `{c}.css`; reporta typos y unauthorized extensions
  bidireccionalmente.

- THEMING.md §19: nueva sección "Variants son canon del eidos, NO
  del theme" con argumentación (portabilidad, type safety, archetypes
  perceptuales paralelos a sema families), tabla de las 3 capas de
  la cebolla, referencia a `EIDOS_VARIANTS`, comparación con Radix
  Themes 3.x / Mantine 7 / Chakra v3 / Ark / shadcn. TOC actualizado.

- eidos/README.md: tabla de referencia ampliada con §19.

- CLAUDE.md: hand-off "2026-05-27 #6 (variants canon)".

- CONTINUE.md: nota de la decisión arquitectónica.

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) — no relacionados.

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

@ -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

@ -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

@ -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.** `<Toggle variant="outline">` 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<string, …>` 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<Record<string, readonly string[]>>;
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<ButtonVariant, ControlVariant>;
```
### 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.

@ -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 `<Toggle variant="outline">`
* 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<SelectionVariant, 'solid'>`
* 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<Record<string, readonly string[]>>;
/**
* 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<string>([
...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

@ -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 `<Toggle variant="outline">` 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<string> {
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<string>()
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<string> | 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<string>()
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.
*

Loading…
Cancel
Save

Powered by TurnKey Linux.