feat(eidos): shape engine — Fase 1 (continuity + families) + SHAPE_ENGINE_RFC

The 8th book channel (forma) — the one no web design system has elevated. Fase 1
lays the foundation: corner continuity + perceptual families as portable tokens.

- ShapePrimitiveSet (config-driven): `smoothing` (superellipse exponent, 1=arc ->
  2=squircle) + `families` map. STATIC_SHAPE ships rounded/continuous/cut/scoop.
- Emission: `--shape-smoothing` token + `[data-shape=`{family}`]` rules setting
  `corner-shape` (round / superellipse(var(--shape-smoothing)) / bevel / scoop).
  Opt-in: magnitude stays in `--radius-*` (untouched), so corners degrade to the
  plain border-radius arc where `corner-shape` is unsupported (progressive, like
  the wide-gamut oklch of color). var() works inside superellipse() (Chrome 146).
- Validation (validateShapePrimitives) + 2 tests + regen.

Why it matters: the whole web field (Tailwind/shadcn/Chakra/Mantine/Ant/Radix
Themes/Carbon/Fluent/Spectrum/Polaris/Primer) is "radius scale + circular arc +
static". Continuity exists only in Apple (platform-locked); none on the web ships
squircle as a token. This is the first.

Verified: npm run check 0 errors; eidos config+generated 54/54. data-shape applies
in the live runtime (continuous->squircle, cut->bevel, scoop->scoop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 6998c5bde2
commit 7e26608ee0

@ -0,0 +1,144 @@
# RFC — Motor de forma (shape) de Eidos
> Hermano de `COLOR_ENGINE_RFC.md`, `TYPOGRAPHY_ENGINE_RFC.md` y `DEPTH_ENGINE_RFC.md`.
> Lleva el canal de **forma** (`shape` del libro *Diseñando lo que ocurre*) a reference-grade
> **rompiendo** el modelo de los referentes — con la **jaula abierta**. Es el **8º y último
> canal de expresión** del libro sin elevar (tiempo · movimiento · presencia · profundidad ·
> **forma** · color · sonido · háptica).
## 0. Tesis
> **La forma no es un número de redondeo que un elemento _tiene_; es una cualidad perceptual
> — continuidad, familia, tensión — que el sistema _compone_ y que, en su momento, _ocurre_.**
Todos reducen la forma a UN número: `border-radius`. Pero la esquina tiene más ejes que su
magnitud: **cuán continua** es (arco circular vs superelipse), a **qué familia** pertenece
(redondeada / continua / cortada / píldora), cómo se **relaciona** con las formas anidadas, y
**qué le ocurre** cuando el elemento es presionado o confirma. Eidos trata la forma como un
**canal de primera clase**: unificado (magnitud + continuidad + familia + armonía), y
**eventful** (la forma puede tensarse/relajarse), bajo la **jaula abierta**.
## 1. El estudio — cómo lo hacen los referentes y dónde topan
| Framework | Modelo | Límite estructural |
|---|---|---|
| **Tailwind / Bootstrap** | escala `rounded-sm…full`, arco circular | sin continuidad, sin armonía anidada, sin familia, estática; la esquina es un arco y punto |
| **Material 3** | shape scale + mapping por componente + (M3 Expressive) *morph* + familia `cut` | arco/cut; el morph es reciente y acotado; sistema **cerrado** (eliges de la escala) |
| **Apple / SwiftUI** | esquinas **continuas** (superelipse) `.continuous` | atado a plataforma, no es token portable; no eventful; anidado manual |
| **CSS hoy** | `border-radius` = arcos elípticos | sin superelipse nativa universal (`corner-shape` emergente); squircles vía SVG/clip-path hacks |
**Límite común**: forma = **un número de radio de arco**, propiedad **estática** que asignas,
**desligada** de los elementos vecinos/anidados, sin **continuidad** real, y **desconectada de
lo que ocurre**. SwiftUI es el único con continuidad — pero cerrada y por plataforma. Material
es el único con familia + morph — pero arco y cerrado. Nadie los **unifica** ni los hace
**abiertos**.
## 2. Dónde está Eidos hoy (coherente, pero no novedoso)
- **Escala de radius** en `--radius-{none·sm·md·lg·xl·xxl·full}` (4/6/10/16/20px + pill). ✓
- Asignada **por componente** vía el size primitive (`radius: 'md'`). ✓
- **PERO** el modelo es exactamente "un número de **arco circular**": sin continuidad
(squircle), sin **armonía** entre radios anidados, sin **familia** perceptual, y **estática**
(nunca ocurre). Estás ~a la par de Tailwind; por debajo de SwiftUI (continuidad) y de M3
(familia + morph).
## 3. El modelo novel — 4 piezas
1. **Continuidad (superelipse)** — un eje nuevo `--shape-smoothing` (0 = arco circular …
1 = superelipse tipo iOS). **Output progresivo** (estrategia idéntica al oklch del color):
se emite `border-radius` (universal, el fallback) **y** `corner-shape: superellipse(…)` donde
se soporta. Resuelve de raíz la esquina-de-arco que todos arrastran, sin romper a nadie.
2. **Armonía de radios anidados** — radio interior **derivado** del exterior:
`interior = max(0, exterior − gap)` (concéntrico), computado en `calc()`. Una convención
`--shape-nest` para que tarjeta-dentro-de-tarjeta (o input-dentro-de-panel) nunca desencaje.
3. **Familia de forma** — `rounded · continuous · cut · pill` (+ `sharp`). Eje **perceptual**,
no de magnitud. Canon del eidos (como roles/variants): valores themeables, set canon.
4. **Forma _eventful_ (lo genuinamente nuevo)** — la forma se **tensa/relaja** al ocurrir algo:
`contact`→aprieta esquinas (squeeze), `commit`→las redondea, vía la **misma firma** que ya
dirige motion/depth. Forma-como-suceso, coordinada desde **un solo evento sema**.
## 3.bis Los dos momentos de la forma
La forma respeta el **modelo de dos momentos** del framework (motion F1) — no lo reinventa:
| Momento | Atributo (eje) | Para shape | Fase |
|---|---|---|---|
| **estado** | `data-state` (persistente) | la **forma en reposo** — magnitud (`--radius-*`) + familia + continuidad: cómo *es* el elemento | 1 |
| **evento** | `data-event-*` (transitorio, durante el `hold`) | el **morph** — apretar/redondear al `contact`/`commit`: lo que *ocurre*. Ordenado por `sequence`, junto a las firmas de motion/depth | 3 |
Un control **tiene** una forma (estado) **y la tensa** al pulsarse (evento) — los dos momentos,
nunca colapsados. Mismo rigor del libro (evento ≠ estado) llevado a la forma.
## 4. Doctrina — _default fuerte, jaula abierta_
| Pieza | Default fuerte | Puerta abierta |
|---|---|---|
| **escala** | `--radius-*` canónica | config-driven (`EidosConfig` size/radius); el `border-radius` crudo siempre a un paso |
| **continuidad** | `--shape-smoothing` por defecto (sutil) | override global/por-componente/por-elemento; `0` vuelve al arco puro |
| **familia** | set canónico (rounded/continuous/cut/pill/sharp) | el set es **canon** pero themeable; un wrapper compone una propia sin inventar familia |
| **anidado** | convención `--shape-nest` (concéntrico) | opt-in; el cálculo es transparente (`calc()`), anulable |
| **eventful** | `contact`↓apretar / `commit`↑redondear | **opt-in/opt-out** (como sound/haptic/depth); registro abierto de sema; **degrada con `prefers-reduced-motion`**; la forma estática funciona sin el canal |
| **sistema entero** | tema canónico | **`applyShape(seed)`** runtime (hermano de `applyColorScheme`/`applyTypeScale`/`applyDepth`) |
Es exactamente cómo ya operan color/tipografía/profundidad: canon retintable + builders
runtime + primitivos siempre accesibles. Forma hereda el mismo contrato de apertura.
## 5. Contrato de tokens (añadidos, congelados)
Bare-prefixed por sistema (`--shape-…`). La **magnitud** sigue en `--radius-*` (no se renombra
nada → cero rotura):
```
--radius-{key} magnitud (escala existente — se mantiene)
--shape-smoothing factor de superelipse 0..1 (Fase 1) → corner-shape: superellipse(k)
--shape-nest-gap gap por defecto del radio anidado (Fase 2)
```
- **Continuidad**: `[style] { border-radius: var(--radius-md) }` (universal) + donde se soporte
`corner-shape: superellipse(var(--shape-smoothing-k))` (progresivo, degrada al arco).
- **Familia**: atributo `data-shape='cut'|'continuous'|…` o recipe del componente (no un token
global) — selecciona el `corner-shape` / clip apropiado.
- **Anidado**: `border-radius: max(0px, calc(var(--_outer-radius) - var(--shape-nest-gap)))` —
convención en `calc()`, sin token de salida nuevo.
## 6. Fases (la flexibilidad va horneada en cada una)
1. **Continuidad + escala** — `--shape-smoothing` + emisión progresiva (`border-radius` +
`corner-shape` donde se soporte) + validación + test + regen. Puertas: smoothing
config-driven/override, `--radius-*` intactos, escape al `border-radius` crudo.
2. **Armonía anidada** — convención/helper de radio anidado computado (`--shape-nest-gap`).
3. **Forma _eventful_ (momento-evento)** — firma `shape-morph` (squeeze/round) **sobre el
sistema de `signatures` existente**, coordinada con motion/depth desde un evento, degradando
con reduced-motion. Anulable (sobreescribe keyframes/signatures). No un sistema paralelo.
4. **Familias** — `rounded/continuous/cut/pill/sharp` como atributo + recipe + builder
`applyShape(seed)`.
5. **Showcase + docs** — `/temas/forma` (continuidad/anidado/familias/eventful en vivo) +
THEMING §shape.
## 7. Composición con lo existente
- **Escala `--radius-*`** → la magnitud de cada forma (se reusa, no se duplica).
- **Sistema de `signatures`** (motion/depth) → el morph eventful (Fase 3), no un motor nuevo.
- **Dos momentos de motion** → el morph es un movimiento de la forma, no un salto.
- **Bus de eventos sema** → el disparo eventful (Fase 3), como sound/haptic/depth.
- **Espaciado** → el gap del radio anidado (Fase 2).
No se reinventa nada: se **unifica + se hace continuo, armónico y eventful** lo que hoy es un
número suelto, bajo la doctrina del libro.
## 8. Doctrina (paralela a color / tipografía / profundidad)
- **Escala + familias = canon del eidos** (como roles/variants y planos): valores themeables,
pero el *set* es canon.
- **Continuidad = cualidad por defecto**, retuneable; `0` = arco puro (jaula abierta).
- **Forma eventful = capacidad del motor**, opt-in con degradación (como sound/haptic/depth).
- **Theme = retintar/retunear lo perceptualmente fijo**: cambia CUÁNTO redondea `md` o cuánta
continuidad lleva, no QUÉ significa `cut`.
- **Jaula abierta**: opinión fuerte que nunca atrapa — `border-radius` crudo siempre accesible.
## 9. Fuera de alcance
- Blobs / formas orgánicas aleatorias y morphing de `path` arbitrario (la forma es perceptual,
no un motor de geometría libre).
- Recortes complejos por geometría (clip-path a medida por componente queda en cada recipe).
- Esquinas por-lado asimétricas como sistema (el `border-radius` por-esquina crudo ya las cubre).

@ -519,6 +519,30 @@ describe('ActiveEidos config', () => {
eidos.clearDepth(); // reverts cleanly, no throw
});
it('emits the shape channel — --shape-smoothing + data-shape family rules (continuity)', () => {
const css = createThemeBaseEidos().renderStaticCss();
expect(css).toContain('--shape-smoothing: 2;');
// continuity is opt-in via data-shape; magnitude (--radius-*) is never touched here
expect(css).toContain("[data-shape='continuous'] {");
expect(css).toContain('corner-shape: superellipse(var(--shape-smoothing));');
expect(css).toContain("[data-shape='rounded'] {");
expect(css).toContain('corner-shape: round;');
expect(css).toContain("[data-shape='cut'] {");
expect(css).toContain('corner-shape: bevel;');
});
it('shape is config-driven — a theme can add a family / retune smoothing (jaula abierta)', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS);
;(cfg.primitives as Record<string, unknown>).shape = {
smoothing: '1.4',
families: { squircle: 'squircle' }
};
const css = createEidos(cfg).renderStaticCss();
expect(css).toContain('--shape-smoothing: 1.4;');
expect(css).toContain("[data-shape='squircle'] {");
expect(css).toContain('corner-shape: squircle;');
});
it('emits the Phase 3 typography scales + optical tracking', () => {
const css = createThemeBaseEidos().renderStaticCss()
expect(css).toContain('--tracking-tight: -0.02em;')

@ -457,6 +457,7 @@
--depth-recessed-surface: var(--color-surface-muted);
--depth-recessed-shadow: inset 0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 12%, transparent);
--depth-recessed-z: var(--z-index-base);
--shape-smoothing: 2;
--control-height-2xs: var(--control-height-xxs);
--radius-xs: var(--radius-sm);
--font-sans: var(--font-family-primary);
@ -4361,6 +4362,22 @@
z-index: var(--depth-recessed-z);
}
[data-shape='rounded'] {
corner-shape: round;
}
[data-shape='continuous'] {
corner-shape: superellipse(var(--shape-smoothing));
}
[data-shape='cut'] {
corner-shape: bevel;
}
[data-shape='scoop'] {
corner-shape: scoop;
}
[data-density='compact'] {
--density-space-scale: var(--density-compact-space-scale);
--density-control-scale: var(--density-compact-control-scale);

@ -698,6 +698,21 @@ export interface DepthPrimitiveSet {
readonly planes: Record<string, DepthPlane>;
}
/**
* The shape system — corner continuity + perceptual families. `smoothing` is the superellipse
* exponent for the `continuous` family (1 = circular arc, 2 = iOS-like squircle); `families`
* maps a `data-shape='{family}'` name to a `corner-shape` value. Config-driven (a theme can
* retune the smoothing or add families); the magnitude stays in `--radius-*`. Progressive: the
* `corner-shape` rules degrade to the plain `border-radius` arc where unsupported.
* (SHAPE_ENGINE_RFC)
*/
export interface ShapePrimitiveSet {
/** Superellipse exponent for the `continuous` family, emitted as `--shape-smoothing`. */
readonly smoothing: string;
/** `data-shape='{family}'` → `corner-shape` value (round / superellipse(…) / bevel / scoop). */
readonly families: Record<string, string>;
}
export interface PrimitiveSet {
readonly color?: ColorPrimitiveSet;
readonly size?: SizePrimitiveSet;
@ -715,6 +730,7 @@ export interface PrimitiveSet {
readonly opacity?: OpacityPrimitiveSet;
readonly zIndex?: ZIndexPrimitiveSet;
readonly depth?: DepthPrimitiveSet;
readonly shape?: ShapePrimitiveSet;
}
export interface SemanticSet {

@ -129,6 +129,7 @@ export function validateEidosConfig(options: EidosConfig): EidosValidationReport
validateMotionPrimitives(options, issues)
validateIconPrimitives(options, issues)
validateDepthPrimitives(options, issues)
validateShapePrimitives(options, issues)
validateRecipeTokens(options, issues)
validateMotion(options, issues)
validateRecordPrimitive(
@ -927,6 +928,21 @@ function validateDepthPrimitives(options: EidosConfig, issues: EidosValidationIs
}
}
function validateShapePrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
const shape = options.primitives.shape
if (shape === undefined) return
validateNonEmptyCssValue('primitives.shape.smoothing', shape.smoothing, issues)
if (!isPlainRecord(shape.families)) {
issues.push({ path: 'primitives.shape.families', message: 'shape families must be a plain object' })
return
}
for (const [family, value] of Object.entries(shape.families)) {
const path = `primitives.shape.families.${family}`
validateCssTokenSuffix(path, family, 'shape family', issues)
validateNonEmptyCssValue(path, value, issues)
}
}
function validateSizePrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
const sizes = options.primitives.size
if (!sizes) return

@ -1,4 +1,9 @@
import type { DepthPrimitiveSet, PrimitiveSet, SizePrimitiveSet } from '../config-types'
import type {
DepthPrimitiveSet,
PrimitiveSet,
ShapePrimitiveSet,
SizePrimitiveSet
} from '../config-types'
import { STATIC_TYPOGRAPHY } from './typography'
export const STATIC_SPACE = {
@ -239,6 +244,20 @@ export const STATIC_DEPTH: DepthPrimitiveSet = {
}
}
// Shape — corner continuity + perceptual families. Magnitude stays in STATIC_RADIUS; this adds
// the `corner-shape` axis: `continuous` = superellipse (squircle), opt-in via `data-shape`.
// `smoothing` 2 ≈ the iOS squircle (1 = circular arc). Progressive — degrades to the arc where
// `corner-shape` is unsupported. (SHAPE_ENGINE_RFC)
export const STATIC_SHAPE: ShapePrimitiveSet = {
smoothing: '2',
families: {
rounded: 'round',
continuous: 'superellipse(var(--shape-smoothing))',
cut: 'bevel',
scoop: 'scoop'
}
}
export const STATIC_SIZE: SizePrimitiveSet = {
xxs: {
controlHeight: 'xxs',
@ -321,6 +340,7 @@ export const STATIC_PRIMITIVES: Pick<
| 'opacity'
| 'zIndex'
| 'depth'
| 'shape'
> = {
size: STATIC_SIZE,
space: STATIC_SPACE,
@ -335,5 +355,6 @@ export const STATIC_PRIMITIVES: Pick<
icon: STATIC_ICON,
opacity: STATIC_OPACITY,
zIndex: STATIC_Z_INDEX,
depth: STATIC_DEPTH
depth: STATIC_DEPTH,
shape: STATIC_SHAPE
}

@ -24,6 +24,7 @@ import {
type EidosCssVariableValue,
type EidosConfig,
type DepthPrimitiveSet,
type ShapePrimitiveSet,
type FontFace,
type FontFallback,
type FontFamily,
@ -215,9 +216,14 @@ export function renderStaticCss(options: EidosConfig): string {
appendDepthDeclarations(declarations, primitives.depth)
}
if (primitives.shape) {
appendShapeDeclarations(declarations, primitives.shape)
}
appendTransitionAliasDeclarations(declarations, options)
const scopedRecipeBlocks = appendRecipeDeclarations(declarations, options.recipes)
const depthBlocks = primitives.depth ? renderDepthBlocks(primitives.depth) : []
const shapeBlocks = primitives.shape ? renderShapeBlocks(primitives.shape) : []
const blocks: string[] = []
// @font-face first (config-driven loading); the family-stack tokens reference them.
@ -225,7 +231,12 @@ export function renderStaticCss(options: EidosConfig): string {
const fontFaceCss = renderFontFaceBlocks(primitives.typography)
if (fontFaceCss) blocks.push(fontFaceCss)
}
blocks.push(renderBlock(':root', declarations), ...scopedRecipeBlocks, ...depthBlocks)
blocks.push(
renderBlock(':root', declarations),
...scopedRecipeBlocks,
...depthBlocks,
...shapeBlocks
)
// Responsive typography style overrides — one media-query block per
// breakpoint that has at least one responsive size override.
@ -811,6 +822,26 @@ function renderDepthBlocks(depth: DepthPrimitiveSet): string[] {
return blocks
}
// ── Shape (SHAPE_ENGINE_RFC) ─────────────────────────────────────────────────
/** Emit the shape token — `--shape-smoothing` (superellipse exponent of the `continuous` family). */
function appendShapeDeclarations(declarations: string[], shape: ShapePrimitiveSet): void {
declarations.push(cssVar('shape-smoothing', shape.smoothing))
}
/**
* `[data-shape='{family}']` sets `corner-shape` (continuity / perceptual family) — opt-in, so the
* magnitude (`border-radius`) stays universal and the corners degrade to the plain arc where
* `corner-shape` is unsupported. Magnitude is never touched here. (SHAPE_ENGINE_RFC)
*/
function renderShapeBlocks(shape: ShapePrimitiveSet): string[] {
const blocks: string[] = []
for (const [family, value] of Object.entries(shape.families)) {
blocks.push(renderBlock(`[data-shape='${family}']`, [`corner-shape: ${value};`]))
}
return blocks
}
/**
* Breakpoint min-width thresholds matching `$libs/dom/responsive`.
* `base` is the implicit 0 fallback — emitted into `:root`, not as a

Loading…
Cancel
Save

Powered by TurnKey Linux.