feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad

Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
  (color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
  The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
  surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
  shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
  scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
  + config validation + STATIC_DEPTH + regenerated generated/base.css.

Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware

Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.

Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.

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

@ -81,7 +81,8 @@ primitivos existentes** — no los reemplaza:
``` ```
--depth-{plane}-surface → var(--color-surface-{…}) (tinte tonal, mode-aware) --depth-{plane}-surface → var(--color-surface-{…}) (tinte tonal, mode-aware)
--depth-{plane}-shadow → var(--shadow-{…}) (computado en Fase 2) --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}-z → var(--z-index-{…}) (banda)
--depth-{plane}-blur → atmósfera (Fase 4) --depth-{plane}-blur → atmósfera (Fase 4)
--depth-{plane}-scrim → atmósfera (Fase 4) --depth-{plane}-scrim → atmósfera (Fase 4)
@ -96,9 +97,15 @@ fondos de componente. Sin renombrar nada existente → **cero rotura de componen
1. ✅ **Plano unificado** — `EidosConfig.depth` + planos canónicos + emisión `--depth-{plane}-*` 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. (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. Puertas: set config-driven, mezcla por plano, primitivos intactos, escape a raw.
2. ⏸️ **Mezcla computada mode-adaptive** — diferida: las sombras YA son mode-aware (light slate 2. ✅ **Mezcla computada mode-adaptive** — la sombra de gota mantiene la escala themed (slate
sutil / dark negro más opaco), así que es **refinamiento** (sombra OKLCH desde la superficie + sutil en light / negro más opaco en dark — ya un tinte, no negro plano); Fase 2 añade el cue
halo en dark), no un hueco. Se retoma como pulido o se funde en el showcase. **`halo`**: un rim-light de borde superior **computado en oklab**
(`color-mix(in oklab, white N%, transparent)`, escalado por plano: 5/7/8% en raised/overlay/
modal). Es invisible sobre superficies claras (manda la gota) y se vuelve la señal de
elevación sobre superficies oscuras (donde la gota apenas se ve) — resuelve "la sombra miente
en dark" **sin tocar las sombras globales**: vive solo en el canal depth, compuesto en el
`box-shadow` de `[data-depth]` (`shadow, halo`). El halo derivado de la superficie + el frost
atmosférico quedan para Fase 4.
3. ✅ **Canal `depth` eventful (momento-evento)** — la dimensión de ELEVACIÓN vive en la *firma*: 3. ✅ **Canal `depth` eventful (momento-evento)** — la dimensión de ELEVACIÓN vive en la *firma*:
`present-rise` (emerge) hace crecer la sombra desde plano → la de reposo del elemento (sube); `present-rise` (emerge) hace crecer la sombra desde plano → la de reposo del elemento (sube);
`press-squeeze` (contact) la aplana a la superficie (recede). Generic (flush = no-op), `press-squeeze` (contact) la aplana a la superficie (recede). Generic (flush = no-op),
@ -106,7 +113,10 @@ fondos de componente. Sin renombrar nada existente → **cero rotura de componen
reduced-motion (cap global). Anulable: un tema sobreescribe los keyframes/signatures. Monta reduced-motion (cap global). Anulable: un tema sobreescribe los keyframes/signatures. Monta
sobre el sistema de `signatures` existente, **no** un sistema paralelo. 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** — blur/scrim en config; opt-out por overlay. + builder `applyDepth(seed)`.
5. **Showcase + docs** — `/temas/profundidad` (planos/mezcla/eventful en vivo) + THEMING §depth. 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
*elevation* de Material añadiendo los dos ejes que le faltan (eventful + jaula abierta). + THEMING §29.
## 7. Composición con lo existente ## 7. Composición con lo existente

@ -2273,8 +2273,11 @@ superficie + z). Guía canónica: `DEPTH_ENGINE_RFC.md`. **Dos momentos**:
overlay · modal · recessed`) que cohere superficie + sombra + z. Los tokens overlay · modal · recessed`) que cohere superficie + sombra + z. Los tokens
`--depth-{plane}-{cue}` **componen los primitivos existentes** (`--color-surface-*`, `--depth-{plane}-{cue}` **componen los primitivos existentes** (`--color-surface-*`,
`--shadow-*`, `--z-index-*`), así que la mezcla es mode-aware gratis. La regla `--shadow-*`, `--z-index-*`), así que la mezcla es mode-aware gratis. La regla
`[data-depth]` aplica las señales aditivas seguras (sombra + z); `surface` queda opt-in `[data-depth]` aplica las señales aditivas seguras (`box-shadow` = gota `shadow` + rim-light
(no pisa fondos de componente). `halo`, más `z-index`); `surface` queda opt-in (no pisa fondos de componente). El **`halo`**
es un rim de borde superior computado en oklab (`color-mix(in oklab, white N%, transparent)`):
invisible sobre superficies claras (manda la gota), señal de elevación sobre oscuras — la
respuesta mode-adaptive a "la sombra miente en dark".
- **Evento** — al **emerger** la sombra crece desde plano → la de reposo (sube); al - **Evento** — al **emerger** la sombra crece desde plano → la de reposo (sube); al
**presionar** se aplana (recede). Vive en la *firma* (`present-rise` / `press-squeeze` **presionar** se aplana (recede). Vive en la *firma* (`present-rise` / `press-squeeze`
sobre `data-event-*`), coordinado con motion + sound + haptic desde **un solo evento**. sobre `data-event-*`), coordinado con motion + sound + haptic desde **un solo evento**.
@ -2285,10 +2288,9 @@ añade/renombra/retunea); los primitivos siguen accesibles (`box-shadow`/`z-inde
paso); la capa eventful es aditiva y anulable (sobreescribe keyframes/signatures). Demo en paso); la capa eventful es aditiva y anulable (sobreescribe keyframes/signatures). Demo en
vivo: `/temas/profundidad`. vivo: `/temas/profundidad`.
**Pendiente** (no del núcleo): mezcla computada OKLCH + halo dark (refinamiento; las sombras **Pendiente** (no del núcleo): atmósfera frost/scrim (Fase 4 — requiere superficies
ya son mode-aware), atmósfera frost/scrim (requiere superficies translúcidas), y **adopción translúcidas) + builder `applyDepth(seed)`, y **adopción por componentes** (hoy
por componentes** (hoy card/dialog/popover cablean sombra/z aparte; migrarlos a `data-depth` card/dialog/popover cablean sombra/z aparte; migrarlos a `data-depth` es una pasada separada).
es una pasada separada).
--- ---

@ -469,16 +469,22 @@ describe('ActiveEidos config', () => {
eidos.clearTypeScale(); // reverts cleanly, no throw eidos.clearTypeScale(); // reverts cleanly, no throw
}); });
it('emits depth plane tokens + [data-depth] rules (composing surface/shadow/z)', () => { it('emits depth plane tokens + [data-depth] rules (composing surface/shadow/halo/z)', () => {
const css = createThemeBaseEidos().renderStaticCss(); const css = createThemeBaseEidos().renderStaticCss();
// tokens compose the existing primitives — no new shadow/surface/z math // surface/shadow/z compose the existing primitives — no new math there
expect(css).toContain('--depth-overlay-shadow: var(--shadow-overlay);'); expect(css).toContain('--depth-overlay-shadow: var(--shadow-overlay);');
expect(css).toContain('--depth-overlay-z: var(--z-index-popover);'); expect(css).toContain('--depth-overlay-z: var(--z-index-popover);');
expect(css).toContain('--depth-raised-surface: var(--color-surface-raised);'); 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 // Fase 2 — the rim-light halo is the one computed cue (oklab), the dark-mode lift signal
expect(css).toContain(
'--depth-overlay-halo: inset 0 1px 0 0 color-mix(in oklab, white 7%, transparent);'
);
// the rule applies the safe additive cues; box-shadow composes the drop shadow + the rim
expect(css).toContain("[data-depth='overlay'] {"); expect(css).toContain("[data-depth='overlay'] {");
expect(css).toContain('box-shadow: var(--depth-overlay-shadow);'); expect(css).toContain('box-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo);');
expect(css).toContain('z-index: var(--depth-overlay-z);'); expect(css).toContain('z-index: var(--depth-overlay-z);');
// a plane without a halo (flush) keeps a single-value box-shadow — no dangling comma
expect(css).toContain('box-shadow: var(--depth-flush-shadow);');
}); });
it('depth planes are config-driven — a theme can add a plane (jaula abierta)', () => { it('depth planes are config-driven — a theme can add a plane (jaula abierta)', () => {

@ -442,12 +442,15 @@
--depth-flush-z: var(--z-index-base); --depth-flush-z: var(--z-index-base);
--depth-raised-surface: var(--color-surface-raised); --depth-raised-surface: var(--color-surface-raised);
--depth-raised-shadow: var(--shadow-raised); --depth-raised-shadow: var(--shadow-raised);
--depth-raised-halo: inset 0 1px 0 0 color-mix(in oklab, white 5%, transparent);
--depth-raised-z: var(--z-index-raised); --depth-raised-z: var(--z-index-raised);
--depth-overlay-surface: var(--color-surface-overlay); --depth-overlay-surface: var(--color-surface-overlay);
--depth-overlay-shadow: var(--shadow-overlay); --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-z: var(--z-index-popover);
--depth-modal-surface: var(--color-surface-overlay); --depth-modal-surface: var(--color-surface-overlay);
--depth-modal-shadow: var(--shadow-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-z: var(--z-index-modal);
--depth-recessed-surface: var(--color-surface-muted); --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-shadow: inset 0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 12%, transparent);
@ -4325,17 +4328,17 @@
} }
[data-depth='raised'] { [data-depth='raised'] {
box-shadow: var(--depth-raised-shadow); box-shadow: var(--depth-raised-shadow), var(--depth-raised-halo);
z-index: var(--depth-raised-z); z-index: var(--depth-raised-z);
} }
[data-depth='overlay'] { [data-depth='overlay'] {
box-shadow: var(--depth-overlay-shadow); box-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo);
z-index: var(--depth-overlay-z); z-index: var(--depth-overlay-z);
} }
[data-depth='modal'] { [data-depth='modal'] {
box-shadow: var(--depth-modal-shadow); box-shadow: var(--depth-modal-shadow), var(--depth-modal-halo);
z-index: var(--depth-modal-z); z-index: var(--depth-modal-z);
} }

@ -673,6 +673,14 @@ export interface DepthPlane {
readonly surface?: string; readonly surface?: string;
/** Box-shadow (`var(--shadow-{…})`, a raw shadow, or `none`). */ /** Box-shadow (`var(--shadow-{…})`, a raw shadow, or `none`). */
readonly shadow?: string; readonly shadow?: string;
/**
* Rim-light halo — a top-edge inset highlight composed alongside `shadow` in the
* `[data-depth]` box-shadow. Computed in oklab (`color-mix(in oklab, white N%, transparent)`)
* so it is invisible against light surfaces (where the drop shadow leads) but reads as the
* lift cue against dark surfaces (where a downward shadow barely shows). The mode-adaptive
* answer to "shadow lies in dark". (DEPTH_ENGINE_RFC §3 piece 2 / Fase 2)
*/
readonly halo?: string;
/** z-index (`var(--z-index-{…})` or a raw value). */ /** z-index (`var(--z-index-{…})` or a raw value). */
readonly z?: string; readonly z?: string;
/** Backdrop blur (atmosphere — DEPTH_ENGINE_RFC Fase 4). */ /** Backdrop blur (atmosphere — DEPTH_ENGINE_RFC Fase 4). */

@ -920,7 +920,7 @@ function validateDepthPrimitives(options: EidosConfig, issues: EidosValidationIs
issues.push({ path, message: 'depth plane must be a plain object' }) issues.push({ path, message: 'depth plane must be a plain object' })
continue continue
} }
for (const cue of ['surface', 'shadow', 'z', 'blur', 'scrim'] as const) { for (const cue of ['surface', 'shadow', 'halo', 'z', 'blur', 'scrim'] as const) {
const value = (cues as Record<string, unknown>)[cue] const value = (cues as Record<string, unknown>)[cue]
if (value !== undefined) validateNonEmptyCssValue(`${path}.${cue}`, value, issues) if (value !== undefined) validateNonEmptyCssValue(`${path}.${cue}`, value, issues)
} }

@ -214,16 +214,19 @@ export const STATIC_DEPTH: DepthPrimitiveSet = {
raised: { raised: {
surface: 'var(--color-surface-raised)', surface: 'var(--color-surface-raised)',
shadow: 'var(--shadow-raised)', shadow: 'var(--shadow-raised)',
halo: 'inset 0 1px 0 0 color-mix(in oklab, white 5%, transparent)',
z: 'var(--z-index-raised)' z: 'var(--z-index-raised)'
}, },
overlay: { overlay: {
surface: 'var(--color-surface-overlay)', surface: 'var(--color-surface-overlay)',
shadow: 'var(--shadow-overlay)', shadow: 'var(--shadow-overlay)',
halo: 'inset 0 1px 0 0 color-mix(in oklab, white 7%, transparent)',
z: 'var(--z-index-popover)' z: 'var(--z-index-popover)'
}, },
modal: { modal: {
surface: 'var(--color-surface-overlay)', surface: 'var(--color-surface-overlay)',
shadow: 'var(--shadow-overlay)', shadow: 'var(--shadow-overlay)',
halo: 'inset 0 1px 0 0 color-mix(in oklab, white 8%, transparent)',
z: 'var(--z-index-modal)' z: 'var(--z-index-modal)'
}, },
recessed: { recessed: {

@ -768,7 +768,7 @@ function appendTypographyDeclarations(
/** Emit `--depth-{plane}-{cue}` tokens — each plane's cue values composed into `:root`. */ /** Emit `--depth-{plane}-{cue}` tokens — each plane's cue values composed into `:root`. */
function appendDepthDeclarations(declarations: string[], depth: DepthPrimitiveSet): void { function appendDepthDeclarations(declarations: string[], depth: DepthPrimitiveSet): void {
for (const [plane, cues] of Object.entries(depth.planes)) { for (const [plane, cues] of Object.entries(depth.planes)) {
for (const cue of ['surface', 'shadow', 'z', 'blur', 'scrim'] as const) { for (const cue of ['surface', 'shadow', 'halo', 'z', 'blur', 'scrim'] as const) {
const value = cues[cue] const value = cues[cue]
if (value !== undefined) declarations.push(cssVar(`depth-${plane}-${cue}`, value)) if (value !== undefined) declarations.push(cssVar(`depth-${plane}-${cue}`, value))
} }
@ -777,15 +777,20 @@ function appendDepthDeclarations(declarations: string[], depth: DepthPrimitiveSe
/** /**
* `[data-depth='{plane}']` applies the *safe additive* depth cues — box-shadow + z-index — * `[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` * referencing the plane tokens (so `setCssVariables` / a theme can retune them). The
* (background) + `blur`/`scrim` (atmosphere) stay opt-in tokens, never forced here, so the * box-shadow composes the drop `shadow` with the rim-light `halo` (comma-joined) when both
* rule never fights a component's own background. (DEPTH_ENGINE_RFC §5) * are declared, so the lift cue survives in dark mode. `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[] { function renderDepthBlocks(depth: DepthPrimitiveSet): string[] {
const blocks: string[] = [] const blocks: string[] = []
for (const [plane, cues] of Object.entries(depth.planes)) { for (const [plane, cues] of Object.entries(depth.planes)) {
const lines: string[] = [] const lines: string[] = []
if (cues.shadow !== undefined) lines.push(`box-shadow: var(--depth-${plane}-shadow);`) const shadowParts: string[] = []
if (cues.shadow !== undefined) shadowParts.push(`var(--depth-${plane}-shadow)`)
if (cues.halo !== undefined) shadowParts.push(`var(--depth-${plane}-halo)`)
if (shadowParts.length) lines.push(`box-shadow: ${shadowParts.join(', ')};`)
if (cues.z !== undefined) lines.push(`z-index: var(--depth-${plane}-z);`) if (cues.z !== undefined) lines.push(`z-index: var(--depth-${plane}-z);`)
if (lines.length) blocks.push(renderBlock(`[data-depth='${plane}']`, lines)) if (lines.length) blocks.push(renderBlock(`[data-depth='${plane}']`, lines))
} }

@ -1,12 +1,17 @@
<script lang="ts"> <script lang="ts">
/** /**
* Depth demo — the Eidos depth channel, made visceral. * Depth demo — the Eidos depth channel, made visceral, at reference depth.
* *
* Not abstract tiles: real UI that RESPONDS. Three beats — * Not abstract tiles: real UI that RESPONDS, plus the full catalog surface.
* 1. Reacciona — cards lift on hover (state moment: depth responds to interaction). * 1. Reacciona — cards lift on hover (state moment).
* 2. Asciende — a dialog RISES over a scrim on open (event moment: the `present-rise` * 2. Estados — depth responds to EVERY interaction state (dynamic elevation).
* firma — shadow grows + lift — fired by stamping `data-event-*`, as sema would). * 3. Asciende — a dialog RISES over a scrim on open (event moment: `present-rise`).
* 3. Cada plano, un rol — the 5 planes shown as the real components they're for. * 4. Escalera — the 5 planes as an ascending z-stack (the elevation scale).
* 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.
* *
* Self-contained: eidos foundation (planes + firma) + `data-theme`. * Self-contained: eidos foundation (planes + firma) + `data-theme`.
*/ */
@ -14,6 +19,21 @@
let theme = $state<'light' | 'dark'>('dark') let theme = $state<'light' | 'dark'>('dark')
// Mirror the theme onto <html>. The `--depth-*` foundation tokens live at
// `:root` and reference `var(--color-surface-*)` / `var(--shadow-*)`; custom
// properties resolve var() at their declaration site, so the theme must be
// active at `:root` (not just on a descendant div) for the depth surface +
// shadow to stay mode-aware. This is the faithful real-app setup.
$effect(() => {
const el = document.documentElement
const prev = el.getAttribute('data-theme')
el.setAttribute('data-theme', theme)
return () => {
if (prev === null) el.removeAttribute('data-theme')
else el.setAttribute('data-theme', prev)
}
})
// ── Ascenso: a dialog that rises over a scrim (the event moment) ──────────── // ── Ascenso: a dialog that rises over a scrim (the event moment) ────────────
let open = $state(false) let open = $state(false)
let evt = $state<{ event?: string; phase?: string }>({}) let evt = $state<{ event?: string; phase?: string }>({})
@ -29,6 +49,47 @@
open = false open = false
evt = {} evt = {}
} }
// ── Jaula abierta: retune the depth cue live (escape hatch) ─────────────────
const SHADOW_VARIANTS = [
{ key: 'canon', label: 'canónica', shadow: 'var(--depth-overlay-shadow)' },
{ key: 'intense', label: 'intensa', shadow: '0 30px 70px rgb(0 0 0 / 0.5)' },
{
key: 'brand',
label: 'de marca',
shadow: '0 24px 56px color-mix(in oklab, var(--color-primary-solid) 55%, transparent)'
},
{ key: 'raw', label: 'primitivo crudo', shadow: 'var(--shadow-3)' }
] as const
let shadowKey = $state<string>('canon')
const activeShadow = $derived(
SHADOW_VARIANTS.find((v) => v.key === shadowKey)?.shadow ?? SHADOW_VARIANTS[0].shadow
)
// ── Estados de interacción: la profundidad responde a CADA estado (dynamic elevation) ──
const STATE_LADDER = [
{ state: 'reposo', moves: 'en su plano', shadow: 'var(--depth-raised-shadow)', lift: '0px' },
{ state: 'hover', moves: 'sube un plano', shadow: 'var(--depth-overlay-shadow)', lift: '-5px' },
{ state: 'focus', moves: 'sube + anillo', shadow: 'var(--depth-overlay-shadow)', lift: '-5px' },
{ state: 'pressed', moves: 'se hunde', shadow: 'var(--depth-raised-shadow)', lift: '1px' },
{ state: 'disabled', moves: 'sin relieve', shadow: 'none', lift: '0px' }
] as const
// ── Catálogo de planos en reposo (resting elevation — el equivalente a la tabla de Material) ──
const PLANE_CATALOG = [
{ plane: 'recessed', z: 'base', use: 'inputs activos · pozos · tracks · sliders' },
{ plane: 'flush', z: 'base', use: 'chips · badges · separadores · botones planos' },
{ plane: 'raised', z: 'raised', use: 'tarjetas · paneles · app bar · acordeones' },
{ plane: 'overlay', z: 'popover', use: 'menús · popovers · tooltips · selects · toasts' },
{ plane: 'modal', z: 'modal', use: 'diálogos · sheets · drawers · command palette' }
] as const
// The 5 planes bottom→top, for the z-stack ladder.
const PLANE_LADDER = ['recessed', 'flush', 'raised', 'overlay', 'modal'] as const
// The rim-light halo (canonical overlay value) — written inline in the light/dark panels so
// each resolves against its own nested `data-theme` (the `--depth-*` tokens can't, see $effect).
const RIM_HALO = 'inset 0 1px 0 0 color-mix(in oklab, white 7%, transparent)'
</script> </script>
<svelte:head><title>Profundidad · Eidos</title></svelte:head> <svelte:head><title>Profundidad · Eidos</title></svelte:head>
@ -65,7 +126,39 @@
</div> </div>
</section> </section>
<!-- ── 2 · Asciende (evento · la firma) ───────────────────────────────── --> <!-- ── 2 · Estados (la profundidad responde a CADA estado) ──────────────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">Responde a cada estado</h2>
<p class="sec-note">
No solo al hover. Reposo, foco, pulsado, deshabilitado — la profundidad <em>cambia con cada
estado</em>. Donde Material publica una tabla de niveles por estado, aquí es un solo canal que
reacciona. El último control es real: pásale el cursor, enfócalo con <kbd>Tab</kbd>, púlsalo.
</p>
</div>
<div class="states">
{#each STATE_LADDER as s}
<figure class="state">
<div class="state-stage">
<span
class="state-chip"
data-state={s.state}
style="box-shadow: {s.shadow}; translate: 0 {s.lift};"
>Guardar</span>
</div>
<figcaption><b>{s.state}</b> · {s.moves}</figcaption>
</figure>
{/each}
<figure class="state">
<div class="state-stage">
<button class="state-chip state-btn">Guardar</button>
</div>
<figcaption><b>en vivo</b> · hover · foco · pulsa</figcaption>
</figure>
</div>
</section>
<!-- ── 3 · Asciende (evento · la firma) ───────────────────────────────── -->
<section class="section"> <section class="section">
<div class="sec-head"> <div class="sec-head">
<h2 class="sec-title">Asciende</h2> <h2 class="sec-title">Asciende</h2>
@ -112,7 +205,27 @@
</div> </div>
</section> </section>
<!-- ── 3 · Cada plano, un rol ─────────────────────────────────────────── --> <!-- ── 4 · Escalera de planos (la jerarquía de un vistazo) ──────────────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">La escalera de planos</h2>
<p class="sec-note">
Cinco posiciones en la jerarquía de atención, de hundido a cima:
<code>recessed → flush → raised → overlay → modal</code>. Cada peldaño compone superficie +
sombra + z — míralos trepar hacia ti.
</p>
</div>
<div class="ladder">
{#each PLANE_LADDER as plane, i}
<div class="ladder-step" data-depth={plane} style="--i: {i};">
<span class="ladder-name">{plane}</span>
<span class="ladder-z">z · {PLANE_CATALOG.find((p) => p.plane === plane)?.z}</span>
</div>
{/each}
</div>
</section>
<!-- ── 5 · Cada plano, un rol ─────────────────────────────────────────── -->
<section class="section"> <section class="section">
<div class="sec-head"> <div class="sec-head">
<h2 class="sec-title">Cada plano, un rol</h2> <h2 class="sec-title">Cada plano, un rol</h2>
@ -183,6 +296,119 @@
</div> </div>
</section> </section>
<!-- ── 6 · Catálogo de planos en reposo (la tabla, en nuestro vocabulario) ─ -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">Catálogo de planos en reposo</h2>
<p class="sec-note">
La elevación en reposo de cada familia de componente — el equivalente a la tabla de
especificación de Material, pero en planos nombrados por su rol, no en milímetros.
</p>
</div>
<div class="catalog" role="table" aria-label="planos en reposo">
<div class="cat-row cat-head" role="row">
<span role="columnheader">Plano</span>
<span role="columnheader">Banda z</span>
<span role="columnheader">Componentes</span>
<span role="columnheader">Muestra</span>
</div>
{#each PLANE_CATALOG as p}
<div class="cat-row" role="row">
<span class="cat-plane" role="cell"><b>{p.plane}</b></span>
<span class="cat-z" role="cell">{p.z}</span>
<span class="cat-use" role="cell">{p.use}</span>
<span class="cat-sample" role="cell"><span class="cat-tile" data-depth={p.plane}></span></span>
</div>
{/each}
</div>
</section>
<!-- ── 7 · Luz vs sombra (cómo la señal se adapta al modo — Fase 2) ─────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">Luz vs sombra</h2>
<p class="sec-note">
La misma tarjeta <code>overlay</code>, los dos modos a la vez. En claro manda la
<strong>sombra</strong> proyectada y el halo es invisible. En oscuro la sombra apenas se ve —
toma el relevo el <strong>halo</strong>: un rim de luz computado en oklab. La señal se
<em>adapta</em> al modo en vez de mentir.
</p>
</div>
<div class="modes">
<div class="mode-panel" data-theme="light">
<div class="mode-card" style="box-shadow: var(--shadow-overlay), {RIM_HALO};">
<span class="mode-label">claro</span>
<span class="mode-cue">la sombra define el relieve</span>
</div>
</div>
<div class="mode-panel" data-theme="dark">
<div class="mode-card" style="box-shadow: var(--shadow-overlay), {RIM_HALO};">
<span class="mode-label">oscuro</span>
<span class="mode-cue">el halo toma el relevo</span>
</div>
</div>
</div>
</section>
<!-- ── 8 · La jaula abierta (escape hatch en vivo) ────────────────────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">La jaula abierta</h2>
<p class="sec-note">
Opinión fuerte que <em>nunca</em> atrapa. El plano <code>overlay</code> trae una sombra
canónica — pero la mezcla es tuya: retúnea por config, anula por componente, o baja al
primitivo crudo. La misma tarjeta, cuatro mezclas — cámbiala y míralo en vivo.
</p>
</div>
<div class="cage">
<div class="cage-stage">
<div class="cage-card" data-depth="overlay" style="box-shadow: {activeShadow}">
<span class="cage-card-tag">data-depth="overlay"</span>
<span class="cage-card-title">Sombra {SHADOW_VARIANTS.find((v) => v.key === shadowKey)?.label}</span>
<span class="cage-card-sub">el plano no cambia — su mezcla, sí</span>
</div>
</div>
<div class="cage-panel">
<div class="cage-chips" role="group" aria-label="variante de sombra">
{#each SHADOW_VARIANTS as v}
<button
class="cage-chip"
data-active={v.key === shadowKey}
onclick={() => (shadowKey = v.key)}
>
{v.label}
</button>
{/each}
</div>
<code class="cage-code">box-shadow: {activeShadow};</code>
<p class="cage-foot">
<b>canónica</b> = el plano sin tocar · <b>de marca</b> = mezcla OKLCH con el color
primario · <b>primitivo crudo</b> = <code>var(--shadow-3)</code>, siempre a un paso. En
una app real esto vive en <code>EidosConfig.depth</code> o en un override de recipe — el
inline aquí es solo el atajo más corto a la misma puerta.
</p>
</div>
</div>
</section>
<!-- ── 9 · Accesibilidad (la profundidad nunca es la única señal) ───────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">Accesibilidad</h2>
<p class="sec-note">La profundidad acompaña — nunca carga sola con el significado.</p>
</div>
<div class="a11y">
{#each [{ t: 'Nunca el único canal', d: 'Cada plano va con borde, superficie y posición — no solo sombra. Quítale la sombra y la jerarquía sigue legible.' }, { t: 'Respeta reduced-motion', d: 'El ascenso eventful (la firma) degrada a un cambio de estado instantáneo cuando se prefiere menos movimiento.' }, { t: 'Sobrevive a forced-colors', d: 'En alto contraste el box-shadow desaparece; el plano se apoya en borde y superficie, que el modo sí respeta.' }, { t: 'Contraste, no relieve', d: 'La profundidad no sustituye al contraste de texto: el contenido cumple APCA en cualquier plano.' }] as item}
<div class="a11y-card" data-depth="raised">
<span class="a11y-title">{item.t}</span>
<span class="a11y-body">{item.d}</span>
</div>
{/each}
</div>
</section>
<footer class="footnote"> <footer class="footnote">
<p> <p>
<strong>Default fuerte, jaula abierta.</strong> Planos config-driven, primitivos <strong>Default fuerte, jaula abierta.</strong> Planos config-driven, primitivos
@ -267,16 +493,16 @@
.app-check { inline-size: 1.05rem; block-size: 1.05rem; border-radius: 5px; border: 1.5px solid var(--rule); flex: none; } .app-check { inline-size: 1.05rem; block-size: 1.05rem; border-radius: 5px; border: 1.5px solid var(--rule); flex: none; }
.app-check[data-on='true'] { background: var(--color-primary-solid); border-color: var(--color-primary-solid); } .app-check[data-on='true'] { background: var(--color-primary-solid); border-color: var(--color-primary-solid); }
/* transparent click-catcher only — a dark scrim would swallow the rising dialog's
shadow (the whole point of a depth demo). The content recedes via `.dimmed` instead. */
.scrim { .scrim {
position: absolute; position: absolute;
inset: 0; inset: 0;
border: 0; border: 0;
padding: 0; padding: 0;
background: color-mix(in srgb, var(--color-neutral-contrast, #000) 55%, transparent); background: transparent;
cursor: pointer; cursor: pointer;
animation: depth-scrim-in 240ms var(--ease-out, ease) both;
} }
@keyframes depth-scrim-in { from { opacity: 0; } to { opacity: 1; } }
.risen { .risen {
position: absolute; position: absolute;
@ -341,6 +567,99 @@
.field-label { font-size: var(--font-size-xs); color: var(--color-content-muted); } .field-label { font-size: var(--font-size-xs); color: var(--color-content-muted); }
.field-input { padding: 0.6rem 0.8rem; border-radius: var(--radius-md); background: var(--color-surface-muted); color: var(--color-content-muted); font-size: var(--font-size-sm); } .field-input { padding: 0.6rem 0.8rem; border-radius: var(--radius-md); background: var(--color-surface-muted); color: var(--color-content-muted); font-size: var(--font-size-sm); }
/* 4 · La jaula abierta */
.cage { display: grid; grid-template-columns: 1fr; gap: clamp(1.5rem, 4vw, 3rem); align-items: center; }
@media (min-width: 48rem) { .cage { grid-template-columns: 1.05fr 1fr; } }
.cage-stage { min-block-size: 17rem; display: grid; place-items: center; padding: clamp(1.5rem, 4vw, 3rem); border-radius: var(--radius-lg); background: var(--color-surface-default); border: 1px solid color-mix(in srgb, var(--rule) 60%, transparent); }
.cage-card {
inline-size: min(18rem, 100%);
padding: 1.75rem;
border-radius: var(--radius-xl);
background: var(--color-surface-overlay);
border: 1px solid var(--rule);
display: flex;
flex-direction: column;
gap: 0.4rem;
transition: box-shadow 320ms var(--ease-out, cubic-bezier(0.2, 0, 0, 1));
}
.cage-card-tag { font-family: var(--font-family-mono); font-size: var(--font-size-xs); letter-spacing: 0.02em; color: var(--color-content-muted); }
.cage-card-title { font-family: var(--font-family-display); font-size: var(--font-size-lg); font-weight: 600; color: var(--color-content-primary); }
.cage-card-sub { font-size: var(--font-size-xs); color: var(--color-content-muted); }
.cage-panel { display: flex; flex-direction: column; gap: 1rem; }
.cage-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.cage-chip { font-family: var(--font-family-mono); font-size: var(--font-size-xs); padding: 0.45rem 0.9rem; border-radius: 999px; cursor: pointer; background: var(--color-surface-raised); color: var(--color-content-secondary); border: 1px solid var(--rule); transition: color 160ms, background 160ms, border-color 160ms; }
.cage-chip:hover { color: var(--color-content-primary); }
.cage-chip[data-active='true'] { background: var(--color-primary-solid); color: var(--color-primary-on-solid, #fff); border-color: var(--color-primary-solid); }
.cage-code { display: block; font-family: var(--font-family-mono); font-size: var(--font-size-xs); line-height: 1.5; padding: 0.85rem 1rem; border-radius: var(--radius-md); background: var(--color-surface-muted); color: var(--color-content-secondary); word-break: break-all; }
.cage-foot { margin: 0; font-size: var(--font-size-xs); line-height: 1.6; color: var(--color-content-muted); }
.cage-foot b { color: var(--color-content-secondary); font-weight: 600; }
/* 2 · Estados (dynamic elevation) */
.states { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 8.5rem), 1fr)); gap: 1.25rem; }
.state { margin: 0; display: flex; flex-direction: column; gap: 0.75rem; }
.state-stage { min-block-size: 7rem; display: grid; place-items: center; padding: 1rem; border-radius: var(--radius-lg); background: var(--color-surface-default); border: 1px solid color-mix(in srgb, var(--rule) 60%, transparent); }
.state-chip { font-family: var(--font-family-primary); font-size: var(--font-size-sm); font-weight: 600; padding: 0.6rem 1.15rem; border-radius: var(--radius-md); background: var(--color-surface-raised); color: var(--color-content-primary); border: 1px solid var(--rule); }
.state-chip[data-state='focus'] { outline: 2px solid var(--color-primary-solid); outline-offset: 2px; }
.state-chip[data-state='pressed'] { background: var(--color-surface-muted); }
.state-chip[data-state='disabled'] { opacity: 0.45; }
.state-btn { cursor: pointer; box-shadow: var(--depth-raised-shadow); transition: box-shadow 200ms var(--ease-out, cubic-bezier(0.2, 0, 0, 1)), translate 200ms var(--ease-out, cubic-bezier(0.2, 0, 0, 1)); }
.state-btn:hover { box-shadow: var(--depth-overlay-shadow); translate: 0 -5px; }
.state-btn:focus-visible { box-shadow: var(--depth-overlay-shadow); translate: 0 -5px; outline: 2px solid var(--color-primary-solid); outline-offset: 2px; }
.state-btn:active { box-shadow: var(--depth-raised-shadow); translate: 0 1px; }
.state figcaption { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-content-muted); }
.state figcaption b { color: var(--color-content-primary); font-weight: 600; text-transform: capitalize; }
@media (prefers-reduced-motion: reduce) { .state-btn { transition: box-shadow 200ms; } .state-btn:hover, .state-btn:focus-visible, .state-btn:active { translate: none; } }
kbd { font-family: var(--font-family-mono); font-size: 0.85em; padding: 0.1em 0.4em; border-radius: 4px; background: var(--color-surface-raised); border: 1px solid var(--rule); color: var(--color-content-secondary); }
/* 4 · Escalera de planos (z-stack) */
.ladder { display: flex; flex-direction: column; align-items: center; padding: clamp(2rem, 7vw, 5rem) 1rem clamp(3rem, 8vw, 6rem); }
.ladder-step {
inline-size: min(20rem, 80%);
padding: 1rem 1.4rem;
border-radius: var(--radius-lg);
background: var(--color-surface-raised);
border: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
translate: calc(var(--i) * 1.4rem - 2.8rem) calc(var(--i) * -0.35rem);
margin-block-start: -0.6rem;
}
.ladder-step:first-child { margin-block-start: 0; }
.ladder-name { font-family: var(--font-family-mono); font-size: var(--font-size-sm); color: var(--color-content-primary); font-weight: 600; }
.ladder-z { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-content-muted); }
/* 6 · Catálogo (resting elevation table) */
.catalog { display: flex; flex-direction: column; border: 1px solid var(--rule); border-radius: var(--radius-lg); overflow: hidden; background: var(--color-surface-raised); }
.cat-row { display: grid; grid-template-columns: 7rem 5rem 1fr 4rem; gap: 1rem; align-items: center; padding: 0.9rem 1.25rem; border-block-end: 1px solid color-mix(in srgb, var(--rule) 50%, transparent); }
.cat-row:last-child { border-block-end: 0; }
.cat-head { font-family: var(--font-family-mono); font-size: var(--font-size-xs); letter-spacing: 0.04em; text-transform: uppercase; color: var(--color-content-muted); background: var(--color-surface-muted); }
.cat-plane { font-family: var(--font-family-mono); font-size: var(--font-size-sm); }
.cat-plane b { color: var(--color-content-primary); font-weight: 600; }
.cat-z { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-content-secondary); }
.cat-use { font-size: var(--font-size-sm); color: var(--color-content-secondary); }
.cat-sample { display: flex; justify-content: flex-end; }
.cat-tile { inline-size: 3rem; block-size: 1.75rem; border-radius: var(--radius-sm); background: var(--color-surface-default); border: 1px solid color-mix(in srgb, var(--rule) 50%, transparent); }
@media (max-width: 34rem) {
.cat-row { grid-template-columns: 6rem 1fr; }
.cat-z, .cat-sample, .cat-head span:nth-child(2), .cat-head span:nth-child(4) { display: none; }
}
/* 7 · Luz vs sombra (mode-adaptive cue) */
.modes { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
@media (min-width: 38rem) { .modes { grid-template-columns: 1fr 1fr; } }
.mode-panel { border-radius: var(--radius-lg); padding: clamp(2rem, 6vw, 4rem); display: grid; place-items: center; min-block-size: 14rem; background: var(--color-surface-default); border: 1px solid var(--color-border-default); }
.mode-card { inline-size: min(16rem, 100%); padding: 1.5rem; border-radius: var(--radius-lg); background: var(--color-surface-overlay); border: 1px solid var(--color-border-default); display: flex; flex-direction: column; gap: 0.35rem; }
.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 */
.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); }
.a11y-body { font-size: var(--font-size-sm); line-height: 1.55; color: var(--color-content-muted); }
.footnote { max-inline-size: 60rem; padding-block-start: 2rem; border-block-start: 1px solid var(--rule); } .footnote { max-inline-size: 60rem; padding-block-start: 2rem; border-block-start: 1px solid var(--rule); }
.footnote p { margin: 0; font-size: var(--font-size-sm); line-height: 1.6; color: var(--color-content-muted); text-wrap: pretty; } .footnote p { margin: 0; font-size: var(--font-size-sm); line-height: 1.6; color: var(--color-content-muted); text-wrap: pretty; }
</style> </style>

Loading…
Cancel
Save

Powered by TurnKey Linux.