feat(typography): 8 primitives — Heading, Display, Code, CodeBlock, Kbd, Mark, Highlight, Link
Full canon for the rest of the typography family, following Text
(commit `03531709`) as template. Each primitive ships morfo (passive,
scope eidos, 0 events) + full eidos set (svelte/css/types/index +
README with required sections) + canon 6-tab demo (≥400 lines each).
**Heading** — semantic h1..h6 via `level` prop, visual style decoupled
via `style` prop (defaults to match level but `<Heading level={3}
style="h1">` allowed). Demo 594 lines.
**Display** — always-h1 page-title primitive. Always uses
`style="hero"`. Tracking via literal em values until foundation emits
hero-scale tracking tokens. Demo 546 lines.
**Code** — inline code via `<code>`. `variant: 'plain' | 'soft' |
'outline'` for subtle background chrome. Demo 423 lines.
**CodeBlock** — multi-line `<pre><code>` with optional title +
copy button (button utility, NOT a sema verb — kept out of morfo per
2-of-3 rule). Demo 402 lines.
**Kbd** — keyboard shortcut chrome over `style="code"`. Demo 418 lines.
**Mark** — text highlight via `<mark>`. Palette uses canonical
`ColorRole` intents (default `fulfill` ≈ legacy yellow). Demo 402 lines.
**Highlight** — substring highlighter inside Text. Scans children for
`query` and wraps matches. Same intent palette as Mark. Demo 407 lines.
**Link** — text link via `<a>`. `variant: 'default' | 'subtle' |
'plain'` for underline style, `intent: ColorRole` for color. Passive
on the morfo — hover/active is CSS-only, navigation belongs to the
consumer (router). Demo 448 lines.
**Architectural deviations from air documented per README's
Decisiones section:**
- Heading: semantic level vs visual style decoupling
- Code split into inline `<Code>` + block `<CodeBlock>` (was one
component with `variant='block'` in air)
- Mark/Highlight palette swapped from literal hue names to canonical
ColorRole intents (theme contract)
- Link palette opened to full ColorRole; `quiet`/`emphasized` air
variants dropped in favour of composable patterns
`npm run component:audit`: **90 / 90 PASS**, 0 NEEDS-WORK.
svelte-check: 0 errors, 0 new warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
< script lang = "ts" >
import {
CodeBlock,
type CodeBlockVariant,
type CodeBlockSize
} from '$uix/eidos/components/code-block';
import { compileMorfo } from '$uix/morfo';
import { codeBlockMorfo } from '@/uix/morfo/components/code-block';
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);
let variant = $state< CodeBlockVariant > ('surface');
let size = $state< CodeBlockSize | ' ' > ('');
let title = $state< string > ('example.ts');
let language = $state< string > ('ts');
let showCopy = $state(true);
let content = $state< string > (
`import { CodeBlock } from '$uix/eidos/components/code-block';\n\nexport function example() { \ n return 42 ; \ n } `
);
const variants: CodeBlockVariant[] = ['surface', 'outline', 'ghost'];
const sizes: CodeBlockSize[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl'];
const compiled = compileMorfo(codeBlockMorfo);
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();
});
const eidosSnippet = $derived(
[
"< script lang = 'ts' > " ,
" import { CodeBlock } from '$uix/eidos/components/code-block';",
'< /' + 'script>',
'',
'< CodeBlock ' ,
variant !== 'surface' && ` variant="${ variant } "`,
size && ` size="${ size } "`,
title && ` title="${ title } "`,
language && ` language="${ language } "`,
!showCopy && ' showCopy={ false } ',
'>',
' { `' + content.replace(/` /g, '\\`') + '` } ',
'< / CodeBlock > '
]
.filter(Boolean)
.join('\n')
);
< / script >
< div data-uix-canvas-inner >
< header >
< div data-uix-eyebrow > Typography · CodeBlock< / div >
< h1 data-uix-page-title > CodeBlock< / h1 >
< p data-uix-page-lede >
Multi-line code block primitive. Renders a < code > { `<div>` } </ code > shell wrapping a
< code > { `<pre><code>` } </ code >
body, with an optional header strip carrying title, language badge, and copy-to-clipboard
button. The copy button is a leaf utility — it does not emit Sema events. Single morfo
part. Eidos-native: no soma backing, no semantic events.
< / 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 > variants< / span > 3
< / span >
< span data-uix-meta-pill >
< span data-uix-meta-key > copy< / span > opt-in
< / span >
< span data-uix-meta-pill >
< span data-uix-meta-key > scope< / span > eidos
< / span >
< / div >
< / header >
< div data-uix-stage >
< div data-uix-stage-area bind:this = { stageRef } >
< div style = "max-inline-size: 42rem;" >
< CodeBlock
{ variant }
size={ size || undefined }
title={ title || undefined }
language={ language || undefined }
{ showCopy }
>
{ content }
< / CodeBlock >
< / div >
< / 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 > variant< / span >
< span > { variant } </ span >
< span style = "margin-inline-start: auto;" >
< span data-uix-stage-trace-key > language</ span > { language || 'none' } ·
< span data-uix-stage-trace-key > copy</ span > { showCopy ? 'on' : 'off' }
< / 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 >
CodeBlock is eidos-native — no < span data-uix-layer-badge = "soma" > soma< / span > split. The
< code > variant< / code >
picks chrome; the header strip auto-renders when < code > title< / code > , < code > language< / code > ,
or < code > showCopy< / code > is present.
< / p >
< div data-uix-subsection-head >
< span data-uix-layer-badge = "eidos" > eidos< / span > props · block + header
< / div >
< div data-uix-controls >
< label data-uix-control >
< span data-uix-control-label > variant< / span >
< span data-uix-chips role = "radiogroup" >
{ #each variants as opt }
< button data-uix-chip data-active = { variant === opt } onclick= {() => ( variant = opt )}
>{ opt } < /button
>
{ /each }
< / span >
< / label >
< label data-uix-control >
< span data-uix-control-label > size< / span >
< span data-uix-chips role = "radiogroup" >
< button data-uix-chip data-active = { size === '' } onclick= {() => ( size = '' )}
>inherit< /button
>
{ #each sizes as opt }
< button data-uix-chip data-active = { size === opt } onclick= {() => ( size = opt )}
>{ opt } < /button
>
{ /each }
< / span >
< / label >
< label data-uix-control >
< span data-uix-control-label > title< / span >
< input type = "text" bind:value = { title } style="inline-size: 14rem ;" />
< / label >
< label data-uix-control >
< span data-uix-control-label > language< / span >
< input type = "text" bind:value = { language } style="inline-size: 10rem ;" />
< / label >
< label data-uix-control >
< span data-uix-control-label > showCopy< / span >
< span data-uix-switch >
< input type = "checkbox" bind:checked = { showCopy } / >
< span data-uix-switch-label > { showCopy ? 'on' : 'off' } </ span >
< / span >
< / label >
< label data-uix-control style = "grid-column: span 2;" >
< span data-uix-control-label > content< / span >
< textarea
bind:value={ content }
rows="4"
style="inline-size: 100%; font-family: var(--font-family-mono); font-size: var(--font-size-sm);"
>< / textarea >
< / label >
< / div >
<!-- ── Code snippet (eidos - native — no soma layer) ──────────────── -->
feat(typography): 8 primitives — Heading, Display, Code, CodeBlock, Kbd, Mark, Highlight, Link
Full canon for the rest of the typography family, following Text
(commit `03531709`) as template. Each primitive ships morfo (passive,
scope eidos, 0 events) + full eidos set (svelte/css/types/index +
README with required sections) + canon 6-tab demo (≥400 lines each).
**Heading** — semantic h1..h6 via `level` prop, visual style decoupled
via `style` prop (defaults to match level but `<Heading level={3}
style="h1">` allowed). Demo 594 lines.
**Display** — always-h1 page-title primitive. Always uses
`style="hero"`. Tracking via literal em values until foundation emits
hero-scale tracking tokens. Demo 546 lines.
**Code** — inline code via `<code>`. `variant: 'plain' | 'soft' |
'outline'` for subtle background chrome. Demo 423 lines.
**CodeBlock** — multi-line `<pre><code>` with optional title +
copy button (button utility, NOT a sema verb — kept out of morfo per
2-of-3 rule). Demo 402 lines.
**Kbd** — keyboard shortcut chrome over `style="code"`. Demo 418 lines.
**Mark** — text highlight via `<mark>`. Palette uses canonical
`ColorRole` intents (default `fulfill` ≈ legacy yellow). Demo 402 lines.
**Highlight** — substring highlighter inside Text. Scans children for
`query` and wraps matches. Same intent palette as Mark. Demo 407 lines.
**Link** — text link via `<a>`. `variant: 'default' | 'subtle' |
'plain'` for underline style, `intent: ColorRole` for color. Passive
on the morfo — hover/active is CSS-only, navigation belongs to the
consumer (router). Demo 448 lines.
**Architectural deviations from air documented per README's
Decisiones section:**
- Heading: semantic level vs visual style decoupling
- Code split into inline `<Code>` + block `<CodeBlock>` (was one
component with `variant='block'` in air)
- Mark/Highlight palette swapped from literal hue names to canonical
ColorRole intents (theme contract)
- Link palette opened to full ColorRole; `quiet`/`emphasized` air
variants dropped in favour of composable patterns
`npm run component:audit`: **90 / 90 PASS**, 0 NEEDS-WORK.
svelte-check: 0 errors, 0 new warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
< div data-uix-code >
< div data-uix-code-head >
< span data-uix-layer-badge = "eidos" > eidos< / span >
< span > visual · variant + header composition< / 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 >
CodeBlock ships a structural shell with an optional header (title + language badge + copy
button) and a < code > { `<pre><code>` } </ code > body. Composition over visibility: omit
< code > title< / code > + < code > language< / code > + set < code > showCopy=false< / code > to drop the
header entirely.
< / p >
< div data-uix-subsection-head > Props< / 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" > title< / td > < td class = "type" > string< / td > < td class = "default" > —< / td > < td > Filename / label. Appears in the header.< / td > < / tr >
< tr >< td class = "name" > language</ td >< td class = "type" > string</ td >< td class = "default" > —</ td >< td > Language badge. Stamped as < code > data-language</ code > on inner < code > { `<code>` } </ code > .</ td ></ tr >
< tr > < td class = "name" > showCopy< / td > < td class = "type" > boolean< / td > < td class = "default" > true< / td > < td > Render the copy-to-clipboard button.< / td > < / tr >
< tr > < td class = "name" > variant< / td > < td class = "type" > 'surface' | 'outline' | 'ghost'< / td > < td class = "default" > 'surface'< / td > < td > Visual chrome.< / td > < / tr >
< tr > < td class = "name" > size< / td > < td class = "type" > 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'< / td > < td class = "default" > inherit< / td > < td > Code font size override.< / td > < / tr >
< tr > < td class = "name" > tagStyle< / td > < td class = "type" > string< / td > < td class = "default" > —< / td > < td > Extra inline CSS.< / td > < / tr >
< / tbody >
< / table >
< / div >
< 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 > < Code> < / code > + custom< / td > < td > Radix has no dedicated multi-line surface — UIX ships the header + copy as built-in.< / td > < / tr >
< tr > < td class = "name" > chakra-ui< / td > < td > composed< / td > < td > Chakra composes Code + Box + IconButton; UIX bundles them.< / td > < / tr >
< tr > < td class = "name" > mantine< / td > < td > < code > < CodeHighlight> < / code > < / td > < td > Mantine ships Shiki integration; UIX reserves < code > data-language< / code > for a separate action.< / td > < / tr >
< tr > < td class = "name" > shadcn/ui< / td > < td > recipe-only< / td > < td > shadcn provides Tailwind classes; UIX ships a proper component.< / 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 > { codeBlockMorfo . name } </ td ></ tr >
< tr >< td class = "name" > kebab</ td >< td >< code > { codeBlockMorfo . kebab } </ code ></ td ></ tr >
< tr >< td class = "name" > scope</ td >< td > { codeBlockMorfo . 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" > < { part . defaultElement } > </ 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);" >
The Provider stamps < code > data-code-block< / code > + < code > data-variant< / code > . Internal
header / copy / pre / code leaves are eidos-only attrs (< code > data-code-block-header< / code > ,
< code > data-code-block-title< / code > , etc.) — the morfo declares only the Provider because
the inner parts are consumed exclusively by the 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 >
CodeBlock declares no semantic events. The copy button is a utility leaf — clipboard
write, no semantic transition. By the 2-of-3 rule, a < code > commit-copy< / code > event
would only be consumed by eidos (visual flip) — not enough to justify a morfo extension.
If a consumer needs sema on copy (e.g. fulfilment of a tutorial step), they compose
CodeBlock inside a Stepper or Form that owns the verb.
< / 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/code-block/code-block.css< / code > . The
outer shell selector is < code > [data-code-block]< / code > ; the header / pre / code leaves
use eidos-only attrs.
< / 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-code-block]< / code > < / td > < td > < span data-uix-tag data-kind = "morfo" > morfo< / span > < / td > < td > Provider marker.< / td > < / tr >
< tr >< td class = "name" >< code > { `[data-code-block][data-variant='surface'|'outline'|'ghost']` } </ code ></ td >< td >< span data-uix-tag data-kind = "eidos" > eidos</ span ></ td >< td > Chrome treatment.</ td ></ tr >
< tr > < td class = "name" > < code > [data-code-block-header]< / code > < / td > < td > < span data-uix-tag data-kind = "eidos" > eidos< / span > < / td > < td > Title + lang + copy strip.< / td > < / tr >
< tr > < td class = "name" > < code > [data-code-block-copy]< / code > < / td > < td > < span data-uix-tag data-kind = "eidos" > eidos< / span > < / td > < td > Copy button with < code > :hover< / code > / < code > :focus-visible< / code > / < code > [data-copied]< / code > states.< / td > < / tr >
< tr > < td class = "name" > < code > [data-code-block-pre]< / code > / < code > [data-code-block-code]< / code > < / td > < td > < span data-uix-tag data-kind = "eidos" > eidos< / span > < / td > < td > Block body.< / 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 > The block is a generic < code > { `<div>` } </ code > wrapping < code > { `<pre><code>` } </ code > . AT announce the code content.</ td ></ tr >
< tr > < td class = "name" > Copy button< / td > < td > < code > aria-label="Copy code to clipboard"< / code > in English. Focus ring via < code > :focus-visible< / code > .< / td > < / tr >
< tr > < td class = "name" > Copied feedback< / td > < td > Button text flips from "Copy" to "Copied" + < code > data-copied< / code > color shift. Re-announced by AT because the text content changes.< / td > < / tr >
< tr > < td class = "name" > Keyboard< / td > < td > Copy button reachable via Tab. Enter / Space activate. No custom keyboard contract.< / td > < / tr >
< / tbody >
< / table >
< / div >
< / section >
{ /if }
< / div >