From 7e26608ee0881ad70bb030df0a22fb0f5b5bf7e4 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 20:59:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20shape=20engine=20=E2=80=94=20Fas?= =?UTF-8?q?e=201=20(continuity=20+=20families)=20+=20SHAPE=5FENGINE=5FRFC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/uix/eidos/SHAPE_ENGINE_RFC.md | 144 ++++++++++++++++++++++ src/uix/eidos/active-eidos-config.test.ts | 24 ++++ src/uix/eidos/generated/base.css | 17 +++ src/uix/eidos/lib/config-types.ts | 16 +++ src/uix/eidos/lib/config.ts | 16 +++ src/uix/eidos/lib/primitives/static.ts | 25 +++- src/uix/eidos/lib/render-css.ts | 33 ++++- 7 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 src/uix/eidos/SHAPE_ENGINE_RFC.md diff --git a/src/uix/eidos/SHAPE_ENGINE_RFC.md b/src/uix/eidos/SHAPE_ENGINE_RFC.md new file mode 100644 index 000000000..e880e079c --- /dev/null +++ b/src/uix/eidos/SHAPE_ENGINE_RFC.md @@ -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). diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index 6da3b96fd..00093144e 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -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).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;') diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 2a9311133..8d71582ec 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -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); diff --git a/src/uix/eidos/lib/config-types.ts b/src/uix/eidos/lib/config-types.ts index 95baab769..b26ed6158 100644 --- a/src/uix/eidos/lib/config-types.ts +++ b/src/uix/eidos/lib/config-types.ts @@ -698,6 +698,21 @@ export interface DepthPrimitiveSet { readonly planes: Record; } +/** + * 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; +} + 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 { diff --git a/src/uix/eidos/lib/config.ts b/src/uix/eidos/lib/config.ts index a5256f7a1..8d5db5b88 100644 --- a/src/uix/eidos/lib/config.ts +++ b/src/uix/eidos/lib/config.ts @@ -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 diff --git a/src/uix/eidos/lib/primitives/static.ts b/src/uix/eidos/lib/primitives/static.ts index 61f1577fc..f538a727c 100644 --- a/src/uix/eidos/lib/primitives/static.ts +++ b/src/uix/eidos/lib/primitives/static.ts @@ -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 } diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index c70b3615b..041789d84 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -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