feat(temas): color system demo at /temas/color (motion-demo style)

Palette (31 scales x 12 steps), the 9 canonical roles with their slots, the
APCA on-solid pick live via uix.color, translucent role surfaces, and the
neutral chrome. Rendered through live CSS custom properties with a light/dark
toggle. Verified in-browser: swatches resolve, risk flips to dark text, modes
invert. Not linked from the /temas index (it is a system demo, not a brand
theme — like /temas/animations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 2d1ccfbeab
commit 71f97cc470

@ -0,0 +1,596 @@
<script lang="ts">
/**
* Color demo — the Eidos color system, same shape as the Motion demo.
*
* Shows every layer of the model, all rendered through the live CSS custom
* properties (so they reflect the active theme + light/dark, and will reflect
* the OKLCH/P3 output once it lands):
* - the PALETTE: 31 functional scales × 12 steps (`--scale-{name}-{step}`);
* - the 9 canonical ROLES (hierarchy = brand alias, intents = auto-derived by
* the book's convention) and their 9 slots (`--color-{role}-{slot}`);
* - the on-solid TEXT pick by APCA — computed live via the `uix.color` engine,
* the same metric `render-css` uses (white unless it fails the floor);
* - translucent ROLE SURFACES (compositing-inverse alpha) over a busy backdrop;
* - the neutral CHROME semantics (surface / content / border).
*
* The eidos foundation CSS (tokens + base theme blocks) is imported statically;
* `data-theme` on `.root` activates light/dark. No portals here, so no need to
* project to documentElement.
*/
import '@/uix/eidos/index.css'
import {
THEME_BASE_COLOR_ROLES,
THEME_BASE_LIGHT_COLOR_SCALES,
THEME_BASE_DARK_COLOR_SCALES
} from '$uix/eidos/lib/themes/base'
import {
COLOR_SCALE_STEPS,
COLOR_ROLE_SLOTS,
COLOR_ROLES,
HIERARCHY_COLOR_ROLES,
completeColorRoleMap,
type ColorRole
} from '$uix/eidos/lib/config-types'
import { INTENTS } from '$uix/intent'
import { apcaLc, oklchToGammaRgb, parseColor, wcagContrastRatio } from '$color'
let theme = $state<'light' | 'dark'>('light')
const SCALE_NAMES = Object.keys(THEME_BASE_LIGHT_COLOR_SCALES)
const ROLES = completeColorRoleMap(THEME_BASE_COLOR_ROLES)
const STEPS = COLOR_SCALE_STEPS
const SLOTS = COLOR_ROLE_SLOTS
const GROUPS: { label: string; note: string; roles: readonly ColorRole[] }[] = [
{ label: 'Jerarquía', note: 'alias de marca · el tema elige la escala', roles: HIERARCHY_COLOR_ROLES },
{ label: 'Intents', note: 'auto-derivados por convención del libro · identidad = paso 9', roles: INTENTS }
]
const roleScale = (role: ColorRole): string => {
const def = ROLES[role]
return typeof def === 'string' ? def : def.scale
}
const slotVar = (slot: string): string => slot.replace(/([A-Z])/g, '-$1').toLowerCase()
// Scales for the active mode — drives the APCA computation from real hex.
const activeScales = $derived(
theme === 'dark' ? THEME_BASE_DARK_COLOR_SCALES : THEME_BASE_LIGHT_COLOR_SCALES
)
const ON_SOLID = '#ffffff'
const ON_SOLID_DARK = '#1c1917'
// Mirror render-css's policy: white stays unless it fails APCA |Lc| < 60 OR
// WCAG < 3 on this role's solid; then the slot flips to the dark on-solid.
function onSolidInfo(role: ColorRole): { picked: 'white' | 'dark'; lc: number; wcag: number } {
const hex = activeScales[roleScale(role)]?.['9'] ?? '#888888'
const solid = oklchToGammaRgb(parseColor(hex))
const white = oklchToGammaRgb(parseColor(ON_SOLID))
const lcWhite = apcaLc(white, solid)
const wcagWhite = wcagContrastRatio(white, solid)
const flips = Math.abs(lcWhite) < 60 || wcagWhite < 3
if (!flips) return { picked: 'white', lc: lcWhite, wcag: wcagWhite }
const dark = oklchToGammaRgb(parseColor(ON_SOLID_DARK))
return { picked: 'dark', lc: apcaLc(dark, solid), wcag: wcagContrastRatio(dark, solid) }
}
const SURFACES = ['default', 'raised', 'overlay', 'muted'] as const
const CONTENTS = ['primary', 'secondary', 'muted', 'disabled'] as const
const BORDERS = ['subtle', 'default', 'strong'] as const
</script>
<svelte:head>
<title>Color · Eidos</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap"
/>
</svelte:head>
<div class="root" data-theme={theme}>
<header class="topbar">
<div class="brand">
<strong>Color</strong>
<small>Eidos · paleta → roles → intents · motor uix.color</small>
</div>
<div class="controls">
<div class="seg" role="group" aria-label="Tema">
<button type="button" data-active={theme === 'light'} onclick={() => (theme = 'light')}>
Light
</button>
<button type="button" data-active={theme === 'dark'} onclick={() => (theme = 'dark')}>
Dark
</button>
</div>
</div>
</header>
<main class="canvas">
<p class="lede">
El color tiene <strong>tres capas</strong>: una <strong>paleta</strong> de 31 escalas
funcionales de 12 pasos (<code>--scale-*</code>), unos <strong>roles</strong> de jerarquía que
son alias de marca, y unos <strong>intents</strong> evaluativos que
<strong>auto-derivan</strong> de la paleta por convención del libro. Los componentes consumen los
<strong>9 slots</strong> (<code>--color-{'{role}'}-{'{slot}'}</code>). El motor
<code>uix.color</code> (OKLCH · APCA · alpha compositing-inverse) decide lo perceptual — abajo, el
texto on-solid lo elige <strong>APCA en vivo</strong>. Todo se renderiza vía las custom properties
activas, así que cambia con el tema.
</p>
<!-- La paleta — 31 escalas × 12 pasos -->
<section class="preset">
<div class="head">
<span class="eyebrow">--scale-&lbrace;name&rbrace;-&lbrace;1..12&rbrace;</span>
<h2>La paleta <small>(31 escalas · 12 pasos)</small></h2>
<p>
Escalas funcionales (1-2 fondos · 3-5 componente · 6-8 bordes · <strong>9 sólido</strong> ·
10 hover · 11-12 texto). El <strong>representativo</strong> de cada escala es el
<strong>paso 9</strong>. La paleta la trae el tema; el framework envía estas 31 (valores
Radix exactos, listos para autoría OKLCH).
</p>
</div>
<div class="palette">
<div class="scale-row step-head">
<span class="scale-name"></span>
<div class="swatches">
{#each STEPS as s}
<span class="step-num" class:nine={s === '9'}>{s}</span>
{/each}
</div>
</div>
{#each SCALE_NAMES as name}
<div class="scale-row">
<span class="scale-name">{name}</span>
<div class="swatches">
{#each STEPS as s}
<span
class="sw"
class:nine={s === '9'}
style="background: var(--scale-{name}-{s})"
title="--scale-{name}-{s}"
></span>
{/each}
</div>
</div>
{/each}
</div>
</section>
<!-- Roles canónicos — 9 roles × 9 slots -->
<section class="preset">
<div class="head">
<span class="eyebrow">--color-&lbrace;role&rbrace;-&lbrace;slot&rbrace;</span>
<h2>Roles canónicos <small>(9 roles · 9 slots)</small></h2>
<p>
Los 3 roles de <strong>jerarquía</strong> (el tema los mapea a una escala) y los 6
<strong>intents</strong> (auto-derivados: <code>neutral→gray · affirm→teal · fulfill→green ·
risk→amber · threat→red · loss→plum</code>). Cada rol expone los 9 slots
(<code>track · element · hover · active · border · solid · solid-hover · text · contrast</code>).
</p>
</div>
{#each GROUPS as group}
<div class="role-group">
<h3>{group.label} <small>{group.note}</small></h3>
{#each group.roles as role}
<div class="role-row">
<span class="role-name">
<strong>{role}</strong>
<small>{roleScale(role)}</small>
</span>
<div class="swatches slots">
{#each SLOTS as slot}
<span
class="sw"
style="background: var(--color-{role}-{slotVar(slot)})"
title="--color-{role}-{slotVar(slot)}"
></span>
{/each}
</div>
</div>
{/each}
</div>
{/each}
<div class="slot-legend">
{#each SLOTS as slot}<span>{slotVar(slot)}</span>{/each}
</div>
</section>
<!-- Texto on-solid — APCA pick, live via uix.color -->
<section class="preset">
<div class="head">
<span class="eyebrow">uix.color · pickOnSolid (APCA)</span>
<h2>Texto on-solid <small>(elegido por APCA · en vivo)</small></h2>
<p>
El color del texto SOBRE el sólido (<code>--color-{'{role}'}-contrast</code>). El motor
prefiere <strong>blanco</strong> salvo que falle el suelo APCA (|Lc| &lt; 60) o WCAG (&lt; 3)
— entonces voltea a <strong>oscuro</strong>. El chip usa los tokens REALES; el pie muestra
el cálculo APCA en vivo. En el tema base solo <strong>risk</strong> (naranja) voltea.
</p>
</div>
<div class="onsolid-grid">
{#each COLOR_ROLES as role}
{@const info = onSolidInfo(role)}
<div
class="chip"
style="background: var(--color-{role}-solid); color: var(--color-{role}-contrast)"
>
<strong>{role}</strong>
<small>on-solid: {info.picked} · |Lc| {Math.abs(info.lc).toFixed(0)} · WCAG {info.wcag.toFixed(2)}</small>
</div>
{/each}
</div>
</section>
<!-- Superficies translúcidas — compositing-inverse alpha -->
<section class="preset">
<div class="head">
<span class="eyebrow">--color-&lbrace;role&rbrace;-surface · alpha</span>
<h2>Superficies tintadas <small>(alpha compositing-inverse)</small></h2>
<p>
El tinte de la variante <code>soft</code> (<code>--color-{'{role}'}-surface</code>) es
<strong>translúcido</strong> (alpha derivado por inversión de composición), así que
<strong>compone</strong> sobre fondos no uniformes en vez de taparlos. Sobre las rayas se ve
el hue, no un bloque opaco.
</p>
</div>
<div class="alpha-stage">
{#each COLOR_ROLES as role}
<span class="surface-chip" style="background: var(--color-{role}-surface)">
{role}
</span>
{/each}
</div>
</section>
<!-- Chrome semántico — surface / content / border -->
<section class="preset">
<div class="head">
<span class="eyebrow">--color-surface/content/border-*</span>
<h2>Chrome semántico <small>(neutral · superficies / texto / bordes)</small></h2>
<p>
Los tokens neutrales que visten la UI: las <strong>superficies</strong> (fondos por
elevación), el <strong>texto</strong> por jerarquía y los <strong>bordes</strong> por fuerza.
Se invierten solos entre light y dark.
</p>
</div>
<div class="chrome">
<div class="chrome-col">
<span class="chrome-label">surface</span>
{#each SURFACES as s}
<div class="surface-row" style="background: var(--color-surface-{s})">
<span>{s}</span>
</div>
{/each}
</div>
<div class="chrome-col">
<span class="chrome-label">content</span>
{#each CONTENTS as c}
<div class="content-row" style="color: var(--color-content-{c})">Aa · {c}</div>
{/each}
</div>
<div class="chrome-col">
<span class="chrome-label">border</span>
{#each BORDERS as b}
<div class="border-row" style="border-color: var(--color-border-{b})">{b}</div>
{/each}
</div>
</div>
</section>
</main>
</div>
<style>
.root {
min-height: 100dvh;
background: var(--color-surface-default);
color: var(--color-content-primary);
font-family: var(--font-family-primary, 'Inter', system-ui, sans-serif);
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-6);
background: color-mix(in srgb, var(--color-surface-default) 86%, transparent);
backdrop-filter: blur(8px);
border-block-end: 1px solid var(--color-border-subtle);
}
.brand {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.brand strong {
font-size: var(--font-size-lg);
font-weight: 700;
}
.brand small {
color: var(--color-content-muted);
font-size: var(--font-size-xs);
}
.controls {
display: flex;
align-items: center;
gap: var(--space-4);
}
.seg {
display: inline-flex;
padding: 2px;
gap: 2px;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
}
.seg button {
appearance: none;
border: 0;
cursor: pointer;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-content-secondary);
font: inherit;
font-size: var(--font-size-sm);
}
.seg button[data-active='true'] {
background: var(--color-primary-solid);
color: var(--color-primary-contrast);
}
.canvas {
max-width: 920px;
margin-inline: auto;
padding: var(--space-8) var(--space-6) var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.lede {
color: var(--color-content-secondary);
font-size: var(--font-size-md);
line-height: 1.6;
max-width: 72ch;
}
.preset {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
}
.head h2 {
margin: var(--space-1) 0 var(--space-2);
font-size: var(--font-size-xl);
font-weight: 700;
}
.head h2 small {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-content-muted);
}
.head p {
margin: 0;
color: var(--color-content-secondary);
line-height: 1.55;
max-width: 68ch;
}
.role-group h3 {
margin: 0 0 var(--space-2);
font-size: var(--font-size-md);
font-weight: 600;
}
.role-group h3 small {
font-weight: 400;
color: var(--color-content-muted);
font-size: var(--font-size-xs);
}
.eyebrow {
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: var(--font-size-xs);
letter-spacing: 0.02em;
color: var(--color-primary-text, var(--color-content-muted));
}
code {
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: 0.88em;
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-content-muted) 12%, transparent);
}
/* Palette grid */
.palette {
display: flex;
flex-direction: column;
gap: 3px;
}
.scale-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.scale-name {
flex: none;
inline-size: 84px;
text-align: end;
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
.swatches {
display: flex;
flex: 1;
gap: 0;
min-inline-size: 0;
}
.sw {
flex: 1;
block-size: 26px;
min-inline-size: 0;
}
.swatches .sw:first-child {
border-start-start-radius: var(--radius-sm);
border-end-start-radius: var(--radius-sm);
}
.swatches .sw:last-child {
border-start-end-radius: var(--radius-sm);
border-end-end-radius: var(--radius-sm);
}
.sw.nine {
position: relative;
z-index: 1;
box-shadow: inset 0 0 0 2px var(--color-surface-default);
}
.step-head .step-num {
flex: 1;
text-align: center;
font-size: 10px;
color: var(--color-content-muted);
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
}
.step-head .step-num.nine {
color: var(--color-content-primary);
font-weight: 700;
}
/* Roles */
.role-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.role-group + .role-group {
margin-block-start: var(--space-4);
}
.role-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.role-name {
flex: none;
inline-size: 84px;
display: flex;
flex-direction: column;
line-height: 1.15;
}
.role-name strong {
font-size: var(--font-size-sm);
font-weight: 600;
}
.role-name small {
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: 10px;
color: var(--color-content-muted);
}
.slots .sw {
block-size: 34px;
}
.slot-legend {
display: flex;
gap: 0;
margin-inline-start: calc(84px + var(--space-3));
}
.slot-legend span {
flex: 1;
text-align: center;
font-size: 9px;
color: var(--color-content-muted);
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* On-solid chips */
.onsolid-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-2);
}
.chip {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
box-shadow: var(--shadow-2);
}
.chip strong {
font-size: var(--font-size-sm);
font-weight: 700;
}
.chip small {
font-size: 10px;
opacity: 0.85;
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
}
/* Translucent surfaces over a busy backdrop */
.alpha-stage {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-4);
border-radius: var(--radius-lg);
background:
repeating-linear-gradient(
45deg,
color-mix(in srgb, var(--color-content-muted) 22%, transparent) 0 10px,
transparent 10px 20px
),
var(--color-surface-default);
}
.surface-chip {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-content-primary);
}
/* Chrome semantics */
.chrome {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-4);
}
.chrome-col {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.chrome-label {
font-family: var(--font-family-mono, 'Roboto Mono', ui-monospace, monospace);
font-size: var(--font-size-xs);
color: var(--color-content-muted);
}
.surface-row {
padding: var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-subtle);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
.content-row {
font-size: var(--font-size-md);
font-weight: 600;
}
.border-row {
padding: var(--space-3);
border-radius: var(--radius-md);
border: 2px solid var(--color-border-default);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
</style>

@ -0,0 +1,2 @@
// Live demo of the Eidos color system — no static prerender.
export const prerender = false
Loading…
Cancel
Save

Powered by TurnKey Linux.