feat(eidos): depth Fase 4 — atmosphere (frost) + applyDepth runtime builder

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 271a2f8ff0
commit 6998c5bde2

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

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

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

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

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

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

@ -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<string, Partial<DepthPlane>>;
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;
}

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

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

@ -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 @@
</div>
</section>
<!-- ── 8 · La jaula abierta (escape hatch en vivo) ────────────────────── -->
<!-- ── 8 · Materiales (frost · atmósfera, Fase 4) ─────────────────────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">Materiales</h2>
<p class="sec-note">
La atmósfera del canal: un panel <code>data-frost</code> sobre un plano se vuelve
<em>vidrio esmerilado</em> — superficie translúcida + <code>backdrop-filter</code> que
desenfoca lo que recede. Opt-in: nunca vuelve translúcido un overlay opaco por defecto.
</p>
</div>
<div class="frost-stage">
<div class="frost-bg" aria-hidden="true"></div>
<div class="frost-panel" data-depth="overlay" data-frost>
<span class="frost-tag">data-depth="overlay" data-frost</span>
<span class="frost-title">Vidrio esmerilado</span>
<span class="frost-sub">la sombra eleva · el blur deja ver lo que recede</span>
</div>
</div>
</section>
<!-- ── 9 · La jaula abierta (escape hatch en vivo) ────────────────────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">La jaula abierta</h2>
@ -393,7 +414,7 @@
</div>
</section>
<!-- ── 9 · Accesibilidad (la profundidad nunca es la única señal) ───────── -->
<!-- ── 10 · Accesibilidad (la profundidad nunca es la única señal) ──────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">Accesibilidad</h2>
@ -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); }

Loading…
Cancel
Save

Powered by TurnKey Linux.