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
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;
|
||||
};
|
||||
@ -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;
|
||||
@ -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><Code></code> + custom</td><td>Radix has no dedicated multi-line surface — UIX ships the header + copy as built-in.</td></tr>
|
||||
<tr><td class="name">chakra-ui</td><td>composed</td><td>Chakra composes Code + Box + IconButton; UIX bundles them.</td></tr>
|
||||
<tr><td class="name">mantine</td><td><code><CodeHighlight></code></td><td>Mantine ships Shiki integration; UIX reserves <code>data-language</code> for a separate action.</td></tr>
|
||||
<tr><td class="name">shadcn/ui</td><td>recipe-only</td><td>shadcn provides Tailwind classes; UIX ships a proper component.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Morfo contract</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">name</td><td>{codeBlockMorfo.name}</td></tr>
|
||||
<tr><td class="name">kebab</td><td><code>{codeBlockMorfo.kebab}</code></td></tr>
|
||||
<tr><td class="name">scope</td><td>{codeBlockMorfo.scope.join(', ')}</td></tr>
|
||||
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
|
||||
<tr><td class="name">events</td><td>0</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Parts</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead
|
||||
><tr
|
||||
><th>kebab</th><th>marker</th><th>element</th><th>archetype</th><th>optional</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr>
|
||||
<td class="name">{part.kebab}</td>
|
||||
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
||||
<td class="type"><{part.defaultElement}></td>
|
||||
<td class="type">{part.archetype}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
The Provider stamps <code>data-code-block</code> + <code>data-variant</code>. Internal
|
||||
header / copy / pre / code leaves are eidos-only attrs (<code>data-code-block-header</code>,
|
||||
<code>data-code-block-title</code>, etc.) — the morfo declares only the Provider because
|
||||
the inner parts are consumed exclusively by the recipe.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="sema">sema</span> · events
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
CodeBlock declares no semantic events. The copy button is a utility leaf — clipboard
|
||||
write, no semantic transition. By the 2-of-3 rule, a <code>commit-copy</code> event
|
||||
would only be consumed by eidos (visual flip) — not enough to justify a morfo extension.
|
||||
If a consumer needs sema on copy (e.g. fulfilment of a tutorial step), they compose
|
||||
CodeBlock inside a Stepper or Form that owns the verb.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
Recipe lives in <code>src/uix/eidos/components/code-block/code-block.css</code>. The
|
||||
outer shell selector is <code>[data-code-block]</code>; the header / pre / code leaves
|
||||
use eidos-only attrs.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name"><code>[data-code-block]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Provider marker.</td></tr>
|
||||
<tr><td class="name"><code>{`[data-code-block][data-variant='surface'|'outline'|'ghost']`}</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Chrome treatment.</td></tr>
|
||||
<tr><td class="name"><code>[data-code-block-header]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Title + lang + copy strip.</td></tr>
|
||||
<tr><td class="name"><code>[data-code-block-copy]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Copy button with <code>:hover</code> / <code>:focus-visible</code> / <code>[data-copied]</code> states.</td></tr>
|
||||
<tr><td class="name"><code>[data-code-block-pre]</code> / <code>[data-code-block-code]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Block body.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">Role</td><td>The block is a generic <code>{`<div>`}</code> wrapping <code>{`<pre><code>`}</code>. AT announce the code content.</td></tr>
|
||||
<tr><td class="name">Copy button</td><td><code>aria-label="Copy code to clipboard"</code> in English. Focus ring via <code>:focus-visible</code>.</td></tr>
|
||||
<tr><td class="name">Copied feedback</td><td>Button text flips from "Copy" to "Copied" + <code>data-copied</code> color shift. Re-announced by AT because the text content changes.</td></tr>
|
||||
<tr><td class="name">Keyboard</td><td>Copy button reachable via Tab. Enter / Space activate. No custom keyboard contract.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -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><Code></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><Code></code></td><td>Chakra uses <code>colorScheme</code>; UIX restricts to content-role.</td></tr>
|
||||
<tr><td class="name">mantine</td><td><code><Code></code></td><td>Mantine has <code>block</code> boolean; UIX splits into Code + CodeBlock.</td></tr>
|
||||
<tr><td class="name">MDN <code><code></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"><{part.defaultElement}></td>
|
||||
<td class="type">{part.archetype}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
The Provider stamps <code>data-code</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> <{asTag}> ·
|
||||
<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)}>
|
||||
<{opt}>
|
||||
</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><Heading size="9"></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><Heading as="h1"></code></td><td>Chakra reuses Heading; UIX splits.</td></tr>
|
||||
<tr><td class="name">mantine</td><td><code><Title order={`{1}`}></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"><{part.defaultElement}></td>
|
||||
<td class="type">{part.archetype}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
The Provider stamps <code>data-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><Heading></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><Heading></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><Title order={`{1..6}`}></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><Typography variant="h1"></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"><{part.defaultElement}></td>
|
||||
<td class="type">{part.archetype}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
The Provider stamps <code>data-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><Highlight></code></td><td>Chakra uses colorScheme; UIX restricts to intent palette.</td></tr>
|
||||
<tr><td class="name">mantine</td><td><code><Highlight></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"><{part.defaultElement}></td>
|
||||
<td class="type">{part.archetype}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
The Provider stamps <code>data-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><Kbd></code></td><td>Radix offers size only; UIX adds variant.</td></tr>
|
||||
<tr><td class="name">chakra-ui</td><td><code><Kbd></code></td><td>Chakra uses <code>colorScheme</code>; UIX has variant.</td></tr>
|
||||
<tr><td class="name">mantine</td><td><code><Kbd></code></td><td>Mantine ships a single visual; UIX has 3 variants.</td></tr>
|
||||
<tr><td class="name">MDN <code><kbd></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"><{part.defaultElement}></td>
|
||||
<td class="type">{part.archetype}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
The Provider stamps <code>data-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><Link></code></td><td>Radix has size + weight; UIX has intent palette.</td></tr>
|
||||
<tr><td class="name">chakra-ui</td><td><code><Link></code></td><td>Chakra uses <code>colorScheme</code>; UIX restricts to intent.</td></tr>
|
||||
<tr><td class="name">mantine</td><td><code><Anchor></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"><{part.defaultElement}></td>
|
||||
<td class="type">{part.archetype}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
The Provider stamps <code>data-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><Mark></code> via Highlight</td><td>Chakra wraps via Highlight only.</td></tr>
|
||||
<tr><td class="name">mantine</td><td><code><Mark></code></td><td>Mantine has free <code>color</code>; UIX restricts to intent palette.</td></tr>
|
||||
<tr><td class="name">MDN <code><mark></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"><{part.defaultElement}></td>
|
||||
<td class="type">{part.archetype}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
The Provider stamps <code>data-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…
Reference in new issue