feat(eidos): structural systems — space rhythm builder (buildSpaceScale/applySpacing) + RFC

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 27c8214d98
commit 66ff993211

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

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

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

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

@ -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(<value> * 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;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.