feat(eidos): depth engine — Fase 1 (plano unificado) + DEPTH_ENGINE_RFC

Arranca el sprint de profundidad: depth como un plano unificado, config-driven y
semántico — la base del canal "la profundidad es algo que OCURRE" del libro (el
disparo eventful por sema llega en Fase 3).

  - DEPTH_ENGINE_RFC.md — la guía de diseño: estudio de los límites de los
    referentes, la tesis novel, y la doctrina "default fuerte, jaula abierta".
  - EidosConfig.depth.planes (DepthPlane + DepthPrimitiveSet). Set canónico:
    flush · raised · overlay · modal · recessed — cada uno COMPONE los primitivos
    existentes (surface/shadow/z), sin matemática nueva → cero rotura.
  - emite tokens --depth-{plane}-{cue} + reglas [data-depth='{plane}'] que aplican
    las señales aditivas seguras (box-shadow + z-index); surface + blur/scrim
    quedan como tokens opt-in (no pisan fondos de componente).
  - validado; config-driven (un tema añade/retunea planos — jaula abierta).
  - arregla un punto y coma latente en la emisión de variable-fonts, cazado aquí.

Verificado: check 0 errores; tests nuevos (emisión canónica + plano custom);
generated/base.css regenerado; navegador — data-depth='overlay' aplica la sombra
overlay + z 400, 'recessed' aplica una sombra inset. (3 fallos de words pre-existentes.)

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

@ -0,0 +1,114 @@
# RFC — Motor de profundidad (depth) de Eidos
> Hermano de `COLOR_ENGINE_RFC.md` y `TYPOGRAPHY_ENGINE_RFC.md`. Lleva el canal de
> **profundidad** (`depth/presence` del libro *Diseñando lo que ocurre*) a reference-grade
> **rompiendo** el modelo de los referentes, no copiándolo — con la **jaula abierta**.
## 0. Tesis
> **La profundidad no es algo que un elemento _tiene_; es algo que _ocurre_.**
Un overlay no "tiene elevación 4": **enuncia una emergencia** — asciende a un plano
delantero al aparecer, recede al irse — gobernado por el **mismo evento sema** que ya dirige
su motion/sound/haptic. La profundidad pasa a ser un **canal de expresión de primera clase**
(uno de los 8 del libro), **unificado** (una sola noción cohere superficie + sombra + z +
atmósfera), **eventful** (lo dispara el motor sema) y con **jaula abierta** (default fuerte,
puertas en cada capa).
## 1. El estudio — cómo lo hacen los referentes y dónde topan
| Framework | Modelo | Límite estructural |
|---|---|---|
| **Material 3** | elevación = tinte tonal + sombra, escala `dp` 0–5 | `dp` = altura física con 1 foco de luz; la elevación es **propiedad** del componente, no expresión de un suceso; escala fija |
| **Tailwind** | `shadow-sm…2xl` presets planos | sin modo (la sombra en dark *miente*), sin superficie, sin semántica |
| **Apple / SwiftUI** | materiales (blur/vibrancy) + sombra sutil | atado a plataforma (backdrop), no es sistema de tokens portable |
| **Polaris / Carbon / Radix** | escala de sombra por rol o numérica | estática; rol fijo, desligada de eventos |
**Límite común** (incluso M3, el más fino): profundidad = **propiedad estática que asignas**
+ **modelo de luz skeuomórfico** + **desconectada** de lo que ocurre y de sus propias señales
entre sí. Nadie trata la profundidad como **expresión de un suceso**. Y todos eligen un bando:
Tailwind = flexible **sin opinión**; Material = **opinión fuerte pero jaula cerrada**.
## 2. Dónde está Eidos hoy (ya por encima de la media, pero no novedoso)
- **z-index** en bandas semánticas: `base · raised · sticky · dropdown · popover · tooltip · modal · toast`. ✓
- **Escalera de superficie tonal y mode-aware**: `surface-default→raised→overlay` = `neutral-1→2→4` (en dark, "más arriba" = más claro — el tonal-overlay de M3, ya presente). ✓
- **Sombras nombradas por rol**: `shadow-subtle/raised/overlay` (→ `shadow-1/3/5`), set propio por tema (`THEME_BASE_DARK_SHADOW`). ✓ (por encima de Tailwind)
- **PERO** son **3 sistemas separados** (eliges sombra + superficie + z a mano), la profundidad es **estática** (un prop) y el modelo sigue siendo el de sombra-skeuomórfica. Estás ~a la par de M3.
## 3. El modelo novel — 4 piezas
1. **Plano semántico unificado** — `flush · raised · overlay · modal · recessed`. Un nivel cohere superficie + sombra + z + (blur/scrim) de una vez. Nombra por **rol en la jerarquía de atención**, no por milímetros.
2. **Mezcla multi-señal adaptada al modo, _computada_** — light se apoya en sombra; dark en tinte de superficie + halo (la sombra en dark miente). Sombras derivadas del **color de la superficie en OKLCH** (no negro-alpha plano), reusando el motor de color. Resuelve el problema sombra-en-dark de raíz.
3. **Profundidad _eventful_ (lo genuinamente nuevo)** — un canal que el motor **sema** dispara, como sound/haptic: `emerge`→sube, `contact`→hunde, `signal/threat`→empuja al frente; coordinado con el modelo de **dos momentos** de motion.
4. **Atmósfera / presencia** — backdrop-blur (frost) + scrim: lo que está al frente desenfoca/dimma lo que recede (solapamiento con el canal *presence*).
## 4. Doctrina — _default fuerte, jaula abierta_
> La capa semántica es **aditiva** sobre primitivos que **nunca desaparecen**; el set, la
> mezcla y el canal son **extensibles/anulables** por config + el registro abierto de sema.
| Pieza | Default fuerte | Puerta abierta |
|---|---|---|
| **planos** | set canónico (5) | el **set es config-driven** (`EidosConfig.depth.planes`); añade `sheet`/`peek`, renombra — no un enum cerrado |
| **mezcla** | mezcla computada por plano | **retunear la mezcla por plano** en config; **override por componente** de cualquier señal; los **primitivos siguen ahí** (`--shadow-N`, `z-index`, `box-shadow` crudo a un paso) |
| **canal eventful** | `emerge↑ / contact↓` | **opt-in/opt-out** (como sound+haptic); reglas en el **registro abierto de sema** (declaration merging + cascada appendable → la app añade/anula el mapa evento→profundidad); **degrada con `prefers-reduced-motion`**; la profundidad **estática funciona sin el canal** (eventful es aditivo, nunca un muro) |
| **atmósfera** | frost+scrim en overlays | amount/opacidad en config; **opt-out** por overlay (coste/preferencia) |
| **sistema entero** | tema canónico | **`applyDepth(seed)`** runtime (hermano de `applyColorScheme`/`applyTypeScale`) |
Esto **no es nuevo en el framework**: es exactamente cómo ya operan sema (registro abierto),
eidos (config + `setCssVariables` + overrides de recipe) y color/tipografía (canon retintable
+ builders runtime). Depth hereda el mismo contrato de apertura.
## 5. Contrato de tokens (añadidos, congelados)
Bare-prefixed por sistema (`--depth-…`). El plano es un **bundle nombrado** que **compone los
primitivos existentes** — no los reemplaza:
```
--depth-{plane}-surface → var(--color-surface-{…}) (tinte tonal, mode-aware)
--depth-{plane}-shadow → var(--shadow-{…}) (computado en Fase 2)
--depth-{plane}-z → var(--z-index-{…}) (banda)
--depth-{plane}-blur → atmósfera (Fase 4)
--depth-{plane}-scrim → atmósfera (Fase 4)
```
Consumo: `[data-depth='{plane}']` aplica las señales **aditivas seguras** (sombra + z + blur);
la **superficie** es un token opt-in (`background: var(--depth-{plane}-surface)`) para no pisar
fondos de componente. Sin renombrar nada existente → **cero rotura de componentes**.
## 6. Fases (la flexibilidad va horneada en cada una)
1. **Plano unificado** — `EidosConfig.depth` + planos canónicos + emisión `--depth-{plane}-*`
(componen surface/shadow/z) + regla `[data-depth]` (sombra+z) + validación + test + regen.
Puertas: set config-driven, mezcla por plano, primitivos intactos, escape a raw.
2. **Mezcla computada mode-adaptive** — sombras OKLCH derivadas de la superficie; light↑sombra /
dark↑tinte+halo. Override literal siempre disponible.
3. **Canal sema `depth`** — `emerge↑ / contact↓` vía el registro abierto de sema; opt-in/opt-out;
degrada en reduced-motion; reglas anulables. Coordinado con dos-momentos de motion.
4. **Atmósfera** — blur/scrim en config; opt-out por overlay. + builder `applyDepth(seed)`.
5. **Showcase + docs** — `/temas/profundidad` (planos/mezcla/eventful en vivo) + THEMING §depth.
## 7. Composición con lo existente
- **Escalera de superficie** (color) → la señal `surface` del plano (mode-aware gratis).
- **Motor de color OKLCH** → sombras computadas (Fase 2) y halos.
- **Dos momentos de motion** → la transición de ascenso/recesión (Fase 3) es un movimiento, no un salto.
- **Bus de eventos sema** → el disparo eventful (Fase 3), como sound/haptic.
- **z-index bands** → la señal `z` del plano.
No se reinventa nada: se **unifica + se hace eventful** lo disperso, bajo la doctrina del libro.
## 8. Doctrina (paralela a color / tipografía)
- **Planos + canal = canon del eidos** (como roles/variants y estilos nombrados): valores
themeables, pero el *set* es canon.
- **Profundidad eventful = capacidad del motor**, opt-in con degradación (como sound/haptic).
- **Theme = retintar/retunear lo perceptualmente fijo**: cambia CUÁNTA sombra es `overlay`,
no QUÉ significa `overlay`.
- **Jaula abierta**: opinión fuerte que nunca atrapa — primitivos siempre accesibles.
## 9. Fuera de alcance
- 3D real / perspectiva / parallax con giroscopio (la profundidad es perceptual, no un motor 3D).
- Ray-tracing de sombras por geometría (se computa el tinte/halo, no la oclusión física).

@ -469,6 +469,28 @@ describe('ActiveEidos config', () => {
eidos.clearTypeScale(); // reverts cleanly, no throw
});
it('emits depth plane tokens + [data-depth] rules (composing surface/shadow/z)', () => {
const css = createThemeBaseEidos().renderStaticCss();
// tokens compose the existing primitives — no new shadow/surface/z math
expect(css).toContain('--depth-overlay-shadow: var(--shadow-overlay);');
expect(css).toContain('--depth-overlay-z: var(--z-index-popover);');
expect(css).toContain('--depth-raised-surface: var(--color-surface-raised);');
// the rule applies the safe additive cues (shadow + z); surface stays an opt-in token
expect(css).toContain("[data-depth='overlay'] {");
expect(css).toContain('box-shadow: var(--depth-overlay-shadow);');
expect(css).toContain('z-index: var(--depth-overlay-z);');
});
it('depth planes are config-driven — a theme can add a plane (jaula abierta)', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS);
;(cfg.primitives as Record<string, unknown>).depth = {
planes: { sheet: { shadow: '0 -8px 30px rgba(0, 0, 0, 0.2)', z: 'var(--z-index-modal)' } }
};
const css = createEidos(cfg).renderStaticCss();
expect(css).toContain('--depth-sheet-shadow: 0 -8px 30px rgba(0, 0, 0, 0.2);');
expect(css).toContain("[data-depth='sheet'] {");
});
it('emits the Phase 3 typography scales + optical tracking', () => {
const css = createThemeBaseEidos().renderStaticCss()
expect(css).toContain('--tracking-tight: -0.02em;')

@ -438,6 +438,20 @@
--size-xxl-padding-block: var(--space-3);
--size-xxl-gap: var(--space-4);
--size-xxl-radius: var(--radius-xxl);
--depth-flush-shadow: none;
--depth-flush-z: var(--z-index-base);
--depth-raised-surface: var(--color-surface-raised);
--depth-raised-shadow: var(--shadow-raised);
--depth-raised-z: var(--z-index-raised);
--depth-overlay-surface: var(--color-surface-overlay);
--depth-overlay-shadow: var(--shadow-overlay);
--depth-overlay-z: var(--z-index-popover);
--depth-modal-surface: var(--color-surface-overlay);
--depth-modal-shadow: var(--shadow-overlay);
--depth-modal-z: var(--z-index-modal);
--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);
--control-height-2xs: var(--control-height-xxs);
--radius-xs: var(--radius-sm);
--font-sans: var(--font-family-primary);
@ -4305,6 +4319,31 @@
--toggle-palette-contrast: var(--toggle-threat-contrast);
}
[data-depth='flush'] {
box-shadow: var(--depth-flush-shadow);
z-index: var(--depth-flush-z);
}
[data-depth='raised'] {
box-shadow: var(--depth-raised-shadow);
z-index: var(--depth-raised-z);
}
[data-depth='overlay'] {
box-shadow: var(--depth-overlay-shadow);
z-index: var(--depth-overlay-z);
}
[data-depth='modal'] {
box-shadow: var(--depth-modal-shadow);
z-index: var(--depth-modal-z);
}
[data-depth='recessed'] {
box-shadow: var(--depth-recessed-shadow);
z-index: var(--depth-recessed-z);
}
[data-density='compact'] {
--density-space-scale: var(--density-compact-space-scale);
--density-control-scale: var(--density-compact-control-scale);

@ -661,6 +661,35 @@ export interface TypographyPrimitiveSet {
readonly semanticTracking?: Record<string, string>;
}
/**
* A depth plane — a named position in the attention hierarchy that bundles the depth cues
* (surface tint + shadow + z-index, later blur/scrim). Each cue is a full CSS value: a
* `var(--…)` reference to a primitive (the canonical planes compose the existing
* surface/shadow/z tokens) or a raw value (the escape hatch). All cues optional.
* (DEPTH_ENGINE_RFC §5)
*/
export interface DepthPlane {
/** Surface tint — a `background` token, opt-in (`var(--color-surface-{…})` or raw). */
readonly surface?: string;
/** Box-shadow (`var(--shadow-{…})`, a raw shadow, or `none`). */
readonly shadow?: string;
/** z-index (`var(--z-index-{…})` or a raw value). */
readonly z?: string;
/** Backdrop blur (atmosphere — DEPTH_ENGINE_RFC Fase 4). */
readonly blur?: string;
/** Backdrop scrim (atmosphere — Fase 4). */
readonly scrim?: string;
}
/**
* The depth system — a set of named planes. Config-driven (a theme can add / rename /
* retune planes); the canonical set is `flush · raised · overlay · modal · recessed`.
* (DEPTH_ENGINE_RFC)
*/
export interface DepthPrimitiveSet {
readonly planes: Record<string, DepthPlane>;
}
export interface PrimitiveSet {
readonly color?: ColorPrimitiveSet;
readonly size?: SizePrimitiveSet;
@ -677,6 +706,7 @@ export interface PrimitiveSet {
readonly icon?: IconPrimitiveSet;
readonly opacity?: OpacityPrimitiveSet;
readonly zIndex?: ZIndexPrimitiveSet;
readonly depth?: DepthPrimitiveSet;
}
export interface SemanticSet {

@ -128,6 +128,7 @@ export function validateEidosConfig(options: EidosConfig): EidosValidationReport
validateDensityPrimitives(options, issues)
validateMotionPrimitives(options, issues)
validateIconPrimitives(options, issues)
validateDepthPrimitives(options, issues)
validateRecipeTokens(options, issues)
validateMotion(options, issues)
validateRecordPrimitive(
@ -905,6 +906,27 @@ function readRequiredPlainRecord(
return undefined
}
function validateDepthPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
const depth = options.primitives.depth
if (depth === undefined) return
if (!isPlainRecord(depth.planes)) {
issues.push({ path: 'primitives.depth.planes', message: 'depth planes must be a plain object' })
return
}
for (const [plane, cues] of Object.entries(depth.planes)) {
const path = `primitives.depth.planes.${plane}`
validateCssTokenSuffix(path, plane, 'depth plane', issues)
if (!isPlainRecord(cues)) {
issues.push({ path, message: 'depth plane must be a plain object' })
continue
}
for (const cue of ['surface', 'shadow', 'z', 'blur', 'scrim'] as const) {
const value = (cues as Record<string, unknown>)[cue]
if (value !== undefined) validateNonEmptyCssValue(`${path}.${cue}`, value, issues)
}
}
}
function validateSizePrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
const sizes = options.primitives.size
if (!sizes) return

@ -1,4 +1,4 @@
import type { PrimitiveSet, SizePrimitiveSet } from '../config-types'
import type { DepthPrimitiveSet, PrimitiveSet, SizePrimitiveSet } from '../config-types'
import { STATIC_TYPOGRAPHY } from './typography'
export const STATIC_SPACE = {
@ -205,6 +205,35 @@ export const STATIC_Z_INDEX = {
toast: 900
} as const
// Depth planes — named positions in the attention hierarchy. Each composes the existing
// surface / shadow / z primitives (or a raw value). `data-depth='{plane}'` applies the safe
// additive cues (shadow + z); `surface` is an opt-in `background` token. (DEPTH_ENGINE_RFC)
export const STATIC_DEPTH: DepthPrimitiveSet = {
planes: {
flush: { shadow: 'none', z: 'var(--z-index-base)' },
raised: {
surface: 'var(--color-surface-raised)',
shadow: 'var(--shadow-raised)',
z: 'var(--z-index-raised)'
},
overlay: {
surface: 'var(--color-surface-overlay)',
shadow: 'var(--shadow-overlay)',
z: 'var(--z-index-popover)'
},
modal: {
surface: 'var(--color-surface-overlay)',
shadow: 'var(--shadow-overlay)',
z: 'var(--z-index-modal)'
},
recessed: {
surface: 'var(--color-surface-muted)',
shadow: 'inset 0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 12%, transparent)',
z: 'var(--z-index-base)'
}
}
}
export const STATIC_SIZE: SizePrimitiveSet = {
xxs: {
controlHeight: 'xxs',
@ -286,6 +315,7 @@ export const STATIC_PRIMITIVES: Pick<
| 'icon'
| 'opacity'
| 'zIndex'
| 'depth'
> = {
size: STATIC_SIZE,
space: STATIC_SPACE,
@ -299,5 +329,6 @@ export const STATIC_PRIMITIVES: Pick<
motion: STATIC_MOTION,
icon: STATIC_ICON,
opacity: STATIC_OPACITY,
zIndex: STATIC_Z_INDEX
zIndex: STATIC_Z_INDEX,
depth: STATIC_DEPTH
}

@ -23,6 +23,7 @@ import {
type EidosCssVariableMap,
type EidosCssVariableValue,
type EidosConfig,
type DepthPrimitiveSet,
type FontFace,
type FontFallback,
type FontFamily,
@ -202,7 +203,7 @@ export function renderStaticCss(options: EidosConfig): string {
// Variable fonts: enable optical sizing globally when any family declares an `opsz`
// axis, so the font's optical-size axis tracks the rendered font-size. (RFC §5)
if (Object.values(primitives.typography.families).some((f) => f.axes?.opsz)) {
declarations.push('font-optical-sizing: auto')
declarations.push('font-optical-sizing: auto;')
}
}
@ -210,8 +211,13 @@ export function renderStaticCss(options: EidosConfig): string {
appendSizeDeclarations(declarations, primitives.size)
}
if (primitives.depth) {
appendDepthDeclarations(declarations, primitives.depth)
}
appendTransitionAliasDeclarations(declarations, options)
const scopedRecipeBlocks = appendRecipeDeclarations(declarations, options.recipes)
const depthBlocks = primitives.depth ? renderDepthBlocks(primitives.depth) : []
const blocks: string[] = []
// @font-face first (config-driven loading); the family-stack tokens reference them.
@ -219,7 +225,7 @@ export function renderStaticCss(options: EidosConfig): string {
const fontFaceCss = renderFontFaceBlocks(primitives.typography)
if (fontFaceCss) blocks.push(fontFaceCss)
}
blocks.push(renderBlock(':root', declarations), ...scopedRecipeBlocks)
blocks.push(renderBlock(':root', declarations), ...scopedRecipeBlocks, ...depthBlocks)
// Responsive typography style overrides — one media-query block per
// breakpoint that has at least one responsive size override.
@ -757,6 +763,35 @@ function appendTypographyDeclarations(
}
}
// ── Depth (DEPTH_ENGINE_RFC) ─────────────────────────────────────────────────
/** Emit `--depth-{plane}-{cue}` tokens — each plane's cue values composed into `:root`. */
function appendDepthDeclarations(declarations: string[], depth: DepthPrimitiveSet): void {
for (const [plane, cues] of Object.entries(depth.planes)) {
for (const cue of ['surface', 'shadow', 'z', 'blur', 'scrim'] as const) {
const value = cues[cue]
if (value !== undefined) declarations.push(cssVar(`depth-${plane}-${cue}`, value))
}
}
}
/**
* `[data-depth='{plane}']` applies the *safe additive* depth cues — box-shadow + z-index —
* referencing the plane tokens (so `setCssVariables` / a theme can retune them). `surface`
* (background) + `blur`/`scrim` (atmosphere) stay opt-in tokens, never forced here, so the
* rule never fights a component's own background. (DEPTH_ENGINE_RFC §5)
*/
function renderDepthBlocks(depth: DepthPrimitiveSet): string[] {
const blocks: string[] = []
for (const [plane, cues] of Object.entries(depth.planes)) {
const lines: string[] = []
if (cues.shadow !== undefined) lines.push(`box-shadow: var(--depth-${plane}-shadow);`)
if (cues.z !== undefined) lines.push(`z-index: var(--depth-${plane}-z);`)
if (lines.length) blocks.push(renderBlock(`[data-depth='${plane}']`, lines))
}
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.