From 6998c5bde229ddc75244aeeb85e9280a2ed63cbf Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 20:15:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20depth=20Fase=204=20=E2=80=94=20a?= =?UTF-8?q?tmosphere=20(frost)=20+=20applyDepth=20runtime=20builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the depth channel. Atmosphere as the channel's materials layer: - Per-plane `blur` cue (overlay 10px, modal 16px) + an opt-in frost rule `[data-depth=`{plane}`][data-frost]` -> translucent surface (color-mix 80%) + backdrop-filter blur. Gated on data-frost so it never turns an opaque overlay translucent by default; specificity 0,2,0 reliably overrides the component bg. - buildDepth(planes) (pure) + ActiveEidos.applyDepth/clearDepth — retune any plane cue (surface/shadow/halo/blur/scrim/z) at runtime, the depth sibling of applyColorScheme / applyTypeScale. Exported from $uix/eidos. Showcase: /temas/profundidad section Materiales — a frosted-glass panel over a vivid color mesh (frost blur + shadow + halo). The `scrim` cue stays an available token without a wired rule — modal backdrops are component-managed. Verified: npm run check 0 errors; eidos 185/188 (3 pre-existing `words` failures, unrelated). New tests: frost emission + applyDepth. Regenerated generated/base.css. Docs: DEPTH_ENGINE_RFC Fase 4 + token contract, THEMING 29. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/DEPTH_ENGINE_RFC.md | 12 ++++-- src/uix/eidos/THEMING.md | 12 +++++- src/uix/eidos/active-eidos-config.test.ts | 22 ++++++++++ src/uix/eidos/active-eidos.svelte.ts | 52 +++++++++++++++++++++++ src/uix/eidos/generated/base.css | 14 ++++++ src/uix/eidos/index.ts | 5 ++- src/uix/eidos/lib/build-depth.ts | 44 +++++++++++++++++++ src/uix/eidos/lib/primitives/static.ts | 2 + src/uix/eidos/lib/render-css.ts | 14 ++++++ web/routes/temas/profundidad/+page.svelte | 50 +++++++++++++++++++--- 10 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 src/uix/eidos/lib/build-depth.ts diff --git a/src/uix/eidos/DEPTH_ENGINE_RFC.md b/src/uix/eidos/DEPTH_ENGINE_RFC.md index 47a51c190..d89770aaf 100644 --- a/src/uix/eidos/DEPTH_ENGINE_RFC.md +++ b/src/uix/eidos/DEPTH_ENGINE_RFC.md @@ -84,8 +84,8 @@ primitivos existentes** — no los reemplaza: --depth-{plane}-shadow → var(--shadow-{…}) (gota, mode-aware vía tema) --depth-{plane}-halo → oklab rim-light (lift en dark · Fase 2) --depth-{plane}-z → var(--z-index-{…}) (banda) ---depth-{plane}-blur → atmósfera (Fase 4) ---depth-{plane}-scrim → atmósfera (Fase 4) +--depth-{plane}-blur → frost backdrop-blur (Fase 4 ✅ · opt-in vía data-frost) +--depth-{plane}-scrim → atmósfera (token disponible; sin regla — backdrop por componente) ``` Consumo: `[data-depth='{plane}']` aplica las señales **aditivas seguras** (sombra + z + blur); @@ -112,7 +112,13 @@ fondos de componente. Sin renombrar nada existente → **cero rotura de componen coordinado con posición/escala y con sound+haptic desde **un solo evento**, degradando con reduced-motion (cap global). Anulable: un tema sobreescribe los keyframes/signatures. Monta sobre el sistema de `signatures` existente, **no** un sistema paralelo. -4. **Atmósfera** — blur/scrim en config; opt-out por overlay. + builder `applyDepth(seed)`. +4. ✅ **Atmósfera (frost)** — cue `blur` por plano (overlay/modal) + regla **opt-in** + `[data-depth='{plane}'][data-frost]` (superficie translúcida `color-mix` 80% + + `backdrop-filter: blur`), gated para no volver translúcido un overlay opaco por defecto. + + builder runtime **`applyDepth(planes)`** / `clearDepth()` (+ `buildDepth` puro) que retune + cualquier cue de plano en vivo — hermano de `applyColorScheme` / `applyTypeScale`. (El cue + `scrim` queda disponible como token; el backdrop dim de modales lo siguen gestionando los + componentes, así que no se cabló a una regla.) 5. ✅ **Showcase + docs** — `/temas/profundidad` a profundidad de referencia: reacciona · estados (dynamic elevation) · asciende (firma) · escalera de planos · catálogo en reposo · luz vs sombra (el halo) · jaula abierta · a11y. Supera la amplitud de la referencia de diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index 207c7a63d..7fefc2230 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -2297,8 +2297,16 @@ componente (las bandas z son más finas que los 5 planos) — la adopción es so sombra+halo, **cero riesgo de stacking**. La adopción plena vía atributo `data-depth` (que unificaría también la z) queda como opción futura. -**Pendiente** (no del núcleo): atmósfera frost/scrim (Fase 4 — requiere superficies -translúcidas) + builder `applyDepth(seed)`. +**Atmósfera** (frost, hecha 2026-06-05): cue `blur` por plano + regla **opt-in** +`[data-depth='{plane}'][data-frost]` → superficie translúcida (`color-mix` 80%) + +`backdrop-filter: blur(var(--depth-{plane}-blur))`. Gated, nunca por defecto (un overlay opaco +sigue opaco salvo que pida `data-frost`). El builder runtime `ActiveEidos.applyDepth(planes)` / +`clearDepth()` (+ `buildDepth` puro, exportado de `$uix/eidos`) retune cualquier cue de plano +en vivo — hermano de `applyColorScheme` / `applyTypeScale`. Demo: `/temas/profundidad` +§Materiales. + +**Pendiente** (menor): el cue `scrim` está disponible como token (`--depth-{plane}-scrim`) pero +sin regla cableada — el backdrop dim de los modales lo gestiona hoy cada componente. --- diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index 711d6f695..6da3b96fd 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -497,6 +497,28 @@ describe('ActiveEidos config', () => { expect(css).toContain("[data-depth='sheet'] {"); }); + it('emits the Fase 4 frost atmosphere (opt-in [data-frost] + blur tokens)', () => { + const css = createThemeBaseEidos().renderStaticCss(); + expect(css).toContain('--depth-overlay-blur: 10px;'); + expect(css).toContain('--depth-modal-blur: 16px;'); + // frost is opt-in (gated on data-frost) so it never turns an opaque overlay translucent + expect(css).toContain("[data-depth='overlay'][data-frost] {"); + expect(css).toContain('backdrop-filter: blur(var(--depth-overlay-blur));'); + expect(css).toContain( + 'background-color: color-mix(in srgb, var(--depth-overlay-surface) 80%, transparent);' + ); + }); + + it('applyDepth retunes planes at runtime (jaula-abierta depth builder)', () => { + const eidos = createThemeBaseEidos(); + const result = eidos.applyDepth({ + overlay: { shadow: '0 30px 70px rgb(0 0 0 / 0.5)', halo: 'inset 0 1px 0 0 white' } + }); + expect(result.variables).toContain('--depth-overlay-shadow: 0 30px 70px rgb(0 0 0 / 0.5);'); + expect(result.variables).toContain('--depth-overlay-halo: inset 0 1px 0 0 white;'); + eidos.clearDepth(); // reverts cleanly, no throw + }); + 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/active-eidos.svelte.ts b/src/uix/eidos/active-eidos.svelte.ts index 380ae1b1e..d4d296b8c 100644 --- a/src/uix/eidos/active-eidos.svelte.ts +++ b/src/uix/eidos/active-eidos.svelte.ts @@ -53,6 +53,7 @@ import { type TypeScaleSeed, type BuildTypeScaleResult } from './lib/build-type-scale'; +import { buildDepth, type DepthOverrides, type BuildDepthResult } from './lib/build-depth'; import { collectFontPreloads, type FontPreload } from './lib/font-preload'; import type { Oklch } from '$color'; import type { EngineMotion } from '$motion'; @@ -146,6 +147,17 @@ interface ActiveEidosTypeScaleSpec { readonly options: ApplyTypeScaleOptions; } +/** Options for {@link ActiveEidos.applyDepth}. */ +export interface ApplyDepthOptions { + /** CSS selector the override targets. @default ':root' */ + readonly selector?: string; +} + +interface ActiveEidosDepthSpec { + readonly planes: DepthOverrides; + readonly options: ApplyDepthOptions; +} + export interface ActiveEidosOptions { readonly config?: EidosConfig | EidosConfigDocument; readonly themeBase?: EidosConfigPatch; @@ -206,6 +218,7 @@ export class ActiveEidos { #cssVariablesOptions: ActiveEidosCssVariablesOptions; #schemeSpec: ActiveEidosSchemeSpec | undefined; #typeScaleSpec: ActiveEidosTypeScaleSpec | undefined; + #depthSpec: ActiveEidosDepthSpec | undefined; #unsubscribe: (() => void) | undefined; #lastAttrs: ActiveEidosLastAttrs | undefined; #disposed = false; @@ -494,6 +507,27 @@ export class ActiveEidos { this.apply(); } + /** + * Retune depth planes at runtime — the depth analogue of {@link applyColorScheme} / + * {@link applyTypeScale}. Given per-plane cue overrides (surface / shadow / halo / blur / + * scrim / z) it writes a managed block of `--depth-{plane}-{cue}` overrides that wins over + * the static foundation, so every component on a retuned plane follows. The jaula-abierta + * runtime of the depth channel. + */ + applyDepth(planes: DepthOverrides, options: ApplyDepthOptions = {}): BuildDepthResult { + this.#depthSpec = { planes, options }; + const result = buildDepth(planes); + this.apply(); + return result; + } + + /** Remove the applied depth retune, reverting to the foundation's planes. */ + clearDepth(): void { + if (!this.#depthSpec) return; + this.#depthSpec = undefined; + this.apply(); + } + #buildSchemeResult(): BuildSchemeResult { const spec = this.#schemeSpec; if (!spec) throw new ActiveEidosConfigError('no color scheme applied'); @@ -579,6 +613,16 @@ export class ActiveEidos { } else { this.#removeStyle(host, typeScaleStyleId); } + + // The runtime depth retune (applyDepth) is written last so its `--depth-{plane}-*` + // overrides win over the static foundation's planes at equal specificity. + const depthStyleId = `${this.#styleId}-depth`; + const depthCss = this.#renderDepthCss(); + if (depthCss) { + this.#writeStyle(host, depthStyleId, depthCss); + } else { + this.#removeStyle(host, depthStyleId); + } } #renderSchemeCss(): string { @@ -605,6 +649,14 @@ export class ActiveEidos { return body ? `${selector} {\n${body}\n}` : ''; } + #renderDepthCss(): string { + if (!this.#depthSpec) return ''; + const result = buildDepth(this.#depthSpec.planes); + const selector = this.#depthSpec.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/generated/base.css b/src/uix/eidos/generated/base.css index b7cace221..2a9311133 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -448,10 +448,12 @@ --depth-overlay-shadow: var(--shadow-overlay); --depth-overlay-halo: inset 0 1px 0 0 color-mix(in oklab, white 7%, transparent); --depth-overlay-z: var(--z-index-popover); + --depth-overlay-blur: 10px; --depth-modal-surface: var(--color-surface-overlay); --depth-modal-shadow: var(--shadow-overlay); --depth-modal-halo: inset 0 1px 0 0 color-mix(in oklab, white 8%, transparent); --depth-modal-z: var(--z-index-modal); + --depth-modal-blur: 16px; --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); @@ -4337,11 +4339,23 @@ z-index: var(--depth-overlay-z); } +[data-depth='overlay'][data-frost] { + background-color: color-mix(in srgb, var(--depth-overlay-surface) 80%, transparent); + backdrop-filter: blur(var(--depth-overlay-blur)); + -webkit-backdrop-filter: blur(var(--depth-overlay-blur)); +} + [data-depth='modal'] { box-shadow: var(--depth-modal-shadow), var(--depth-modal-halo); z-index: var(--depth-modal-z); } +[data-depth='modal'][data-frost] { + background-color: color-mix(in srgb, var(--depth-modal-surface) 80%, transparent); + backdrop-filter: blur(var(--depth-modal-blur)); + -webkit-backdrop-filter: blur(var(--depth-modal-blur)); +} + [data-depth='recessed'] { box-shadow: var(--depth-recessed-shadow); z-index: var(--depth-recessed-z); diff --git a/src/uix/eidos/index.ts b/src/uix/eidos/index.ts index a12524037..b9c410a01 100644 --- a/src/uix/eidos/index.ts +++ b/src/uix/eidos/index.ts @@ -26,6 +26,8 @@ export type { TypeScaleSize, BuildTypeScaleResult } from './lib/build-type-scale'; +export { buildDepth, depthDeclarations } from './lib/build-depth'; +export type { DepthOverrides, BuildDepthResult } from './lib/build-depth'; export { collectFontPreloads } from './lib/font-preload'; export type { FontPreload } from './lib/font-preload'; export { @@ -99,7 +101,8 @@ export type { ActiveEidosThemeSource, ActiveEidosUserOptions, ApplyColorSchemeOptions, - ApplyTypeScaleOptions + ApplyTypeScaleOptions, + ApplyDepthOptions } from './active-eidos.svelte'; export type { EidosConfigDocument, diff --git a/src/uix/eidos/lib/build-depth.ts b/src/uix/eidos/lib/build-depth.ts new file mode 100644 index 000000000..ecb8fb4f6 --- /dev/null +++ b/src/uix/eidos/lib/build-depth.ts @@ -0,0 +1,44 @@ +/** + * Runtime depth retune — the depth analogue of `build-scheme.ts` (color) and + * `build-type-scale.ts` (typography). Pure + isomorphic, DOM-free. Given per-plane cue + * overrides it emits the `--depth-{plane}-{cue}` declarations for a managed `:root` block, + * so an app can retune any plane's surface / shadow / halo / blur / scrim / z at runtime — + * the jaula-abierta runtime of the depth channel. + * + * Unlike the color / type builders there is no seed math here: depth shadows reference the + * mode-aware `--shadow-*` scale, so scaling them from a scalar would freeze mode-awareness. + * The honest runtime knob is therefore explicit per-plane overrides — full control, no magic. + * + * `ActiveEidos.applyDepth(planes)` writes the result as a managed block that wins over the + * static foundation at equal specificity. The composition is here; the DOM application lives + * on ActiveEidos, matching `applyColorScheme` / `applyTypeScale`. + */ +import type { DepthPlane } from './config-types'; + +/** Cue keys a plane can override, mirroring the foundation emission order. */ +const DEPTH_CUES = ['surface', 'shadow', 'halo', 'z', 'blur', 'scrim'] as const; + +/** Per-plane cue overrides — `{ overlay: { shadow, halo }, modal: { blur } }`. */ +export type DepthOverrides = Record>; + +export interface BuildDepthResult { + /** `--depth-{plane}-{cue}: value;` declarations, in plane / cue order. */ + readonly variables: readonly string[]; +} + +/** Build `--depth-{plane}-{cue}` override declarations from per-plane cue overrides. */ +export function buildDepth(planes: DepthOverrides): BuildDepthResult { + const variables: string[] = []; + for (const [plane, cues] of Object.entries(planes)) { + for (const cue of DEPTH_CUES) { + const value = cues[cue]; + if (value !== undefined) variables.push(`--depth-${plane}-${cue}: ${value};`); + } + } + return { variables }; +} + +/** Flatten a built depth retune to CSS declaration lines (for a managed `:root` block). */ +export function depthDeclarations(result: BuildDepthResult): readonly string[] { + return result.variables; +} diff --git a/src/uix/eidos/lib/primitives/static.ts b/src/uix/eidos/lib/primitives/static.ts index e96f8577a..61f1577fc 100644 --- a/src/uix/eidos/lib/primitives/static.ts +++ b/src/uix/eidos/lib/primitives/static.ts @@ -221,12 +221,14 @@ export const STATIC_DEPTH: DepthPrimitiveSet = { surface: 'var(--color-surface-overlay)', shadow: 'var(--shadow-overlay)', halo: 'inset 0 1px 0 0 color-mix(in oklab, white 7%, transparent)', + blur: '10px', z: 'var(--z-index-popover)' }, modal: { surface: 'var(--color-surface-overlay)', shadow: 'var(--shadow-overlay)', halo: 'inset 0 1px 0 0 color-mix(in oklab, white 8%, transparent)', + blur: '16px', z: 'var(--z-index-modal)' }, recessed: { diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 11f169d57..c70b3615b 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -793,6 +793,20 @@ function renderDepthBlocks(depth: DepthPrimitiveSet): string[] { if (shadowParts.length) lines.push(`box-shadow: ${shadowParts.join(', ')};`) if (cues.z !== undefined) lines.push(`z-index: var(--depth-${plane}-z);`) if (lines.length) blocks.push(renderBlock(`[data-depth='${plane}']`, lines)) + + // Atmosphere (Fase 4) — frost is opt-in via `data-frost` so it never turns an opaque + // overlay translucent by default. Gated on the plane attribute → reliably overrides the + // component's own background (specificity 0,2,0). Needs a surface to dilute + a blur. + if (cues.blur !== undefined) { + const frost: string[] = [] + if (cues.surface !== undefined) + frost.push( + `background-color: color-mix(in srgb, var(--depth-${plane}-surface) 80%, transparent);` + ) + frost.push(`backdrop-filter: blur(var(--depth-${plane}-blur));`) + frost.push(`-webkit-backdrop-filter: blur(var(--depth-${plane}-blur));`) + blocks.push(renderBlock(`[data-depth='${plane}'][data-frost]`, frost)) + } } return blocks } diff --git a/web/routes/temas/profundidad/+page.svelte b/web/routes/temas/profundidad/+page.svelte index 4a02819f3..b1faa7a06 100644 --- a/web/routes/temas/profundidad/+page.svelte +++ b/web/routes/temas/profundidad/+page.svelte @@ -10,8 +10,9 @@ * 5. Cada plano… — the planes shown as the real components they're for. * 6. Catálogo — resting-plane table (the Material-style spec, our vocabulary). * 7. Luz vs sombra — light/dark side-by-side: how the cue adapts to the mode. - * 8. Jaula abierta — retune the depth cue live (escape hatch). - * 9. Accesibilidad — depth is never the only signal; degrades by preference. + * 8. Materiales — frost: translucent surface + backdrop-blur (atmosphere, Fase 4). + * 9. Jaula abierta — retune the depth cue live (escape hatch). + * 10. Accesibilidad — depth is never the only signal; degrades by preference. * * Self-contained: eidos foundation (planes + firma) + `data-theme`. */ @@ -350,7 +351,27 @@ - + +
+
+

Materiales

+

+ La atmósfera del canal: un panel data-frost sobre un plano se vuelve + vidrio esmerilado — superficie translúcida + backdrop-filter que + desenfoca lo que recede. Opt-in: nunca vuelve translúcido un overlay opaco por defecto. +

+
+
+ +
+ data-depth="overlay" data-frost + Vidrio esmerilado + la sombra eleva · el blur deja ver lo que recede +
+
+
+ +

La jaula abierta

@@ -393,7 +414,7 @@
- +

Accesibilidad

@@ -654,7 +675,26 @@ .mode-label { font-family: var(--font-family-mono); font-size: var(--font-size-xs); text-transform: uppercase; letter-spacing: 0.08em; color: var(--color-content-muted); } .mode-cue { font-size: var(--font-size-sm); color: var(--color-content-primary); } - /* 9 · Accesibilidad */ + /* 8 · Materiales (frost) */ + .frost-stage { position: relative; min-block-size: 19rem; border-radius: var(--radius-lg); overflow: hidden; border: 1px solid var(--rule); display: grid; place-items: center; padding: clamp(1.5rem, 4vw, 3rem); } + .frost-bg { + position: absolute; + inset: 0; + background: + radial-gradient(38% 55% at 18% 22%, var(--color-primary-solid), transparent 70%), + radial-gradient(40% 50% at 82% 28%, var(--color-fulfill-solid), transparent 70%), + radial-gradient(48% 58% at 62% 88%, var(--color-risk-solid), transparent 72%), + var(--color-surface-default); + opacity: 0.92; + } + /* no background here — the [data-depth='overlay'][data-frost] foundation rule supplies the + translucent surface + backdrop-blur, and [data-depth='overlay'] supplies shadow + halo + z */ + .frost-panel { position: relative; inline-size: min(22rem, 100%); padding: 1.75rem; border-radius: var(--radius-xl); border: 1px solid color-mix(in srgb, var(--color-border-default) 55%, transparent); color: var(--color-content-primary); display: flex; flex-direction: column; gap: 0.4rem; } + .frost-tag { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-content-muted); } + .frost-title { font-family: var(--font-family-display); font-size: var(--font-size-lg); font-weight: 600; } + .frost-sub { font-size: var(--font-size-xs); color: var(--color-content-secondary); } + + /* 10 · Accesibilidad */ .a11y { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); gap: 1.25rem; } .a11y-card { background: var(--color-surface-raised); border: 1px solid var(--rule); border-radius: var(--radius-lg); padding: 1.4rem; display: flex; flex-direction: column; gap: 0.5rem; } .a11y-title { font-family: var(--font-family-display); font-size: var(--font-size-md); font-weight: 600; color: var(--color-content-primary); }