diff --git a/src/uix/eidos/DEPTH_ENGINE_RFC.md b/src/uix/eidos/DEPTH_ENGINE_RFC.md new file mode 100644 index 000000000..d6382152f --- /dev/null +++ b/src/uix/eidos/DEPTH_ENGINE_RFC.md @@ -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). diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index fd5867fbc..50c3775a2 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('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).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;') diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 34a6fad06..d07b8dc42 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -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); diff --git a/src/uix/eidos/lib/config-types.ts b/src/uix/eidos/lib/config-types.ts index 15a9aeb59..555c5874c 100644 --- a/src/uix/eidos/lib/config-types.ts +++ b/src/uix/eidos/lib/config-types.ts @@ -661,6 +661,35 @@ export interface TypographyPrimitiveSet { readonly semanticTracking?: Record; } +/** + * 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; +} + 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 { diff --git a/src/uix/eidos/lib/config.ts b/src/uix/eidos/lib/config.ts index 6aaf90567..92fe4e4d9 100644 --- a/src/uix/eidos/lib/config.ts +++ b/src/uix/eidos/lib/config.ts @@ -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)[cue] + if (value !== undefined) validateNonEmptyCssValue(`${path}.${cue}`, 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 43c6bfd35..0ec2df5e2 100644 --- a/src/uix/eidos/lib/primitives/static.ts +++ b/src/uix/eidos/lib/primitives/static.ts @@ -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 } diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 48d6451eb..56525102b 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -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