From d421f1fb5d25452892e3a52b928d71a88b9d7ba9 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 20:05:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(typography):=208=20primitives=20=E2=80=94?= =?UTF-8?q?=20Heading,=20Display,=20Code,=20CodeBlock,=20Kbd,=20Mark,=20Hi?= =?UTF-8?q?ghlight,=20Link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 ``. `variant: 'plain' | 'soft' | 'outline'` for subtle background chrome. Demo 423 lines. **CodeBlock** — multi-line `
` 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 ``. 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 ``. `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 `` + block `` (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) 
---
 src/uix/eidos/components/code-block/README.md | 112 ++++
 .../components/code-block/code-block.css      | 126 ++++
 .../components/code-block/code-block.svelte   | 103 +++
 src/uix/eidos/components/code-block/index.ts  |  21 +
 src/uix/eidos/components/code-block/types.ts  |  27 +
 src/uix/eidos/components/code/README.md       |  90 +++
 src/uix/eidos/components/code/code.css        |  38 ++
 src/uix/eidos/components/code/code.svelte     |  63 ++
 src/uix/eidos/components/code/index.ts        |  18 +
 src/uix/eidos/components/code/types.ts        |  39 ++
 src/uix/eidos/components/display/README.md    |  89 +++
 src/uix/eidos/components/display/display.css  |  55 ++
 .../eidos/components/display/display.svelte   |  91 +++
 src/uix/eidos/components/display/index.ts     |  27 +
 src/uix/eidos/components/display/types.ts     |  71 +++
 src/uix/eidos/components/heading/README.md    | 108 ++++
 src/uix/eidos/components/heading/heading.css  | 106 ++++
 .../eidos/components/heading/heading.svelte   |  86 +++
 src/uix/eidos/components/heading/index.ts     |  29 +
 src/uix/eidos/components/heading/types.ts     |  69 ++
 src/uix/eidos/components/highlight/README.md  |  83 +++
 .../eidos/components/highlight/highlight.css  |  20 +
 .../components/highlight/highlight.svelte     |  72 +++
 src/uix/eidos/components/highlight/index.ts   |  18 +
 src/uix/eidos/components/highlight/types.ts   |  22 +
 src/uix/eidos/components/kbd/README.md        |  82 +++
 src/uix/eidos/components/kbd/index.ts         |  18 +
 src/uix/eidos/components/kbd/kbd.css          |  49 ++
 src/uix/eidos/components/kbd/kbd.svelte       |  51 ++
 src/uix/eidos/components/kbd/types.ts         |  23 +
 src/uix/eidos/components/link/README.md       | 112 ++++
 src/uix/eidos/components/link/index.ts        |  20 +
 src/uix/eidos/components/link/link.css        | 128 ++++
 src/uix/eidos/components/link/link.svelte     |  96 +++
 src/uix/eidos/components/link/types.ts        |  45 ++
 src/uix/eidos/components/mark/README.md       |  87 +++
 src/uix/eidos/components/mark/index.ts        |  18 +
 src/uix/eidos/components/mark/mark.css        |  50 ++
 src/uix/eidos/components/mark/mark.svelte     |  38 ++
 src/uix/eidos/components/mark/types.ts        |  18 +
 src/uix/eidos/index.css                       |   8 +
 src/uix/morfo/components/code-block.ts        |  33 +
 src/uix/morfo/components/code.ts              |  29 +
 src/uix/morfo/components/display.ts           |  30 +
 src/uix/morfo/components/heading.ts           |  34 +
 src/uix/morfo/components/highlight.ts         |  28 +
 src/uix/morfo/components/kbd.ts               |  28 +
 src/uix/morfo/components/link.ts              |  34 +
 src/uix/morfo/components/mark.ts              |  29 +
 web/routes/uix/+layout@.svelte                |  12 +-
 .../uix/components/code-block/+page.svelte    | 402 ++++++++++++
 web/routes/uix/components/code/+page.svelte   | 423 +++++++++++++
 .../uix/components/display/+page.svelte       | 546 ++++++++++++++++
 .../uix/components/heading/+page.svelte       | 594 ++++++++++++++++++
 .../uix/components/highlight/+page.svelte     | 407 ++++++++++++
 web/routes/uix/components/kbd/+page.svelte    | 418 ++++++++++++
 web/routes/uix/components/link/+page.svelte   | 448 +++++++++++++
 web/routes/uix/components/mark/+page.svelte   | 402 ++++++++++++
 58 files changed, 6322 insertions(+), 1 deletion(-)
 create mode 100644 src/uix/eidos/components/code-block/README.md
 create mode 100644 src/uix/eidos/components/code-block/code-block.css
 create mode 100644 src/uix/eidos/components/code-block/code-block.svelte
 create mode 100644 src/uix/eidos/components/code-block/index.ts
 create mode 100644 src/uix/eidos/components/code-block/types.ts
 create mode 100644 src/uix/eidos/components/code/README.md
 create mode 100644 src/uix/eidos/components/code/code.css
 create mode 100644 src/uix/eidos/components/code/code.svelte
 create mode 100644 src/uix/eidos/components/code/index.ts
 create mode 100644 src/uix/eidos/components/code/types.ts
 create mode 100644 src/uix/eidos/components/display/README.md
 create mode 100644 src/uix/eidos/components/display/display.css
 create mode 100644 src/uix/eidos/components/display/display.svelte
 create mode 100644 src/uix/eidos/components/display/index.ts
 create mode 100644 src/uix/eidos/components/display/types.ts
 create mode 100644 src/uix/eidos/components/heading/README.md
 create mode 100644 src/uix/eidos/components/heading/heading.css
 create mode 100644 src/uix/eidos/components/heading/heading.svelte
 create mode 100644 src/uix/eidos/components/heading/index.ts
 create mode 100644 src/uix/eidos/components/heading/types.ts
 create mode 100644 src/uix/eidos/components/highlight/README.md
 create mode 100644 src/uix/eidos/components/highlight/highlight.css
 create mode 100644 src/uix/eidos/components/highlight/highlight.svelte
 create mode 100644 src/uix/eidos/components/highlight/index.ts
 create mode 100644 src/uix/eidos/components/highlight/types.ts
 create mode 100644 src/uix/eidos/components/kbd/README.md
 create mode 100644 src/uix/eidos/components/kbd/index.ts
 create mode 100644 src/uix/eidos/components/kbd/kbd.css
 create mode 100644 src/uix/eidos/components/kbd/kbd.svelte
 create mode 100644 src/uix/eidos/components/kbd/types.ts
 create mode 100644 src/uix/eidos/components/link/README.md
 create mode 100644 src/uix/eidos/components/link/index.ts
 create mode 100644 src/uix/eidos/components/link/link.css
 create mode 100644 src/uix/eidos/components/link/link.svelte
 create mode 100644 src/uix/eidos/components/link/types.ts
 create mode 100644 src/uix/eidos/components/mark/README.md
 create mode 100644 src/uix/eidos/components/mark/index.ts
 create mode 100644 src/uix/eidos/components/mark/mark.css
 create mode 100644 src/uix/eidos/components/mark/mark.svelte
 create mode 100644 src/uix/eidos/components/mark/types.ts
 create mode 100644 src/uix/morfo/components/code-block.ts
 create mode 100644 src/uix/morfo/components/code.ts
 create mode 100644 src/uix/morfo/components/display.ts
 create mode 100644 src/uix/morfo/components/heading.ts
 create mode 100644 src/uix/morfo/components/highlight.ts
 create mode 100644 src/uix/morfo/components/kbd.ts
 create mode 100644 src/uix/morfo/components/link.ts
 create mode 100644 src/uix/morfo/components/mark.ts
 create mode 100644 web/routes/uix/components/code-block/+page.svelte
 create mode 100644 web/routes/uix/components/code/+page.svelte
 create mode 100644 web/routes/uix/components/display/+page.svelte
 create mode 100644 web/routes/uix/components/heading/+page.svelte
 create mode 100644 web/routes/uix/components/highlight/+page.svelte
 create mode 100644 web/routes/uix/components/kbd/+page.svelte
 create mode 100644 web/routes/uix/components/link/+page.svelte
 create mode 100644 web/routes/uix/components/mark/+page.svelte

diff --git a/src/uix/eidos/components/code-block/README.md b/src/uix/eidos/components/code-block/README.md
new file mode 100644
index 000000000..04ffbcfcd
--- /dev/null
+++ b/src/uix/eidos/components/code-block/README.md
@@ -0,0 +1,112 @@
+# Eidos CodeBlock
+
+Multi-line code block primitive. Renders a `
` shell wrapping a +`
` 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
+
+  {`const x = 1;
+console.log(x);`}
+
+
+
+  {`npm run dev`}
+
+
+
+  {`Plain block with no chrome.`}
+
+```
+
+## Baseline
+
+Origen: `air/components/typography/code-block` (rama `morfo-runtime`).
+Adaptaciones para eidos:
+
+- **Separated from ``**: air shipped a `variant='block'` on
+  ``. 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 `` 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.
diff --git a/src/uix/eidos/components/code-block/code-block.css b/src/uix/eidos/components/code-block/code-block.css
new file mode 100644
index 000000000..ae5125e71
--- /dev/null
+++ b/src/uix/eidos/components/code-block/code-block.css
@@ -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;
+}
diff --git a/src/uix/eidos/components/code-block/code-block.svelte b/src/uix/eidos/components/code-block/code-block.svelte
new file mode 100644
index 000000000..18cad72a3
--- /dev/null
+++ b/src/uix/eidos/components/code-block/code-block.svelte
@@ -0,0 +1,103 @@
+
+
+
+ {#if hasHeader} +
+ {#if title} + {title} + {/if} + {#if language} + {language} + {/if} + {#if showCopy} + + {/if} +
+ {/if} +
{@render children?.()}
+
diff --git a/src/uix/eidos/components/code-block/index.ts b/src/uix/eidos/components/code-block/index.ts new file mode 100644 index 000000000..ea2fd1825 --- /dev/null +++ b/src/uix/eidos/components/code-block/index.ts @@ -0,0 +1,21 @@ +// CodeBlock — eidos single-component multi-line code block. +// +// import { CodeBlock } from '$uix/eidos/components/code-block'; +// +// +// {`const x = 1;\nconsole.log(x);`} +// +// +// Single-component primitive — `` IS the root. Ships an +// optional header strip (title + language badge + copy button) and a +// `
` 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';
diff --git a/src/uix/eidos/components/code-block/types.ts b/src/uix/eidos/components/code-block/types.ts
new file mode 100644
index 000000000..4c864b046
--- /dev/null
+++ b/src/uix/eidos/components/code-block/types.ts
@@ -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, '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 ``. */
+	language?: string;
+	/** Render a copy-to-clipboard button in the header. @default true */
+	showCopy?: boolean;
+	/** Visual chrome variant. @default 'surface' */
+	variant?: ResponsiveProp;
+	/** Code font size override. */
+	size?: ResponsiveProp;
+	/** Extra inline style. */
+	tagStyle?: string;
+	/** Extra class names. */
+	class?: string;
+	children?: Snippet;
+};
diff --git a/src/uix/eidos/components/code/README.md b/src/uix/eidos/components/code/README.md
new file mode 100644
index 000000000..e95b4e617
--- /dev/null
+++ b/src/uix/eidos/components/code/README.md
@@ -0,0 +1,90 @@
+# Eidos Code
+
+Inline monospace text primitive. Renders `` 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 `` primitive.
+
+## Superficie
+
+```svelte
+

Run npm install to install dependencies.

+

Press Ctrl+K to open search.

+readonly id: string +``` + +## Baseline + +Origen: `air/components/typography/code` (rama `morfo-runtime`). +Adaptaciones para eidos: + +- **Split inline vs block**: air shipped a single `` with + `variant: 'inline' | 'block'`. Eidos splits them — `` is + inline only, `` 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 + +- **`` is inline only**: matches Radix Themes' split. The block + surface is `` 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 `` 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 `` (que ya lo lleva). | +| Free color (hex/CSS) | **descartar** | Mantiene contrato de contraste; usar `tagStyle`. | +| Diff coloring (added/removed line markers) | **diferir** | Pertenece a `` 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 ``: 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 `` que AT entienden nativamente). La +recipe consume `--style-code-*` del foundation + `--_code-{axis}` +overrides inline. diff --git a/src/uix/eidos/components/code/code.css b/src/uix/eidos/components/code/code.css new file mode 100644 index 000000000..b7ad4e080 --- /dev/null +++ b/src/uix/eidos/components/code/code.css @@ -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; +} diff --git a/src/uix/eidos/components/code/code.svelte b/src/uix/eidos/components/code/code.svelte new file mode 100644 index 000000000..398bfd548 --- /dev/null +++ b/src/uix/eidos/components/code/code.svelte @@ -0,0 +1,63 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/code/index.ts b/src/uix/eidos/components/code/index.ts new file mode 100644 index 000000000..2e3b64255 --- /dev/null +++ b/src/uix/eidos/components/code/index.ts @@ -0,0 +1,18 @@ +// Code — eidos single-component inline monospace primitive. +// +// import { Code } from '$uix/eidos/components/code'; +// +// const x = 1 +// npm run dev +// +// Single-component primitive — `` IS the root. Renders as +// `` by default. For multi-line blocks, use ``. +import CodeComponent from './code.svelte'; + +const Code = CodeComponent; + +export { Code }; + +export default Code; + +export type { CodeProps, CodeSize, CodeVariant, CodeColor } from './types'; diff --git a/src/uix/eidos/components/code/types.ts b/src/uix/eidos/components/code/types.ts new file mode 100644 index 000000000..1e7479211 --- /dev/null +++ b/src/uix/eidos/components/code/types.ts @@ -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, '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; + /** Size override. Maps to `var(--font-size-{key})` / matching line-height. */ + size?: ResponsiveProp; + /** Content-role color slot. */ + color?: ResponsiveProp; + /** + * 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; +}; diff --git a/src/uix/eidos/components/display/README.md b/src/uix/eidos/components/display/README.md new file mode 100644 index 000000000..9f4e9546e --- /dev/null +++ b/src/uix/eidos/components/display/README.md @@ -0,0 +1,89 @@ +# Eidos Display + +Hero typography primitive — the page's display title (above an `

` +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 +Welcome to UIX. +Big. +Spread. +Render display typography on a paragraph element. +``` + +## Baseline + +Origen: `air/components/typography/display` (rama `morfo-runtime`). +Adaptaciones para eidos: + +- **Default tag is `

`**: air defaulted to `
`. 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 (``) | No (use ``) | No (use ``) | 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. diff --git a/src/uix/eidos/components/display/display.css b/src/uix/eidos/components/display/display.css new file mode 100644 index 000000000..303768942 --- /dev/null +++ b/src/uix/eidos/components/display/display.css @@ -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; +} diff --git a/src/uix/eidos/components/display/display.svelte b/src/uix/eidos/components/display/display.svelte new file mode 100644 index 000000000..8842633d6 --- /dev/null +++ b/src/uix/eidos/components/display/display.svelte @@ -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> diff --git a/src/uix/eidos/components/display/index.ts b/src/uix/eidos/components/display/index.ts new file mode 100644 index 000000000..fb5da997b --- /dev/null +++ b/src/uix/eidos/components/display/index.ts @@ -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'; diff --git a/src/uix/eidos/components/display/types.ts b/src/uix/eidos/components/display/types.ts new file mode 100644 index 000000000..0ee8d7d66 --- /dev/null +++ b/src/uix/eidos/components/display/types.ts @@ -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; +}; diff --git a/src/uix/eidos/components/heading/README.md b/src/uix/eidos/components/heading/README.md new file mode 100644 index 000000000..621537a26 --- /dev/null +++ b/src/uix/eidos/components/heading/README.md @@ -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. diff --git a/src/uix/eidos/components/heading/heading.css b/src/uix/eidos/components/heading/heading.css new file mode 100644 index 000000000..cd9840fa0 --- /dev/null +++ b/src/uix/eidos/components/heading/heading.css @@ -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; +} diff --git a/src/uix/eidos/components/heading/heading.svelte b/src/uix/eidos/components/heading/heading.svelte new file mode 100644 index 000000000..87704962e --- /dev/null +++ b/src/uix/eidos/components/heading/heading.svelte @@ -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> diff --git a/src/uix/eidos/components/heading/index.ts b/src/uix/eidos/components/heading/index.ts new file mode 100644 index 000000000..1af2cab51 --- /dev/null +++ b/src/uix/eidos/components/heading/index.ts @@ -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'; diff --git a/src/uix/eidos/components/heading/types.ts b/src/uix/eidos/components/heading/types.ts new file mode 100644 index 000000000..deeb71cc7 --- /dev/null +++ b/src/uix/eidos/components/heading/types.ts @@ -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; +}; diff --git a/src/uix/eidos/components/highlight/README.md b/src/uix/eidos/components/highlight/README.md new file mode 100644 index 000000000..12955ae3b --- /dev/null +++ b/src/uix/eidos/components/highlight/README.md @@ -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>`. diff --git a/src/uix/eidos/components/highlight/highlight.css b/src/uix/eidos/components/highlight/highlight.css new file mode 100644 index 000000000..2dbb3be1c --- /dev/null +++ b/src/uix/eidos/components/highlight/highlight.css @@ -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; +} diff --git a/src/uix/eidos/components/highlight/highlight.svelte b/src/uix/eidos/components/highlight/highlight.svelte new file mode 100644 index 000000000..c92f35fb8 --- /dev/null +++ b/src/uix/eidos/components/highlight/highlight.svelte @@ -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> diff --git a/src/uix/eidos/components/highlight/index.ts b/src/uix/eidos/components/highlight/index.ts new file mode 100644 index 000000000..2d25641aa --- /dev/null +++ b/src/uix/eidos/components/highlight/index.ts @@ -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'; diff --git a/src/uix/eidos/components/highlight/types.ts b/src/uix/eidos/components/highlight/types.ts new file mode 100644 index 000000000..96a0a585f --- /dev/null +++ b/src/uix/eidos/components/highlight/types.ts @@ -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; +}; diff --git a/src/uix/eidos/components/kbd/README.md b/src/uix/eidos/components/kbd/README.md new file mode 100644 index 000000000..09aa1233f --- /dev/null +++ b/src/uix/eidos/components/kbd/README.md @@ -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. diff --git a/src/uix/eidos/components/kbd/index.ts b/src/uix/eidos/components/kbd/index.ts new file mode 100644 index 000000000..2133e43f7 --- /dev/null +++ b/src/uix/eidos/components/kbd/index.ts @@ -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'; diff --git a/src/uix/eidos/components/kbd/kbd.css b/src/uix/eidos/components/kbd/kbd.css new file mode 100644 index 000000000..5ceffd4df --- /dev/null +++ b/src/uix/eidos/components/kbd/kbd.css @@ -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; +} diff --git a/src/uix/eidos/components/kbd/kbd.svelte b/src/uix/eidos/components/kbd/kbd.svelte new file mode 100644 index 000000000..f6a47f53b --- /dev/null +++ b/src/uix/eidos/components/kbd/kbd.svelte @@ -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> diff --git a/src/uix/eidos/components/kbd/types.ts b/src/uix/eidos/components/kbd/types.ts new file mode 100644 index 000000000..9ab189207 --- /dev/null +++ b/src/uix/eidos/components/kbd/types.ts @@ -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; +}; diff --git a/src/uix/eidos/components/link/README.md b/src/uix/eidos/components/link/README.md new file mode 100644 index 000000000..dd373a778 --- /dev/null +++ b/src/uix/eidos/components/link/README.md @@ -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. diff --git a/src/uix/eidos/components/link/index.ts b/src/uix/eidos/components/link/index.ts new file mode 100644 index 000000000..bdaa45db0 --- /dev/null +++ b/src/uix/eidos/components/link/index.ts @@ -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'; diff --git a/src/uix/eidos/components/link/link.css b/src/uix/eidos/components/link/link.css new file mode 100644 index 000000000..cb2fc2d35 --- /dev/null +++ b/src/uix/eidos/components/link/link.css @@ -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; +} diff --git a/src/uix/eidos/components/link/link.svelte b/src/uix/eidos/components/link/link.svelte new file mode 100644 index 000000000..b8f0557ed --- /dev/null +++ b/src/uix/eidos/components/link/link.svelte @@ -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} diff --git a/src/uix/eidos/components/link/types.ts b/src/uix/eidos/components/link/types.ts new file mode 100644 index 000000000..6e8703646 --- /dev/null +++ b/src/uix/eidos/components/link/types.ts @@ -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; +}; diff --git a/src/uix/eidos/components/mark/README.md b/src/uix/eidos/components/mark/README.md new file mode 100644 index 000000000..38d6ed434 --- /dev/null +++ b/src/uix/eidos/components/mark/README.md @@ -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. diff --git a/src/uix/eidos/components/mark/index.ts b/src/uix/eidos/components/mark/index.ts new file mode 100644 index 000000000..8f39594dc --- /dev/null +++ b/src/uix/eidos/components/mark/index.ts @@ -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'; diff --git a/src/uix/eidos/components/mark/mark.css b/src/uix/eidos/components/mark/mark.css new file mode 100644 index 000000000..1ab9b7341 --- /dev/null +++ b/src/uix/eidos/components/mark/mark.css @@ -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); +} diff --git a/src/uix/eidos/components/mark/mark.svelte b/src/uix/eidos/components/mark/mark.svelte new file mode 100644 index 000000000..0f26a09f0 --- /dev/null +++ b/src/uix/eidos/components/mark/mark.svelte @@ -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> diff --git a/src/uix/eidos/components/mark/types.ts b/src/uix/eidos/components/mark/types.ts new file mode 100644 index 000000000..ee7afe354 --- /dev/null +++ b/src/uix/eidos/components/mark/types.ts @@ -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; +}; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index f4e0cf1d4..e84570b11 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -74,6 +74,14 @@ @import './components/banner/banner.css'; @import './components/float/float.css'; @import './components/text/text.css'; +@import './components/heading/heading.css'; +@import './components/display/display.css'; +@import './components/code/code.css'; +@import './components/code-block/code-block.css'; +@import './components/kbd/kbd.css'; +@import './components/mark/mark.css'; +@import './components/highlight/highlight.css'; +@import './components/link/link.css'; @import './components/icon/icon.css'; @import './components/avatar/avatar.css'; @import './components/breadcrumb/breadcrumb.css'; diff --git a/src/uix/morfo/components/code-block.ts b/src/uix/morfo/components/code-block.ts new file mode 100644 index 000000000..91930c4a5 --- /dev/null +++ b/src/uix/morfo/components/code-block.ts @@ -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; diff --git a/src/uix/morfo/components/code.ts b/src/uix/morfo/components/code.ts new file mode 100644 index 000000000..58bb9da02 --- /dev/null +++ b/src/uix/morfo/components/code.ts @@ -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; diff --git a/src/uix/morfo/components/display.ts b/src/uix/morfo/components/display.ts new file mode 100644 index 000000000..b08a03e76 --- /dev/null +++ b/src/uix/morfo/components/display.ts @@ -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; diff --git a/src/uix/morfo/components/heading.ts b/src/uix/morfo/components/heading.ts new file mode 100644 index 000000000..b60df715d --- /dev/null +++ b/src/uix/morfo/components/heading.ts @@ -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; diff --git a/src/uix/morfo/components/highlight.ts b/src/uix/morfo/components/highlight.ts new file mode 100644 index 000000000..94617a931 --- /dev/null +++ b/src/uix/morfo/components/highlight.ts @@ -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; diff --git a/src/uix/morfo/components/kbd.ts b/src/uix/morfo/components/kbd.ts new file mode 100644 index 000000000..89bfd881e --- /dev/null +++ b/src/uix/morfo/components/kbd.ts @@ -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; diff --git a/src/uix/morfo/components/link.ts b/src/uix/morfo/components/link.ts new file mode 100644 index 000000000..66e7d4464 --- /dev/null +++ b/src/uix/morfo/components/link.ts @@ -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; diff --git a/src/uix/morfo/components/mark.ts b/src/uix/morfo/components/mark.ts new file mode 100644 index 000000000..519e668c4 --- /dev/null +++ b/src/uix/morfo/components/mark.ts @@ -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; diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 64bf33319..732d83887 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -200,7 +200,17 @@ { kind: 'group', heading: 'Typography', - items: [{ slug: '/uix/components/text', label: 'Text' }] + items: [ + { slug: '/uix/components/text', label: 'Text' }, + { slug: '/uix/components/heading', label: 'Heading' }, + { slug: '/uix/components/display', label: 'Display' }, + { slug: '/uix/components/code', label: 'Code' }, + { slug: '/uix/components/code-block', label: 'CodeBlock' }, + { slug: '/uix/components/kbd', label: 'Kbd' }, + { slug: '/uix/components/mark', label: 'Mark' }, + { slug: '/uix/components/highlight', label: 'Highlight' }, + { slug: '/uix/components/link', label: 'Link' } + ] }, { kind: 'group', diff --git a/web/routes/uix/components/code-block/+page.svelte b/web/routes/uix/components/code-block/+page.svelte new file mode 100644 index 000000000..a4d41ca1f --- /dev/null +++ b/web/routes/uix/components/code-block/+page.svelte @@ -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> diff --git a/web/routes/uix/components/code/+page.svelte b/web/routes/uix/components/code/+page.svelte new file mode 100644 index 000000000..7972c642a --- /dev/null +++ b/web/routes/uix/components/code/+page.svelte @@ -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> diff --git a/web/routes/uix/components/display/+page.svelte b/web/routes/uix/components/display/+page.svelte new file mode 100644 index 000000000..bae75bcda --- /dev/null +++ b/web/routes/uix/components/display/+page.svelte @@ -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> diff --git a/web/routes/uix/components/heading/+page.svelte b/web/routes/uix/components/heading/+page.svelte new file mode 100644 index 000000000..989f2ea76 --- /dev/null +++ b/web/routes/uix/components/heading/+page.svelte @@ -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> diff --git a/web/routes/uix/components/highlight/+page.svelte b/web/routes/uix/components/highlight/+page.svelte new file mode 100644 index 000000000..365996417 --- /dev/null +++ b/web/routes/uix/components/highlight/+page.svelte @@ -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> diff --git a/web/routes/uix/components/kbd/+page.svelte b/web/routes/uix/components/kbd/+page.svelte new file mode 100644 index 000000000..74c400d74 --- /dev/null +++ b/web/routes/uix/components/kbd/+page.svelte @@ -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> diff --git a/web/routes/uix/components/link/+page.svelte b/web/routes/uix/components/link/+page.svelte new file mode 100644 index 000000000..36b570f9f --- /dev/null +++ b/web/routes/uix/components/link/+page.svelte @@ -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> diff --git a/web/routes/uix/components/mark/+page.svelte b/web/routes/uix/components/mark/+page.svelte new file mode 100644 index 000000000..69f5496a6 --- /dev/null +++ b/web/routes/uix/components/mark/+page.svelte @@ -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>