You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
300 lines
16 KiB
300 lines
16 KiB
<script lang="ts">
|
|
/**
|
|
* Capstone — "Diseñando lo que ocurre" made literal: ONE interaction firing sema's channels at
|
|
* once, with a mixer to mute each and watch them layer.
|
|
*
|
|
* The channels are SEMANTIC (sema). Sema executes `sound` + `haptic` (runtime) and projects the
|
|
* `visual` channel (via `data-event-*`), which it DELEGATES to eidos to materialize — into the
|
|
* book's visual facets: color · motion · depth · forma. So those four are NOT "eidos channels":
|
|
* they are sema's visual channel, made visible by eidos. Here the visual facets are toggled as
|
|
* eidos token effects (so each facet is independently mutable); sound + haptic fire from the
|
|
* REAL `EngineSemantic.emit` (channels filtered by the mixer).
|
|
*
|
|
* The point: an interaction is not color, nor motion, nor sound — it is one perceptual event
|
|
* across sema's channels, under the two-moment model. This is the book's thesis, playable.
|
|
*/
|
|
import '@/uix/eidos/index.css'
|
|
import { EngineSemantic, type SemaChannelId } from '$uix/sema'
|
|
|
|
let theme = $state<'light' | 'dark'>('dark')
|
|
$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)
|
|
}
|
|
})
|
|
|
|
type ChannelId = 'color' | 'motion' | 'depth' | 'shape' | 'sound' | 'haptic';
|
|
type Facet = { id: ChannelId; label: string; note: string };
|
|
// sema's channels. `visual` is ONE channel that eidos materializes — shown here as its facets,
|
|
// each independently mutable. `sound` + `haptic` are sema's runtime channels.
|
|
const VISUAL_FACETS: Facet[] = [
|
|
{ id: 'color', label: 'color', note: 'tinte de intención (→ affirm)' },
|
|
{ id: 'motion', label: 'movimiento', note: 'escala bajo el dedo' },
|
|
{ id: 'depth', label: 'profundidad', note: 'la sombra recede' },
|
|
{ id: 'shape', label: 'forma', note: 'la esquina se afirma' }
|
|
];
|
|
const RUNTIME_CHANNELS: Facet[] = [
|
|
{ id: 'sound', label: 'sonido', note: 'síntesis por intención' },
|
|
{ id: 'haptic', label: 'háptica', note: 'Vibration API' }
|
|
];
|
|
|
|
const ALL_CHANNELS = [...VISUAL_FACETS, ...RUNTIME_CHANNELS];
|
|
|
|
// Visual facets on by default; sound + haptic OFF (audible/tactile side effects — sema canon).
|
|
let channels = $state<Record<ChannelId, boolean>>({
|
|
color: true,
|
|
motion: true,
|
|
depth: true,
|
|
shape: true,
|
|
sound: false,
|
|
haptic: false
|
|
});
|
|
|
|
let pressed = $state(false);
|
|
let pressCount = $state(0);
|
|
let lastFired = $state<string[]>([]);
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let targetEl = $state<HTMLElement | null>(null);
|
|
let engine: EngineSemantic | undefined;
|
|
|
|
function fire(): void {
|
|
pressCount += 1;
|
|
lastFired = ALL_CHANNELS.filter((c) => channels[c.id]).map((c) => c.label);
|
|
// Real sema engine for sound + haptic — the canonical channels, from one emit. The 4 visible
|
|
// channels stay toggleable eidos classes (independently mutable), so we exclude 'visual' from
|
|
// the emit (no data-event-* stamp → no conflict). The mixer filters which channels fire.
|
|
const ch: SemaChannelId[] = [];
|
|
if (channels.sound) ch.push('sound');
|
|
if (channels.haptic) ch.push('haptic');
|
|
if (ch.length && targetEl) {
|
|
engine ??= new EngineSemantic({ visual: false, sound: true, haptic: true });
|
|
void engine.emit({
|
|
target: targetEl,
|
|
name: 'press',
|
|
family: 'contact',
|
|
intent: 'affirm',
|
|
channels: ch
|
|
});
|
|
}
|
|
pressed = true;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => (pressed = false), 300);
|
|
}
|
|
|
|
const activeCount = $derived(Object.values(channels).filter(Boolean).length);
|
|
</script>
|
|
|
|
<svelte:head><title>La orquesta · Eidos</title></svelte:head>
|
|
|
|
<div class="root" data-theme={theme}>
|
|
<header class="masthead">
|
|
<div class="mast-row">
|
|
<p class="kicker">Eidos · diseñando lo que ocurre</p>
|
|
<button class="theme-toggle" onclick={() => (theme = theme === 'light' ? 'dark' : 'light')}>
|
|
{theme === 'light' ? '◐ claro' : '◑ oscuro'}
|
|
</button>
|
|
</div>
|
|
<h1 class="mast-title">Un evento, <em>seis canales</em></h1>
|
|
<p class="mast-lede">
|
|
Una interacción no es color, ni movimiento, ni sonido — es <strong>un solo suceso
|
|
perceptual</strong> repartido en varios canales, a la vez. Hemos construido los seis. Aquí
|
|
tocan juntos: pulsa, y <strong>silencia</strong> cada uno para oír su parte.
|
|
</p>
|
|
</header>
|
|
|
|
<!-- ── La orquesta ──────────────────────────────────────────────────── -->
|
|
<section class="section">
|
|
<div class="sec-head">
|
|
<h2 class="sec-title">El mezclador</h2>
|
|
<p class="sec-note">
|
|
Pulsa: los canales de <em>sema</em> disparan desde <em>un</em> evento. Sema proyecta el
|
|
canal <em>visual</em> —que eidos materializa en color · movimiento · profundidad · forma— y
|
|
ejecuta <em>sonido</em> + <em>háptica</em>. Apaga la forma y la esquina deja de afirmarse;
|
|
apaga la profundidad y la sombra no recede. Un instrumento menos, el mismo gesto.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="stage">
|
|
<div class="stage-target">
|
|
<button
|
|
bind:this={targetEl}
|
|
class="target"
|
|
class:ch-color={channels.color}
|
|
class:ch-motion={channels.motion}
|
|
class:ch-depth={channels.depth}
|
|
class:ch-shape={channels.shape}
|
|
data-shape={channels.shape ? 'continuous' : undefined}
|
|
data-pressed={pressed}
|
|
onpointerdown={fire}
|
|
>Confirmar</button>
|
|
<span class="stage-hint">pulsa →</span>
|
|
</div>
|
|
|
|
<div class="mixer">
|
|
<span class="mixer-group">canal <b>visual</b> · sema proyecta → eidos materializa</span>
|
|
{#each VISUAL_FACETS as c}
|
|
<button class="track" data-on={channels[c.id]} onclick={() => (channels[c.id] = !channels[c.id])}>
|
|
<span class="track-led" data-on={channels[c.id]}></span>
|
|
<span class="track-meta">
|
|
<span class="track-label">{c.label}</span>
|
|
<span class="track-note">{c.note}</span>
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
<span class="mixer-group">canales <b>runtime</b> · sema ejecuta</span>
|
|
{#each RUNTIME_CHANNELS as c}
|
|
<button class="track" data-on={channels[c.id]} onclick={() => (channels[c.id] = !channels[c.id])}>
|
|
<span class="track-led" data-on={channels[c.id]}></span>
|
|
<span class="track-meta">
|
|
<span class="track-label">{c.label}</span>
|
|
<span class="track-note">{c.note}</span>
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="trace">
|
|
<span class="trace-k">último evento</span>
|
|
<span>1 press → {lastFired.length} canal(es){lastFired.length ? `: ${lastFired.join(' · ')}` : ' — pulsa'}</span>
|
|
<span class="trace-sep">·</span>
|
|
<span class="trace-k">activos</span><span>{activeCount}/6</span>
|
|
<span class="trace-sep">·</span>
|
|
<span class="trace-k">presses</span><span>{pressCount}</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Los dos momentos ─────────────────────────────────────────────── -->
|
|
<section class="section">
|
|
<div class="sec-head">
|
|
<h2 class="sec-title">Dos momentos: sema emite, eidos lee</h2>
|
|
<p class="sec-note">
|
|
El suceso fluye por dos momentos unidos por un <em>token</em>. Sema lo <em>emite</em>; eidos
|
|
lo <em>lee</em> y lo materializa. Sema no conoce el DOM; eidos es el único dueño visual. El
|
|
token (<code>data-event-*</code>) es el contrato entre ambos.
|
|
</p>
|
|
</div>
|
|
<div class="moments">
|
|
<div class="moment">
|
|
<span class="moment-h">momento de <b>sema</b> · emite</span>
|
|
<p class="moment-p">Evalúa el suceso —familia · intención · fase— y lo estampa como tokens <code>data-event-*</code>. Sonido + háptica los ejecuta aquí mismo. No toca el DOM/CSS.</p>
|
|
</div>
|
|
<div class="moment moment-event">
|
|
<span class="moment-h">momento de <b>eidos</b> · lee</span>
|
|
<p class="moment-p">Lee esos tokens (+ <code>data-state</code>) y los materializa en CSS — el canal visual: el tinte, la sombra que recede, la esquina que se afirma, la escala.</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<footer class="footnote">
|
|
<p>
|
|
<strong>Todo es sema; los canales son semánticos.</strong> Un evento sema reparte la firma por
|
|
sus canales: el canal <strong>visual</strong> —que sema proyecta vía <code>data-event-*</code>
|
|
y <strong>delega en eidos</strong> para materializarlo en color · movimiento · profundidad ·
|
|
forma— y los canales <strong>sound</strong> + <strong>haptic</strong> que sema ejecuta. No son
|
|
"canales de eidos": son el canal visual de sema, hecho visible por eidos. Aquí los disparamos
|
|
todos desde un <code>EngineSemantic.emit</code> real (sonido + háptica) + los tokens de eidos
|
|
para el visual, bajo el modelo de dos momentos. Ningún referente reúne los canales sobre un
|
|
modelo semántico único.
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
|
|
<style>
|
|
.root {
|
|
min-block-size: 100vh;
|
|
background:
|
|
radial-gradient(130% 90% at 50% -15%, var(--color-surface-raised), transparent 55%),
|
|
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) 6rem;
|
|
--rule: var(--color-border-default);
|
|
}
|
|
|
|
.masthead { max-inline-size: 56rem; 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); 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-size: var(--font-size-xxxl); font-weight: 600; line-height: 1; letter-spacing: -0.03em; margin: 0 0 1.25rem; text-wrap: balance; }
|
|
.mast-title em { font-style: italic; color: var(--color-primary-text, var(--color-content-primary)); }
|
|
.mast-lede { max-inline-size: 52ch; font-size: var(--font-size-lg); line-height: 1.5; color: var(--color-content-secondary); margin: 0; text-wrap: pretty; }
|
|
.mast-lede strong { color: var(--color-content-primary); font-weight: 600; }
|
|
|
|
.section { margin-block-end: clamp(3.5rem, 9vw, 7rem); }
|
|
.sec-head { margin-block-end: 2.5rem; }
|
|
.sec-title { font-family: var(--font-family-display); font-size: var(--font-size-xl); font-weight: 600; letter-spacing: -0.01em; margin: 0 0 0.5rem; }
|
|
.sec-note { margin: 0; max-inline-size: 64ch; font-size: var(--font-size-sm); line-height: 1.55; color: var(--color-content-muted); }
|
|
.sec-note em { color: var(--color-content-primary); font-style: normal; font-weight: 600; }
|
|
code { font-family: var(--font-family-mono); font-size: 0.9em; color: var(--color-content-secondary); background: var(--color-surface-muted); padding: 0.1em 0.4em; border-radius: 4px; }
|
|
|
|
/* The orchestra */
|
|
.stage { display: grid; grid-template-columns: 1fr; gap: clamp(1.5rem, 4vw, 3rem); align-items: center; }
|
|
@media (min-width: 50rem) { .stage { grid-template-columns: 1fr 22rem; } }
|
|
.stage-target { display: flex; flex-direction: column; align-items: center; gap: 1.25rem; min-block-size: 16rem; justify-content: center; border-radius: var(--radius-lg); border: 1px solid color-mix(in srgb, var(--rule) 60%, transparent); background: var(--color-surface-default); padding: clamp(2rem, 5vw, 4rem); }
|
|
.stage-hint { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-content-muted); }
|
|
|
|
/* the target — each channel's effect gated by its class + [data-pressed], all transitioning
|
|
together from one event. --shape-smoothing transitions because it's a registered @property. */
|
|
.target {
|
|
font-family: var(--font-family-primary);
|
|
font-size: var(--font-size-lg);
|
|
font-weight: 600;
|
|
padding: 1.1rem 2.5rem;
|
|
border: 0;
|
|
border-radius: 22px;
|
|
background: var(--color-primary-solid);
|
|
color: var(--color-primary-on-solid, #fff);
|
|
cursor: pointer;
|
|
transition:
|
|
transform 150ms var(--ease-out, cubic-bezier(0.2, 0, 0, 1)),
|
|
box-shadow 150ms var(--ease-out, ease),
|
|
background-color 150ms ease,
|
|
--shape-smoothing 150ms ease;
|
|
will-change: transform, box-shadow;
|
|
}
|
|
.target.ch-depth { box-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo); }
|
|
.target.ch-motion[data-pressed='true'] { transform: scale(0.93); }
|
|
.target.ch-depth[data-pressed='true'] { box-shadow: var(--depth-raised-shadow); }
|
|
.target.ch-shape[data-pressed='true'] { --shape-smoothing: 3.4; }
|
|
.target.ch-color[data-pressed='true'] { background: var(--color-affirm-solid, var(--color-primary-solid)); }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.target { transition: background-color 150ms ease; }
|
|
.target[data-pressed='true'] { transform: none; }
|
|
}
|
|
|
|
.mixer { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
.track { display: flex; align-items: center; gap: 0.85rem; padding: 0.7rem 0.9rem; border-radius: var(--radius-md); border: 1px solid var(--rule); background: var(--color-surface-raised); cursor: pointer; text-align: start; transition: border-color 160ms, background 160ms; }
|
|
.track[data-on='false'] { opacity: 0.5; }
|
|
.track[data-on='true']:hover { border-color: color-mix(in srgb, var(--color-primary-solid) 40%, var(--rule)); }
|
|
.track-led { inline-size: 0.7rem; block-size: 0.7rem; border-radius: 50%; flex: none; background: var(--color-surface-muted); border: 1px solid var(--rule); transition: background 160ms, box-shadow 160ms; }
|
|
.track-led[data-on='true'] { background: var(--color-primary-solid); border-color: var(--color-primary-solid); box-shadow: 0 0 8px color-mix(in srgb, var(--color-primary-solid) 60%, transparent); }
|
|
.track-meta { display: flex; flex-direction: column; gap: 0.1rem; flex: 1; min-inline-size: 0; }
|
|
.track-label { font-family: var(--font-family-mono); font-size: var(--font-size-sm); color: var(--color-content-primary); }
|
|
.track-note { font-size: var(--font-size-xs); color: var(--color-content-muted); }
|
|
.mixer-group { font-family: var(--font-family-mono); font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--color-content-muted); margin-block-start: 0.5rem; padding-inline: 0.2rem; }
|
|
.mixer-group:first-child { margin-block-start: 0; }
|
|
.mixer-group b { color: var(--color-content-secondary); font-weight: 600; }
|
|
|
|
.trace { margin-block-start: 2rem; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-content-secondary); padding-block-start: 1.25rem; border-block-start: 1px solid color-mix(in srgb, var(--rule) 60%, transparent); }
|
|
.trace-k { color: var(--color-content-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
.trace-sep { color: var(--color-content-faint, var(--color-content-muted)); }
|
|
|
|
/* Two moments */
|
|
.moments { display: grid; grid-template-columns: 1fr; gap: 1.25rem; }
|
|
@media (min-width: 44rem) { .moments { grid-template-columns: 1fr 1fr; } }
|
|
.moment { padding: 1.5rem; border-radius: var(--radius-lg); border: 1px solid var(--rule); background: var(--color-surface-raised); display: flex; flex-direction: column; gap: 0.6rem; }
|
|
.moment-event { background: var(--color-surface-overlay); box-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo); }
|
|
.moment-h { font-family: var(--font-family-mono); font-size: var(--font-size-xs); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-content-secondary); }
|
|
.moment-p { margin: 0; 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 p { margin: 0; font-size: var(--font-size-sm); line-height: 1.6; color: var(--color-content-muted); text-wrap: pretty; }
|
|
.footnote strong { color: var(--color-content-primary); font-weight: 600; }
|
|
</style>
|