From 66ff993211b6f39ccd95ca8ce14cfbb1e0b81c60 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 22:59:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20structural=20systems=20=E2=80=94?= =?UTF-8?q?=20space=20rhythm=20builder=20(buildSpaceScale/applySpacing)=20?= =?UTF-8?q?+=20RFC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The space scale was the one structural primitive without a builder — density and scaling were already strong, but the base space scale stayed flat / static / arbitrary. buildSpaceScale (pure) + ActiveEidos.applySpacing/clearSpacing regenerate the --space-{key} ladder from one base unit, optionally FLUID (growth > 1 -> each step clamp()s with the viewport, reusing the type scale fluidClamp), PRESERVING the density x scaling composition (calc(value * --density-space-scale * --scaling)). Opt-in over the authored STATIC_SPACE, same posture as applyTypeScale. Completes the runtime-builder quintet (color/type/depth/shape/space). Thesis (STRUCTURE_ENGINE_RFC): space is rhythm, not a flat px lookup table — modular, fluid, composed with density x scaling from a seed. Structural = state-only (no two-moment; honest). Verified: check 0 errors; eidos config 58/58 (incl. modular + fluid space tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/STRUCTURE_ENGINE_RFC.md | 113 +++++++++++++++++++++ src/uix/eidos/active-eidos-config.test.ts | 22 +++++ src/uix/eidos/active-eidos.svelte.ts | 56 +++++++++++ src/uix/eidos/index.ts | 2 + src/uix/eidos/lib/build-space-scale.ts | 115 ++++++++++++++++++++++ 5 files changed, 308 insertions(+) create mode 100644 src/uix/eidos/STRUCTURE_ENGINE_RFC.md create mode 100644 src/uix/eidos/lib/build-space-scale.ts diff --git a/src/uix/eidos/STRUCTURE_ENGINE_RFC.md b/src/uix/eidos/STRUCTURE_ENGINE_RFC.md new file mode 100644 index 000000000..e35df999e --- /dev/null +++ b/src/uix/eidos/STRUCTURE_ENGINE_RFC.md @@ -0,0 +1,113 @@ +# RFC — Sistemas estructurales (espacio · densidad · escala) de Eidos + +> Hermano de `COLOR_ENGINE_RFC.md`, `TYPOGRAPHY_ENGINE_RFC.md`, `DEPTH_ENGINE_RFC.md` y +> `SHAPE_ENGINE_RFC.md`. Lleva los sistemas **estructurales** a reference-grade. A diferencia de +> los canales **expresivos** (los 8 del libro), lo estructural es **solo-estado** — el escenario, +> no el suceso. Por eso la novedad aquí **no es eventful**: es **ritmo**, **fluidez** y +> **composición de ejes**, bajo la jaula abierta. + +## 0. Tesis + +> **El espacio no es una tabla de búsqueda de píxeles arbitrarios; es un _ritmo_ — derivado de +> una unidad base, _fluido_ (respira con el viewport) y _compuesto_ con densidad y zoom desde una +> semilla mínima.** + +Todos shippean una escala de espacio **plana** (`4 · 8 · 12 · 16 · 24…`), **arbitraria**, +**estática** y desligada de la tipografía. Eidos ya tiene los otros dos ejes estructurales +—**densidad** (compacidad) y **scaling** (zoom)— por encima de la media; falta que el **espacio +mismo** sea ritmo: modular, fluido y con builder runtime, como ya hizo la tipografía. + +## 1. El estudio — cómo lo hacen los referentes y dónde topan + +| Framework | Espacio | Límite | +|---|---|---| +| **Tailwind** | escala fija (`0.25rem` × N) | plana, arbitraria, **estática** | +| **Material** | grid `8dp` | múltiplos de 8, estática, sin fluidez | +| **Radix / Chakra / Mantine** | tokens de space | escala plana estática; densidad (si hay) = preset global | +| **Bootstrap / Ant / Carbon / Fluent** | escala de spacers | igual — plana + estática | +| **Utopia.fyi** | fluid space (técnica) | una **calculadora externa**, no un sistema de tokens integrado con densidad + zoom | + +**Límite común**: el espacio es una **escala plana de px**, **estática** (no respira con el +viewport), **arbitraria** (no deriva de nada), y **desconectada** de la densidad / el zoom como un +sistema. Utopia demostró el fluid space pero como hoja de cálculo, no como motor de tokens. + +## 2. Dónde está Eidos hoy (fuerte en 2 de 3 ejes) + +- **Densidad** — 3 niveles (`compact · comfortable · spacious`) × **2 ejes** (`spaceScale` + + `controlScale`). Aprieta el layout sin tocar la legibilidad del texto. ✓ (por encima de la media) +- **Scaling** — zoom global `90–110` que escala los px **incluida la tipografía** (paridad Radix), + componiendo con densidad. ✓ +- **Layout** — contenedores + padding + breakpoints + aspect-ratios. ✓ +- **Composición** — `--space-{key}` se emite como `calc(value · var(--density-space-scale) · + var(--scaling))`: densidad × zoom ya componen. ✓ +- **PERO el espacio EN SÍ** (`STATIC_SPACE`) es px **plano y arbitrario** (base 4, medios-pasos a + mano), **estático** (no respira) y **sin builder** — a diferencia del tipo, que tiene + `buildTypeScale` (modular + fluido) + `applyTypeScale` (runtime). Es el **eje rezagado**. + +## 3. El modelo novel — el espacio como ritmo + +1. **Modular** — cada paso = unidad base × N (un ladder coherente), no px sueltos. +2. **Fluido** — `clamp()`: el espacio **respira con el viewport** (como el tipo fluido — casi + ningún framework lo hace para el espacio). Reusa el mismo `fluidClamp` del type scale. +3. **Tres ejes ortogonales** — **ritmo** (la escala) × **densidad** (compacidad) × **scaling** + (zoom), compuestos multiplicativamente. Una semilla mínima los gobierna. +4. **Builder runtime** — `buildSpaceScale(seed)` (puro) + `applySpacing(seed)` (DOM), hermano de + `applyColorScheme` / `applyTypeScale` / `applyDepth` / `applyShape`. Completa el quinteto. + +## 3.bis Estructural = solo-estado (sin dos momentos) + +A diferencia de motion / depth / shape, el espacio **no “ocurre”**: es el escenario, no el +suceso. El modelo de **dos momentos** (estado vs evento) pertenece a los canales **expresivos**. +Forzar un “espacio eventful” sería disfraz — la honestidad doctrinal es que aquí la novedad es +**ritmo + fluidez + composición de ejes**, no eventful. (Mismo rigor: no inventar un momento que +no existe.) + +## 4. Doctrina — _default fuerte, jaula abierta_ + +| Pieza | Default fuerte | Puerta abierta | +|---|---|---| +| **escala de espacio** | `STATIC_SPACE` authored (estable, curada) | `buildSpaceScale` / `applySpacing` = alternativa **modular + fluida opt-in** (misma postura que `applyTypeScale` sobre la escala authored) | +| **densidad** | 3 niveles × 2 ejes | config-driven + runtime (`[data-density]`) | +| **scaling** | `90–110`, factores universales | runtime (`[data-scaling]`); compone con densidad | +| **composición** | `calc(value · density · scaling)` | los primitivos `--space-*` siempre accesibles; el builder **preserva** la composición | +| **sistema entero** | tema canónico | `applySpacing(seed)` runtime | + +## 5. Contrato de tokens + +``` +--space-{key} value · var(--density-space-scale) · var(--scaling) (escala existente — se mantiene) +``` + +El builder **reescribe el `value`** (bloque gestionado) por uno modular/fluido, **preservando** el +`calc(… · density · scaling)` para que densidad y zoom sigan componiendo. Cero renombrado → cero +rotura. + +## 6. Fases + +1. **Builder de espacio** — `buildSpaceScale(seed)` (puro: unidad base × ladder, fluido vía + `fluidClamp`) + `ActiveEidos.applySpacing` / `clearSpacing` (bloque gestionado que preserva + `· density · scaling`) + export + test. Opt-in; `STATIC_SPACE` intacto. +2. **Showcase + docs** — `/temas/estructura` (densidad × scaling × espacio fluido en vivo) + + THEMING §estructura + esta RFC. +3. ⏸️ (futuro) **`applyTheme(seed)`** — una semilla que compone tipo + espacio (ritmo compartido). + +## 7. Composición con lo existente + +- **`fluidClamp`** (del type scale) → el espacio fluido (no se reinventa). +- **`calc(value · density · scaling)`** → se preserva (densidad + zoom siguen componiendo). +- **`buildTypeScale`** → el patrón exacto que `buildSpaceScale` refleja (semilla → ladder fluido). +- **Box/Flex/Grid/Stack/Container** → consumen `--space-*`; no se tocan. + +## 8. Doctrina (paralela a color / tipografía / depth / shape) + +- **Escala authored = canon estable**; el builder = alternativa matemática **opt-in** (igual que + tipografía). El theme retunea, el builder recompone. +- **Densidad y scaling = ejes ortogonales** al ritmo; los tres componen. +- **Jaula abierta**: `--space-*` crudo siempre a un paso. + +## 9. Fuera de alcance + +- **Baseline grid rígido** (vertical rhythm pixel-perfect): el ritmo modular + fluido da cadencia + sin imponer una rejilla rígida que pelee con el contenido real. +- **Reinventar el layout**: `Box · Flex · Grid · Stack · Container · AutoGrid` ya cubren la + composición; aquí elevamos el **espacio**, no las primitivas de layout. diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index fcaa94b5e..90996f590 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -469,6 +469,28 @@ describe('ActiveEidos config', () => { eidos.clearTypeScale(); // reverts cleanly, no throw }); + it('applySpacing returns a modular space ladder, preserving density × scaling', () => { + const eidos = createThemeBaseEidos(); + const result = eidos.applySpacing({ base: 4 }); + expect(result.steps).toHaveLength(18); + // base 4 reproduces the authored scale, wrapped in the density × scaling composition + expect(result.variables).toContain( + '--space-4: calc(16px * var(--density-space-scale) * var(--scaling));' + ); + // zero stays a bare length (no pointless calc(0 * x)) + expect(result.variables).toContain('--space-0: 0px;'); + eidos.clearSpacing(); // reverts cleanly, no throw + }); + + it('applySpacing fluid — growth > 1 makes each step a clamp() that still composes', () => { + const eidos = createThemeBaseEidos(); + const result = eidos.applySpacing({ base: 4, growth: 1.5 }); + const sp4 = result.variables.find((v) => v.startsWith('--space-4:')); + expect(sp4).toContain('clamp('); + expect(sp4).toContain('var(--density-space-scale) * var(--scaling)'); + eidos.clearSpacing(); + }); + it('emits depth plane tokens + [data-depth] rules (composing surface/shadow/halo/z)', () => { const css = createThemeBaseEidos().renderStaticCss(); // surface/shadow/z compose the existing primitives — no new math there diff --git a/src/uix/eidos/active-eidos.svelte.ts b/src/uix/eidos/active-eidos.svelte.ts index 9b6bbb69c..553b61aa2 100644 --- a/src/uix/eidos/active-eidos.svelte.ts +++ b/src/uix/eidos/active-eidos.svelte.ts @@ -55,6 +55,11 @@ import { } from './lib/build-type-scale'; import { buildDepth, type DepthOverrides, type BuildDepthResult } from './lib/build-depth'; import { buildShape, type ShapeSeed, type BuildShapeResult } from './lib/build-shape'; +import { + buildSpaceScale, + type SpaceScaleSeed, + type BuildSpaceScaleResult +} from './lib/build-space-scale'; import { collectFontPreloads, type FontPreload } from './lib/font-preload'; import type { Oklch } from '$color'; import type { EngineMotion } from '$motion'; @@ -170,6 +175,17 @@ interface ActiveEidosShapeSpec { readonly options: ApplyShapeOptions; } +/** Options for {@link ActiveEidos.applySpacing}. */ +export interface ApplySpacingOptions { + /** CSS selector the override targets. @default ':root' */ + readonly selector?: string; +} + +interface ActiveEidosSpaceScaleSpec { + readonly seed: SpaceScaleSeed; + readonly options: ApplySpacingOptions; +} + export interface ActiveEidosOptions { readonly config?: EidosConfig | EidosConfigDocument; readonly themeBase?: EidosConfigPatch; @@ -232,6 +248,7 @@ export class ActiveEidos { #typeScaleSpec: ActiveEidosTypeScaleSpec | undefined; #depthSpec: ActiveEidosDepthSpec | undefined; #shapeSpec: ActiveEidosShapeSpec | undefined; + #spaceScaleSpec: ActiveEidosSpaceScaleSpec | undefined; #unsubscribe: (() => void) | undefined; #lastAttrs: ActiveEidosLastAttrs | undefined; #disposed = false; @@ -561,6 +578,27 @@ export class ActiveEidos { this.apply(); } + /** + * Derive + apply a whole space scale from one base unit — the spacing analogue of + * {@link applyTypeScale}. Regenerates `--space-{key}` as a modular ladder (base × N), + * optionally FLUID (`growth > 1` → each step `clamp()`s with the viewport), preserving the + * density × scaling composition. The space *is rhythm* (STRUCTURE_ENGINE_RFC), not a flat + * lookup table. Opt-in over the authored `STATIC_SPACE`. + */ + applySpacing(seed: SpaceScaleSeed = {}, options: ApplySpacingOptions = {}): BuildSpaceScaleResult { + this.#spaceScaleSpec = { seed, options }; + const result = buildSpaceScale(seed); + this.apply(); + return result; + } + + /** Remove the applied space scale, reverting to the theme's authored spacing. */ + clearSpacing(): void { + if (!this.#spaceScaleSpec) return; + this.#spaceScaleSpec = undefined; + this.apply(); + } + #buildSchemeResult(): BuildSchemeResult { const spec = this.#schemeSpec; if (!spec) throw new ActiveEidosConfigError('no color scheme applied'); @@ -666,6 +704,16 @@ export class ActiveEidos { } else { this.#removeStyle(host, shapeStyleId); } + + // The runtime space scale (applySpacing) is written last so its `--space-*` overrides win + // over the static foundation's authored scale at equal specificity. + const spacingStyleId = `${this.#styleId}-spacing`; + const spacingCss = this.#renderSpacingCss(); + if (spacingCss) { + this.#writeStyle(host, spacingStyleId, spacingCss); + } else { + this.#removeStyle(host, spacingStyleId); + } } #renderSchemeCss(): string { @@ -709,6 +757,14 @@ export class ActiveEidos { return [varBlock, ...result.families].filter(Boolean).join('\n'); } + #renderSpacingCss(): string { + if (!this.#spaceScaleSpec) return ''; + const result = buildSpaceScale(this.#spaceScaleSpec.seed); + const selector = this.#spaceScaleSpec.options.selector ?? ':root'; + const body = result.variables.map((line) => `\t${line}`).join('\n'); + return body ? `${selector} {\n${body}\n}` : ''; + } + dispose(): void { if (this.#disposed) return; this.#disposed = true; diff --git a/src/uix/eidos/index.ts b/src/uix/eidos/index.ts index f13013a7d..2ef7e7c80 100644 --- a/src/uix/eidos/index.ts +++ b/src/uix/eidos/index.ts @@ -30,6 +30,8 @@ export { buildDepth, depthDeclarations } from './lib/build-depth'; export type { DepthOverrides, BuildDepthResult } from './lib/build-depth'; export { buildShape } from './lib/build-shape'; export type { ShapeSeed, BuildShapeResult } from './lib/build-shape'; +export { buildSpaceScale, spaceScaleDeclarations } from './lib/build-space-scale'; +export type { SpaceScaleSeed, SpaceScaleStep, BuildSpaceScaleResult } from './lib/build-space-scale'; export { collectFontPreloads } from './lib/font-preload'; export type { FontPreload } from './lib/font-preload'; export { diff --git a/src/uix/eidos/lib/build-space-scale.ts b/src/uix/eidos/lib/build-space-scale.ts new file mode 100644 index 000000000..454f09da3 --- /dev/null +++ b/src/uix/eidos/lib/build-space-scale.ts @@ -0,0 +1,115 @@ +/** + * Runtime modular + fluid SPACE-scale builder. Pure + isomorphic, DOM-free — the spacing + * analogue of `build-type-scale.ts`. Given a seed (base unit + optional fluid growth) it + * regenerates the full `--space-{key}` ladder, **preserving** the density × scaling composition + * (`calc(value * var(--density-space-scale) * var(--scaling))`) the static emission uses, so + * `[data-density]` / `[data-scaling]` keep working on top. + * + * `ActiveEidos.applySpacing(seed)` writes the result as a managed `:root` block that overrides + * the theme's authored space scale at runtime — the same opt-in posture as `applyTypeScale`: + * the authored `STATIC_SPACE` is hand-tuned, this is the mathematical / fluid alternative. + * The space *is rhythm* (STRUCTURE_ENGINE_RFC), not a flat px lookup table. + */ +import { fluidClamp } from './type-scale'; + +/** + * Canonical space ladder — each step as a multiple of the base unit (matches `STATIC_SPACE` + * at `base = 4`). A single base unit drives the whole ladder; half-steps keep the fine cadence. + */ +const SPACE_STEPS = { + '0': 0, + '0-5': 0.5, + '1': 1, + '1-5': 1.5, + '2': 2, + '2-5': 2.5, + '3': 3, + '3-5': 3.5, + '4': 4, + '4-5': 4.5, + '5': 5, + '5-5': 5.5, + '6': 6, + '7': 7, + '8': 8, + '10': 10, + '12': 12, + '16': 16 +} as const; + +export interface SpaceScaleSeed { + /** Base unit in px (the `1` step — the rhythm's atom). @default 4 */ + readonly base?: number; + /** + * Growth multiplier at the max viewport — makes the space FLUID (each step `clamp()`s from its + * base size up to `base × growth` as the viewport widens). Omit or `1` for a static scale. + */ + readonly growth?: number; + /** Fluid viewport floor in px. @default 480 */ + readonly minVw?: number; + /** Fluid viewport ceiling in px. @default 1280 */ + readonly maxVw?: number; +} + +export interface SpaceScaleStep { + readonly key: string; + readonly multiple: number; + readonly minPx: number; + readonly maxPx: number; + /** The resolved length (a `clamp()` when fluid, a fixed `px` when static), pre-`calc()` wrap. */ + readonly value: string; +} + +export interface BuildSpaceScaleResult { + /** `--space-{key}: calc( * var(--density-space-scale) * var(--scaling));` declarations. */ + readonly variables: readonly string[]; + readonly steps: readonly SpaceScaleStep[]; +} + +function round(n: number): number { + return Math.round(n * 1000) / 1000; +} + +/** + * Build a complete `--space-{key}` ladder from a base unit. With `growth > 1` the scale is fluid + * (each step interpolates between `minVw` and `maxVw`). Composition with density × scaling is + * preserved verbatim, matching `render-css`'s static emission. + */ +export function buildSpaceScale(seed: SpaceScaleSeed = {}): BuildSpaceScaleResult { + const base = seed.base ?? 4; + const growth = seed.growth ?? 1; + const minVw = `${seed.minVw ?? 480}px`; + const maxVw = `${seed.maxVw ?? 1280}px`; + const variables: string[] = []; + const steps: SpaceScaleStep[] = []; + + for (const key of Object.keys(SPACE_STEPS) as (keyof typeof SPACE_STEPS)[]) { + const multiple = SPACE_STEPS[key]; + const minPx = round(base * multiple); + const maxPx = round(minPx * growth); + + // Zero stays a bare length — `calc(0 * x)` is pointless and `0px` must remain valid. + if (minPx === 0) { + variables.push(`--space-${key}: 0px;`); + steps.push({ key, multiple, minPx: 0, maxPx: 0, value: '0px' }); + continue; + } + + const value = + growth > 1 + ? fluidClamp({ min: `${minPx}px`, max: `${maxPx}px`, minVw, maxVw }) + : `${minPx}px`; + // Preserve the density × scaling composition so [data-density] / [data-scaling] still apply. + variables.push( + `--space-${key}: calc(${value} * var(--density-space-scale) * var(--scaling));` + ); + steps.push({ key, multiple, minPx, maxPx, value }); + } + + return { variables, steps }; +} + +/** Flatten a built space scale to CSS declaration lines (for a managed `:root` block). */ +export function spaceScaleDeclarations(result: BuildSpaceScaleResult): readonly string[] { + return result.variables; +}