feat(demos): /temas/tipografia showcase — live type scale + axes

Typography showcase mirroring /temas/color: dogfoods the engine through
live CSS tokens.

  - Escala: live `buildTypeScale` ladder — base + modular ratio (+ fluid
    ratioMax/viewport) re-derive all 8 sizes; the named styles (hero/h1-h6/
    body/caption) re-scale too (they reference --font-size-* via var()).
  - Familias: the 4 config-driven @font-face slots in their own fonts.
  - Ejes: tracking / leading / measure / wrap / numeric driven live via the
    --_text-* per-instance vars the recipe reads.

Self-contained (eidos foundation CSS + raw recipe data-attrs + data-theme),
same posture as the color demo. Editorial Lora-display aesthetic.

Verified in browser: live scale responds (xxxl 16->77px at ratio 1.6);
4 real fonts load (Instrument Sans / Lora / Azeret Mono); axes apply
(measure narrow = 54ch). `npm run check` 0 errors.

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

@ -0,0 +1,457 @@
<script lang="ts">
/**
* Typography demo — the Eidos type system, same shape as the Color demo.
*
* Everything renders through the live CSS custom properties (so it reflects the
* active theme + light/dark):
* - the SCALE: a live modular/fluid ladder built by `buildTypeScale` (the runtime
* `applyTypeScale` engine) — drag the ratio + the whole ladder re-derives, named
* styles included (they reference `--font-size-*` via `var()`);
* - the FAMILIES: the config-driven `@font-face` slots (primary/secondary/mono/display);
* - the AXES: tracking · leading · measure · wrap · numeric driven live through the
* `--_text-*` per-instance vars the recipe reads.
*
* Self-contained: imports the eidos foundation CSS, renders raw elements with the
* recipe `data-*` attrs. `data-theme` on `.root` activates light/dark.
*/
import '@/uix/eidos/index.css'
import { buildTypeScale } from '$uix/eidos'
let theme = $state<'light' | 'dark'>('light')
const SIZES = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl', 'xxxl'] as const
const STYLES = [
{ style: 'hero', label: 'Hero', sample: 'Diseñando lo que ocurre' },
{ style: 'h1', label: 'H1', sample: 'Una escala que respira' },
{ style: 'h2', label: 'H2', sample: 'Ritmo y jerarquía' },
{ style: 'h3', label: 'H3', sample: 'Cada paso, intencional' },
{ style: 'h4', label: 'H4', sample: 'Detalle tipográfico' },
{ style: 'body', label: 'Body', sample: 'El cuerpo de texto sostiene la lectura larga con una medida cómoda y un interlineado generoso.' },
{ style: 'caption', label: 'Caption', sample: 'Metadatos, pies y notas al margen' }
] as const
// ── Live type scale (buildTypeScale — the applyTypeScale engine) ──────────
let base = $state(16)
let ratio = $state(1.25)
let fluid = $state(true)
let ratioMax = $state(1.333)
let vw = $state(820)
const RATIOS = [
{ value: 1.125, name: 'segunda mayor' },
{ value: 1.2, name: 'tercera menor' },
{ value: 1.25, name: 'tercera mayor' },
{ value: 1.333, name: 'cuarta justa' },
{ value: 1.414, name: 'tritono' },
{ value: 1.5, name: 'quinta justa' },
{ value: 1.618, name: 'áurea' }
] as const
const scale = $derived(
buildTypeScale({
base,
ratio,
// keep the fluid endpoint ≥ the base ratio so the scale never inverts on wide screens
ratioMax: fluid ? Math.max(ratio, ratioMax) : undefined,
minVw: 480,
maxVw: 1280
})
)
const scaleVars = $derived(scale.variables.join(' '))
const ratioName = $derived(RATIOS.find((r) => Math.abs(r.value - ratio) < 0.001)?.name ?? 'custom')
// px at a chosen viewport, for the readout (linear interp between min/max viewport).
function pxAt(min: number, max: number): number {
if (min === max) return min
const t = Math.min(1, Math.max(0, (vw - 480) / (1280 - 480)))
return Math.round((min + (max - min) * t) * 100) / 100
}
// ── Axes playground ───────────────────────────────────────────────────────
const TRACKINGS = ['tighter', 'tight', 'normal', 'wide', 'wider'] as const
const LEADINGS = ['tight', 'snug', 'normal', 'relaxed', 'loose'] as const
const MEASURES = ['narrow', 'normal', 'wide'] as const
const WRAPS = ['pretty', 'balance', 'nowrap'] as const
let tracking = $state<(typeof TRACKINGS)[number]>('normal')
let leading = $state<(typeof LEADINGS)[number]>('normal')
let measure = $state<(typeof MEASURES)[number]>('normal')
let wrap = $state<(typeof WRAPS)[number]>('pretty')
let numeric = $state(false)
const axisStyle = $derived(
[
`--_text-letter-spacing: var(--tracking-${tracking})`,
`--_text-line-height: var(--leading-${leading})`,
`--_text-measure: var(--measure-${measure})`,
`--_text-wrap: ${wrap}`,
`--_text-font-feature: ${numeric ? 'var(--font-feature-tabular)' : 'normal'}`
].join('; ')
)
const FAMILIES = [
{ slot: 'primary', label: 'Primary', note: 'UI · cuerpo' },
{ slot: 'display', label: 'Display', note: 'titulares' },
{ slot: 'secondary', label: 'Secondary', note: 'prosa' },
{ slot: 'mono', label: 'Mono', note: 'código · cifras' }
] as const
</script>
<svelte:head><title>Tipografía · Eidos</title></svelte:head>
<div class="root" data-theme={theme}>
<header class="masthead">
<div class="mast-row">
<p class="kicker">Eidos · sistema tipográfico</p>
<button class="theme-toggle" onclick={() => (theme = theme === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? '◐ claro' : '◑ oscuro'}
</button>
</div>
<h1 class="mast-title" style="font-size: var(--font-size-xxxl)">Tipografía que se construye sola</h1>
<p class="mast-lede">
Escala fluida derivada de un ratio · familias config-driven · ejes ópticos en vivo.
Todo a través de los tokens del motor — mueve un control y mira recomponerse.
</p>
</header>
<!-- ── Escala (live) ──────────────────────────────────────────────────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">Escala</h2>
<p class="sec-note">
<code>buildTypeScale</code> · el motor de <code>applyTypeScale</code>. Un ratio modular
deriva los 8 pasos desde <code>md</code>; con fluido el ratio interpola entre 480 y 1280px.
</p>
</div>
<div class="controls">
<label class="ctrl">
<span class="ctrl-label">base <em>{base}px</em></span>
<input type="range" min="13" max="20" step="1" bind:value={base} />
</label>
<label class="ctrl">
<span class="ctrl-label">ratio <em>{ratio.toFixed(3)} · {ratioName}</em></span>
<input type="range" min="1.1" max="1.7" step="0.001" bind:value={ratio} />
</label>
<div class="ctrl chips-ctrl">
<span class="ctrl-label">presets</span>
<div class="chips">
{#each RATIOS as r}
<button class="chip" data-on={Math.abs(r.value - ratio) < 0.001} onclick={() => (ratio = r.value)}>{r.value}</button>
{/each}
</div>
</div>
<label class="ctrl switch-ctrl">
<span class="ctrl-label">fluido</span>
<input type="checkbox" bind:checked={fluid} />
</label>
{#if fluid}
<label class="ctrl">
<span class="ctrl-label">ratio máx <em>{ratioMax.toFixed(3)}</em></span>
<input type="range" min={ratio} max="1.8" step="0.001" bind:value={ratioMax} />
</label>
<label class="ctrl">
<span class="ctrl-label">viewport <em>{vw}px</em></span>
<input type="range" min="480" max="1280" step="10" bind:value={vw} />
</label>
{/if}
</div>
<div class="ladder" style={scaleVars}>
{#each scale.sizes as s}
<div class="rung">
<span class="rung-key">{s.key}</span>
<span class="rung-sample" style="font-size: var(--font-size-{s.key})">Grumpy wizards</span>
<span class="rung-val">
{#if s.minPx === s.maxPx}{s.minPx}px{:else}{pxAt(s.minPx, s.maxPx)}px <i>·{s.minPx}–{s.maxPx}</i>{/if}
</span>
</div>
{/each}
</div>
</section>
<!-- ── Estilos nombrados (reflejan la escala) ─────────────────────────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">Estilos nombrados</h2>
<p class="sec-note">
<code>hero · h1–h6 · body · caption</code> referencian <code>--font-size-*</code> por
<code>var()</code> → re-escalan con el ratio de arriba.
</p>
</div>
<div class="styles" style={scaleVars}>
{#each STYLES as st}
<div class="style-row">
<span class="style-tag">{st.label}</span>
{#if st.style === 'body' || st.style === 'caption'}
<p data-text data-style={st.style} class="style-sample">{st.sample}</p>
{:else}
<div data-heading data-style={st.style} class="style-sample">{st.sample}</div>
{/if}
</div>
{/each}
</div>
</section>
<!-- ── Familias ───────────────────────────────────────────────────────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">Familias</h2>
<p class="sec-note"><code>@font-face</code> config-driven · fallback con métricas (anti-CLS).</p>
</div>
<div class="families">
{#each FAMILIES as f}
<div class="family">
<div class="family-meta"><span class="family-label">{f.label}</span><span class="family-note">{f.note}</span></div>
<div class="family-sample" style="font-family: var(--font-family-{f.slot})">
<span class="family-aa">Aa</span>
<span class="family-glyphs">Habría jugado · 0123456789 · ¿Qué? ¡Ya!</span>
</div>
</div>
{/each}
</div>
</section>
<!-- ── Ejes (live) ────────────────────────────────────────────────────── -->
<section class="section">
<div class="sec-head">
<h2 class="sec-title">Ejes ópticos</h2>
<p class="sec-note">tracking · leading · measure · wrap · numeric — vía los <code>--_text-*</code> que lee el recipe.</p>
</div>
<div class="controls">
<div class="ctrl chips-ctrl">
<span class="ctrl-label">tracking</span>
<div class="chips">{#each TRACKINGS as t}<button class="chip" data-on={tracking === t} onclick={() => (tracking = t)}>{t}</button>{/each}</div>
</div>
<div class="ctrl chips-ctrl">
<span class="ctrl-label">leading</span>
<div class="chips">{#each LEADINGS as l}<button class="chip" data-on={leading === l} onclick={() => (leading = l)}>{l}</button>{/each}</div>
</div>
<div class="ctrl chips-ctrl">
<span class="ctrl-label">measure</span>
<div class="chips">{#each MEASURES as m}<button class="chip" data-on={measure === m} onclick={() => (measure = m)}>{m}</button>{/each}</div>
</div>
<div class="ctrl chips-ctrl">
<span class="ctrl-label">wrap</span>
<div class="chips">{#each WRAPS as w}<button class="chip" data-on={wrap === w} onclick={() => (wrap = w)}>{w}</button>{/each}</div>
</div>
<label class="ctrl switch-ctrl">
<span class="ctrl-label">tabular</span>
<input type="checkbox" bind:checked={numeric} />
</label>
</div>
<div class="axis-stage">
<p data-text data-style="prose" class="axis-sample" style={axisStyle}>
La tipografía no es lo que ves, es lo que ocurre cuando lees: 1.250 palabras, 48px de
salto, 66ch de medida. El tracking se afina, el interlineado respira, la columna se
contiene — y el texto desaparece para dejar pasar el sentido.
</p>
</div>
</section>
</div>
<style>
.root {
min-block-size: 100vh;
background: var(--color-surface-default);
color: var(--color-content-primary);
font-family: var(--font-family-primary);
padding: clamp(1.5rem, 5vw, 5rem) clamp(1.25rem, 5vw, 6rem) 8rem;
--rule: color-mix(in srgb, var(--color-border-default) 100%, transparent);
}
.masthead {
max-inline-size: 60rem;
margin-block-end: clamp(3rem, 8vw, 6rem);
}
.mast-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-block-end: 2.5rem;
}
.kicker {
margin: 0;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-content-muted);
}
.theme-toggle {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
letter-spacing: 0.06em;
color: var(--color-content-secondary);
background: var(--color-surface-raised);
border: 1px solid var(--rule);
border-radius: 999px;
padding: 0.5rem 1rem;
cursor: pointer;
}
.theme-toggle:hover { color: var(--color-content-primary); }
.mast-title {
font-family: var(--font-family-display);
font-weight: 600;
line-height: 1;
letter-spacing: -0.03em;
margin: 0 0 1.25rem;
text-wrap: balance;
}
.mast-lede {
max-inline-size: 42ch;
font-size: var(--font-size-lg);
line-height: 1.5;
color: var(--color-content-secondary);
margin: 0;
text-wrap: pretty;
}
.section { margin-block-end: clamp(3.5rem, 9vw, 7rem); }
.sec-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.75rem 2rem;
padding-block-end: 1.25rem;
margin-block-end: 2rem;
border-block-end: 1px solid var(--rule);
}
.sec-title {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--color-content-primary);
margin: 0;
}
.sec-note {
margin: 0;
max-inline-size: 56ch;
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-content-muted);
}
.sec-note code, .rung-val i, .family-note {
font-family: var(--font-family-mono);
}
.sec-note code {
font-size: 0.92em;
color: var(--color-content-secondary);
background: var(--color-surface-muted);
padding: 0.1em 0.4em;
border-radius: 4px;
}
/* Controls */
.controls {
display: flex;
flex-wrap: wrap;
gap: 1.25rem 2.5rem;
margin-block-end: 2.5rem;
padding: 1.5rem;
background: var(--color-surface-raised);
border: 1px solid var(--rule);
border-radius: 14px;
}
.ctrl { display: flex; flex-direction: column; gap: 0.6rem; min-inline-size: 13rem; }
.ctrl-label {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-content-muted);
display: flex;
justify-content: space-between;
gap: 1rem;
}
.ctrl-label em { font-style: normal; color: var(--color-content-primary); text-transform: none; letter-spacing: 0; }
input[type='range'] { width: 100%; accent-color: var(--color-primary-solid); cursor: pointer; }
.switch-ctrl { flex-direction: row; align-items: center; gap: 0.75rem; min-inline-size: 0; }
.switch-ctrl input { accent-color: var(--color-primary-solid); inline-size: 1.1rem; block-size: 1.1rem; }
.chips-ctrl { min-inline-size: 0; }
.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.chip {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
background: var(--color-surface-default);
border: 1px solid var(--rule);
border-radius: 7px;
padding: 0.3rem 0.6rem;
cursor: pointer;
}
.chip[data-on='true'] {
color: var(--color-primary-on-solid, #fff);
background: var(--color-primary-solid);
border-color: var(--color-primary-solid);
}
/* Ladder */
.ladder { display: flex; flex-direction: column; }
.rung {
display: grid;
grid-template-columns: 3.5rem 1fr auto;
align-items: baseline;
gap: 1.5rem;
padding-block: 0.6rem;
border-block-end: 1px solid color-mix(in srgb, var(--rule) 55%, transparent);
}
.rung-key {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-content-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.rung-sample {
font-family: var(--font-family-display);
line-height: 1.05;
letter-spacing: -0.01em;
color: var(--color-content-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rung-val {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
white-space: nowrap;
}
.rung-val i { color: var(--color-content-muted); font-style: normal; }
/* Named styles */
.styles { display: flex; flex-direction: column; gap: 1.75rem; }
.style-row { display: grid; grid-template-columns: 4rem 1fr; gap: 1.5rem; align-items: baseline; }
.style-tag {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-content-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding-block-start: 0.4em;
}
.style-sample { margin: 0; color: var(--color-content-primary); text-wrap: balance; }
/* Families */
.families { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); gap: 1px; background: var(--rule); border: 1px solid var(--rule); border-radius: 14px; overflow: hidden; }
.family { background: var(--color-surface-default); padding: 1.75rem; display: flex; flex-direction: column; gap: 1.1rem; }
.family-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.family-label { font-family: var(--font-family-mono); font-size: var(--font-size-xs); letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-content-primary); }
.family-note { font-size: var(--font-size-xs); color: var(--color-content-muted); }
.family-sample { display: flex; flex-direction: column; gap: 0.5rem; color: var(--color-content-primary); }
.family-aa { font-size: var(--font-size-xxl); line-height: 1; letter-spacing: -0.02em; }
.family-glyphs { font-size: var(--font-size-sm); color: var(--color-content-secondary); }
/* Axis stage */
.axis-stage {
padding: clamp(1.5rem, 4vw, 3.5rem);
background: var(--color-surface-raised);
border: 1px solid var(--rule);
border-radius: 14px;
}
.axis-sample { margin: 0; color: var(--color-content-primary); font-size: var(--font-size-lg); }
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.