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>
active-uix
dev 5 months ago
parent 0353170982
commit d421f1fb5d

@ -0,0 +1,112 @@
# Eidos CodeBlock
Multi-line code block primitive. Renders a `<div>` shell wrapping a
`<pre><code>` body, with an optional header strip carrying a title,
a language badge, and a copy-to-clipboard button. The copy button is
a leaf utility — it does not emit Sema events.
## Superficie
```svelte
<CodeBlock language="ts" title="example.ts">
{`const x = 1;
console.log(x);`}
</CodeBlock>
<CodeBlock variant="outline" showCopy={false} language="bash">
{`npm run dev`}
</CodeBlock>
<CodeBlock variant="ghost">
{`Plain block with no chrome.`}
</CodeBlock>
```
## Baseline
Origen: `air/components/typography/code-block` (rama `morfo-runtime`).
Adaptaciones para eidos:
- **Separated from `<Code>`**: air shipped a `variant='block'` on
`<Code>`. Eidos splits them so the inline and block APIs stay
independent.
- **`filename` → `title`**: generic name — the slot accepts whatever
label fits (file name, hostname, label, etc.).
- **`copyable` → `showCopy`**: aligns with composition-over-visibility
ergonomics (the prop controls whether the button is included).
- **Translator removed**: button labels are inline literal strings
("Copy" / "Copied"). If localization is needed later, expose a
snippet-based slot.
- **Variants added**: `surface` (default, filled), `outline`
(transparent + border), `ghost` (no chrome). Air had only one
visual treatment.
- **Sizes**: air `1..12` → eidos `xxs..xl` via foundation tokens.
- **Tokens**: `--air-code-block-*` → consume the foundation directly
(`--style-code-*`, `--color-neutral-*`, `--space-{n}`, `--radius-*`).
## Comparativa
| Capacidad | UIX CodeBlock | Radix Themes CodeBlock | Chakra | Mantine CodeHighlight |
| --- | --- | --- | --- | --- |
| Header (title + lang badge) | Sí | No (one component) | composable | Sí |
| Copy button | Sí (`showCopy`) | No | composable | Sí (`withCopyButton`) |
| Variants | surface / outline / ghost | size + variant | colorScheme | filled / outline |
| Language hint | Sí (`language`) | No | No | Sí |
| Syntax highlighting | No (reserved) | No | No | Sí (Shiki) |
| Size override | Sí | Sí | Sí | Sí |
| Line numbers | No | No | composable | Sí |
## Decisiones
- **Copy is a button, not a Sema event**: copy belongs to the
"utility" tier (clipboard write, no semantic transition). If a
consumer wants a celebrate-on-copy sound they can compose the
block inside a Toast affordance — the block itself stays passive.
- **Showed copy by default**: docs are the dominant use case;
removing the button is the opt-out.
- **Variant `surface` default**: filled neutral matches the
most-common docs look. `outline` for embedded contexts (inside a
Banner or Note), `ghost` for sub-component embedding.
- **No syntax highlighting in core**: stamped `data-language` on the
inner `<code>` so a Shiki/Prism action can hydrate later without
re-rendering the block.
- **Tokens come from the foundation directly**: no per-component
`--code-block-*` aliases unless a real consumer drift emerges. The
recipe references `--style-code-*` and `--color-neutral-*`.
## Eventos Sema
CodeBlock declara 0 eventos. La copy button es una utility leaf, no
evento semántico — copying not a transition the user "feels"
beyond a brief affordance flip ("Copy" → "Copied"). Si surge un caso
donde copy debe disparar sema (e.g. fulfilment of a tutorial step), el
consumer compone CodeBlock dentro de un componente con sema (Form
step, Stepper item) que posee el verbo `commit-fulfill`.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Syntax highlighting | **diferir** | Reservado vía `language` + `data-language`. Integración Shiki / Prism vía Svelte action separada. |
| Line numbers | **diferir** | UI cosmético. Si emerge, añadir como `showLineNumbers` prop. |
| Per-line diff (added/removed) | **diferir** | Complementa highlighting. Bajo prioridad. |
| Translatable copy button labels | **diferir** | Hoy son literales. Si surge i18n requirement, exponer slot custom. |
| Custom copy handler | **diferir** | `onclick` handler today; un `oncopy` prop puede llegar si emerge un caso real. |
| Sema event on copy | **descartar** | Copy es utility leaf, no verbo perceptivo del primitive. |
## Referencias
- Radix Themes Code: https://www.radix-ui.com/themes/docs/components/code
- Mantine CodeHighlight: https://mantine.dev/x/code-highlight/
- Chakra UI Code (block usage): https://chakra-ui.com/docs/components/code
- shadcn/ui block patterns: https://ui.shadcn.com/
## Passive justification
`scope: ['eidos']` — visual-only primitive. The morfo declares a
single Provider part (`[data-code-block]`). The copy button is a
recipe-internal leaf that exists only in eidos; per the 2-of-3 rule
(only one layer would consume a `commit-copy` event) it stays out
of the morfo contract. The block has no perceptual transitions
beyond the brief "Copy → Copied" flip handled in local component
state.

@ -0,0 +1,126 @@
/*
* CodeBlock recipe — multi-line code block with optional header.
*
* Variants:
* - `surface` filled with neutral element bg + border
* - `outline` transparent fill, border only
* - `ghost` transparent shell, no border (for sub-component embedding)
*/
[data-code-block] {
position: relative;
display: flex;
flex-direction: column;
border-radius: var(--radius-md);
overflow: hidden;
color: var(--color-content-primary);
}
[data-code-block][data-variant='surface'] {
background: var(--color-neutral-element);
border: 1px solid var(--color-neutral-border);
}
[data-code-block][data-variant='outline'] {
background: transparent;
border: 1px solid var(--color-neutral-border);
}
[data-code-block][data-variant='ghost'] {
background: transparent;
border: 0;
}
[data-code-block-header] {
display: flex;
align-items: center;
gap: var(--space-2);
padding-inline: var(--space-3);
padding-block: var(--space-1);
border-bottom: 1px solid var(--color-neutral-border);
background: var(--color-neutral-track);
}
[data-code-block][data-variant='ghost'] [data-code-block-header] {
background: transparent;
border-bottom-color: var(--color-neutral-border);
}
[data-code-block-title] {
flex: 1 1 auto;
min-inline-size: 0;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-code-block-lang] {
font-family: var(--font-family-mono);
font-size: var(--font-size-xxs);
color: var(--color-content-muted);
text-transform: lowercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
/* When there is no title, the lang badge stays at the start. When both
* exist, the title takes the flex slot and the lang sits next to it.
*/
[data-code-block-header]:not(:has([data-code-block-title])) [data-code-block-lang] {
margin-inline-end: auto;
}
[data-code-block-copy] {
appearance: none;
font: inherit;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
background: transparent;
border: 1px solid var(--color-neutral-border);
border-radius: var(--radius-sm);
padding-inline: var(--space-2);
padding-block: 2px;
cursor: pointer;
white-space: nowrap;
transition:
background 120ms ease,
border-color 120ms ease,
color 120ms ease;
}
[data-code-block-copy]:hover {
background: var(--color-neutral-hover);
color: var(--color-content-primary);
}
[data-code-block-copy]:focus-visible {
outline: 2px solid var(--color-primary-solid);
outline-offset: 2px;
}
[data-code-block-copy][data-copied] {
color: var(--color-affirm-text);
border-color: var(--color-affirm-border);
}
[data-code-block-pre] {
margin: 0;
padding: var(--space-3);
overflow-x: auto;
tab-size: 2;
}
[data-code-block-code] {
display: block;
font-family: var(--style-code-font-family);
font-size: var(--_code-block-font-size, var(--style-code-font-size));
font-weight: var(--style-code-font-weight);
line-height: var(--_code-block-line-height, var(--style-code-line-height));
letter-spacing: var(--style-code-letter-spacing);
color: inherit;
background: transparent;
}

@ -0,0 +1,103 @@
<script lang="ts">
/**
* Eidos `<CodeBlock>` — multi-line code block.
*
* <CodeBlock language="ts" title="example.ts">
* {`const x = 1;\nconsole.log(x);`}
* </CodeBlock>
*
* Renders a `<div data-code-block>` shell with an optional header
* (title + language badge + copy button) and a `<pre><code>` inner
* block. The copy button is a leaf utility — no Sema events.
*/
import { ActiveEidos } from '$uix/eidos';
import type { CodeBlockProps } from './types';
let {
title,
language,
showCopy = true,
variant,
size,
tagStyle,
class: className,
children,
...restProps
}: CodeBlockProps = $props();
const eidos = ActiveEidos.require();
let codeRef = $state<HTMLElement | null>(null);
let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;
const resolvedVariant = $derived(eidos.resolve(variant) ?? 'surface');
const resolvedSize = $derived(eidos.resolve(size));
function pushVar(decls: string[], name: string, value: string | undefined): void {
if (!value) return;
decls.push(`${name}: ${value};`);
}
const inlineStyle = $derived.by(() => {
const decls: string[] = [];
if (resolvedSize) {
pushVar(decls, '--_code-block-font-size', `var(--font-size-${resolvedSize})`);
pushVar(decls, '--_code-block-line-height', `var(--font-line-height-${resolvedSize})`);
}
const inline = decls.join(' ');
return [inline, tagStyle].filter(Boolean).join(' ');
});
const hasHeader = $derived(Boolean(title) || Boolean(language) || showCopy);
async function handleCopy() {
if (typeof navigator === 'undefined' || !codeRef) return;
const text = codeRef.textContent ?? '';
try {
await navigator.clipboard.writeText(text);
copied = true;
clearTimeout(copyTimer);
copyTimer = setTimeout(() => {
copied = false;
}, 1800);
} catch {
// Clipboard unavailable — keep silent. The button still fires
// onclick so the consumer can wire a fallback if needed.
}
}
</script>
<div
{...restProps}
class={className}
data-code-block=""
data-variant={resolvedVariant}
style={inlineStyle}
>
{#if hasHeader}
<div data-code-block-header>
{#if title}
<span data-code-block-title>{title}</span>
{/if}
{#if language}
<span data-code-block-lang>{language}</span>
{/if}
{#if showCopy}
<button
type="button"
data-code-block-copy
data-copied={copied ? '' : undefined}
aria-label="Copy code to clipboard"
onclick={handleCopy}
>
{copied ? 'Copied' : 'Copy'}
</button>
{/if}
</div>
{/if}
<pre data-code-block-pre><code
data-code-block-code
data-language={language}
bind:this={codeRef}>{@render children?.()}</code></pre>
</div>

@ -0,0 +1,21 @@
// CodeBlock — eidos single-component multi-line code block.
//
// import { CodeBlock } from '$uix/eidos/components/code-block';
//
// <CodeBlock language="ts" title="example.ts">
// {`const x = 1;\nconsole.log(x);`}
// </CodeBlock>
//
// Single-component primitive — `<CodeBlock>` IS the root. Ships an
// optional header strip (title + language badge + copy button) and a
// `<pre><code>` inner block. The copy button is a leaf utility; it
// does not widen the morfo's surface because only eidos consumes it.
import CodeBlockComponent from './code-block.svelte';
const CodeBlock = CodeBlockComponent;
export { CodeBlock };
export default CodeBlock;
export type { CodeBlockProps, CodeBlockSize, CodeBlockVariant } from './types';

@ -0,0 +1,27 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/** Discrete size scale for the block's code font. */
export type CodeBlockSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Visual chrome. */
export type CodeBlockVariant = 'surface' | 'outline' | 'ghost';
export type CodeBlockProps = Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'children' | 'title'> & {
/** Title shown in the header (typically a filename or label). */
title?: string;
/** Language hint shown as a badge in the header. Also stamped as `data-language` on the `<code>`. */
language?: string;
/** Render a copy-to-clipboard button in the header. @default true */
showCopy?: boolean;
/** Visual chrome variant. @default 'surface' */
variant?: ResponsiveProp<CodeBlockVariant>;
/** Code font size override. */
size?: ResponsiveProp<CodeBlockSize>;
/** Extra inline style. */
tagStyle?: string;
/** Extra class names. */
class?: string;
children?: Snippet;
};

@ -0,0 +1,90 @@
# Eidos Code
Inline monospace text primitive. Renders `<code>` by default applying
the foundation's `--style-code-*` tokens. Sits inline within prose —
for keywords, identifiers, file names. Multi-line code blocks use
the separate `<CodeBlock>` primitive.
## Superficie
```svelte
<p>Run <Code>npm install</Code> to install dependencies.</p>
<p>Press <Code variant="outline" size="sm">Ctrl+K</Code> to open search.</p>
<Code variant="soft" language="ts">readonly id: string</Code>
```
## Baseline
Origen: `air/components/typography/code` (rama `morfo-runtime`).
Adaptaciones para eidos:
- **Split inline vs block**: air shipped a single `<Code>` with
`variant: 'inline' | 'block'`. Eidos splits them — `<Code>` is
inline only, `<CodeBlock>` is the multi-line surface with optional
copy. This matches Radix Themes' Code/CodeBlock split and avoids
the runtime `if (variant === 'block')` branch.
- **Sizes**: air `1..12` → eidos `xxs..xl`. The foundation already
emits `--style-code-font-size` (defaults to `sm`); overrides go
through `var(--font-size-{key})`.
- **Visual variant added**: `plain` (default, no chrome), `soft`
(subtle pill), `outline`. Air's inline was always plain; the soft
pill is a Radix Themes / Chakra convenience for surfacing inline
code from surrounding prose.
- **Color content-role slot**: same contract as Text.
- **`language` retained**: stamped as `data-language` for future
syntax-highlighting hooks. No highlighting in this version.
## Comparativa
| Capacidad | UIX Code | Radix Themes Code | Chakra Code | Mantine Code |
| --- | --- | --- | --- | --- |
| Inline-only primitive | Sí | Sí | Sí | Sí |
| Visual variants | plain / soft / outline | size + variant (`solid` / `soft` / `outline` / `surface`) | colorScheme + variant | block boolean |
| Size override | Sí (`size`) | Sí (`size`) | Sí (`fontSize`) | Sí (`size`) |
| Color override | Sí (content-role) | Sí (`color`) | Sí (free) | Sí (free) |
| Language hint | Sí (`language`, reserved) | No | No | No |
| Syntax highlighting | No (reserved hook) | No | No | No (Mantine has separate Highlight) |
## Decisiones
- **`<Code>` is inline only**: matches Radix Themes' split. The block
surface is `<CodeBlock>` with its own chrome (copy button, header).
Keeps the inline primitive a single CSS rule per variant.
- **Default variant `plain`**: respect ambient typography. The `soft`
pill is opt-in when the consumer wants the code to surface from
prose.
- **Background uses `--color-neutral-element`**: neutral palette so
Code doesn't accidentally inherit `primary` tints from a colored
ancestor. Consumer can override with `tagStyle`.
- **Language hint is reserved**: stamped as `data-language` so a
future syntax-highlighting hook (a Svelte action or a layout-level
hydration pass) can target `<Code[data-language]>` without
re-rendering the primitive.
## Eventos Sema
Code declara 0 eventos. Visual-only primitive — no estado interactivo.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Syntax highlighting | **diferir** | Reservado vía `language` prop + `data-language`. Capa de tooling separada (Shiki / Prism) integrada vía Svelte action. |
| Click-to-copy on inline code | **descartar** | El primitive es passive. Si necesitas copy, usa `<CodeBlock>` (que ya lo lleva). |
| Free color (hex/CSS) | **descartar** | Mantiene contrato de contraste; usar `tagStyle`. |
| Diff coloring (added/removed line markers) | **diferir** | Pertenece a `<CodeBlock>` cuando llegue. |
## Referencias
- Radix Themes Code: https://www.radix-ui.com/themes/docs/components/code
- Chakra UI Code: https://chakra-ui.com/docs/components/code
- Mantine Code: https://mantine.dev/core/code/
- MDN `<code>`: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/code
## Passive justification
Visual-only (`scope: ['eidos']`). Una sola part Provider, marker
`[data-code]`. Sin estados, sin keyboard, sin ARIA propia (la
semántica viene del tag `<code>` que AT entienden nativamente). La
recipe consume `--style-code-*` del foundation + `--_code-{axis}`
overrides inline.

@ -0,0 +1,38 @@
/*
* Code recipe — inline monospace text.
*
* Consumes `--style-code-*` from the foundation as the base typography.
* The `data-variant` flag picks a chrome treatment:
* - `plain` bare mono text, inherits color
* - `soft` subtle pill with a neutral element background
* - `outline` outlined pill (same shape, transparent fill)
*
* Per-axis overrides come from local `--_code-{axis}` custom properties.
*/
[data-code] {
font-family: var(--_code-font-family, var(--style-code-font-family));
font-size: var(--_code-font-size, var(--style-code-font-size));
font-weight: var(--_code-font-weight, var(--style-code-font-weight));
line-height: var(--_code-line-height, var(--style-code-line-height));
letter-spacing: var(--_code-letter-spacing, var(--style-code-letter-spacing));
color: var(--_code-color, var(--style-code-color));
margin: 0;
padding: 0;
background: transparent;
border: 0;
border-radius: var(--radius-sm);
vertical-align: baseline;
}
[data-code][data-variant='soft'] {
background: var(--color-neutral-element);
padding-inline: 0.32em;
padding-block: 0.1em;
}
[data-code][data-variant='outline'] {
border: 1px solid var(--color-neutral-border);
padding-inline: 0.32em;
padding-block: 0.1em;
}

@ -0,0 +1,63 @@
<script lang="ts">
/**
* Eidos `<Code>` — inline monospace text.
*
* <Code>const x = 1</Code>
* <Code variant="soft">npm run dev</Code>
* <Code variant="outline" size="sm">Ctrl+K</Code>
*
* Renders `<code>` by default, applying the foundation's
* `--style-code-*` tokens. The `[data-code]` marker is the canonical
* recipe selector.
*/
import { ActiveEidos } from '$uix/eidos';
import type { CodeProps } from './types';
let {
variant,
size,
color,
language,
as = 'code',
tagStyle,
class: className,
children,
...restProps
}: CodeProps = $props();
const eidos = ActiveEidos.require();
function pushVar(decls: string[], name: string, value: string | number | undefined): void {
if (value === undefined || value === null || value === '') return;
decls.push(`${name}: ${value};`);
}
const inlineStyle = $derived.by(() => {
const decls: string[] = [];
const sizeKey = eidos.resolve(size);
const colorKey = eidos.resolve(color);
if (sizeKey) {
pushVar(decls, '--_code-font-size', `var(--font-size-${sizeKey})`);
pushVar(decls, '--_code-line-height', `var(--font-line-height-${sizeKey})`);
}
if (colorKey) pushVar(decls, '--_code-color', `var(--color-content-${colorKey})`);
const inline = decls.join(' ');
return [inline, tagStyle].filter(Boolean).join(' ');
});
const resolvedVariant = $derived(eidos.resolve(variant) ?? 'plain');
</script>
<svelte:element
this={as}
{...restProps}
class={className}
data-code=""
data-variant={resolvedVariant}
data-language={language}
style={inlineStyle}
>
{@render children?.()}
</svelte:element>

@ -0,0 +1,18 @@
// Code — eidos single-component inline monospace primitive.
//
// import { Code } from '$uix/eidos/components/code';
//
// <Code>const x = 1</Code>
// <Code variant="soft">npm run dev</Code>
//
// Single-component primitive — `<Code>` IS the root. Renders as
// `<code>` by default. For multi-line blocks, use `<CodeBlock>`.
import CodeComponent from './code.svelte';
const Code = CodeComponent;
export { Code };
export default Code;
export type { CodeProps, CodeSize, CodeVariant, CodeColor } from './types';

@ -0,0 +1,39 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/** Discrete size scale. */
export type CodeSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Visual chrome. `plain` = no background (uses code font only). `soft` = subtle bg pill. `outline` = outline pill. */
export type CodeVariant = 'plain' | 'soft' | 'outline';
/** Content-role color slot. */
export type CodeColor = 'primary' | 'secondary' | 'muted' | 'disabled' | 'on-solid';
export type CodeProps = Omit<HTMLAttributes<HTMLElement>, 'style' | 'children'> & {
/**
* Visual treatment.
* - `plain` bare mono text, no chrome (default).
* - `soft` subtle background pill — surfaces the inline code in prose.
* - `outline` outline pill — same shape, no fill.
* @default 'plain'
*/
variant?: ResponsiveProp<CodeVariant>;
/** Size override. Maps to `var(--font-size-{key})` / matching line-height. */
size?: ResponsiveProp<CodeSize>;
/** Content-role color slot. */
color?: ResponsiveProp<CodeColor>;
/**
* Language hint. Reserved for future syntax-highlighting extension —
* stamped as `data-language` for downstream tooling.
*/
language?: string;
/** Render a different tag. @default 'code' */
as?: string;
/** Extra inline style. */
tagStyle?: string;
/** Extra class names. */
class?: string;
children?: Snippet;
};

@ -0,0 +1,89 @@
# Eidos Display
Hero typography primitive — the page's display title (above an `<h1>`
headline). Always uses the foundation's `--style-hero-*` tokens, which
scale up at the `md` breakpoint. Smaller scope than Heading: a single
visual treatment, with per-axis overrides + a deliberate `tracking`
prop because at hero sizes letter-spacing is a design decision.
## Superficie
```svelte
<Display>Welcome to UIX.</Display>
<Display size="xxxl" weight="bold">Big.</Display>
<Display tracking="tight" align="center">Spread.</Display>
<Display as="p">Render display typography on a paragraph element.</Display>
```
## Baseline
Origen: `air/components/typography/display` (rama `morfo-runtime`).
Adaptaciones para eidos:
- **Default tag is `<h1>`**: air defaulted to `<div>`. Display titles
are virtually always the document hero, so the heading semantics
match the most common use; the `as` prop switches the tag when
needed (e.g. `as="p"` for a marketing pull-quote).
- **Single style — hero**: air carried `size` (visual paso 1..12).
Eidos collapses to the foundation's hero token + per-axis overrides
via `--font-size-{key}` aliases.
- **`tracking` retained**: kept the explicit letter-spacing prop
because at 44-60px tracking is a visual decision — but air's mapped
to `--air-text-{n}-ls`; eidos maps to fixed em values (-0.02em /
0 / 0.02em) since the foundation's letter-spacing tokens are mostly
0 across the scale.
- **Color content-role slot**: same contract as Text / Heading.
## Comparativa
| Capacidad | UIX Display | Radix Themes Heading | Chakra Heading | Mantine Title | MUI Typography |
| --- | --- | --- | --- | --- | --- |
| Dedicated hero primitive | Sí | No (`<Heading size="9">`) | No (use `<Heading>`) | No (use `<Title order={1}>`) | Sí (`variant="h1"`) |
| Default tag | `<h1>` | inherits | `<h2>` | `<h1>` | `<h1>` |
| `as` polymorphism | Sí | Sí | Sí | Sí | Sí (`component`) |
| Tracking override | Sí (`tracking`) | No | Sí (`letterSpacing`) | Sí (`ls`) | Sí (`letterSpacing`) |
| Size scale | xxs..xxxl override | numeric | xs..6xl | xs..xxl | h1..h6 only |
| Family / weight / color overrides | Sí | Sí | Sí | Sí | Sí |
| Truncate / clamp | Sí | Sí | Sí | Sí | partial |
| Responsive defaults | Sí (md breakpoint) | Sí | Sí | partial | Sí |
## Decisiones
- **Always `hero` style**: that's the whole point of Display — a single
oversize treatment. If you want `h1`..`h6` scale, use Heading.
- **Default tag `<h1>`**: page hero. Override via `as` when display
typography is needed outside heading semantics.
- **Tracking maps to fixed em values**: the foundation's
`--font-letter-spacing-*` tokens are mostly 0 — they exist to
reserve the API but don't carry hero-scale tracking. Display
shortcuts past them with literal em values so the prop produces
visible output today.
- **No `level` prop**: Display is always semantically a heading; if
the consumer needs `<h3 style="display">`, they pass `as="h3"`.
## Eventos Sema
Display declara 0 eventos. Como Heading/Text, es una primitiva pasiva.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Free hex/rgb `color` | **descartar** | Rompe contrato de contraste; usar `tagStyle`. |
| Gradient text fill | **diferir** | Composable con `tagStyle`. Si emerge demanda, añadir `<DisplayGradient>` dedicado. |
| Per-breakpoint custom hero responsive curve | **diferir** | Cubierto por la curva default (`hero@base`, `hero@md`); ajustes per-instance vía `size`. |
| `tracking` mapped to foundation tokens | **diferir** | Cuando los tokens `--font-letter-spacing-*` emerjan con valores hero-apropiados, reemplazar los em literales por `var(--font-letter-spacing-{key})`. |
## Referencias
- Radix Themes Heading: https://www.radix-ui.com/themes/docs/components/heading
- Chakra UI Heading: https://chakra-ui.com/docs/components/heading
- Mantine Title: https://mantine.dev/core/title/
- MUI Typography (h1 variant): https://mui.com/material-ui/react-typography/
## Passive justification
Visual-only (`scope: ['eidos']`). Una sola part Provider, marker
`[data-display]`. Sin estados, sin keyboard, sin ARIA propia. La
recipe consume `--style-hero-*` del foundation + `--_display-{axis}`
overrides escritas inline.

@ -0,0 +1,55 @@
/*
* Display recipe — hero typography primitive.
*
* Always consumes the foundation's `--style-hero-*` tokens (responsive:
* scales up at the `md` breakpoint to the next size step). Per-axis
* overrides via local `--_display-*` variables.
*
* Order of resolution:
* 1. Per-instance override (`--_display-{axis}`) inline by the wrapper.
* 2. Hero style token (`--style-hero-{axis}`) from the foundation.
*/
[data-display] {
font-family: var(--_display-font-family, var(--style-hero-font-family));
font-size: var(--_display-font-size, var(--style-hero-font-size));
font-weight: var(--_display-font-weight, var(--style-hero-font-weight));
line-height: var(--_display-line-height, var(--style-hero-line-height));
letter-spacing: var(--_display-letter-spacing, var(--style-hero-letter-spacing));
color: var(--_display-color, var(--style-hero-color));
text-align: var(--_display-align, inherit);
font-style: normal;
text-decoration: none;
margin: 0;
display: block;
}
[data-display][data-italic] {
font-style: italic;
}
[data-display][data-underline] {
text-decoration: underline;
text-underline-offset: 0.12em;
}
[data-display][data-truncate] {
max-inline-size: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-display][data-clamp] {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--_display-clamp, 1);
line-clamp: var(--_display-clamp, 1);
overflow: hidden;
}
[data-display][data-clamp][data-truncate] {
display: -webkit-box;
white-space: normal;
text-overflow: clip;
}

@ -0,0 +1,91 @@
<script lang="ts">
/**
* Eidos `<Display>` — hero / oversize typography primitive.
*
* Always uses the foundation's `--style-hero-*` tokens. Smaller scope
* than Heading — for the page's "display" title above an `<h1>`
* headline. Default tag is `<h1>` so it slots into the heading
* hierarchy; `as` switches to non-heading tags when needed.
*
* <Display>Welcome to UIX.</Display>
* <Display size="xxxl" weight="bold">Big.</Display>
* <Display tracking="tight" align="center">Spread.</Display>
*
* The `[data-display]` marker is the canonical selector for the recipe.
*/
import { ActiveEidos } from '$uix/eidos';
import type { DisplayProps } from './types';
let {
size,
family,
weight,
color,
align,
tracking,
italic = false,
underline = false,
truncate = false,
clamp,
as = 'h1',
tagStyle,
class: className,
children,
...restProps
}: DisplayProps = $props();
const eidos = ActiveEidos.require();
const TRACKING_VALUE: Record<string, string> = {
tight: '-0.02em',
normal: '0',
wide: '0.02em'
};
function pushVar(decls: string[], name: string, value: string | number | undefined): void {
if (value === undefined || value === null || value === '') return;
decls.push(`${name}: ${value};`);
}
const inlineStyle = $derived.by(() => {
const decls: string[] = [];
const sizeKey = eidos.resolve(size);
const familyKey = eidos.resolve(family);
const weightKey = eidos.resolve(weight);
const colorKey = eidos.resolve(color);
const alignKey = eidos.resolve(align);
const trackingKey = eidos.resolve(tracking);
if (sizeKey) {
pushVar(decls, '--_display-font-size', `var(--font-size-${sizeKey})`);
pushVar(decls, '--_display-line-height', `var(--font-line-height-${sizeKey})`);
pushVar(decls, '--_display-letter-spacing', `var(--font-letter-spacing-${sizeKey})`);
}
if (familyKey) pushVar(decls, '--_display-font-family', `var(--font-family-${familyKey})`);
if (weightKey) pushVar(decls, '--_display-font-weight', `var(--font-weight-${weightKey})`);
if (colorKey) pushVar(decls, '--_display-color', `var(--color-content-${colorKey})`);
if (alignKey) pushVar(decls, '--_display-align', alignKey);
if (trackingKey && trackingKey in TRACKING_VALUE) {
pushVar(decls, '--_display-letter-spacing', TRACKING_VALUE[trackingKey]);
}
if (typeof clamp === 'number' && clamp > 0) pushVar(decls, '--_display-clamp', String(clamp));
const inline = decls.join(' ');
return [inline, tagStyle].filter(Boolean).join(' ');
});
</script>
<svelte:element
this={as}
{...restProps}
class={className}
data-display=""
data-tracking={tracking ? eidos.resolve(tracking) : undefined}
data-italic={italic ? '' : undefined}
data-underline={underline ? '' : undefined}
data-truncate={truncate ? '' : undefined}
data-clamp={typeof clamp === 'number' && clamp > 0 ? '' : undefined}
style={inlineStyle}
>
{@render children?.()}
</svelte:element>

@ -0,0 +1,27 @@
// Display — eidos single-component hero typography primitive.
//
// import { Display } from '$uix/eidos/components/display';
//
// <Display>Welcome to UIX.</Display>
// <Display tracking="tight" weight="bold">Big.</Display>
//
// Single-component primitive — `<Display>` IS the root. Renders as
// `<h1>` by default; `as` switches the tag. Eidos-native: no soma
// backing, no semantic events.
import DisplayComponent from './display.svelte';
const Display = DisplayComponent;
export { Display };
export default Display;
export type {
DisplayProps,
DisplaySize,
DisplayFamily,
DisplayWeight,
DisplayColor,
DisplayAlign,
DisplayTracking
} from './types';

@ -0,0 +1,71 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/** Discrete size scale — matches eidos shared `Size`. */
export type DisplaySize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl';
/** Font family slot. */
export type DisplayFamily = 'primary' | 'secondary' | 'mono' | 'display';
/** Weight slot. */
export type DisplayWeight = 'regular' | 'medium' | 'semibold' | 'bold';
/** Content-role color slot. */
export type DisplayColor = 'primary' | 'secondary' | 'muted' | 'disabled' | 'on-solid';
/** Horizontal alignment. */
export type DisplayAlign = 'start' | 'center' | 'end' | 'justify';
/**
* Display lets the consumer override letter-spacing because at hero
* sizes (44-60px) tracking becomes a design decision. Maps to a fixed
* em-based value chosen for hero copy:
* - `tight` → -0.02em
* - `normal` → 0
* - `wide` → 0.02em
*/
export type DisplayTracking = 'tight' | 'normal' | 'wide';
export type DisplayProps = Omit<HTMLAttributes<HTMLElement>, 'style' | 'children'> & {
/**
* Override the hero style's font-size. Defaults to whatever the
* `--style-hero-font-size` token emits (responsive: scales up at
* the `md` breakpoint).
*/
size?: ResponsiveProp<DisplaySize>;
/** Override the hero style's font-family. */
family?: ResponsiveProp<DisplayFamily>;
/** Override the hero style's font-weight. */
weight?: ResponsiveProp<DisplayWeight>;
/** Override the hero style's color. */
color?: ResponsiveProp<DisplayColor>;
/** Text alignment. */
align?: ResponsiveProp<DisplayAlign>;
/**
* Explicit tracking override. At display sizes letter-spacing is a
* design choice, not a derived value. Maps to
* `var(--font-letter-spacing-{key})`.
*/
tracking?: ResponsiveProp<DisplayTracking>;
/** Italic shorthand. @default false */
italic?: boolean;
/** Underline shorthand. @default false */
underline?: boolean;
/** Single-line truncation with ellipsis. */
truncate?: boolean;
/** Multi-line clamp via CSS `-webkit-line-clamp`. Set 0 to disable. */
clamp?: number;
/**
* HTML tag to render. Default `<h1>` — Display is most often used
* as the page hero title and should slot into the heading hierarchy
* accordingly.
* @default 'h1'
*/
as?: string;
/** Extra inline style — merged after the recipe variables. */
tagStyle?: string;
/** Extra class names. */
class?: string;
children?: Snippet;
};

@ -0,0 +1,108 @@
# Eidos Heading
Semantic heading typography primitive. `level` chooses the HTML tag
(`h1`..`h6`), `style` chooses the visual treatment — by default they
mirror each other (`<Heading level={1}>` renders `<h1>` with the h1
style), but they can be decoupled (`<Heading level={3} style="h1">`)
for the common case where semantics and visual weight diverge.
## Superficie
```svelte
<Heading>Page title</Heading> <!-- h2, style h2 -->
<Heading level={1}>Hero section</Heading> <!-- h1, style h1 -->
<Heading level={3} style="h1">Subhead</Heading> <!-- visual h1, semantic h3 -->
<Heading size="xxl" color="muted">Override scale + color</Heading>
<Heading truncate>Single-line headline that may overflow…</Heading>
<Heading clamp={2}>Two-line clamped title.</Heading>
```
## Baseline
Origen: `air/components/typography/heading` (rama `morfo-runtime`).
Adaptaciones para eidos:
- **Visual + semantic split**: air carried `size` (visual paso 1..12) +
`level` (1..6) independent props. Eidos renames `size` to `style`
(`h1..h6`) tying it to the foundation tokens, while keeping `level`
for HTML semantics. The two default-mirror each other but can be
decoupled.
- **Sizes**: air `1..12` → eidos `xxs..xxxl` mapping to the foundation
`--font-size-{key}` axis. Available as `size` override.
- **Families**: air `'sans'|'serif'` → eidos
`primary/secondary/mono/display` from the foundation contract.
- **Named foundation styles**: nuevo respecto a air. The recipe
consumes `--style-h{n}-*` (family + size + weight + line-height +
letter-spacing + color), with responsive overrides at `md` for
`h1`/`h2`. Per-prop overrides ganan vía local `--_heading-{axis}`.
## Comparativa
| Capacidad | UIX Heading | Radix Themes Heading | Chakra Heading | Mantine Title | MUI Typography |
| --- | --- | --- | --- | --- | --- |
| Semantic level prop | Sí (`level`) | Sí (`as`) | Sí (`as`) | Sí (`order`) | Sí (`component`) |
| Visual style prop (decoupled) | Sí (`style`) | Numerical `size` | `textStyle` | Numerical `order` only | `variant` |
| Decouple level vs style | Sí | Sí | Sí | Limited (order ties both) | Sí |
| Size override | Sí (`size`) | Sí (`size`) | Sí (`fontSize`) | Sí (`size`) | Sí (`fontSize`) |
| Family override | Sí (`family`) | Sí | Sí (`fontFamily`) | Sí (`ff`) | Sí |
| Weight override | Sí | Sí (`weight`) | Sí (`fontWeight`) | Sí (`fw`) | Sí |
| Color override (content-role) | Sí | Sí (intent) | Sí (free) | Sí (free) | Sí (free) |
| Align | Sí | Sí | Sí | Sí (`ta`) | Sí |
| Italic shorthand | Sí | No | Sí | Sí | Sí |
| Underline shorthand | Sí | Sí | Sí | Sí | Sí |
| Truncate | Sí | Sí | Sí | Sí | Sí |
| Clamp (multi-line) | Sí (`clamp={N}`) | No | Sí (`noOfLines`) | Sí (`lineClamp`) | No |
| Responsive props | Sí | Sí | Sí | Sí | Sí |
## Decisiones
- **`level` drives the rendered tag**: rendering `<h{level}>` keeps
the heading hierarchy correct for screen readers. The `as` prop
is available as an escape hatch when the visual look is needed
outside heading semantics.
- **`style` mirrors `level` by default**: avoids a footgun where
consumers forget to set both. Common case is "h2 looks like h2";
decoupling is opt-in (`level={3} style="h1"`).
- **Named foundation styles only**: keeps the visual scale aligned
with the design tokens. Per-axis overrides via `size`, `family`,
`weight` give granular control without breaking the contract.
- **No `tracking` axis prop**: Display owns the explicit letter-spacing
prop because at hero sizes the value is a design decision; for
h1..h6 the foundation token already encodes the right tracking.
- **`color` restricted to content-role slots**: same contract as Text.
Free hex/rgb values go through `tagStyle` escape hatch.
- **Default tag is `<h2>`**: matches the most common in-page heading
level — h1 is reserved for the page title (use `level={1}`
explicitly). Mirrors air's default.
## Eventos Sema
Heading declara 0 eventos. Como Text/Box/Avatar, es una primitiva
pasiva. Componentes que animan headings en aparición componen
Heading dentro de un primitive interactivo (dialog, collapsible,
toast) que posee los verbos sema relevantes.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Canvas-based line-count measurement (`text` string + bindable `lineCount`) | **diferir** | Pesa y complica SSR. CSS `-webkit-line-clamp` cubre el caso común; añadir cuando emerja la demanda real. |
| Anchor-link (`#fragment` heading) integration | **diferir** | Patrón docs-specific, no del primitive. Construir un wrapper `<DocsHeading>` cuando la docs lo necesite. |
| `tracking` prop como en Display | **descartar** | No-goal: h1..h6 tienen tracking definido por el token; si se necesita ajuste, usar `tagStyle`. |
| Color libre (hex / CSS custom) en `color` prop | **descartar** | Rompe contrato de contraste. Escape hatch vía `tagStyle`. |
| Variants tipográficas (small-caps, oldstyle figures) | **diferir** | Bajo prioridad. |
## Referencias
- Radix Themes Heading: https://www.radix-ui.com/themes/docs/components/heading
- Chakra UI Heading: https://chakra-ui.com/docs/components/heading
- Mantine Title: https://mantine.dev/core/title/
- MUI Typography: https://mui.com/material-ui/react-typography/
## Passive justification
Visual-only (`scope: ['eidos']`). Una sola part Provider, marker
`[data-heading]`. Sin estados, sin keyboard, sin ARIA propia (la
semántica viene del tag `<h{level}>` que el navegador y AT entienden
nativamente). La recipe consume `--style-h{n}-*` del foundation +
`--_heading-{axis}` overrides escritas inline.

@ -0,0 +1,106 @@
/*
* Heading recipe — semantic heading primitive.
*
* Applies one of the foundation's `--style-h{n}-*` named typography styles
* (h1..h6) per the `data-style` attribute, with per-axis overrides via
* local `--_heading-*` variables. The default `data-style` mirrors `level`,
* so `<Heading level={1}>` renders the h1 style automatically.
*
* Order of resolution (per CSS variable cascade):
* 1. Per-instance override (`--_heading-{axis}`) set inline by the
* wrapper when the consumer passes a prop like `size="lg"`.
* 2. Named style token (`--style-h{n}-{axis}`) selected by `data-style`.
*
* Headings inherit a `margin: 0` reset — flow margins live on the
* containing layout primitive (Stack / Section).
*/
[data-heading] {
font-family: var(--_heading-font-family, var(--style-h2-font-family));
font-size: var(--_heading-font-size, var(--style-h2-font-size));
font-weight: var(--_heading-font-weight, var(--style-h2-font-weight));
line-height: var(--_heading-line-height, var(--style-h2-line-height));
letter-spacing: var(--_heading-letter-spacing, var(--style-h2-letter-spacing));
color: var(--_heading-color, var(--style-h2-color));
text-align: var(--_heading-align, inherit);
font-style: normal;
text-decoration: none;
margin: 0;
}
[data-heading][data-style='h1'] {
font-family: var(--_heading-font-family, var(--style-h1-font-family));
font-size: var(--_heading-font-size, var(--style-h1-font-size));
font-weight: var(--_heading-font-weight, var(--style-h1-font-weight));
line-height: var(--_heading-line-height, var(--style-h1-line-height));
letter-spacing: var(--_heading-letter-spacing, var(--style-h1-letter-spacing));
color: var(--_heading-color, var(--style-h1-color));
}
[data-heading][data-style='h3'] {
font-family: var(--_heading-font-family, var(--style-h3-font-family));
font-size: var(--_heading-font-size, var(--style-h3-font-size));
font-weight: var(--_heading-font-weight, var(--style-h3-font-weight));
line-height: var(--_heading-line-height, var(--style-h3-line-height));
letter-spacing: var(--_heading-letter-spacing, var(--style-h3-letter-spacing));
color: var(--_heading-color, var(--style-h3-color));
}
[data-heading][data-style='h4'] {
font-family: var(--_heading-font-family, var(--style-h4-font-family));
font-size: var(--_heading-font-size, var(--style-h4-font-size));
font-weight: var(--_heading-font-weight, var(--style-h4-font-weight));
line-height: var(--_heading-line-height, var(--style-h4-line-height));
letter-spacing: var(--_heading-letter-spacing, var(--style-h4-letter-spacing));
color: var(--_heading-color, var(--style-h4-color));
}
[data-heading][data-style='h5'] {
font-family: var(--_heading-font-family, var(--style-h5-font-family));
font-size: var(--_heading-font-size, var(--style-h5-font-size));
font-weight: var(--_heading-font-weight, var(--style-h5-font-weight));
line-height: var(--_heading-line-height, var(--style-h5-line-height));
letter-spacing: var(--_heading-letter-spacing, var(--style-h5-letter-spacing));
color: var(--_heading-color, var(--style-h5-color));
}
[data-heading][data-style='h6'] {
font-family: var(--_heading-font-family, var(--style-h6-font-family));
font-size: var(--_heading-font-size, var(--style-h6-font-size));
font-weight: var(--_heading-font-weight, var(--style-h6-font-weight));
line-height: var(--_heading-line-height, var(--style-h6-line-height));
letter-spacing: var(--_heading-letter-spacing, var(--style-h6-letter-spacing));
color: var(--_heading-color, var(--style-h6-color));
}
[data-heading][data-italic] {
font-style: italic;
}
[data-heading][data-underline] {
text-decoration: underline;
text-underline-offset: 0.15em;
}
[data-heading][data-truncate] {
display: inline-block;
max-inline-size: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
[data-heading][data-clamp] {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--_heading-clamp, 1);
line-clamp: var(--_heading-clamp, 1);
overflow: hidden;
}
[data-heading][data-clamp][data-truncate] {
display: -webkit-box;
white-space: normal;
text-overflow: clip;
}

@ -0,0 +1,86 @@
<script lang="ts">
/**
* Eidos `<Heading>` — semantic heading (h1..h6) typography primitive.
*
* `level` drives the rendered HTML tag (`<h{level}>`) and defaults to
* `2`. `style` selects the visual treatment (defaults to mirror `level`)
* — the two are decoupled so you can render a semantic h3 with the
* visual weight of an h1.
*
* <Heading>Page title</Heading> <!-- h2, style="h2" -->
* <Heading level={1}>Hero title</Heading> <!-- h1, style="h1" -->
* <Heading level={3} style="h1">Section</Heading>
*
* The `[data-heading]` marker is the canonical selector for the recipe.
*/
import { ActiveEidos } from '$uix/eidos';
import type { HeadingProps, HeadingLevel, HeadingStyle } from './types';
let {
level = 2,
style: headingStyle,
size,
family,
weight,
color,
align,
italic = false,
underline = false,
truncate = false,
clamp,
as,
tagStyle,
class: className,
children,
...restProps
}: HeadingProps = $props();
const eidos = ActiveEidos.require();
const resolvedTag = $derived(as ?? `h${level}`);
const resolvedStyle = $derived<HeadingStyle>(headingStyle ?? (`h${level}` as HeadingStyle));
function pushVar(decls: string[], name: string, value: string | number | undefined): void {
if (value === undefined || value === null || value === '') return;
decls.push(`${name}: ${value};`);
}
const inlineStyle = $derived.by(() => {
const decls: string[] = [];
const sizeKey = eidos.resolve(size);
const familyKey = eidos.resolve(family);
const weightKey = eidos.resolve(weight);
const colorKey = eidos.resolve(color);
const alignKey = eidos.resolve(align);
if (sizeKey) {
pushVar(decls, '--_heading-font-size', `var(--font-size-${sizeKey})`);
pushVar(decls, '--_heading-line-height', `var(--font-line-height-${sizeKey})`);
pushVar(decls, '--_heading-letter-spacing', `var(--font-letter-spacing-${sizeKey})`);
}
if (familyKey) pushVar(decls, '--_heading-font-family', `var(--font-family-${familyKey})`);
if (weightKey) pushVar(decls, '--_heading-font-weight', `var(--font-weight-${weightKey})`);
if (colorKey) pushVar(decls, '--_heading-color', `var(--color-content-${colorKey})`);
if (alignKey) pushVar(decls, '--_heading-align', alignKey);
if (typeof clamp === 'number' && clamp > 0) pushVar(decls, '--_heading-clamp', String(clamp));
const inline = decls.join(' ');
return [inline, tagStyle].filter(Boolean).join(' ');
});
</script>
<svelte:element
this={resolvedTag}
{...restProps}
class={className}
data-heading=""
data-level={level}
data-style={resolvedStyle}
data-italic={italic ? '' : undefined}
data-underline={underline ? '' : undefined}
data-truncate={truncate ? '' : undefined}
data-clamp={typeof clamp === 'number' && clamp > 0 ? '' : undefined}
style={inlineStyle}
>
{@render children?.()}
</svelte:element>

@ -0,0 +1,29 @@
// Heading — eidos single-component typography primitive.
//
// import { Heading } from '$uix/eidos/components/heading';
//
// <Heading>Page title</Heading> <!-- h2, style="h2" -->
// <Heading level={1}>Hero title</Heading> <!-- h1, style="h1" -->
// <Heading level={3} style="h1">Section</Heading>
//
// Single-component primitive — `<Heading>` IS the root, no compound
// parts. Renders as `<h{level}>` (default `<h2>`). Eidos-native: no
// soma backing, no semantic events.
import HeadingComponent from './heading.svelte';
const Heading = HeadingComponent;
export { Heading };
export default Heading;
export type {
HeadingProps,
HeadingStyle,
HeadingLevel,
HeadingSize,
HeadingFamily,
HeadingWeight,
HeadingColor,
HeadingAlign
} from './types';

@ -0,0 +1,69 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/** Named heading styles emitted by the foundation as `--style-h{n}-*`. */
export type HeadingStyle = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
/** Semantic HTML heading level (h1..h6). */
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
/** Discrete size scale — matches eidos `Size`. */
export type HeadingSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl';
/** Font family slot. Maps to `--font-family-{key}`. */
export type HeadingFamily = 'primary' | 'secondary' | 'mono' | 'display';
/** Weight slot. Maps to `--font-weight-{key}`. */
export type HeadingWeight = 'regular' | 'medium' | 'semibold' | 'bold';
/** Content-role color slot. */
export type HeadingColor = 'primary' | 'secondary' | 'muted' | 'disabled' | 'on-solid';
/** Horizontal alignment. */
export type HeadingAlign = 'start' | 'center' | 'end' | 'justify';
export type HeadingProps = Omit<HTMLAttributes<HTMLHeadingElement>, 'style' | 'children'> & {
/**
* Semantic HTML level — renders `<h{level}>`. Default `2`. Independent
* of `style`, allowing decoupling (`level={3} style="h1"` for visual h1
* with semantic h3).
* @default 2
*/
level?: HeadingLevel;
/**
* Named typography style. When omitted, mirrors `level` (h1..h6).
* Applies the full `--style-h{n}-*` token set (family + size + weight +
* line-height + letter-spacing + color).
*/
style?: HeadingStyle;
/** Override the style's font-size. */
size?: ResponsiveProp<HeadingSize>;
/** Override the style's font-family. */
family?: ResponsiveProp<HeadingFamily>;
/** Override the style's font-weight. */
weight?: ResponsiveProp<HeadingWeight>;
/** Override the style's color. */
color?: ResponsiveProp<HeadingColor>;
/** Text alignment. */
align?: ResponsiveProp<HeadingAlign>;
/** Italic shorthand. @default false */
italic?: boolean;
/** Underline shorthand. @default false */
underline?: boolean;
/** Single-line truncation with ellipsis. @default false */
truncate?: boolean;
/** Multi-line clamp via CSS `-webkit-line-clamp`. Set 0 to disable. */
clamp?: number;
/**
* Override the rendered tag. By default the tag is derived from `level`
* (`<h{level}>`). Use this to render a non-heading element while keeping
* the heading typography.
*/
as?: string;
/** Extra inline style — merged after the recipe variables. */
tagStyle?: string;
/** Extra class names. */
class?: string;
children?: Snippet;
};

@ -0,0 +1,83 @@
# Eidos Highlight
Substring highlighter primitive. Wraps a `<span>` shell containing the
input `text` and replaces matches of `query` with `<mark>` elements
that inherit the same recipe as the standalone `<Mark>` primitive.
Composes inside any inline-flow primitive (Text, Heading, paragraph).
## Superficie
```svelte
<Highlight text="Hello world" query="world" />
<Highlight text={message} query={['error', 'failed']} color="threat" />
<Highlight text="Case study" query="case" ignoreCase={false} />
```
## Baseline
Origen: `air/components/typography/highlight` (rama `morfo-runtime`).
Adaptaciones para eidos:
- **Reuses Mark recipe**: air's Highlight imported `../mark/mark.css`
directly. Eidos keeps the same idea — the inner `<mark>` elements
stamp `data-mark` so the Mark recipe applies without duplication.
- **Palette canonized**: same migration as Mark — `yellow / green /
blue / red / purple` → intent vocabulary.
- **Default `fulfill`**: consistent with Mark.
- **Pure CSS**: the highlight shell is `<span>` + `font: inherit`.
Air did the same.
## Comparativa
| Capacidad | UIX Highlight | Mantine Highlight | Radix Themes | Chakra Highlight |
| --- | --- | --- | --- | --- |
| Substring match | Sí | Sí | No | Sí |
| Multiple terms | Sí (`query` array) | Sí (`highlight` array) | No | Sí (`query` array) |
| Case-insensitive | Sí (`ignoreCase`) | Sí (`highlightStyles`) | No | Sí (`styles.matchCase`) |
| Color palette | Intent | free | No | colorScheme |
| Regex query | No (literal escape) | Yes (with prop) | No | No |
| Inherits typography | Sí | Sí | No | Sí |
## Decisiones
- **Inner marks reuse Mark recipe**: avoids two parallel highlight
surfaces. The Mark recipe is the single visual source.
- **Outer shell is `<span>`**: keeps Highlight composable inline.
Consumers wrap it inside `<Text>` / `<Heading>` for typography.
- **`query` is literal**: each term is regex-escaped, so consumers
can pass user input safely. No `regex` opt-in today — if a power
use case emerges, add a `pattern` prop that accepts a `RegExp`.
- **`ignoreCase` default `true`**: search-result UX expects
case-insensitive matching. Opt out via `ignoreCase={false}` when
the consumer needs case-sensitive logic (code search).
- **No empty-query gotcha**: if `query` is empty / whitespace-only,
render the text unchanged (no marks).
## Eventos Sema
Highlight declara 0 eventos. Visual-only — el primitive es un
formatter, no input.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Regex query support | **diferir** | Si emerge, añadir `pattern: RegExp` prop como alternativa a `query`. |
| Token / word-boundary match | **diferir** | Requiere parser; cubrir cuando llegue un caso real. |
| Snippet-based render override | **diferir** | Si un consumer necesita custom Mark per term, ya puede componer Mark manualmente sin Highlight. |
| Per-term different colors | **diferir** | Hoy todos los matches usan el mismo `color`. Si emerge, aceptar un map `{ term: color }`. |
| Free hex color | **descartar** | Mismo razón que Mark — contrato de contraste. |
## Referencias
- Mantine Highlight: https://mantine.dev/core/highlight/
- Chakra UI Highlight: https://chakra-ui.com/docs/components/highlight
- MDN `<mark>`: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/mark
## Passive justification
Visual-only (`scope: ['eidos']`). Una sola part Provider (`<span
data-highlight>` shell). El primitive es un formatter: dado `text` +
`query` produce un árbol de segmentos, marcando los matches con
`<mark data-mark>`. Sin estados, sin keyboard, sin ARIA propia.
La recipe del mark inner es compartida con `<Mark>`.

@ -0,0 +1,20 @@
/*
* Highlight recipe — substring highlighter shell.
*
* The shell is purely a structural anchor; all visible chrome lives on
* the inner `<mark data-mark>` elements which inherit the Mark recipe
* (see ../mark/mark.css). The shell consumes `font: inherit` so it
* composes inside any inline-flow primitive without breaking line
* height.
*
* Per-instance color is stamped via `data-color` on the outer shell
* for consumer overrides (the inner marks duplicate the value so the
* Mark recipe matches without traversing the cascade).
*/
[data-highlight] {
font: inherit;
color: inherit;
background: transparent;
display: inline;
}

@ -0,0 +1,72 @@
<script lang="ts">
/**
* Eidos `<Highlight>` — substring highlighter.
*
* <Highlight text="Hello world" query="world" />
* <Highlight text={message} query={['error', 'failed']} color="threat" />
*
* Wraps the input `text` in a `<span>` shell and replaces matches of
* `query` (string or array) with `<mark data-mark>` elements that
* inherit the same recipe as the standalone `<Mark>` primitive.
*/
import { ActiveEidos } from '$uix/eidos';
import type { HighlightProps } from './types';
let {
text,
query,
ignoreCase = true,
color,
as = 'span',
tagStyle,
class: className,
...restProps
}: HighlightProps = $props();
const eidos = ActiveEidos.require();
const resolvedColor = $derived(eidos.resolve(color) ?? 'fulfill');
type Segment = { text: string; mark: boolean };
const segments = $derived.by<Segment[]>(() => {
const terms = (Array.isArray(query) ? query : [query]).filter((t) => t && t.length > 0);
if (terms.length === 0) return [{ text, mark: false }];
const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = escaped.join('|');
const flags = ignoreCase ? 'gi' : 'g';
const regex = new RegExp(`(${pattern})`, flags);
const result: Segment[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push({ text: text.slice(lastIndex, match.index), mark: false });
}
result.push({ text: match[0], mark: true });
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
result.push({ text: text.slice(lastIndex), mark: false });
}
return result.length > 0 ? result : [{ text, mark: false }];
});
</script>
<svelte:element
this={as}
{...restProps}
class={className}
data-highlight=""
data-color={resolvedColor}
style={tagStyle}
>
{#each segments as seg}
{#if seg.mark}
<mark data-mark="" data-color={resolvedColor}>{seg.text}</mark>
{:else}
{seg.text}
{/if}
{/each}
</svelte:element>

@ -0,0 +1,18 @@
// Highlight — eidos single-component substring highlighter.
//
// import { Highlight } from '$uix/eidos/components/highlight';
//
// <Highlight text="Hello world" query="world" />
// <Highlight text={message} query={['error', 'failed']} color="threat" />
//
// Single-component primitive — `<Highlight>` IS the root. Renders a
// `<span>` shell wrapping matches in `<mark data-mark>` elements.
import HighlightComponent from './highlight.svelte';
const Highlight = HighlightComponent;
export { Highlight };
export default Highlight;
export type { HighlightProps, HighlightColor } from './types';

@ -0,0 +1,22 @@
import type { HTMLAttributes } from 'svelte/elements';
import type { ColorRole, ResponsiveProp } from '$uix/eidos/lib/types';
/** Intent palette for the highlight tint. */
export type HighlightColor = ColorRole;
export type HighlightProps = Omit<HTMLAttributes<HTMLElement>, 'style' | 'children'> & {
/** Full text to render. Matches of `query` will be wrapped in `<mark>`. */
text: string;
/** Substring(s) to highlight. Pass an array for multiple matches. */
query: string | string[];
/** Case-insensitive match. @default true */
ignoreCase?: boolean;
/** Intent palette for the highlight tint. @default 'fulfill' */
color?: ResponsiveProp<HighlightColor>;
/** Render a different tag for the outer shell. @default 'span' */
as?: string;
/** Extra inline style on the outer shell. */
tagStyle?: string;
/** Extra class names on the outer shell. */
class?: string;
};

@ -0,0 +1,82 @@
# Eidos Kbd
Keyboard shortcut typography primitive. Renders `<kbd>` with a small
chrome (border + background + inner padding) so the key looks like a
physical cap. Inline within prose — for shortcut hints, key sequences.
## Superficie
```svelte
<p>Press <Kbd>Ctrl</Kbd> + <Kbd>K</Kbd> to open search.</p>
<p>Hit <Kbd variant="outline" size="sm">Esc</Kbd> to dismiss.</p>
<p>Type <Kbd variant="ghost">tab</Kbd> to switch focus.</p>
```
## Baseline
Origen: `air/components/typography/kbd` (rama `morfo-runtime`).
Adaptaciones para eidos:
- **Default size aligned with foundation**: air defaulted `size=2`
(small). Eidos reads `--font-size-xs` directly so the default
pulls from the foundation contract instead of a numeric paso.
- **Variants added**: `surface` (default, filled with neutral element
bg + border), `outline` (transparent + border), `ghost` (no chrome).
Air had only one treatment.
- **Lower border emphasis preserved**: `surface` keeps the
`border-bottom-width: 2px` + inset shadow trick to read like a
physical cap; `outline` and `ghost` drop it for cleaner inline
flow.
- **Tokens**: `--air-kbd-*` → consume the foundation directly
(`--style-code-*`, `--color-neutral-*`, `--font-size-{key}`).
## Comparativa
| Capacidad | UIX Kbd | Radix Themes Kbd | Chakra Kbd | Mantine Kbd | shadcn/ui Kbd |
| --- | --- | --- | --- | --- | --- |
| Visual key-cap chrome | Sí | Sí | Sí | Sí | Sí |
| Variants | surface / outline / ghost | size only | colorScheme | none | none |
| Size override | Sí | Sí | Sí | Sí | inherits font-size |
| Mono family | Sí (`--style-code-font-family`) | Sí | Sí | Sí | Sí |
| Inline semantics (`<kbd>`) | Sí | Sí | Sí | Sí | Sí |
| `as` polymorphism | Sí | Sí | Sí | Sí | No |
## Decisiones
- **Default `surface` variant**: the chrome is the entire point of the
primitive — bare mono text would just be `<Code>`.
- **Mono family from `--style-code-font-family`**: same source as Code
/ CodeBlock. Keeps the typography contract single-rooted.
- **`min-inline-size: 1.5em`**: ensures single-char keys ("K", "1")
read as square caps and don't collapse to text width.
- **No platform key mapping**: air did not ship `<Kbd cmd>` to swap
`Ctrl` → `⌘` on macOS. Defer until a clear use case emerges; the
consumer can branch on `navigator.platform` today.
## Eventos Sema
Kbd declara 0 eventos. Visual-only — el shortcut event lives on
the consumer (a Toolbar button, a global key handler), no en el
primitive de label.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Platform-aware key swap (`Ctrl` ↔ `⌘`) | **diferir** | El consumer branches en `navigator.platform`. Si el patrón se repite, añadir un helper `kbdForPlatform()` o un wrapper docs-level. |
| Sequence helper (`<Kbd sequence={['Ctrl','K']} />`) | **diferir** | Composable inline — `<Kbd>Ctrl</Kbd> + <Kbd>K</Kbd>` cubre el caso sin contratar más superficie. |
| Sound on key press demo (educational) | **descartar** | El primitive es label, no input. Sound on press pertenece a una demo o componente Sound separado. |
## Referencias
- Radix Themes Kbd: https://www.radix-ui.com/themes/docs/components/kbd
- Chakra UI Kbd: https://chakra-ui.com/docs/components/kbd
- Mantine Kbd: https://mantine.dev/core/kbd/
- MDN `<kbd>`: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/kbd
## Passive justification
Visual-only (`scope: ['eidos']`). Una sola part Provider, marker
`[data-kbd]`. Sin estados, sin keyboard, sin ARIA propia. La recipe
consume `--style-code-*` + `--color-neutral-*` del foundation + el
override `--_kbd-font-size` inline.

@ -0,0 +1,18 @@
// Kbd — eidos single-component keyboard shortcut primitive.
//
// import { Kbd } from '$uix/eidos/components/kbd';
//
// <Kbd>Ctrl</Kbd> + <Kbd>K</Kbd>
// <Kbd variant="outline" size="sm">Esc</Kbd>
//
// Single-component primitive — `<Kbd>` IS the root. Renders as
// `<kbd>` by default.
import KbdComponent from './kbd.svelte';
const Kbd = KbdComponent;
export { Kbd };
export default Kbd;
export type { KbdProps, KbdSize, KbdVariant } from './types';

@ -0,0 +1,49 @@
/*
* Kbd recipe — keyboard shortcut visual.
*
* Uses the foundation's code typography (mono family + small size) plus
* a chrome that mimics a physical key cap. The `data-variant` flag
* picks the chrome treatment:
* - `surface` filled with neutral element bg + border
* - `outline` transparent fill, border only
* - `ghost` no chrome, mono font only
*/
[data-kbd] {
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--style-code-font-family);
font-size: var(--_kbd-font-size, var(--font-size-xs));
font-weight: var(--font-weight-medium);
line-height: 1;
color: var(--color-content-primary);
background: transparent;
border: 0;
border-radius: var(--radius-sm);
padding-inline: 0.4em;
padding-block: 0.18em;
min-inline-size: 1.5em;
vertical-align: baseline;
user-select: none;
white-space: nowrap;
letter-spacing: 0.02em;
}
[data-kbd][data-variant='surface'] {
background: var(--color-neutral-element);
border: 1px solid var(--color-neutral-border);
border-bottom-width: 2px;
box-shadow: 0 1px 0 var(--color-neutral-border) inset;
}
[data-kbd][data-variant='outline'] {
background: transparent;
border: 1px solid var(--color-neutral-border);
}
[data-kbd][data-variant='ghost'] {
background: transparent;
border: 0;
padding-inline: 0;
}

@ -0,0 +1,51 @@
<script lang="ts">
/**
* Eidos `<Kbd>` — keyboard shortcut visual.
*
* <Kbd>Ctrl</Kbd> + <Kbd>K</Kbd>
* <Kbd variant="outline" size="sm">Esc</Kbd>
*
* Renders `<kbd>` with a small chrome (border, background, inner
* padding) so the key looks like a physical cap.
*/
import { ActiveEidos } from '$uix/eidos';
import type { KbdProps } from './types';
let {
variant,
size,
as = 'kbd',
tagStyle,
class: className,
children,
...restProps
}: KbdProps = $props();
const eidos = ActiveEidos.require();
const resolvedVariant = $derived(eidos.resolve(variant) ?? 'surface');
function pushVar(decls: string[], name: string, value: string | undefined): void {
if (!value) return;
decls.push(`${name}: ${value};`);
}
const inlineStyle = $derived.by(() => {
const decls: string[] = [];
const sizeKey = eidos.resolve(size);
if (sizeKey) pushVar(decls, '--_kbd-font-size', `var(--font-size-${sizeKey})`);
const inline = decls.join(' ');
return [inline, tagStyle].filter(Boolean).join(' ');
});
</script>
<svelte:element
this={as}
{...restProps}
class={className}
data-kbd=""
data-variant={resolvedVariant}
style={inlineStyle}
>
{@render children?.()}
</svelte:element>

@ -0,0 +1,23 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/** Discrete size scale. */
export type KbdSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Visual treatment. */
export type KbdVariant = 'surface' | 'outline' | 'ghost';
export type KbdProps = Omit<HTMLAttributes<HTMLElement>, 'style' | 'children'> & {
/** Visual treatment. @default 'surface' */
variant?: ResponsiveProp<KbdVariant>;
/** Size override. @default inherits foundation `--style-code-font-size` */
size?: ResponsiveProp<KbdSize>;
/** Render a different tag. @default 'kbd' */
as?: string;
/** Extra inline style. */
tagStyle?: string;
/** Extra class names. */
class?: string;
children?: Snippet;
};

@ -0,0 +1,112 @@
# Eidos Link
Inline text link primitive. Renders `<a>` by default — or
`<span aria-disabled>` when disabled — so the link composes
mid-paragraph without breaking the ambient typography. Visual
variants drive color + underline; `external` adds the standard
`target="_blank"` + `rel="noopener noreferrer"` + accessibility
affordances.
## Superficie
```svelte
<p>Read the <Link href="/docs">documentation</Link>.</p>
<Link href="https://github.com/" external>GitHub</Link>
<Link variant="subtle" underline="always" color="secondary">Tertiary</Link>
<Link disabled>Unavailable</Link>
```
## Baseline
Origen: `air/components/typography/link` (rama `morfo-runtime`).
Adaptaciones para eidos:
- **Variants**: air shipped `default / subtle / quiet / emphasized`.
Eidos consolidates to `default / subtle / plain`:
- `default` = colored underline-on-hover (most common)
- `subtle` = inherits color, underline-on-hover (sidebar / nav)
- `plain` = inherits color, no underline (button-like inline)
`emphasized` is replaceable via `weight` / `color` overrides on Text
if the user truly needs a bold link.
- **Underline contract preserved**: `none / hover / always` — air's
shape.
- **`color` opened to full intent palette**: air locked to a single
`primary` color. Eidos accepts the canonical `ColorRole` union so
destructive-link variants (`color="risk"`) and affirmative
(`color="affirm"`) compose with the rest of the system.
- **`size` reduced to xxs..xl**: air used `sm/md/lg`. Eidos aligns
with the foundation `--font-size-{key}` scale; default inherits
ambient typography.
- **External-link icon kept**: tiny inline SVG + SR-only "(opens in
new tab)" text. Air's pattern.
- **`disabled` renders `<span aria-disabled>`**: matches air; drops
`href`, disables pointer events.
## Comparativa
| Capacidad | UIX Link | Radix Themes Link | Chakra Link | Mantine Anchor |
| --- | --- | --- | --- | --- |
| Inline anchor element | Sí | Sí | Sí | Sí |
| Variant treatments | default / subtle / plain | size + weight | colorScheme | underline + colors |
| Underline control | none / hover / always | hover only | hover + always | hover + always |
| Color (intent palette) | Sí (full ColorRole) | accent | colorScheme | colors |
| External link affordance | Sí (`external` + SR text + icon) | manual | manual | manual |
| Disabled state | Sí | No | Sí (custom) | Sí |
| Inherits typography | Sí | Sí | Sí | Sí |
| `size` override | Sí | Sí (size scale) | Sí | Sí |
## Decisiones
- **Inherits ambient typography**: Link is mid-paragraph; the font /
weight / line-height come from the surrounding `<Text>` /
`<Heading>` / `<p>`. Local overrides are limited to size, color,
underline.
- **`color` opens to full ColorRole**: enables `color="risk"` for
destructive links (`Delete account`) and `color="affirm"` for
positive actions, while keeping the contrast contract via the
intent palette.
- **Default `variant='default'` + `underline='hover'`**: matches
Radix / Chakra default. The colored-underline-on-hover is the
most familiar inline link affordance.
- **`disabled` renders `<span aria-disabled>`**: keeps the anchor
out of the tab order without removing the visual position. The
consumer can swap to a `<button disabled>` if they need keyboard
focus parity with form controls.
- **External-link affordance is opt-in**: `external` is the explicit
prop. SR-only text reads "(opens in new tab)"; the SVG icon is
visible. Reverts to plain `<a>` when omitted.
## Eventos Sema
Link declara 0 eventos en su morfo. Navigation/click ownership belongs
to the consumer (SvelteKit router, custom action, sema-driven verb on
the surrounding component). Si Link se usa como affordance que disparara
un commit (form submit, dialog action), el consumer compone un
`<Toolbar.Button>` u otro componente con sema — Link no es ese surface.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| `emphasized` air variant (bold link) | **diferir** | Composable: `<Text weight="semibold"><Link>…</Link></Text>` cubre el caso sin contratar superficie. |
| Custom external icon | **diferir** | Si emerge demanda real, exponer un slot snippet. |
| `prefetch` / SvelteKit-specific props | **descartar** | El consumer pasa atributos directamente al `<a>`. |
| Sema `commit-navigate` event | **diferir** | Bajo el contrato actual la navegación es responsabilidad del consumer; si emerge un caso (analytics, perceptual feedback), añadir cuando 2 capas lo consuman. |
| Disabled state + focus ring | **descartar** | Cuando deshabilitado, no debe estar en el tab order. |
## Referencias
- Radix Themes Link: https://www.radix-ui.com/themes/docs/components/link
- Chakra UI Link: https://chakra-ui.com/docs/components/link
- Mantine Anchor: https://mantine.dev/core/anchor/
- shadcn/ui Link patterns: https://ui.shadcn.com/
## Passive justification
`scope: ['eidos']` — visual-only primitive. Una sola part Provider
(`<a data-link>` o `<span data-link data-disabled>`). El primitive
expone estados visuales (`hover`, `active`, `focus-visible`,
`disabled`) pero **no** semantic events: hover y active no son
verbos sema, y commit-navigate pertenece al consumer (router /
analytics). La recipe consume `--color-{intent}-*` + `--radius-sm`
del foundation.

@ -0,0 +1,20 @@
// Link — eidos single-component inline text link.
//
// import { Link } from '$uix/eidos/components/link';
//
// <Link href="/docs">docs</Link>
// <Link href="https://github.com/" external>GitHub</Link>
// <Link variant="subtle" underline="always">Tertiary</Link>
//
// Single-component primitive — `<Link>` IS the root. Renders `<a>`
// (or `<span aria-disabled>` when disabled). Inherits ambient
// typography.
import LinkComponent from './link.svelte';
const Link = LinkComponent;
export { Link };
export default Link;
export type { LinkProps, LinkVariant, LinkUnderline, LinkSize, LinkColor } from './types';

@ -0,0 +1,128 @@
/*
* Link recipe — inline text link.
*
* Inherits ambient typography (no own font-family / weight). The recipe
* controls color, underline behavior, focus ring, hover / active visual
* states, and an external-link icon.
*
* Variant matrix:
* - `default` intent color, underline-on-hover
* - `subtle` inherits color, underline-on-hover
* - `plain` inherits color, no underline ever
*
* Underline override (`always` / `hover` / `none`) wins over variant
* default when explicitly set.
*/
[data-link] {
font: inherit;
font-size: var(--_link-font-size, inherit);
line-height: var(--_link-line-height, inherit);
color: var(--color-primary-text);
background: transparent;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
gap: 0.25em;
cursor: pointer;
text-decoration: none;
transition:
color 120ms ease,
text-decoration-color 120ms ease;
}
[data-link][data-color='primary'] {
color: var(--color-primary-text);
}
[data-link][data-color='secondary'] {
color: var(--color-secondary-text);
}
[data-link][data-color='neutral'] {
color: var(--color-neutral-text);
}
[data-link][data-color='affirm'] {
color: var(--color-affirm-text);
}
[data-link][data-color='fulfill'] {
color: var(--color-fulfill-text);
}
[data-link][data-color='risk'] {
color: var(--color-risk-text);
}
[data-link][data-color='threat'] {
color: var(--color-threat-text);
}
[data-link][data-color='loss'] {
color: var(--color-loss-text);
}
[data-link][data-variant='subtle'],
[data-link][data-variant='plain'] {
color: inherit;
}
/* Underline behaviour. `data-underline` overrides the variant default. */
[data-link][data-underline='always'] {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
[data-link][data-underline='hover']:hover,
[data-link][data-underline='hover']:focus-visible {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
[data-link][data-underline='none'] {
text-decoration: none;
}
[data-link][data-variant='plain'][data-underline='hover']:hover,
[data-link][data-variant='plain'][data-underline='hover']:focus-visible {
text-decoration: none;
}
[data-link]:hover {
color: var(--color-primary-solid-hover);
}
[data-link][data-variant='subtle']:hover,
[data-link][data-variant='plain']:hover {
color: inherit;
opacity: 0.85;
}
[data-link]:active {
color: var(--color-primary-active);
}
[data-link]:focus-visible {
outline: 2px solid var(--color-primary-solid);
outline-offset: 2px;
}
[data-link][data-disabled] {
cursor: not-allowed;
opacity: 0.5;
pointer-events: none;
}
[data-link-external-icon] {
width: 0.85em;
height: 0.85em;
fill: currentColor;
flex-shrink: 0;
}
[data-link-sr-only] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

@ -0,0 +1,96 @@
<script lang="ts">
/**
* Eidos `<Link>` — inline text link.
*
* <p>Read the <Link href="/docs">docs</Link>.</p>
* <Link href="https://github.com/" external>GitHub</Link>
* <Link variant="subtle" underline="always">Tertiary nav</Link>
*
* Renders `<a>` by default — or `<span aria-disabled>` when
* `disabled` — so the link composes inline within any prose.
* Inherits the ambient typography; only color / underline / size
* are local concerns.
*/
import { ActiveEidos } from '$uix/eidos';
import type { LinkProps } from './types';
let {
href,
external = false,
variant,
underline,
size,
color,
disabled = false,
tagStyle,
class: className,
children,
...restProps
}: LinkProps = $props();
const eidos = ActiveEidos.require();
const resolvedVariant = $derived(eidos.resolve(variant) ?? 'default');
const resolvedUnderline = $derived(eidos.resolve(underline) ?? 'hover');
const resolvedColor = $derived(eidos.resolve(color) ?? 'primary');
function pushVar(decls: string[], name: string, value: string | undefined): void {
if (!value) return;
decls.push(`${name}: ${value};`);
}
const inlineStyle = $derived.by(() => {
const decls: string[] = [];
const sizeKey = eidos.resolve(size);
if (sizeKey) {
pushVar(decls, '--_link-font-size', `var(--font-size-${sizeKey})`);
pushVar(decls, '--_link-line-height', `var(--font-line-height-${sizeKey})`);
}
const inline = decls.join(' ');
return [inline, tagStyle].filter(Boolean).join(' ');
});
const externalAttrs = $derived(
external ? { target: '_blank', rel: 'noopener noreferrer' } : {}
);
</script>
{#if disabled}
<!-- Drop anchor-specific attributes when rendering the disabled span. -->
<span
{...(restProps as Record<string, unknown>)}
class={className}
data-link=""
data-variant={resolvedVariant}
data-underline={resolvedUnderline}
data-color={resolvedColor}
data-disabled=""
aria-disabled="true"
style={inlineStyle}
>
{@render children?.()}
</span>
{:else}
<a
{href}
{...restProps}
{...externalAttrs}
class={className}
data-link=""
data-variant={resolvedVariant}
data-underline={resolvedUnderline}
data-color={resolvedColor}
data-external={external ? '' : undefined}
style={inlineStyle}
>
{@render children?.()}
{#if external}
<svg data-link-external-icon viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M14 3h7v7h-2V6.41l-9.29 9.3-1.42-1.42L17.59 5H14V3zM5 5h6v2H7v10h10v-4h2v6H5V5z"
/>
</svg>
<span data-link-sr-only>(opens in new tab)</span>
{/if}
</a>
{/if}

@ -0,0 +1,45 @@
import type { Snippet } from 'svelte';
import type { HTMLAnchorAttributes } from 'svelte/elements';
import type { ColorRole, ResponsiveProp } from '$uix/eidos/lib/types';
/** Visual variant. `default` = colored underline-on-hover, `subtle` = inherits color, `plain` = no underline ever. */
export type LinkVariant = 'default' | 'subtle' | 'plain';
/** Underline behavior. */
export type LinkUnderline = 'none' | 'hover' | 'always';
/** Size override. */
export type LinkSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Intent color slot. */
export type LinkColor = ColorRole;
export type LinkProps = Omit<HTMLAnchorAttributes, 'href' | 'children' | 'style'> & {
/** Target URL. Omit to render without href (still a focusable anchor). */
href?: string;
/**
* When `true`, adds `target="_blank"` + `rel="noopener noreferrer"`
* and a screen-reader-only "opens in new tab" affordance.
* @default false
*/
external?: boolean;
/** @default 'default' */
variant?: ResponsiveProp<LinkVariant>;
/** @default 'hover' */
underline?: ResponsiveProp<LinkUnderline>;
/** Size override — defaults to inheriting ambient typography. */
size?: ResponsiveProp<LinkSize>;
/** Intent color. @default 'primary' */
color?: ResponsiveProp<LinkColor>;
/**
* Disable the link: renders a `<span aria-disabled>` instead of
* `<a>`, drops the href, and suppresses pointer events.
* @default false
*/
disabled?: boolean;
/** Extra inline style. */
tagStyle?: string;
/** Extra class names. */
class?: string;
children?: Snippet;
};

@ -0,0 +1,87 @@
# Eidos Mark
Inline text highlight primitive. Renders `<mark>` with a soft background
tint per intent color. Inherits the ambient typography so it composes
seamlessly inside Text / Heading / Code. For substring highlighting
of search-result hits, see the higher-level `<Highlight>` primitive
which scans a string and wraps matches in Mark.
## Superficie
```svelte
<p>Lorem ipsum <Mark>dolor sit</Mark> amet.</p>
<p>This is <Mark color="threat">important</Mark>.</p>
<p>And this is <Mark color="affirm">confirmed</Mark>.</p>
```
## Baseline
Origen: `air/components/typography/mark` (rama `morfo-runtime`).
Adaptaciones para eidos:
- **Palette canonized**: air shipped `yellow / green / blue / red /
purple` (literal hue names). Eidos maps to the intent vocabulary
(`primary / secondary / neutral / affirm / fulfill / risk / threat /
loss`) so the highlight respects the theme contract and changes
with the active theme.
- **Default `fulfill`**: closest to the legacy "yellow" — search-hit
affordance. Air defaulted to `yellow`.
- **Tokens**: `--air-mark-bg-{hue}` → `--color-{intent}-element` from
the foundation contract.
- **Inherits typography**: no own font-* declarations. Air did the
same — Mark decorates, doesn't replace, the typography of its
parent.
## Comparativa
| Capacidad | UIX Mark | Radix Themes Mark | Chakra Mark | Mantine Mark |
| --- | --- | --- | --- | --- |
| Inline `<mark>` element | Sí | Sí | uses Highlight | Sí |
| Color palette | Intent (8 roles) | accent + intent | colorScheme | intent |
| Default color | fulfill | yellow accent | yellow | yellow |
| Inherits typography | Sí | Sí | Sí | Sí |
| `as` polymorphism | Sí | Sí | Sí | Sí |
## Decisiones
- **Intent palette**: Mark is a highlight in the design system's
vocabulary, not a "color" surface. Locking to `ColorRole` keeps
the contrast contract and lets the highlight follow the theme.
- **Default `fulfill`**: the closest semantic to "search result hit"
/ "found this" affordance. If the user wants a deliberate
attention-grab, they pass `color="threat"` or `color="risk"`.
- **`color: var(--color-{role}-text)`**: ensures legibility on the
tinted background; the contrast contract from the foundation
guarantees WCAG AA.
- **Tag defaults to `<mark>`**: HTML's native highlight element.
Screen readers announce mark content with an "emphasized" hint
in some configs — but as a hint only; consumers shouldn't rely
on it for meaningful semantics. For required emphasis, use
`<strong>` or `<em>`.
## Eventos Sema
Mark declara 0 eventos. Visual-only — el highlight es un decorador
inline.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Free hex / CSS color | **descartar** | Rompe contrato de contraste con theme. Escape hatch vía `tagStyle`. |
| Underline-only variant (no fill) | **diferir** | Si emerge demanda, añadir `variant: 'fill' | 'underline'`. |
| Strikethrough variant | **descartar** | No es highlight; usar Text con `text-decoration: line-through`. |
| Animated entry (e.g. flash) | **diferir** | Composable vía sema-driven CSS animations en un componente padre. |
## Referencias
- Radix Themes (no Mark, but accent docs): https://www.radix-ui.com/themes
- Mantine Mark: https://mantine.dev/core/mark/
- MDN `<mark>`: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/mark
## Passive justification
Visual-only (`scope: ['eidos']`). Una sola part Provider, marker
`[data-mark]`. Sin estados, sin keyboard, sin ARIA propia. La recipe
consume `--color-{intent}-element` + `--color-{intent}-text` del
foundation contract.

@ -0,0 +1,18 @@
// Mark — eidos single-component highlight primitive.
//
// import { Mark } from '$uix/eidos/components/mark';
//
// <p>Lorem <Mark>highlighted</Mark> ipsum.</p>
// <Mark color="threat">Warning</Mark>
//
// Single-component primitive — `<Mark>` IS the root. Renders as
// `<mark>` by default.
import MarkComponent from './mark.svelte';
const Mark = MarkComponent;
export { Mark };
export default Mark;
export type { MarkProps, MarkColor } from './types';

@ -0,0 +1,50 @@
/*
* Mark recipe — inline text highlight.
*
* Inherits ambient typography (no own font-* declarations). Decorates
* with a soft background tint + light padding so the highlight reads
* without breaking the line height. The intent palette is the full
* `ColorRole` union via `data-color`.
*/
[data-mark] {
font: inherit;
color: inherit;
background: var(--color-fulfill-element);
padding-inline: 0.16em;
padding-block: 0.04em;
border-radius: var(--radius-sm);
}
[data-mark][data-color='primary'] {
background: var(--color-primary-element);
color: var(--color-primary-text);
}
[data-mark][data-color='secondary'] {
background: var(--color-secondary-element);
color: var(--color-secondary-text);
}
[data-mark][data-color='neutral'] {
background: var(--color-neutral-element);
color: var(--color-neutral-text);
}
[data-mark][data-color='affirm'] {
background: var(--color-affirm-element);
color: var(--color-affirm-text);
}
[data-mark][data-color='fulfill'] {
background: var(--color-fulfill-element);
color: var(--color-fulfill-text);
}
[data-mark][data-color='risk'] {
background: var(--color-risk-element);
color: var(--color-risk-text);
}
[data-mark][data-color='threat'] {
background: var(--color-threat-element);
color: var(--color-threat-text);
}
[data-mark][data-color='loss'] {
background: var(--color-loss-element);
color: var(--color-loss-text);
}

@ -0,0 +1,38 @@
<script lang="ts">
/**
* Eidos `<Mark>` — text highlight.
*
* <p>Lorem ipsum <Mark>dolor sit</Mark> amet.</p>
* <p>This is <Mark color="threat">important</Mark>.</p>
*
* Renders `<mark>` with a soft tint per intent color. Inherits the
* ambient typography (no own style scale) and just decorates the
* background + padding.
*/
import { ActiveEidos } from '$uix/eidos';
import type { MarkProps } from './types';
let {
color,
as = 'mark',
tagStyle,
class: className,
children,
...restProps
}: MarkProps = $props();
const eidos = ActiveEidos.require();
const resolvedColor = $derived(eidos.resolve(color) ?? 'fulfill');
</script>
<svelte:element
this={as}
{...restProps}
class={className}
data-mark=""
data-color={resolvedColor}
style={tagStyle}
>
{@render children?.()}
</svelte:element>

@ -0,0 +1,18 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ColorRole, ResponsiveProp } from '$uix/eidos/lib/types';
/** Intent palette for the highlight tint. */
export type MarkColor = ColorRole;
export type MarkProps = Omit<HTMLAttributes<HTMLElement>, 'style' | 'children'> & {
/** Intent palette for the highlight tint. @default 'fulfill' */
color?: ResponsiveProp<MarkColor>;
/** Render a different tag. @default 'mark' */
as?: string;
/** Extra inline style. */
tagStyle?: string;
/** Extra class names. */
class?: string;
children?: Snippet;
};

@ -74,6 +74,14 @@
@import './components/banner/banner.css';
@import './components/float/float.css';
@import './components/text/text.css';
@import './components/heading/heading.css';
@import './components/display/display.css';
@import './components/code/code.css';
@import './components/code-block/code-block.css';
@import './components/kbd/kbd.css';
@import './components/mark/mark.css';
@import './components/highlight/highlight.css';
@import './components/link/link.css';
@import './components/icon/icon.css';
@import './components/avatar/avatar.css';
@import './components/breadcrumb/breadcrumb.css';

@ -0,0 +1,33 @@
import type { Morfo } from '../types';
/**
* CodeBlock — multi-line monospace block primitive.
*
* Eidos-native: renders a `<pre>` shell with an inner `<code>` element.
* Optionally ships a header strip (title + language badge + copy button).
* The copy action is a plain button — it does NOT belong to Sema (it's a
* utility, not a semantic event surface). If the consumer wants
* semantic copy events they can wrap the block in a Toolbar or Toast
* affordance.
*
* Passive — single Provider part. The copy button is a leaf inside the
* recipe's chrome; it doesn't widen the morfo's surface because the
* 2-of-3 rule (only eidos consumes it) drops it from morfo.
*/
export const codeBlockMorfo = {
name: 'CodeBlock',
kebab: 'code-block',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,29 @@
import type { Morfo } from '../types';
/**
* Code — inline monospace text primitive.
*
* Eidos-native: renders `<code>` by default applying the foundation's
* `--style-code-*` tokens. Sits inline within prose — for keywords,
* identifiers, file names. Multi-line code blocks use the separate
* `<CodeBlock>` primitive.
*
* Passive — no semantic events.
*/
export const codeMorfo = {
name: 'Code',
kebab: 'code',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'span',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,30 @@
import type { Morfo } from '../types';
/**
* Display — hero / oversize typography primitive.
*
* Eidos-native: a single shell that renders `<h1>` by default and applies
* the foundation's `--style-hero-*` tokens. Smaller scope than Heading —
* for the page's "display" title (above an `<h1>` headline). The `as`
* prop can switch the rendered tag when display typography is needed
* outside heading semantics (e.g. a marketing landing pull-quote).
*
* Passive — no semantic events. Display is purely a styled inline shell.
*/
export const displayMorfo = {
name: 'Display',
kebab: 'display',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'span',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,34 @@
import type { Morfo } from '../types';
/**
* Heading — semantic heading (h1..h6) typography primitive.
*
* Eidos-native: a single shell that renders `<h{level}>` and applies one of
* the named foundation styles (`h1`..`h6`) via the `--style-h{n}-*` tokens
* the foundation emits. The `level` prop drives semantics; the `style` prop
* drives visual weight — they default to the same value but can be
* decoupled (`<Heading level={3} style="h1">` for a visual h1 with
* semantic h3).
*
* Passive — no semantic events. Heading is a styled inline-block shell that
* does not commit, emerge, or react. Components that animate headings on
* appearance compose Heading inside an interactive primitive (dialog,
* collapsible) which owns the relevant sema verbs.
*/
export const headingMorfo = {
name: 'Heading',
kebab: 'heading',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'span',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,28 @@
import type { Morfo } from '../types';
/**
* Highlight — substring highlighter typography primitive.
*
* Eidos-native: wraps a `<span>` shell containing the input text with
* `<mark>` elements around each match of the `query` term(s). Composes
* inside any inline-flow primitive (Text, Heading, paragraph).
*
* Passive — no semantic events.
*/
export const highlightMorfo = {
name: 'Highlight',
kebab: 'highlight',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'span',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,28 @@
import type { Morfo } from '../types';
/**
* Kbd — keyboard shortcut typography primitive.
*
* Eidos-native: renders `<kbd>` with a small chrome (border + bg + inner
* padding) so the key looks like a physical cap. Inline within prose
* — for shortcut hints, key sequences.
*
* Passive — no semantic events.
*/
export const kbdMorfo = {
name: 'Kbd',
kebab: 'kbd',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'span',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,34 @@
import type { Morfo } from '../types';
/**
* Link — inline text link typography primitive.
*
* Eidos-native: renders `<a>` by default (or `<span aria-disabled>` when
* disabled). Inherits ambient typography — no own `style` prop — so it
* composes mid-paragraph without breaking the text scale. Visual
* variants drive the underline + color treatment.
*
* Passive on the morfo surface: Link has hover/active visual states but
* no semantic events. Click / navigation is the consumer's
* responsibility (SvelteKit `<a>`, custom router, sema-driven actions
* on the surrounding component). When Link is used as a button-style
* affordance (form submit, dialog action), the consumer should wrap a
* `<Toolbar.Button>` instead — that surface owns the sema commit.
*/
export const linkMorfo = {
name: 'Link',
kebab: 'link',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'a',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,29 @@
import type { Morfo } from '../types';
/**
* Mark — text highlight typography primitive.
*
* Eidos-native: renders `<mark>` with a soft background tint per intent
* color. Inline within prose — for emphasis, search-result hits, and
* other visual highlights that aren't semantically `<strong>` or
* `<em>`.
*
* Passive — no semantic events.
*/
export const markMorfo = {
name: 'Mark',
kebab: 'mark',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'span',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -200,7 +200,17 @@
{
kind: 'group',
heading: 'Typography',
items: [{ slug: '/uix/components/text', label: 'Text' }]
items: [
{ slug: '/uix/components/text', label: 'Text' },
{ slug: '/uix/components/heading', label: 'Heading' },
{ slug: '/uix/components/display', label: 'Display' },
{ slug: '/uix/components/code', label: 'Code' },
{ slug: '/uix/components/code-block', label: 'CodeBlock' },
{ slug: '/uix/components/kbd', label: 'Kbd' },
{ slug: '/uix/components/mark', label: 'Mark' },
{ slug: '/uix/components/highlight', label: 'Highlight' },
{ slug: '/uix/components/link', label: 'Link' }
]
},
{
kind: 'group',

@ -0,0 +1,402 @@
<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 somaSnippet = $derived(
[
'<!-- CodeBlock is eidos-native — no soma layer. -->',
'<!-- Equivalent markup: -->',
'',
'<div',
' data-code-block',
` data-variant="${variant}"`,
'>',
' <div data-code-block-header>',
title && ` <span data-code-block-title>${title}</span>`,
language && ` <span data-code-block-lang>${language}</span>`,
showCopy && ' <button data-code-block-copy>Copy</button>',
' </div>',
' <pre data-code-block-pre>',
' <code data-code-block-code>',
' …',
' </code>',
' </pre>',
'</div>'
]
.filter(Boolean)
.join('\n')
);
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>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · code-block 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 · 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>&lt;Code&gt;</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>&lt;CodeHighlight&gt;</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">&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);">
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>

@ -0,0 +1,423 @@
<script lang="ts">
import {
Code,
type CodeVariant,
type CodeSize,
type CodeColor
} from '$uix/eidos/components/code';
import { compileMorfo } from '$uix/morfo';
import { codeMorfo } from '@/uix/morfo/components/code';
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<CodeVariant>('plain');
let size = $state<CodeSize | ''>('');
let color = $state<CodeColor | ''>('');
let language = $state<string>('');
let content = $state<string>('npm run dev');
const variants: CodeVariant[] = ['plain', 'soft', 'outline'];
const sizes: CodeSize[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl'];
const colors: CodeColor[] = ['primary', 'secondary', 'muted', 'disabled', 'on-solid'];
const compiled = compileMorfo(codeMorfo);
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 somaSnippet = $derived(
[
'<!-- Code is eidos-native — no soma layer. -->',
'<!-- Equivalent markup: -->',
'',
'<code',
' data-code',
` data-variant="${variant}"`,
language && ` data-language="${language}"`,
'>',
` ${content}`,
'</code>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Code } from '$uix/eidos/components/code';",
'</' + 'script>',
'',
'<Code',
variant !== 'plain' && ` variant="${variant}"`,
size && ` size="${size}"`,
color && ` color="${color}"`,
language && ` language="${language}"`,
'>',
' ' + content,
'</Code>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Typography · Code</div>
<h1 data-uix-page-title>Code</h1>
<p data-uix-page-lede>
Inline monospace text primitive. Renders <code>{`<code>`}</code> by default applying the
foundation's <code>--style-code-*</code> tokens. Sits inline within prose — for keywords,
identifiers, file names. Multi-line code blocks use the separate
<code>{`<CodeBlock>`}</code>
primitive. Single 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>sizes</span>6
</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}>
<p
style="max-inline-size: 38rem; padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md); font: inherit;"
>
Run the command
<Code
{variant}
size={size || undefined}
color={color || undefined}
language={language || undefined}
>
{content}
</Code>
to start the dev server.
</p>
</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>size</span> {size || 'inherit'} ·
<span data-uix-stage-trace-key>language</span> {language || 'none'}
</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>
Code is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>variant</code>
picks chrome treatment; size + color override the foundation defaults.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · inline mono
</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>color</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={color === ''} onclick={() => (color = '')}
>inherit</button
>
{#each colors as opt}
<button data-uix-chip data-active={color === opt} onclick={() => (color = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>language <span data-uix-control-hint>reserved hint (data-language)</span></span
>
<input
type="text"
bind:value={language}
placeholder="ts, bash, json..."
style="inline-size: 12rem;"
/>
</label>
<label data-uix-control style="grid-column: span 2;">
<span data-uix-control-label>content</span>
<input type="text" bind:value={content} style="inline-size: 100%; font: inherit;" />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · code 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 · variant + axis overrides</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>
Code wraps inline monospace text. <code>variant='plain'</code> is the bare default;
<code>'soft'</code> and <code>'outline'</code> add chrome for surfacing from surrounding
prose.
</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">variant</td><td class="type">'plain' | 'soft' | 'outline'</td><td class="default">'plain'</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>Maps to <code>{`var(--font-size-{key})`}</code>.</td></tr>
<tr><td class="name">color</td><td class="type">'primary' | 'secondary' | 'muted' | 'disabled' | 'on-solid'</td><td class="default">inherit</td><td>Content-role color slot.</td></tr>
<tr><td class="name">language</td><td class="type">string</td><td class="default">—</td><td>Reserved hint for future syntax-highlighting. Stamped as <code>data-language</code>.</td></tr>
<tr><td class="name">as</td><td class="type">string</td><td class="default">'code'</td><td>Override rendered tag.</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>Composability examples</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Use case</th><th>Snippet</th></tr></thead>
<tbody>
<tr>
<td class="name">Inline command</td>
<td><code>{`<p>Run <Code>npm run dev</Code></p>`}</code></td>
</tr>
<tr>
<td class="name">Soft-pill identifier</td>
<td><code>{`<Code variant="soft">USER_ID</Code>`}</code></td>
</tr>
<tr>
<td class="name">Outline shortcut hint</td>
<td><code>{`<Code variant="outline" size="sm">Ctrl+K</Code>`}</code></td>
</tr>
<tr>
<td class="name">Inside a heading</td>
<td>
<code>{`<Heading>How <Code variant="soft">useState</Code> works</Heading>`}</code>
</td>
</tr>
<tr>
<td class="name">With language hint (future highlighting)</td>
<td><code>{`<Code language="ts">readonly id: string</Code>`}</code></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>&lt;Code&gt;</code></td><td>Radix has size + variant (solid/soft/outline/surface); UIX consolidates to 3.</td></tr>
<tr><td class="name">chakra-ui</td><td><code>&lt;Code&gt;</code></td><td>Chakra uses <code>colorScheme</code>; UIX restricts to content-role.</td></tr>
<tr><td class="name">mantine</td><td><code>&lt;Code&gt;</code></td><td>Mantine has <code>block</code> boolean; UIX splits into Code + CodeBlock.</td></tr>
<tr><td class="name">MDN <code>&lt;code&gt;</code></td><td>HTML primitive</td><td>UIX wraps the native element with the foundation tokens.</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>{codeMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{codeMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{codeMorfo.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);">
The Provider stamps <code>data-code</code> on the rendered tag (default
<code>{`<code>`}</code>), plus <code>data-variant</code> and (when set) <code>data-language</code>.
</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>
Code declares no semantic events. The primitive is passive — there is no commit, no
emerge. If the consumer needs click-to-copy on inline code, they typically use the
multi-line CodeBlock primitive instead.
</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/code.css</code>. Base typography
comes from <code>--style-code-*</code>; variants apply chrome through the
<code>data-variant</code> selector.
</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]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Provider marker. Base mono typography.</td></tr>
<tr><td class="name"><code>{`[data-code][data-variant='soft']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Subtle background pill.</td></tr>
<tr><td class="name"><code>{`[data-code][data-variant='outline']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Outlined pill.</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>Native HTML <code>{`<code>`}</code> semantics — AT announce as "code".</td></tr>
<tr><td class="name">Inside prose</td><td>Composes inline. AT read the surrounding text as a single utterance with the code embedded.</td></tr>
<tr><td class="name">Color contrast</td><td>Soft / outline variants use neutral palette tokens that meet WCAG AA on the foundation surfaces.</td></tr>
<tr><td class="name">Language hint</td><td><code>data-language</code> is not announced by AT; it's a styling / tooling hint.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,546 @@
<script lang="ts">
import {
Display,
type DisplaySize,
type DisplayFamily,
type DisplayWeight,
type DisplayColor,
type DisplayAlign,
type DisplayTracking
} from '$uix/eidos/components/display';
import { compileMorfo } from '$uix/morfo';
import { displayMorfo } from '@/uix/morfo/components/display';
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 size = $state<DisplaySize | ''>('');
let family = $state<DisplayFamily | ''>('');
let weight = $state<DisplayWeight | ''>('');
let color = $state<DisplayColor | ''>('');
let align = $state<DisplayAlign | ''>('');
let tracking = $state<DisplayTracking | ''>('');
let italic = $state(false);
let underline = $state(false);
let truncate = $state(false);
let clamp = $state<number>(0);
let content = $state('Welcome to UIX.');
let asTag = $state<string>('h1');
const sizes: DisplaySize[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl', 'xxxl'];
const families: DisplayFamily[] = ['primary', 'secondary', 'mono', 'display'];
const weights: DisplayWeight[] = ['regular', 'medium', 'semibold', 'bold'];
const colors: DisplayColor[] = ['primary', 'secondary', 'muted', 'disabled', 'on-solid'];
const alignOptions = ['', 'start', 'center', 'end', 'justify'] as const;
const trackingOptions = ['', 'tight', 'normal', 'wide'] as const;
const tagOptions = ['h1', 'h2', 'div', 'p', 'span'] as const;
const compiled = compileMorfo(displayMorfo);
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 somaSnippet = $derived(
[
'<!-- Display is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup: -->',
'',
`<${asTag}`,
' data-display',
tracking && ` data-tracking="${tracking}"`,
' style="font-family: var(--style-hero-font-family); …"',
'>',
` ${content}`,
`</${asTag}>`
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Display } from '$uix/eidos/components/display';",
'</' + 'script>',
'',
'<Display',
asTag !== 'h1' && ` as="${asTag}"`,
size && ` size="${size}"`,
family && ` family="${family}"`,
weight && ` weight="${weight}"`,
color && ` color="${color}"`,
align && ` align="${align}"`,
tracking && ` tracking="${tracking}"`,
italic && ' italic',
underline && ' underline',
truncate && ' truncate',
clamp > 0 && ` clamp={${clamp}}`,
'>',
' ' + content,
'</Display>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Typography · Display</div>
<h1 data-uix-page-title>Display</h1>
<p data-uix-page-lede>
Hero / oversize typography primitive. Always uses the foundation's
<code>--style-hero-*</code>
tokens, which scale up at the <code>md</code> breakpoint. Default tag is
<code>{`<h1>`}</code>
(use <code>as</code> when the hero look is needed outside heading semantics). The
<code>tracking</code>
prop is the deliberate letter-spacing knob — at hero sizes the value is a design decision.
Single 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>responsive</span>md
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>tracking</span>3
</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; padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md);"
>
<Display
as={asTag}
size={size || undefined}
family={family || undefined}
weight={weight || undefined}
color={color || undefined}
align={align || undefined}
tracking={tracking || undefined}
{italic}
{underline}
{truncate}
clamp={clamp > 0 ? clamp : undefined}
>
{content}
</Display>
</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>style</span>
<span>hero</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>tag</span> &lt;{asTag}&gt; ·
<span data-uix-stage-trace-key>tracking</span> {tracking || 'default'}
</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>11</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>
Display is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. Always
consumes <code>--style-hero-*</code>; per-axis overrides write inline
<code>--_display-*</code>
CSS variables.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · hero treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>as <span data-uix-control-hint>rendered tag</span></span>
<span data-uix-chips role="radiogroup">
{#each tagOptions as opt}
<button data-uix-chip data-active={asTag === opt} onclick={() => (asTag = opt)}>
&lt;{opt}&gt;
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size override</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>family override</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={family === ''} onclick={() => (family = '')}
>inherit</button
>
{#each families as opt}
<button data-uix-chip data-active={family === opt} onclick={() => (family = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>weight override</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={weight === ''} onclick={() => (weight = '')}
>inherit</button
>
{#each weights as opt}
<button data-uix-chip data-active={weight === opt} onclick={() => (weight = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color override</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={color === ''} onclick={() => (color = '')}
>inherit</button
>
{#each colors as opt}
<button data-uix-chip data-active={color === opt} onclick={() => (color = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>align</span>
<span data-uix-chips role="radiogroup">
{#each alignOptions as opt}
<button data-uix-chip data-active={align === opt} onclick={() => (align = opt)}
>{opt || 'inherit'}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>tracking <span data-uix-control-hint>letter-spacing</span></span
>
<span data-uix-chips role="radiogroup">
{#each trackingOptions as opt}
<button
data-uix-chip
data-active={tracking === opt}
onclick={() => (tracking = opt)}>{opt || 'default'}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>italic</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={italic} />
<span data-uix-switch-label>{italic ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>underline</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={underline} />
<span data-uix-switch-label>{underline ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>truncate</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={truncate} />
<span data-uix-switch-label>{truncate ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>clamp <span data-uix-control-hint>multi-line cap (0 = off)</span></span
>
<input
type="number"
min="0"
max="10"
step="1"
bind:value={clamp}
style="inline-size: 6rem;"
/>
</label>
<label data-uix-control style="grid-column: span 2;">
<span data-uix-control-label>content</span>
<input type="text" bind:value={content} style="inline-size: 100%; font: inherit;" />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · display 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 · hero style + per-axis overrides</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>
Display always uses the foundation's <code>--style-hero-*</code> tokens. Per-axis props
override individual fields without breaking the hero cascade. <code>tracking</code> maps
to fixed em values because foundation letter-spacing tokens are mostly 0 at hero scale.
</p>
<div data-uix-subsection-head>Per-axis overrides</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr
><td class="name">size</td><td class="type"
>'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl'</td
><td>Override the hero size step.</td></tr
>
<tr
><td class="name">family</td><td class="type"
>'primary' | 'secondary' | 'mono' | 'display'</td
><td>Override the hero family.</td></tr
>
<tr
><td class="name">weight</td><td class="type"
>'regular' | 'medium' | 'semibold' | 'bold'</td
><td>Override the hero weight.</td></tr
>
<tr
><td class="name">color</td><td class="type"
>'primary' | 'secondary' | 'muted' | 'disabled' | 'on-solid'</td
><td>Content-role color slot.</td></tr
>
<tr
><td class="name">align</td><td class="type"
>'start' | 'center' | 'end' | 'justify'</td
><td>Maps to <code>text-align</code>.</td></tr
>
<tr
><td class="name">tracking</td><td class="type"
>'tight' | 'normal' | 'wide'</td
><td>Letter-spacing: -0.02em / 0 / 0.02em.</td></tr
>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Shorthand booleans + truncation</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">italic</td><td class="type">boolean</td><td><code>font-style: italic</code>.</td></tr>
<tr><td class="name">underline</td><td class="type">boolean</td><td><code>text-decoration: underline</code>.</td></tr>
<tr><td class="name">truncate</td><td class="type">boolean</td><td>Single-line ellipsis.</td></tr>
<tr><td class="name">clamp</td><td class="type">number</td><td>Multi-line cap via <code>-webkit-line-clamp</code>.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Element + escape hatch</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">as</td><td class="type">string</td><td>HTML tag. Default <code>'h1'</code>.</td></tr>
<tr><td class="name">tagStyle</td><td class="type">string</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>&lt;Heading size="9"&gt;</code></td><td>Radix uses a large heading size; UIX has a dedicated Display surface.</td></tr>
<tr><td class="name">chakra-ui</td><td><code>&lt;Heading as="h1"&gt;</code></td><td>Chakra reuses Heading; UIX splits.</td></tr>
<tr><td class="name">mantine</td><td><code>&lt;Title order={`{1}`}&gt;</code></td><td>Mantine's Title is the only heading primitive; UIX has Heading + Display.</td></tr>
<tr><td class="name">mui Typography</td><td><code>variant="h1"</code></td><td>MUI's h1 ≈ UIX Display when consumer overrides <code>fontSize</code>.</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>{displayMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{displayMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{displayMorfo.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);">
The Provider stamps <code>data-display</code> on the rendered tag (default
<code>{`<h1>`}</code>). The hero cascade is always-on; per-axis overrides come from
<code>--_display-*</code>
inline. <code>data-tracking</code> reflects the active tracking key for downstream
styling.
</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>
Display declares no semantic events. As a passive primitive, it simply renders hero
typography. Components that animate hero titles (landing-page entrance, dialog hero)
compose Display inside an interactive surface that owns the relevant sema 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/display/display.css</code>. The selector
<code>[data-display]</code>
applies the hero cascade. Per-axis overrides come from inline <code>--_display-*</code>
CSS variables; tracking maps to fixed em values via the same channel.
</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-display]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Provider marker. Default hero-style applied.</td></tr>
<tr><td class="name"><code>{`[data-display][data-italic|data-underline|data-truncate|data-clamp]`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Visual modifier flags.</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>Default tag <code>{`<h1>`}</code> — page-level heading. Use <code>as</code> when display typography is needed outside heading semantics (e.g. marketing pull-quote → <code>as="p"</code>).</td></tr>
<tr><td class="name">Single h1 per page</td><td>Convention: only one <code>{`<h1>`}</code> per document. Display defaults to that; switch to <code>as</code> if a second hero appears.</td></tr>
<tr><td class="name">Truncation + AT</td><td>CSS-only — full text remains in DOM. AT read the full hero.</td></tr>
<tr><td class="name">Color contrast</td><td>Content-role colors meet WCAG AA. Free colors via <code>tagStyle</code> bypass that guarantee.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,594 @@
<script lang="ts">
import {
Heading,
type HeadingLevel,
type HeadingStyle,
type HeadingSize,
type HeadingFamily,
type HeadingWeight,
type HeadingColor,
type HeadingAlign
} from '$uix/eidos/components/heading';
import { compileMorfo } from '$uix/morfo';
import { headingMorfo } from '@/uix/morfo/components/heading';
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 ────────────────────────────────────────────────────────
let level = $state<HeadingLevel>(2);
let headingStyle = $state<HeadingStyle | ''>('');
let size = $state<HeadingSize | ''>('');
let family = $state<HeadingFamily | ''>('');
let weight = $state<HeadingWeight | ''>('');
let color = $state<HeadingColor | ''>('');
let align = $state<HeadingAlign | ''>('');
let italic = $state(false);
let underline = $state(false);
let truncate = $state(false);
let clamp = $state<number>(0);
let content = $state('Designed for the eidos visual layer.');
const levelOptions: HeadingLevel[] = [1, 2, 3, 4, 5, 6];
const styleOptions = ['', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;
const sizes: HeadingSize[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl', 'xxxl'];
const families: HeadingFamily[] = ['primary', 'secondary', 'mono', 'display'];
const weights: HeadingWeight[] = ['regular', 'medium', 'semibold', 'bold'];
const colors: HeadingColor[] = ['primary', 'secondary', 'muted', 'disabled', 'on-solid'];
const alignOptions = ['', 'start', 'center', 'end', 'justify'] as const;
// ── Compiled morfo ────────────────────────────────────────────────────
const compiled = compileMorfo(headingMorfo);
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(
[
'<!-- Heading is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup: -->',
'',
`<h${level}`,
' data-heading',
` data-style="${headingStyle || `h${level}`}"`,
headingStyle && headingStyle !== `h${level}` && ' <!-- semantic and visual decoupled -->',
'>',
` ${content}`,
`</h${level}>`
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Heading } from '$uix/eidos/components/heading';",
'</' + 'script>',
'',
'<Heading',
level !== 2 && ` level={${level}}`,
headingStyle && ` style="${headingStyle}"`,
size && ` size="${size}"`,
family && ` family="${family}"`,
weight && ` weight="${weight}"`,
color && ` color="${color}"`,
align && ` align="${align}"`,
italic && ' italic',
underline && ' underline',
truncate && ' truncate',
clamp > 0 && ` clamp={${clamp}}`,
'>',
' ' + content,
'</Heading>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Typography · Heading</div>
<h1 data-uix-page-title>Heading</h1>
<p data-uix-page-lede>
Semantic heading typography primitive. <code>level</code> chooses the HTML tag
(<code>h1</code>..<code>h6</code>) and <code>style</code> chooses the visual treatment — by
default they mirror each other but can be decoupled
(<code>{`<Heading level={3} style="h1">`}</code>) for visual-h1-with-semantic-h3 cases.
Single 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>levels</span>6
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>styles</span>6
</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: 38rem; padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md);"
>
<Heading
{level}
style={headingStyle || undefined}
size={size || undefined}
family={family || undefined}
weight={weight || undefined}
color={color || undefined}
align={align || undefined}
{italic}
{underline}
{truncate}
clamp={clamp > 0 ? clamp : undefined}
>
{content}
</Heading>
</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>level</span>
<span>h{level}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>style</span> {headingStyle || `h${level}`} ·
<span data-uix-stage-trace-key>size</span> {size || 'inherit'}
</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>11</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>
Heading is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>level</code> prop drives semantics; <code>style</code> drives visual weight (defaults
to mirror level). Per-axis overrides write inline
<code>--_heading-*</code> CSS variables.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · semantic + visual
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label
>level <span data-uix-control-hint>HTML tag</span></span
>
<span data-uix-chips role="radiogroup">
{#each levelOptions as opt}
<button
data-uix-chip
data-active={level === opt}
onclick={() => (level = opt)}>h{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>style <span data-uix-control-hint>visual scale, default mirrors level</span></span
>
<span data-uix-chips role="radiogroup">
{#each styleOptions as opt}
<button
data-uix-chip
data-active={headingStyle === opt}
onclick={() => (headingStyle = opt)}>{opt || 'auto'}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size override</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>family override</span>
<span data-uix-chips role="radiogroup">
<button
data-uix-chip
data-active={family === ''}
onclick={() => (family = '')}>inherit</button
>
{#each families as opt}
<button
data-uix-chip
data-active={family === opt}
onclick={() => (family = opt)}>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>weight override</span>
<span data-uix-chips role="radiogroup">
<button
data-uix-chip
data-active={weight === ''}
onclick={() => (weight = '')}>inherit</button
>
{#each weights as opt}
<button
data-uix-chip
data-active={weight === opt}
onclick={() => (weight = opt)}>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>color override <span data-uix-control-hint>content-role slot</span></span
>
<span data-uix-chips role="radiogroup">
<button
data-uix-chip
data-active={color === ''}
onclick={() => (color = '')}>inherit</button
>
{#each colors as opt}
<button
data-uix-chip
data-active={color === opt}
onclick={() => (color = opt)}>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>align</span>
<span data-uix-chips role="radiogroup">
{#each alignOptions as opt}
<button
data-uix-chip
data-active={align === opt}
onclick={() => (align = opt)}>{opt || 'inherit'}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>italic</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={italic} />
<span data-uix-switch-label>{italic ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>underline</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={underline} />
<span data-uix-switch-label>{underline ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>truncate <span data-uix-control-hint>single-line ellipsis</span></span
>
<span data-uix-switch>
<input type="checkbox" bind:checked={truncate} />
<span data-uix-switch-label>{truncate ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>clamp <span data-uix-control-hint>multi-line cap (0 = off)</span></span
>
<input
type="number"
min="0"
max="10"
step="1"
bind:value={clamp}
style="inline-size: 6rem;"
/>
</label>
<label data-uix-control style="grid-column: span 2;">
<span data-uix-control-label>content</span>
<textarea bind:value={content} rows="2" style="inline-size: 100%; font: inherit;"
></textarea>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · heading 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 · level + style + per-axis overrides</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>
<code>level</code> renders <code>{`<h{level}>`}</code>; <code>style</code> selects the
<code>{`--style-h{n}-*`}</code> token set (defaults to mirror level). Per-axis props
override individual fields without breaking the named-style cascade.
</p>
<div data-uix-subsection-head>Semantic + visual</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">level</td><td class="type">1 | 2 | 3 | 4 | 5 | 6</td><td
class="default">2</td
><td>Renders <code>{`<h{level}>`}</code>. Drives semantic hierarchy.</td></tr
>
<tr
><td class="name">style</td><td class="type">'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'</td
><td class="default">mirrors level</td><td
>Visual treatment. Decouples from level when set explicitly.</td
></tr
>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Per-axis overrides</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr
><td class="name">size</td><td class="type"
>'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl'</td
><td>Resolves to <code>{`var(--font-size-{key})`}</code>.</td></tr
>
<tr
><td class="name">family</td><td class="type"
>'primary' | 'secondary' | 'mono' | 'display'</td
><td>Resolves to <code>{`var(--font-family-{key})`}</code>.</td></tr
>
<tr
><td class="name">weight</td><td class="type"
>'regular' | 'medium' | 'semibold' | 'bold'</td
><td>Resolves to <code>{`var(--font-weight-{key})`}</code>.</td></tr
>
<tr
><td class="name">color</td><td class="type"
>'primary' | 'secondary' | 'muted' | 'disabled' | 'on-solid'</td
><td>Resolves to <code>{`var(--color-content-{key})`}</code>.</td></tr
>
<tr
><td class="name">align</td><td class="type"
>'start' | 'center' | 'end' | 'justify'</td
><td>Maps to <code>text-align</code>.</td></tr
>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Shorthand booleans + truncation</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">italic</td><td class="type">boolean</td><td><code>font-style: italic</code>.</td></tr>
<tr><td class="name">underline</td><td class="type">boolean</td><td><code>text-decoration: underline</code>.</td></tr>
<tr><td class="name">truncate</td><td class="type">boolean</td><td>Single-line ellipsis. Requires container width.</td></tr>
<tr><td class="name">clamp</td><td class="type">number</td><td>Multi-line cap via <code>-webkit-line-clamp</code>.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Element + escape hatch</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">as</td><td class="type">string</td><td>Override the rendered tag. Defaults to <code>{`h{level}`}</code>.</td></tr>
<tr><td class="name">tagStyle</td><td class="type">string</td><td>Extra inline CSS. Appended after the recipe's vars.</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>&lt;Heading&gt;</code></td><td>Radix conflates semantic + visual into <code>as</code> + numeric size; UIX keeps them as separate props.</td></tr>
<tr><td class="name">chakra-ui</td><td><code>&lt;Heading&gt;</code></td><td>Chakra ties visual via <code>fontSize</code> only; UIX adds named foundation styles.</td></tr>
<tr><td class="name">mantine</td><td><code>&lt;Title order={`{1..6}`}&gt;</code></td><td>Mantine's <code>order</code> drives both; UIX splits via level/style.</td></tr>
<tr><td class="name">mui Typography</td><td><code>&lt;Typography variant="h1"&gt;</code></td><td>MUI <code>variant</code> ≈ UIX <code>style</code>; consumer composes <code>component="h3"</code> for the semantic 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>{headingMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{headingMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{headingMorfo.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);">
The Provider stamps <code>data-heading</code> on the rendered
<code>{`<h{level}>`}</code>
tag (or the override from <code>as</code>) plus <code>data-level</code>,
<code>data-style</code> and the boolean modifier attrs (<code>data-italic</code>,
<code>data-underline</code>, <code>data-truncate</code>, <code>data-clamp</code>). The
recipe selects the named-style fallback chain via <code>data-style</code>; per-axis
overrides come through inline <code>--_heading-*</code> CSS variables.
</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>
Heading declares no semantic events. As a passive typography primitive, the headline does
not commit, emerge, or react — it just renders its visual treatment. Components that
animate headings on appearance (dialog title, collapsible reveal, toast title) compose
Heading inside the interactive primitive that owns the relevant sema 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/heading/heading.css</code>. The selector
<code>[data-heading]</code> applies the default h2 cascade; matching <code>data-style</code>
swaps to the corresponding <code>{`--style-h{n}-*`}</code> tokens. Per-axis overrides come
from <code>--_heading-*</code> custom properties written inline by the wrapper.
</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-heading]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Provider marker. Default h2-style applied.</td></tr>
<tr><td class="name"><code>{`[data-heading][data-style='h1'..'h6']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Swap to the named style's token set.</td></tr>
<tr><td class="name"><code>{`[data-heading][data-italic|data-underline|data-truncate|data-clamp]`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Visual modifier flags from boolean props.</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>Native HTML heading semantics from <code>{`<h{level}>`}</code>. AT announce the heading and its level automatically.</td></tr>
<tr><td class="name">Heading hierarchy</td><td>Consumer responsibility — pass <code>level</code> per outline depth. Don't skip levels (h2 then h4).</td></tr>
<tr><td class="name">Visual ≠ semantic</td><td>Decoupling via <code>{`level={3} style="h1"`}</code> is safe for AT — they read the level, not the visual style.</td></tr>
<tr><td class="name">Truncation + AT</td><td>CSS-only — full text remains in DOM. AT read the full heading.</td></tr>
<tr><td class="name">Color contrast</td><td>Content-role colors meet WCAG AA. Free colors via <code>tagStyle</code> bypass that guarantee.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,407 @@
<script lang="ts">
import { Highlight, type HighlightColor } from '$uix/eidos/components/highlight';
import { compileMorfo } from '$uix/morfo';
import { highlightMorfo } from '@/uix/morfo/components/highlight';
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 color = $state<HighlightColor>('fulfill');
let ignoreCase = $state(true);
let text = $state(
'The quick brown fox jumps over the lazy dog. Some FOXES are quicker than others.'
);
let queryRaw = $state('fox, quick');
const colors: HighlightColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
const queryArray = $derived(
queryRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
);
const compiled = compileMorfo(highlightMorfo);
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 somaSnippet = $derived(
[
'<!-- Highlight is eidos-native — no soma layer. -->',
'<!-- Equivalent markup (per match): -->',
'',
'<span data-highlight',
` data-color="${color}"`,
'>',
' …text…',
` <mark data-mark data-color="${color}">match</mark>`,
' …more text…',
'</span>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Highlight } from '$uix/eidos/components/highlight';",
'</' + 'script>',
'',
'<Highlight',
' text={' +
JSON.stringify(text.length > 60 ? text.slice(0, 60) + '…' : text) +
'}',
queryArray.length === 1
? ` query=${JSON.stringify(queryArray[0])}`
: ` query={${JSON.stringify(queryArray)}}`,
color !== 'fulfill' && ` color="${color}"`,
!ignoreCase && ' ignoreCase={false}',
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Typography · Highlight</div>
<h1 data-uix-page-title>Highlight</h1>
<p data-uix-page-lede>
Substring highlighter primitive. Wraps a <code>{`<span>`}</code> shell around the input text
and replaces matches of <code>query</code> (string or array) with <code>{`<mark>`}</code>
elements that inherit the same recipe as the standalone Mark primitive. Composes inside any
inline-flow primitive. Single 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>colors</span>8
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>multi-term</span>yes
</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}>
<p
style="max-inline-size: 38rem; padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md); font: inherit;"
>
<Highlight {text} query={queryArray} {color} {ignoreCase} />
</p>
</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>color</span>
<span>{color}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>terms</span> {queryArray.length} ·
<span data-uix-stage-trace-key>ignoreCase</span> {ignoreCase ? '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>5</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>
Highlight is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split.
Multiple terms via comma-separated input. Terms are regex-escaped automatically.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · substring match
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as opt}
<button data-uix-chip data-active={color === opt} onclick={() => (color = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>ignoreCase</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={ignoreCase} />
<span data-uix-switch-label>{ignoreCase ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control style="grid-column: span 2;">
<span data-uix-control-label
>query <span data-uix-control-hint>comma-separated terms</span></span
>
<input type="text" bind:value={queryRaw} style="inline-size: 100%; font: inherit;" />
</label>
<label data-uix-control style="grid-column: span 2;">
<span data-uix-control-label>text</span>
<textarea bind:value={text} rows="3" style="inline-size: 100%; font: inherit;"></textarea>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · highlight 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 · text + query + intent</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>
Highlight scans <code>text</code> for matches of <code>query</code> (string or array of
strings, regex-escaped) and wraps each match in <code>{`<mark data-mark>`}</code>.
</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">text</td><td class="type">string</td><td class="default">—</td><td>Required. The full text to render.</td></tr>
<tr><td class="name">query</td><td class="type">string | string[]</td><td class="default">—</td><td>Substring(s) to highlight.</td></tr>
<tr><td class="name">ignoreCase</td><td class="type">boolean</td><td class="default">true</td><td>Case-insensitive match.</td></tr>
<tr><td class="name">color</td><td class="type">'primary' | 'secondary' | 'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss'</td><td class="default">'fulfill'</td><td>Intent palette for the highlight tint.</td></tr>
<tr><td class="name">as</td><td class="type">string</td><td class="default">'span'</td><td>Override outer shell tag.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Composability examples</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Use case</th><th>Snippet</th></tr></thead>
<tbody>
<tr>
<td class="name">Single-term search result</td>
<td>
<code>{`<Highlight text={row.title} query={search} />`}</code>
</td>
</tr>
<tr>
<td class="name">Multi-term filter</td>
<td>
<code>{`<Highlight text={message} query={['error', 'failed']} color="threat" />`}</code>
</td>
</tr>
<tr>
<td class="name">Case-sensitive code search</td>
<td>
<code>{`<Highlight text={src} query="TODO" ignoreCase={false} />`}</code>
</td>
</tr>
<tr>
<td class="name">Inside a Heading</td>
<td>
<code>{`<Heading><Highlight text={title} query={search} /></Heading>`}</code>
</td>
</tr>
<tr>
<td class="name">List of results</td>
<td>
<code
>{`{#each results as r}<li><Highlight text={r.label} query={search} /></li>{/each}`}</code
>
</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>—</td><td>Radix has no substring highlighter; consumer composes manually.</td></tr>
<tr><td class="name">chakra-ui</td><td><code>&lt;Highlight&gt;</code></td><td>Chakra uses colorScheme; UIX restricts to intent palette.</td></tr>
<tr><td class="name">mantine</td><td><code>&lt;Highlight&gt;</code></td><td>Mantine supports regex via <code>highlight={"{ regex: true }"}</code>; UIX reserves for v2.</td></tr>
<tr><td class="name">react-highlight-words</td><td>library</td><td>Bundles highlighting + regex + custom render; UIX is intentionally minimal.</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>{highlightMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{highlightMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{highlightMorfo.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);">
The Provider stamps <code>data-highlight</code> on the outer shell. Inner matches reuse
the Mark recipe via the embedded <code>data-mark</code> attr, sharing
<code>data-color</code>.
</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>
Highlight declares no semantic events. The primitive is a formatter: given
<code>text</code> + <code>query</code> it produces a tree of segments and decorates the
matches. No commit, no emerge. Animating the appearance of matched text belongs to a
containing list / popover.
</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/highlight/highlight.css</code>. The outer
shell is structural (<code>font: inherit</code>); inner Mark elements pick up the
palette from the standalone Mark recipe.
</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-highlight]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Outer shell. Inherits ambient typography.</td></tr>
<tr><td class="name"><code>[data-highlight] [data-mark]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Reuses the Mark recipe via shared <code>data-mark</code> attr.</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 shell is a generic <code>{`<span>`}</code>; inner matches are <code>{`<mark>`}</code>.</td></tr>
<tr><td class="name">Full text in DOM</td><td>Highlight doesn't truncate or hide content. AT read the full text including the marked segments.</td></tr>
<tr><td class="name">Multi-term ordering</td><td>Matches are found left-to-right in <code>text</code> order. Reading order follows the source.</td></tr>
<tr><td class="name">Color contrast</td><td>Inherits the Mark recipe's contract — each intent's bg + text meets WCAG AA.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,418 @@
<script lang="ts">
import { Kbd, type KbdVariant, type KbdSize } from '$uix/eidos/components/kbd';
import { compileMorfo } from '$uix/morfo';
import { kbdMorfo } from '@/uix/morfo/components/kbd';
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<KbdVariant>('surface');
let size = $state<KbdSize | ''>('');
let content = $state<string>('Ctrl');
const variants: KbdVariant[] = ['surface', 'outline', 'ghost'];
const sizes: KbdSize[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl'];
const compiled = compileMorfo(kbdMorfo);
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 somaSnippet = $derived(
[
'<!-- Kbd is eidos-native — no soma layer. -->',
'<!-- Equivalent markup: -->',
'',
'<kbd',
' data-kbd',
` data-variant="${variant}"`,
'>',
` ${content}`,
'</kbd>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Kbd } from '$uix/eidos/components/kbd';",
'</' + 'script>',
'',
'<Kbd',
variant !== 'surface' && ` variant="${variant}"`,
size && ` size="${size}"`,
'>',
' ' + content,
'</Kbd>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Typography · Kbd</div>
<h1 data-uix-page-title>Kbd</h1>
<p data-uix-page-lede>
Keyboard shortcut typography primitive. Renders <code>{`<kbd>`}</code> with a small chrome
(border + background + inner padding) so the key looks like a physical cap. Inline within
prose — for shortcut hints, key sequences. Single 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>sizes</span>6
</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}>
<p
style="max-inline-size: 38rem; padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md); font: inherit; display: flex; align-items: center; gap: 0.4em; flex-wrap: wrap;"
>
Press
<Kbd {variant} size={size || undefined}>{content}</Kbd>
<span>+</span>
<Kbd {variant} size={size || undefined}>K</Kbd>
<span>to open command palette. Or just</span>
<Kbd {variant} size={size || undefined}>Esc</Kbd>
<span>to dismiss.</span>
</p>
</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>size</span> {size || 'inherit'}
</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>5</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>
Kbd is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>variant</code>
picks chrome (surface = physical-key feel, outline = thin border, ghost = mono only).
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · key cap
</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>first key content</span>
<input type="text" bind:value={content} style="inline-size: 8rem;" />
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> variant gallery
</div>
<div
data-uix-controls
style="grid-template-columns: 1fr; gap: var(--uix-space-2);"
>
<div
style="display: flex; gap: 0.5em; align-items: center; flex-wrap: wrap; padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md);"
>
<span style="color: var(--color-content-muted); min-inline-size: 6em;">surface</span>
<Kbd variant="surface">Ctrl</Kbd>
<Kbd variant="surface">Shift</Kbd>
<Kbd variant="surface">Esc</Kbd>
<Kbd variant="surface" size="xs">F1</Kbd>
<Kbd variant="surface" size="lg">Enter</Kbd>
</div>
<div
style="display: flex; gap: 0.5em; align-items: center; flex-wrap: wrap; padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md);"
>
<span style="color: var(--color-content-muted); min-inline-size: 6em;">outline</span>
<Kbd variant="outline">Ctrl</Kbd>
<Kbd variant="outline">Shift</Kbd>
<Kbd variant="outline">Esc</Kbd>
<Kbd variant="outline" size="xs">F1</Kbd>
<Kbd variant="outline" size="lg">Enter</Kbd>
</div>
<div
style="display: flex; gap: 0.5em; align-items: center; flex-wrap: wrap; padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md);"
>
<span style="color: var(--color-content-muted); min-inline-size: 6em;">ghost</span>
<Kbd variant="ghost">Ctrl</Kbd>
<Kbd variant="ghost">Shift</Kbd>
<Kbd variant="ghost">Esc</Kbd>
<Kbd variant="ghost" size="xs">F1</Kbd>
<Kbd variant="ghost" size="lg">Enter</Kbd>
</div>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · kbd 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 · variant + size</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>
Kbd composes inline within prose. Compose multiple Kbds with literal separators for
sequences: <code>{`<Kbd>Ctrl</Kbd> + <Kbd>K</Kbd>`}</code>.
</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">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>Maps to <code>{`var(--font-size-{key})`}</code>.</td></tr>
<tr><td class="name">as</td><td class="type">string</td><td class="default">'kbd'</td><td>Override rendered tag.</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>Composability examples</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Use case</th><th>Snippet</th></tr></thead>
<tbody>
<tr>
<td class="name">Single key</td>
<td><code>{`<Kbd>Esc</Kbd>`}</code></td>
</tr>
<tr>
<td class="name">Two-key shortcut</td>
<td><code>{`<Kbd>Ctrl</Kbd> + <Kbd>K</Kbd>`}</code></td>
</tr>
<tr>
<td class="name">Sequence</td>
<td><code>{`<Kbd>g</Kbd> then <Kbd>h</Kbd>`}</code></td>
</tr>
<tr>
<td class="name">Inline ghost (Markdown look)</td>
<td><code>{`<Kbd variant="ghost">space</Kbd>`}</code></td>
</tr>
<tr>
<td class="name">Inside a Heading</td>
<td>
<code>{`<Heading level={3}>Press <Kbd size="sm">Tab</Kbd> to switch</Heading>`}</code>
</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>&lt;Kbd&gt;</code></td><td>Radix offers size only; UIX adds variant.</td></tr>
<tr><td class="name">chakra-ui</td><td><code>&lt;Kbd&gt;</code></td><td>Chakra uses <code>colorScheme</code>; UIX has variant.</td></tr>
<tr><td class="name">mantine</td><td><code>&lt;Kbd&gt;</code></td><td>Mantine ships a single visual; UIX has 3 variants.</td></tr>
<tr><td class="name">MDN <code>&lt;kbd&gt;</code></td><td>HTML primitive</td><td>UIX wraps the native element with the foundation tokens.</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>{kbdMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{kbdMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{kbdMorfo.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);">
The Provider stamps <code>data-kbd</code> on the rendered <code>{`<kbd>`}</code> tag plus
<code>data-variant</code>.
</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>
Kbd declares no semantic events. The primitive is a label for a shortcut — the actual
shortcut handler lives on the consumer (a Toolbar button, a global key handler). If
binding the visual to a real shortcut, use a synced state with the handler 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/kbd/kbd.css</code>. Base mono typography
from <code>--style-code-font-family</code>; chrome via <code>data-variant</code>.
</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-kbd]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Provider marker. Base mono cap.</td></tr>
<tr><td class="name"><code>{`[data-kbd][data-variant='surface']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Filled cap with bottom-emphasized border.</td></tr>
<tr><td class="name"><code>{`[data-kbd][data-variant='outline']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Outlined cap, transparent fill.</td></tr>
<tr><td class="name"><code>{`[data-kbd][data-variant='ghost']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Mono font only, no chrome.</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>Native HTML <code>{`<kbd>`}</code> semantics. AT announce as "keyboard input".</td></tr>
<tr><td class="name">Multi-key sequences</td><td>Compose multiple Kbds with literal separators ("+", " then ", " ↓ "). AT read the sequence in order.</td></tr>
<tr><td class="name">Color contrast</td><td>Surface variant uses neutral palette tokens meeting WCAG AA on foundation surfaces.</td></tr>
<tr><td class="name">Selectable text</td><td><code>user-select: none</code> by default. Toggle via <code>tagStyle</code> if the consumer wants the key text selectable.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,448 @@
<script lang="ts">
import {
Link,
type LinkVariant,
type LinkUnderline,
type LinkSize,
type LinkColor
} from '$uix/eidos/components/link';
import { compileMorfo } from '$uix/morfo';
import { linkMorfo } from '@/uix/morfo/components/link';
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<LinkVariant>('default');
let underline = $state<LinkUnderline>('hover');
let size = $state<LinkSize | ''>('');
let color = $state<LinkColor>('primary');
let external = $state(false);
let disabled = $state(false);
let href = $state('/uix/components/link');
let content = $state('documentation');
const variants: LinkVariant[] = ['default', 'subtle', 'plain'];
const underlines: LinkUnderline[] = ['none', 'hover', 'always'];
const sizes: LinkSize[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl'];
const colors: LinkColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
const compiled = compileMorfo(linkMorfo);
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 somaSnippet = $derived(
[
'<!-- Link is eidos-native — no soma layer. -->',
'<!-- Equivalent markup: -->',
'',
disabled ? '<span' : '<a',
!disabled && ` href="${href}"`,
' data-link',
` data-variant="${variant}"`,
` data-underline="${underline}"`,
` data-color="${color}"`,
external && !disabled && ' target="_blank"',
external && !disabled && ' rel="noopener noreferrer"',
disabled && ' aria-disabled="true"',
'>',
' ' + content,
disabled ? '</span>' : '</a>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Link } from '$uix/eidos/components/link';",
'</' + 'script>',
'',
'<Link',
!disabled && ` href="${href}"`,
variant !== 'default' && ` variant="${variant}"`,
underline !== 'hover' && ` underline="${underline}"`,
size && ` size="${size}"`,
color !== 'primary' && ` color="${color}"`,
external && ' external',
disabled && ' disabled',
'>',
' ' + content,
'</Link>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Typography · Link</div>
<h1 data-uix-page-title>Link</h1>
<p data-uix-page-lede>
Inline text link primitive. Renders <code>{`<a>`}</code> by default — or
<code>{`<span aria-disabled>`}</code>
when disabled — so the link composes mid-paragraph without breaking ambient typography.
Visual variants drive color + underline; <code>external</code> adds the standard
<code>target="_blank"</code>
+ <code>rel="noopener noreferrer"</code> + screen-reader affordance. Single part. Eidos-native:
no soma backing, no semantic events on the morfo (navigation belongs to the consumer).
</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>colors</span>8
</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}>
<p
style="max-inline-size: 38rem; padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md); font: inherit;"
>
Read the
<Link
{href}
{variant}
{underline}
size={size || undefined}
{color}
{external}
{disabled}
>
{content}
</Link>
for more information.
</p>
</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>color</span> {color} ·
<span data-uix-stage-trace-key>underline</span> {underline}
</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>9</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>
Link is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. Inherits
ambient typography; local concerns are color + underline + size.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</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>underline</span>
<span data-uix-chips role="radiogroup">
{#each underlines as opt}
<button
data-uix-chip
data-active={underline === opt}
onclick={() => (underline = 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>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as opt}
<button data-uix-chip data-active={color === opt} onclick={() => (color = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>external <span data-uix-control-hint>target=_blank + SR text</span></span
>
<span data-uix-switch>
<input type="checkbox" bind:checked={external} />
<span data-uix-switch-label>{external ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>href</span>
<input type="text" bind:value={href} style="inline-size: 18rem;" />
</label>
<label data-uix-control>
<span data-uix-control-label>content</span>
<input type="text" bind:value={content} style="inline-size: 14rem;" />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · link 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 · variant + color + external</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>
Link inherits ambient typography. Local concerns are color + underline + size. <code
>external</code
>
wires the standard new-tab affordance; <code>disabled</code> swaps the tag to
<code>{`<span aria-disabled>`}</code>.
</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">href</td><td class="type">string</td><td class="default">—</td><td>Target URL.</td></tr>
<tr><td class="name">external</td><td class="type">boolean</td><td class="default">false</td><td>Adds <code>target="_blank"</code> + <code>rel</code> + SR-only "(opens in new tab)".</td></tr>
<tr><td class="name">variant</td><td class="type">'default' | 'subtle' | 'plain'</td><td class="default">'default'</td><td>Visual variant.</td></tr>
<tr><td class="name">underline</td><td class="type">'none' | 'hover' | 'always'</td><td class="default">'hover'</td><td>Underline behavior.</td></tr>
<tr><td class="name">size</td><td class="type">'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'</td><td class="default">inherit</td><td>Size override.</td></tr>
<tr><td class="name">color</td><td class="type">'primary' | 'secondary' | 'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss'</td><td class="default">'primary'</td><td>Intent color.</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Renders <code>{`<span aria-disabled>`}</code>, drops href.</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>&lt;Link&gt;</code></td><td>Radix has size + weight; UIX has intent palette.</td></tr>
<tr><td class="name">chakra-ui</td><td><code>&lt;Link&gt;</code></td><td>Chakra uses <code>colorScheme</code>; UIX restricts to intent.</td></tr>
<tr><td class="name">mantine</td><td><code>&lt;Anchor&gt;</code></td><td>Mantine uses free colors; UIX intent.</td></tr>
<tr><td class="name">shadcn/ui</td><td>recipe-only</td><td>shadcn provides Tailwind classes; UIX ships a component with external affordance.</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>{linkMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{linkMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{linkMorfo.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);">
The Provider stamps <code>data-link</code> on the rendered tag (default <code>{`<a>`}</code>,
or <code>{`<span>`}</code> when disabled) plus <code>data-variant</code>,
<code>data-underline</code>, <code>data-color</code> and (when set)
<code>data-external</code> / <code>data-disabled</code>.
</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>
Link declares no semantic events. Navigation / click ownership belongs to the consumer
(SvelteKit router, custom action, sema-driven verb on the surrounding component). If
Link is used as a button-style affordance (form submit, dialog action), the consumer
should wrap a <code>{`<Toolbar.Button>`}</code> instead — that surface owns the sema
commit. Hover and active visual states are CSS-only — they don't qualify as semantic
transitions.
</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/link/link.css</code>. The shell selector
is <code>[data-link]</code>; underline behavior + color are driven by data-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-link]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Anchor / span shell.</td></tr>
<tr><td class="name"><code>{`[data-link][data-color='X']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Intent palette.</td></tr>
<tr><td class="name"><code>{`[data-link][data-underline='none'|'hover'|'always']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Underline behavior.</td></tr>
<tr><td class="name"><code>{`[data-link][data-variant='subtle'|'plain']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Inherits color (variant override).</td></tr>
<tr><td class="name"><code>[data-link]:hover</code> / <code>:active</code> / <code>:focus-visible</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Interactive states.</td></tr>
<tr><td class="name"><code>[data-link][data-disabled]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Disabled (no pointer events, dimmed).</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>Native HTML <code>{`<a>`}</code> when enabled. AT announce as "link".</td></tr>
<tr><td class="name">Disabled</td><td>Renders <code>{`<span aria-disabled="true">`}</code> without href. Out of tab order via <code>pointer-events: none</code> + missing href.</td></tr>
<tr><td class="name">External links</td><td>SR-only "(opens in new tab)" text + visible icon. <code>target="_blank"</code> always paired with <code>rel="noopener noreferrer"</code>.</td></tr>
<tr><td class="name">Focus ring</td><td>Always-on <code>:focus-visible</code> outline using <code>--color-primary-solid</code>.</td></tr>
<tr><td class="name">Color contrast</td><td>Each intent's text-token meets WCAG AA on the foundation surfaces.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,402 @@
<script lang="ts">
import { Mark, type MarkColor } from '$uix/eidos/components/mark';
import { compileMorfo } from '$uix/morfo';
import { markMorfo } from '@/uix/morfo/components/mark';
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 color = $state<MarkColor>('fulfill');
let content = $state<string>('dolor sit');
const colors: MarkColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
const compiled = compileMorfo(markMorfo);
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 somaSnippet = $derived(
[
'<!-- Mark is eidos-native — no soma layer. -->',
'<!-- Equivalent markup: -->',
'',
'<mark',
' data-mark',
` data-color="${color}"`,
'>',
` ${content}`,
'</mark>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Mark } from '$uix/eidos/components/mark';",
'</' + 'script>',
'',
'<Mark',
color !== 'fulfill' && ` color="${color}"`,
'>',
' ' + content,
'</Mark>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Typography · Mark</div>
<h1 data-uix-page-title>Mark</h1>
<p data-uix-page-lede>
Inline text highlight primitive. Renders <code>{`<mark>`}</code> with a soft background tint
per intent color. Inherits the ambient typography so it composes seamlessly inside Text /
Heading / Code. For substring highlighting of search-result hits, see the higher-level
<code>{`<Highlight>`}</code>
primitive. Single 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>colors</span>8
</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}>
<p
style="max-inline-size: 38rem; padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md); font: inherit;"
>
Lorem ipsum
<Mark {color}>{content}</Mark>
amet, consectetur adipiscing elit.
</p>
</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>color</span>
<span>{color}</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>4</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>
Mark is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>color</code>
accepts the full intent palette; the highlight inherits ambient typography.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · highlight
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as opt}
<button data-uix-chip data-active={color === opt} onclick={() => (color = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control style="grid-column: span 2;">
<span data-uix-control-label>content</span>
<input type="text" bind:value={content} style="inline-size: 100%; font: inherit;" />
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> color gallery
</div>
<div
style="display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: var(--space-2); padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md);"
>
{#each colors as opt}
<div style="display: flex; align-items: center; gap: 0.5em;">
<span
style="color: var(--color-content-muted); font-size: var(--font-size-xs); min-inline-size: 5em;"
>
{opt}
</span>
<Mark color={opt}>highlight</Mark>
</div>
{/each}
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> inline composition
</div>
<div
style="padding: var(--space-3); border: 1px dashed var(--color-neutral-border); border-radius: var(--radius-md); display: flex; flex-direction: column; gap: var(--space-2);"
>
<p style="margin: 0;">
Search returned <Mark color="affirm">3 matches</Mark> for the query
<Mark color="primary">"typography"</Mark>.
</p>
<p style="margin: 0;">
Deadline approaching: <Mark color="threat">2 days remaining</Mark>.
</p>
<p style="margin: 0;">
The file <Mark color="risk">deleted.txt</Mark> cannot be recovered.
</p>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · mark 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 · intent tint</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>
Mark inherits the ambient typography and decorates with a soft background tint. The
intent palette is the canonical ColorRole union, so highlights follow the theme.
</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">color</td><td class="type">'primary' | 'secondary' | 'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss'</td><td class="default">'fulfill'</td><td>Intent palette for the highlight tint.</td></tr>
<tr><td class="name">as</td><td class="type">string</td><td class="default">'mark'</td><td>Override rendered tag.</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>Composability examples</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Use case</th><th>Snippet</th></tr></thead>
<tbody>
<tr>
<td class="name">Search-result hit</td>
<td><code>{`<Mark color="fulfill">match</Mark>`}</code></td>
</tr>
<tr>
<td class="name">Attention grab</td>
<td><code>{`<Mark color="threat">deadline</Mark>`}</code></td>
</tr>
<tr>
<td class="name">Affirmative confirmation</td>
<td><code>{`<Mark color="affirm">approved</Mark>`}</code></td>
</tr>
<tr>
<td class="name">Destructive marker</td>
<td><code>{`<Mark color="risk">removed</Mark>`}</code></td>
</tr>
<tr>
<td class="name">Inside a Heading</td>
<td>
<code>{`<Heading><Mark color="primary">UIX</Mark> typography</Heading>`}</code>
</td>
</tr>
<tr>
<td class="name">Multiple highlights in flow</td>
<td>
<code>{`<p>...<Mark>first</Mark>...<Mark color="neutral">aside</Mark>...</p>`}</code>
</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>—</td><td>Radix has no dedicated Mark; consumer uses Text with custom bg.</td></tr>
<tr><td class="name">chakra-ui</td><td><code>&lt;Mark&gt;</code> via Highlight</td><td>Chakra wraps via Highlight only.</td></tr>
<tr><td class="name">mantine</td><td><code>&lt;Mark&gt;</code></td><td>Mantine has free <code>color</code>; UIX restricts to intent palette.</td></tr>
<tr><td class="name">MDN <code>&lt;mark&gt;</code></td><td>HTML primitive</td><td>UIX wraps with intent palette + theme contract.</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>{markMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{markMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{markMorfo.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);">
The Provider stamps <code>data-mark</code> on the rendered tag (default
<code>{`<mark>`}</code>) plus <code>data-color</code>.
</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>
Mark declares no semantic events. The highlight is a decorator — no commit, no emerge.
If a consumer animates the appearance of marked text (search-result flash), the
animation belongs to the containing list or popover surface.
</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/mark/mark.css</code>. The selector
<code>[data-mark]</code>
inherits typography; <code>data-color</code> swaps the bg + text palette.
</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-mark]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Provider marker. Default fulfill tint.</td></tr>
<tr><td class="name"><code>{`[data-mark][data-color='X']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Intent palette: primary / secondary / neutral / affirm / fulfill / risk / threat / loss.</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>Native HTML <code>{`<mark>`}</code> semantics. Some screen readers announce as "highlighted" or "marked" — but the announcement is implementation-defined, so don't rely on it for meaningful semantics.</td></tr>
<tr><td class="name">For required emphasis</td><td>Use <code>{`<strong>`}</code> or <code>{`<em>`}</code> — both carry semantic emphasis. Mark is decorative.</td></tr>
<tr><td class="name">Color contrast</td><td>Each intent's bg + text combination meets WCAG AA via the foundation contract.</td></tr>
<tr><td class="name">Composition</td><td>Mark inherits typography — composes inside any inline-flow primitive without breaking the parent's line height.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.