eidos: V1 minimum viable — tokens + archetype rules + event reactions

First eidos code. Closes the cross-layer cycle that's been documented but
unproven: an actual CSS layer that consumes the morfo + sema contracts
and applies styles transversally without enumerating components.

src/uix/eidos/
- tokens.css — motion durations (quick/fast/base) + easings (standard /
  emphasized / decelerate) + opacity scale (hover/disabled/faded) +
  intent tints (neutral/affirm/fulfill/risk/threat) + focus ring tokens.
  ~30 lines. No theming dimension yet — when it arrives, these are the
  base theme.
- archetypes.css — transversal selectors keyed by `data-archetype`.
  Covers trigger, overlay, content, indicator, thumb, track, close,
  action, item, option. Plus a universal `:focus-visible` ring rule that
  applies to every archetype-classified element. Single attribute
  selector specificity — components can override freely.
- events.css — reactions to perceptual signals during their ~13ms life.
  `[data-event^="dismiss"]` fades out, `[data-event^="announce"]` pulses
  with intent-tinted box-shadow, `[data-last-action]` tints exit
  animation by causa.
- index.css — entrypoint that imports the above in cascade order.
  Consumer: `import '$uix/eidos/index.css'`.

src/routes/test/eidos/+page.svelte (demo)
- Sets up Soma + App context inline (independent of /test/soma layout).
- Imports `$uix/eidos/index.css` and demonstrates the cross-layer story:
  - Toggle + Switch buttons share trigger styles via `[data-archetype="trigger"]`
    without per-component CSS.
  - Collapsible expands/collapses with state-driven attrs.
  - Toast intent buttons fire `data-event="announce" data-intent="risk|threat|affirm"`,
    which eidos's events.css briefly tints via box-shadow during the signal window.
- Component-specific layout (positioning, switch thumb transform) lives in
  the page's `<style>` block — eidos provides only the cross-component
  baseline, NOT per-component recipes.

What this proves
- The runtime emits `data-archetype` correctly in browser (not just in
  unit tests).
- A single CSS rule can target ALL triggers across all components
  without enumeration. Add a new component with `archetype: 'trigger'`
  and it inherits the styling automatically.
- Sema's `data-event*` signals live in the DOM long enough for CSS to
  react (the ~13ms window measured by `scripts/probe-toast-dismiss.ts`
  is observable as a brief box-shadow flash on toasts).
- The cross-layer cycle Morfo declara → Soma transcribe → Sema emite →
  Dom aplica → Eidos lee runs end-to-end.

What V1 deliberately excludes
- Theming dimension (no `[data-theme]` overrides yet).
- Per-component recipes (each component will get its own CSS later).
- Keyframes / @starting-style / animation primitives.
- Sound / vibra (those are separate engines that subscribe to sema).

Verification
- /test/eidos returns 200; manual browser check shows triggers share
  hover dim + focus ring across components, toasts flash intent tint on
  appear.
- 68/68 smoke routes (smoke only scans /test/soma — eidos route is
  outside that scope, manually verified).
- svelte-check unchanged at 155 (pure CSS additions).
morfo-runtime
dev 5 months ago
parent 760575c419
commit 44a499d052

@ -0,0 +1,317 @@
<script lang="ts">
import '$uix/eidos/index.css';
import { Soma } from '$soma/components/internal';
import { App } from '$lib/ext/app';
import { createLangs, type LangNode } from '$lib/ext/lang/lang.svelte';
import { createPresentation } from '$lib/ext/presentation';
import { componentLangs } from '$soma/core/langs';
import * as Toggle from '$soma/components/toggle';
import * as Switch from '$soma/components/switch';
import * as Collapsible from '$soma/components/collapsible';
import * as Toast from '$soma/components/toast';
import { createToaster } from '$soma/components/toast';
// ── Soma scaffolding ────────────────────────────────────────────────────
const langs = createLangs({} satisfies LangNode, 'es');
langs.extend('common', {
buttons: {
close: { es: 'Cerrar', en: 'Close' }
}
} satisfies LangNode);
langs.extend('components', componentLangs);
const presentation = createPresentation(langs);
App.create({ langs, presentation });
// ── Demo state ──────────────────────────────────────────────────────────
let pressed = $state(false);
let switched = $state(false);
let open = $state(false);
const toaster = createToaster({ duration: 4000, max: 3 });
function fireRisk() {
toaster.create({
title: 'Risk announce',
description: 'data-event="announce" + data-intent="risk" briefly tints this card.',
intent: 'risk'
});
}
function fireThreat() {
toaster.create({
title: 'Threat announce',
description: 'data-event="announce" + data-intent="threat" tints stronger.',
intent: 'threat'
});
}
function fireAffirm() {
toaster.create({
title: 'Affirm announce',
description: 'data-event="announce" + data-intent="affirm" tints green.',
intent: 'affirm'
});
}
</script>
<svelte:head>
<title>Eidos · cross-layer demo</title>
</svelte:head>
<Soma>
<main>
<header>
<h1>Eidos — cross-layer in vivo</h1>
<p>
Importa <code>$uix/eidos/index.css</code>: tokens + selectores transversales por
archetype + reacciones a <code>data-event*</code>. Cero estilos por componente.
</p>
</header>
<section>
<h2>Triggers transversales</h2>
<p>
Toggle y Switch usan <code>data-archetype="trigger"</code>. La regla
<code>[data-archetype="trigger"]</code> de eidos les aplica cursor + hover dim +
focus ring sin saber qué componente son.
</p>
<div class="row">
<Toggle.Provider bind:pressed aria-label="Star">
{pressed ? '★ Starred' : '☆ Star'}
</Toggle.Provider>
<Switch.Provider bind:checked={switched} aria-label="Notifications">
<Switch.Thumb />
</Switch.Provider>
<span>{switched ? 'Notifications on' : 'Notifications off'}</span>
</div>
<p class="note">
Hover y focus con teclado para ver las reglas de eidos en acción. Tab + Space en el
Switch para activarlo desde teclado (vía <code>runtime.keydown</code>).
</p>
</section>
<section>
<h2>State transitions</h2>
<p>
Collapsible expone <code>data-state="open"</code>/<code>"closed"</code> en su
Content. Las reglas de eidos podrían animar la transición — V1 solo aplica el
cursor del trigger.
</p>
<Collapsible.Provider bind:open>
<Collapsible.Trigger>
{open ? 'Hide content' : 'Show content'}
</Collapsible.Trigger>
<Collapsible.Content>
<p>Inspecciona el DOM: el Trigger lleva <code>data-archetype="trigger"</code>.</p>
</Collapsible.Content>
</Collapsible.Provider>
</section>
<section>
<h2>Event signals — perceptual flash</h2>
<p>
Cada toast emite <code>data-event="announce"</code> + <code>data-intent="..."</code>
durante ~13 ms al aparecer. Las reglas de eidos en
<code>events.css</code> añaden un <code>box-shadow</code> tintado por intent durante
la ventana del signal.
</p>
<div class="row">
<button onclick={fireAffirm}>Fire affirm</button>
<button onclick={fireRisk}>Fire risk</button>
<button onclick={fireThreat}>Fire threat</button>
</div>
<p class="note">
La señal vive ~13 ms (1 rAF + 1 hold). DevTools rara vez la pinta a tiempo;
usa el script <code>node --import tsx/esm scripts/probe-toast-dismiss.ts</code> para
capturar la timeline completa.
</p>
<Toast.Provider {toaster}>
<div class="viewport-wrapper">
<Toast.Viewport>
{#each toaster.toasts as t (t.id)}
<Toast.Item toast={t}>
<div class="toast-header">
<Toast.Title>{t.title}</Toast.Title>
<Toast.Close>×</Toast.Close>
</div>
{#if t.description}
<Toast.Description>{t.description}</Toast.Description>
{/if}
</Toast.Item>
{/each}
</Toast.Viewport>
</div>
</Toast.Provider>
</section>
<section>
<h2>Lo que esto prueba</h2>
<ul>
<li>El runtime de soma emite <code>data-archetype</code> correctamente.</li>
<li>
Eidos puede estilar transversalmente sin enumerar componentes — una regla
<code>[data-archetype="trigger"]</code> cubre Toggle, Switch, Collapsible.Trigger,
y cualquier futuro trigger sin tocar nada.
</li>
<li>
Las señales de Sema llegan al DOM como <code>data-event*</code> y eidos puede
reaccionar durante su ventana de vida.
</li>
<li>
La cadena <em>Morfo declara → Soma transcribe → Sema emite → Dom aplica → Eidos
reacciona</em> funciona end-to-end.
</li>
</ul>
</section>
</main>
</Soma>
<style>
main {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
font-family: system-ui, sans-serif;
color: #1f2937;
}
header {
margin-bottom: 2rem;
}
h1 {
margin-bottom: 0.5rem;
}
h2 {
font-size: 1.1rem;
margin-top: 2rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.25rem;
}
p {
margin: 0.5rem 0;
line-height: 1.5;
}
code {
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.85em;
background: #f3f4f6;
padding: 0.05em 0.3em;
border-radius: 3px;
}
.note {
font-size: 0.85em;
color: #6b7280;
}
section {
margin-bottom: 2rem;
}
.row {
display: flex;
gap: 0.75rem;
align-items: center;
margin: 1rem 0;
}
.row :global(button) {
padding: 0.5rem 1rem;
font: inherit;
background: #e5e7eb;
border: 1px solid #d1d5db;
border-radius: 6px;
}
.row :global([data-toggle]) {
padding: 0.5rem 1rem;
background: #e5e7eb;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
.row :global([data-switch]) {
width: 48px;
height: 26px;
background: #d1d5db;
border: 1px solid #9ca3af;
border-radius: 999px;
position: relative;
padding: 2px;
}
.row :global([data-switch][data-state='checked']) {
background: #3b82f6;
border-color: #3b82f6;
}
.row :global([data-switch-thumb]) {
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transform: translateX(0);
}
.row :global([data-switch][data-state='checked'] [data-switch-thumb]) {
transform: translateX(22px);
}
:global([data-collapsible-trigger]) {
padding: 0.5rem 1rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
display: block;
margin-bottom: 0.5rem;
}
:global([data-collapsible-content]) {
padding: 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
}
.viewport-wrapper {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 50;
}
:global([data-toast-viewport]) {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 280px;
}
:global([data-toast-item]) {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.75rem 1rem;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.08);
font-size: 0.9rem;
}
.toast-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
:global([data-toast-title]) {
font-weight: 600;
}
:global([data-toast-description]) {
color: #4b5563;
font-size: 0.85rem;
}
:global([data-toast-close]) {
background: transparent;
border: 0;
font-size: 1.25rem;
line-height: 1;
padding: 0.1em 0.3em;
cursor: pointer;
color: #9ca3af;
}
ul {
padding-left: 1.5rem;
}
li {
margin: 0.25rem 0;
}
</style>

@ -0,0 +1,121 @@
/**
* Eidos — transversal selectors keyed by `data-archetype`.
*
* The whole point: a single rule applies to ALL components that share an
* archetype, regardless of their specific kebab. A `Toggle` button, a
* `Dialog.Trigger`, a `Popover.Trigger` and a `DropdownMenu.Trigger` all
* carry `data-archetype="trigger"`, so the rule below covers them all.
*
* Specificity is intentionally low (single attribute selector). Component
* tokens or scoped overrides win when present.
*/
/* ── Trigger ───────────────────────────────────────────────────────────────
* Applies to anything that the user clicks/taps to activate or open.
*/
[data-archetype='trigger'] {
cursor: pointer;
user-select: none;
transition:
opacity var(--eidos-motion-fast) var(--eidos-easing-standard),
background-color var(--eidos-motion-fast) var(--eidos-easing-standard);
}
[data-archetype='trigger']:hover:not([data-disabled]):not([disabled]) {
opacity: var(--eidos-opacity-hover);
}
[data-archetype='trigger'][data-disabled],
[data-archetype='trigger'][disabled] {
cursor: not-allowed;
opacity: var(--eidos-opacity-disabled);
}
/* ── Overlay ──────────────────────────────────────────────────────────────
* Modal backdrops behind dialog/drawer/popover content.
*/
[data-archetype='overlay'] {
position: fixed;
inset: 0;
pointer-events: auto;
}
/* ── Content ─────────────────────────────────────────────────────────────── */
[data-archetype='content'] {
position: relative;
}
/* ── Indicator ───────────────────────────────────────────────────────────── */
[data-archetype='indicator'] {
pointer-events: none;
transition: transform var(--eidos-motion-fast) var(--eidos-easing-standard);
}
/* ── Thumb ────────────────────────────────────────────────────────────────
* Draggable handles (slider, switch, scroll thumb).
*/
[data-archetype='thumb'] {
cursor: grab;
user-select: none;
transition: transform var(--eidos-motion-fast) var(--eidos-easing-standard);
}
[data-archetype='thumb']:active {
cursor: grabbing;
}
[data-archetype='thumb'][data-disabled] {
cursor: not-allowed;
opacity: var(--eidos-opacity-disabled);
}
/* ── Track ──────────────────────────────────────────────────────────────── */
[data-archetype='track'] {
position: relative;
}
/* ── Close ────────────────────────────────────────────────────────────────
* Inherits trigger's interactive feel; overrides only what's specific.
*/
[data-archetype='close'] {
cursor: pointer;
user-select: none;
transition: opacity var(--eidos-motion-fast) var(--eidos-easing-standard);
}
[data-archetype='close']:hover {
opacity: var(--eidos-opacity-hover);
}
/* ── Action ─────────────────────────────────────────────────────────────── */
[data-archetype='action'] {
cursor: pointer;
user-select: none;
}
/* ── Focus ring — universal across archetype-classified elements ─────────
* Single rule covers triggers, closes, actions, items, options, etc. The
* `:focus-visible` pseudo respects keyboard vs. pointer focus correctly.
*/
[data-archetype]:focus-visible {
outline: var(--eidos-focus-ring-width) solid var(--eidos-focus-ring-color);
outline-offset: var(--eidos-focus-ring-offset);
}
/* ── Item / Option ──────────────────────────────────────────────────────── */
[data-archetype='item'],
[data-archetype='option'] {
cursor: pointer;
user-select: none;
}
[data-archetype='item'][aria-selected='true'],
[data-archetype='option'][aria-selected='true'] {
background-color: hsl(220 90% 55% / 0.1);
}
[data-archetype='item'][data-disabled],
[data-archetype='option'][data-disabled] {
cursor: not-allowed;
opacity: var(--eidos-opacity-disabled);
}

@ -0,0 +1,77 @@
/**
* Eidos — reactions to perceptual signals (`data-event*`).
*
* `Sema` writes `data-event="..." data-event-phase="active" data-intent="..."`
* to the target during ~13ms (1 rAF + 1 hold frame). These rules let CSS
* react to the signal during that window — typically by starting a
* transition that the structural state change (which lands ~5ms later)
* continues smoothly.
*
* Reactions here are intentionally minimal. The point is to demonstrate
* the cross-layer cycle works, not to provide a full motion system.
*/
/* ── Base: any event signal during its active phase ──────────────────────
* Hint that something is happening. Components can override per-event.
*/
[data-event][data-event-phase='active'] {
will-change: opacity, transform;
}
/* ── Dismiss family — fade out ───────────────────────────────────────────
* Matches `data-event="dismiss"` and any `dismiss-*` variant
* (`dismiss-outside`, etc.) via the `^=` prefix selector.
*/
[data-event^='dismiss'][data-event-phase='active'] {
transition: opacity var(--eidos-motion-quick) var(--eidos-easing-standard);
opacity: var(--eidos-opacity-faded);
}
/* ── Close family — same fade ─────────────────────────────────────────── */
[data-event^='close'][data-event-phase='active'] {
transition: opacity var(--eidos-motion-quick) var(--eidos-easing-standard);
opacity: var(--eidos-opacity-faded);
}
/* ── Present / open — fade in via transform ──────────────────────────── */
[data-event^='present'][data-event-phase='active'],
[data-event^='open'][data-event-phase='active'] {
transition:
opacity var(--eidos-motion-fast) var(--eidos-easing-decelerate),
transform var(--eidos-motion-fast) var(--eidos-easing-emphasized);
}
/* ── Announce — pulse via outline color (ARIA assertive surrogate) ──── */
[data-event^='announce'][data-event-phase='active'] {
transition: box-shadow var(--eidos-motion-base) var(--eidos-easing-standard);
}
[data-event^='announce'][data-event-phase='active'][data-intent='risk'] {
box-shadow: 0 0 0 var(--eidos-focus-ring-width) var(--eidos-tint-risk);
}
[data-event^='announce'][data-event-phase='active'][data-intent='threat'] {
box-shadow: 0 0 0 var(--eidos-focus-ring-width) var(--eidos-tint-threat);
}
[data-event^='announce'][data-event-phase='active'][data-intent='affirm'],
[data-event^='announce'][data-event-phase='active'][data-intent='fulfill'] {
box-shadow: 0 0 0 var(--eidos-focus-ring-width) var(--eidos-tint-affirm);
}
/* ── Last action tinting (Dialog close-cancel vs close-save) ─────────
* `data-last-action` is written by `event.prewrite` BEFORE the structural
* change. Lives across the exit animation so CSS can tint by causa.
*/
[data-state='closed'][data-last-action='cancelled'] {
/* could tint border, animation, etc. — placeholder rule for the demo */
--_eidos-exit-tint: var(--eidos-tint-neutral);
}
[data-state='closed'][data-last-action='saved'] {
--_eidos-exit-tint: var(--eidos-tint-fulfill);
}
[data-state='closed'][data-last-action='failed'] {
--_eidos-exit-tint: var(--eidos-tint-threat);
}

@ -0,0 +1,29 @@
/**
* Eidos — V1 entrypoint.
*
* Imports compose into the global cascade in the order tokens →
* archetypes → events. Components scoped CSS may override anything below
* via higher specificity or later import order.
*
* Consumer usage:
* import '$uix/eidos/index.css'
* (in app root or a route layout)
*
* What this V1 ships:
* - tokens: motion durations, easings, opacities, intent tints, focus ring
* - archetypes: transversal `[data-archetype=...]` selectors covering
* trigger / overlay / content / indicator / thumb / track / close /
* action / item / option, plus a universal focus-visible ring
* - events: reactions to `data-event*` perceptual signals (dismiss /
* close / present / open / announce) and `data-last-action` exit tinting
*
* What's deliberately NOT in V1:
* - theming dimension (no `[data-theme]` overrides yet)
* - per-component recipes (each component will get its own CSS later)
* - keyframes / @starting-style / animation primitives
* - sound / vibra (those are separate engines that subscribe to sema)
*/
@import './tokens.css';
@import './archetypes.css';
@import './events.css';

@ -0,0 +1,42 @@
/**
* Eidos — minimal motion + opacity tokens (V1).
*
* Intentionally small. Eidos V1 only ships what the cross-layer demos need:
* motion durations + easings for state transitions, a focus ring color,
* and a hover dim opacity. Full token system + theming arrives later.
*
* Naming: `--eidos-{category}-{name}` (single word per slot when possible).
* No theming dimension yet — when it arrives, these become the "base"
* theme and each `[data-theme]` overrides selectively.
*/
:root {
/* Motion durations — used by archetype selectors + event reactions */
--eidos-motion-quick: 120ms;
--eidos-motion-fast: 200ms;
--eidos-motion-base: 320ms;
/* Easings */
--eidos-easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
--eidos-easing-emphasized: cubic-bezier(0.2, 0, 0, 1);
--eidos-easing-decelerate: cubic-bezier(0, 0, 0.2, 1);
/* Opacity scale (used for hover dim, disabled state, fade-out) */
--eidos-opacity-hover: 0.85;
--eidos-opacity-disabled: 0.5;
--eidos-opacity-faded: 0;
/* Intent tints — V1 uses currentColor + alpha; semantic engines may
* override per-theme. Just enough to prove cross-layer reactivity. */
--eidos-tint-neutral: hsl(220 10% 50%);
--eidos-tint-affirm: hsl(140 50% 45%);
--eidos-tint-fulfill: hsl(160 60% 40%);
--eidos-tint-risk: hsl(35 90% 50%);
--eidos-tint-threat: hsl(0 75% 50%);
/* Focus ring — a single token for now, applies anywhere `:focus-visible`
* lands on an archetype-classified element. */
--eidos-focus-ring-color: hsl(220 90% 55% / 0.6);
--eidos-focus-ring-width: 2px;
--eidos-focus-ring-offset: 2px;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.