feat(layout): canon Flex demo + morfo (mirrors Box pattern)

Second POC for redoing Layout Batch 1 demos. Mirrors `box`'s canonical
shape adapted for a flex container:

- 7 container-side controls (direction, align, justify, wrap, gap,
  padding, inline) — chips for unions, number for space tokens,
  switch for the boolean.
- API tab separates "Container props (Flex-only)" from "Inherited
  from Box" with a forward-link.
- Cross-references: lede + API call out that item-side props
  (alignSelf, gridColumn, …) live on the child `<Box>`, and that
  Stack / Group are direction-specific helpers.
- Reference comparison row reflects the Radix Themes split (item vs
  container) and notes Chakra's `HStack`/`VStack` map to UIX `Stack`.

Adds `src/uix/morfo/components/flex.ts` (scope `eidos`, 1 part, 0
events, mirrors box.ts justification).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 66c6897c61
commit 0dc9c523d1

@ -0,0 +1,29 @@
import type { Morfo } from '../types';
/**
* Flex — `display:flex` container (layout primitive).
*
* Eidos-native: every public prop maps to a `--flex-*` (or inherited
* `--box-*`) custom property. The provider is a single `<div data-box
* data-flex>` shell — Flex composes through `<Box>`.
*
* No semantic events — Flex is a passive container that arranges its
* children. See `box.ts` for the same 0-event justification.
*/
export const flexMorfo = {
name: 'Flex',
kebab: 'flex',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -1,9 +1,105 @@
<script lang="ts">
import { Flex } from '$uix/eidos/components/flex';
import { Flex, type FlexProps } from '$uix/eidos/components/flex';
import { Box } from '$uix/eidos/components/box';
import { compileMorfo } from '$uix/morfo';
import { flexMorfo } from '@/uix/morfo/components/flex';
const cellStyle =
'background: var(--color-surface-2); border-radius: 4px; padding: 8px 12px';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state — Flex container props ──────────────────────────────────
let direction = $state<'row' | 'row-reverse' | 'column' | 'column-reverse'>('row');
let align = $state<'stretch' | 'start' | 'center' | 'end' | 'baseline'>('center');
let justify = $state<'start' | 'center' | 'end' | 'space-between' | 'space-around' | 'space-evenly' | 'stretch'>('space-between');
let wrap = $state<'nowrap' | 'wrap' | 'wrap-reverse'>('nowrap');
let gap = $state<number>(3);
let padding = $state<number>(4);
let inline = $state<boolean>(false);
const directionOptions = ['row', 'row-reverse', 'column', 'column-reverse'] as const;
const alignOptions = ['stretch', 'start', 'center', 'end', 'baseline'] as const;
const justifyOptions = ['start', 'center', 'end', 'space-between', 'space-around', 'space-evenly', 'stretch'] as const;
const wrapOptions = ['nowrap', 'wrap', 'wrap-reverse'] as const;
const flexProps = $derived<Partial<FlexProps>>({
direction,
align,
justify,
wrap,
gap,
padding,
inline
});
// ── Compiled morfo ────────────────────────────────────────────────────
const compiled = compileMorfo(flexMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
// ── Code snippets ─────────────────────────────────────────────────────
const somaSnippet = $derived(
[
'<!-- Flex is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup (not real soma): -->',
'',
'<div',
' data-box',
' data-flex',
' style="display: flex; flex-direction: row; align-items: center; justify-content: space-between; gap: var(--space-3);"',
'>',
' …',
'</div>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Flex } from '$uix/eidos/components/flex';",
'</' + 'script>',
'',
'<Flex',
direction !== 'row' && ` direction="${direction}"`,
align !== 'stretch' && ` align="${align}"`,
justify !== 'start' && ` justify="${justify}"`,
wrap !== 'nowrap' && ` wrap="${wrap}"`,
gap > 0 && ` gap={${gap}}`,
padding > 0 && ` padding={${padding}}`,
inline && ' inline',
'>',
' …',
'</Flex>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
@ -11,85 +107,302 @@
<div data-uix-eyebrow>Layout · Flex</div>
<h1 data-uix-page-title>Flex</h1>
<p data-uix-page-lede>
<code>display:flex</code> container with direction, wrap, alignment and gap.
<code>display:flex</code> container. Adds <code>direction</code> / <code>align</code> /
<code>justify</code> / <code>wrap</code> / <code>rowGap</code> / <code>columnGap</code>
on top of every <a href="/uix/components/box">&lt;Box&gt;</a> prop. For grid containers
use <a href="/uix/components/grid">&lt;Grid&gt;</a>; for flex/grid <em>item</em> props
(alignSelf, gridColumn, order, basis, …) use <code>&lt;Box&gt;</code> on the child.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>extends</span>Box
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<section>
<h2>Live example</h2>
<Flex
direction="row"
gap={3}
align="center"
justify="space-between"
padding={4}
style="background: var(--color-surface-1); border: 1px solid var(--color-border); border-radius: 8px"
>
<Box style={cellStyle}>Start</Box>
<Box style={cellStyle}>Middle</Box>
<Box style={cellStyle}>End</Box>
</Flex>
</section>
<section>
<h2>Props</h2>
<table>
<thead>
<tr>
<th>Prop</th>
<th>Default</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>inline</code></td>
<td>false</td>
<td>switch to <code>inline-flex</code></td>
</tr>
<tr>
<td><code>direction</code></td>
<td>'row'</td>
<td>row · row-reverse · column · column-reverse</td>
</tr>
<tr>
<td><code>wrap</code></td>
<td>'nowrap'</td>
<td>nowrap · wrap · wrap-reverse</td>
</tr>
<tr>
<td><code>align</code></td>
<td>—</td>
<td>stretch · start · center · end · baseline</td>
</tr>
<tr>
<td><code>justify</code></td>
<td>—</td>
<td>start · center · end · space-between · space-around · space-evenly</td>
</tr>
<tr>
<td><code>gap</code> / <code>rowGap</code> / <code>columnGap</code></td>
<td>—</td>
<td>row/columnGap override <code>gap</code> per axis</td>
</tr>
<tr>
<td colspan="3"><em>… plus every <code>BoxProps</code> prop except <code>display</code></em></td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>Reference</h2>
<ul>
<li>
<strong>radix-themes Flex</strong> — same surface (<code>direction</code>,
<code>align</code>, <code>justify</code>, <code>wrap</code>, <code>gap</code>).
</li>
<li>
<strong>chakra-ui Flex</strong> — origin of the responsive Flex container API;
our prop names match.
</li>
</ul>
</section>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<Flex {...flexProps} style="border: 1px dashed var(--color-border-default); border-radius: var(--radius-md); min-block-size: 8rem; min-inline-size: 24rem;">
<Box padding={2} style="border: 1px solid var(--color-primary-border); border-radius: var(--radius-sm); background: var(--color-primary-track); color: var(--color-primary-text);">Item A</Box>
<Box padding={2} style="border: 1px solid var(--color-affirm-border); border-radius: var(--radius-sm); background: var(--color-affirm-track); color: var(--color-affirm-text);">Item B</Box>
<Box padding={2} style="border: 1px solid var(--color-risk-border); border-radius: var(--radius-sm); background: var(--color-risk-track); color: var(--color-risk-text);">Item C</Box>
</Flex>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>direction</span>
<span>{direction}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>align</span> {align} ·
<span data-uix-stage-trace-key>justify</span> {justify}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>7+</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Flex is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The 7
container-side props below map to <code>--flex-*</code> CSS variables. Flex also inherits
every <a href="/uix/components/box">Box</a> prop (padding, margin, size, position) by
composition.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · container behaviour
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>direction</span>
<span data-uix-chips role="radiogroup">
{#each directionOptions as opt}
<button data-uix-chip data-active={direction === opt} onclick={() => (direction = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>align <span data-uix-control-hint>cross-axis</span></span>
<span data-uix-chips role="radiogroup">
{#each alignOptions as opt}
<button data-uix-chip data-active={align === opt} onclick={() => (align = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>justify <span data-uix-control-hint>main-axis</span></span>
<span data-uix-chips role="radiogroup">
{#each justifyOptions as opt}
<button data-uix-chip data-active={justify === opt} onclick={() => (justify = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>wrap</span>
<span data-uix-chips role="radiogroup">
{#each wrapOptions as opt}
<button data-uix-chip data-active={wrap === opt} onclick={() => (wrap = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>gap <span data-uix-control-hint>0–8 → var(--space-N)</span></span>
<input type="number" min="0" max="8" step="1" bind:value={gap} style="inline-size: 6rem;" />
</label>
<label data-uix-control>
<span data-uix-control-label>padding <span data-uix-control-hint>inherited from Box</span></span>
<input type="number" min="0" max="8" step="1" bind:value={padding} style="inline-size: 6rem;" />
</label>
<label data-uix-control>
<span data-uix-control-label>inline <span data-uix-control-hint>display:inline-flex</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={inline} />
<span data-uix-switch-label>{inline ? 'on' : 'off'}</span>
</span>
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · flex is eidos-native — equivalent markup shown</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · container props mapped to --flex-* CSS variables</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
Flex adds container-side flex props on top of every <code>BoxProps</code> field. Pass
<code>inline</code> to switch to <code>inline-flex</code>; otherwise the provider renders
with <code>display:flex</code>. Item-side props (<code>flex</code>, <code>grow</code>,
<code>shrink</code>, <code>basis</code>, <code>order</code>, <code>alignSelf</code>,
<code>justifySelf</code>, <code>placeSelf</code>) live on the child
<a href="/uix/components/box"><code>&lt;Box&gt;</code></a>.
</p>
<div data-uix-subsection-head>Container props (Flex-only)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">inline</td><td class="type">boolean</td><td class="default">false</td><td>Switches to <code>display:inline-flex</code>.</td></tr>
<tr><td class="name">direction</td><td class="type">row | row-reverse | column | column-reverse</td><td class="default">'row'</td><td><code>flex-direction</code>.</td></tr>
<tr><td class="name">align</td><td class="type">stretch | start | center | end | baseline | flex-start | flex-end</td><td class="default empty">—</td><td><code>align-items</code> — cross-axis.</td></tr>
<tr><td class="name">justify</td><td class="type">start | center | end | space-between | space-around | space-evenly | stretch | flex-start | flex-end</td><td class="default empty">—</td><td><code>justify-content</code> — main-axis.</td></tr>
<tr><td class="name">wrap</td><td class="type">nowrap | wrap | wrap-reverse</td><td class="default">'nowrap'</td><td><code>flex-wrap</code>.</td></tr>
<tr><td class="name">gap</td><td class="type">number | string</td><td class="default empty">—</td><td>Number → <code>var(--space-N)</code>.</td></tr>
<tr><td class="name">rowGap / columnGap</td><td class="type">number | string</td><td class="default empty">—</td><td>Per-axis gap override; takes precedence over <code>gap</code>.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Inherited from Box</div>
<p data-uix-section-desc>
Every <a href="/uix/components/box">BoxProps</a> field is forwarded:
<code>padding*</code>, <code>margin*</code>, <code>width</code>, <code>height</code>,
<code>position</code>, <code>top/right/bottom/left</code>, <code>overflow*</code>, plus
item-side props that apply when this Flex is itself an item of a parent flex/grid
(<code>alignSelf</code>, <code>gridColumn</code>, …).
</p>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr></thead>
<tbody>
<tr><td class="name">radix-themes</td><td><code>&lt;Flex&gt;</code></td><td>Same shape: container props on Flex, item props on Box. Radix uses <code>flexShrink</code> spelling; UIX uses <code>shrink</code>.</td></tr>
<tr><td class="name">chakra-ui</td><td><code>&lt;Flex&gt;</code> / <code>&lt;HStack&gt;</code> / <code>&lt;VStack&gt;</code></td><td>Chakra also exposes <code>&lt;HStack&gt;</code>/<code>&lt;VStack&gt;</code> as direction-specific helpers. UIX uses <a href="/uix/components/stack">&lt;Stack&gt;</a> with a <code>direction</code> prop.</td></tr>
<tr><td class="name">mantine</td><td><code>&lt;Flex&gt;</code> / <code>&lt;Group&gt;</code></td><td>Mantine splits row-only into <a href="/uix/components/group">&lt;Group&gt;</a>; UIX has the same split.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{flexMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{flexMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{flexMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>kebab</th><th>marker</th><th>element</th><th>archetype</th><th>optional</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.archetype}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
Flex composes through <code>&lt;Box&gt;</code>, so the rendered DOM is a single
<code>div[data-box][data-flex]</code> shell. Recipe lives at
<code>src/uix/eidos/components/flex/flex.css</code> and layers on top of the Box
recipe.
</p>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
Flex declares no semantic events. As a passive layout primitive, it does not commit,
emerge, or react to anything — it just arranges its children. Components that animate
on appearance should compose Flex with an interactive primitive (popover, drawer,
collapsible) that owns the relevant sema verbs.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/flex/flex.css</code> and layers on top
of the Box recipe. The provider element carries both <code>data-box</code> and
<code>data-flex</code> markers because Flex renders through Box.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-flex]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker.</td>
</tr>
<tr>
<td class="name"><code>[data-box][data-flex] {`{ display: flex | inline-flex; flex-direction: var(--flex-direction, row); … }`}</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Layer container-side flex properties on the Box recipe.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Role</td><td>None implicit. Default element is <code>&lt;div&gt;</code> — semantically neutral. Use a semantic wrapper (<code>&lt;nav&gt;</code>, <code>&lt;header&gt;</code>, <code>&lt;ul role="list"&gt;</code>) around Flex when content needs a landmark or list semantics.</td></tr>
<tr><td class="name">Label</td><td>Not applicable — Flex has no content semantics. Labels belong to the interactive children.</td></tr>
<tr><td class="name">Keyboard</td><td>Flex is not focusable. Tab order follows DOM order even when <code>direction</code> visually reverses (<code>row-reverse</code> / <code>column-reverse</code>). Verify visual order matches focus order for keyboard users.</td></tr>
<tr><td class="name">Reduced motion</td><td>Flex does not animate. Layout shifts on resize follow CSS defaults — no <code>transition</code> on the recipe.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Loading…
Cancel
Save

Powered by TurnKey Linux.