You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
961 lines
31 KiB
961 lines
31 KiB
<script lang="ts">
|
|
import IntentIcon from '$uix/eidos/components/icon/intent-icon.svelte';
|
|
import type { IconCategory } from '$uix/eidos/components/icon/categories';
|
|
import type { IconSize, IconStrokeWidth } from '$uix/eidos/components/icon/types';
|
|
import { compileMorfo } from '$uix/morfo';
|
|
import { iconMorfo } from '@/uix/morfo/components/icon';
|
|
import type { Intent } from '$uix/intent';
|
|
import type { Component } from 'svelte';
|
|
import LazyIcon from './LazyIcon.svelte';
|
|
import { loadIconByName, loadIconCategories } from './icon-loaders';
|
|
|
|
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 iconName = $state<string>('BadgeCheck');
|
|
let size = $state<IconSize>('xl');
|
|
let strokeWidth = $state<IconStrokeWidth>('auto');
|
|
let color = $state('currentColor');
|
|
let decorative = $state(true);
|
|
let title = $state('Verified badge');
|
|
|
|
let intent = $state<Intent>('affirm');
|
|
let loading = $state(false);
|
|
let Glyph = $state<Component | undefined>();
|
|
let glyphRequestId = 0;
|
|
|
|
// ── Browse state ─────────────────────────────────────────────────────
|
|
let search = $state('');
|
|
let activeCategory = $state<string>('all');
|
|
type BrowseSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
|
|
let browseSize = $state<BrowseSize>('md');
|
|
let copiedIcon = $state<string | null>(null);
|
|
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let iconCategories = $state<IconCategory[]>([]);
|
|
let categoriesLoading = $state(false);
|
|
|
|
// Flat unique-pascal list. Some lucide glyphs live in multiple
|
|
// categories; we keep one row per pascal but record every category
|
|
// it belongs to so the filter stays accurate.
|
|
const allUniqueIcons = $derived.by(() => {
|
|
const seen = new Set<string>();
|
|
const out: { name: string; pascal: string; categories: string[] }[] = [];
|
|
for (const cat of iconCategories) {
|
|
for (const ic of cat.icons) {
|
|
if (seen.has(ic.pascal)) {
|
|
const existing = out.find((x) => x.pascal === ic.pascal);
|
|
existing?.categories.push(cat.label);
|
|
continue;
|
|
}
|
|
seen.add(ic.pascal);
|
|
out.push({ name: ic.name, pascal: ic.pascal, categories: [cat.label] });
|
|
}
|
|
}
|
|
return out;
|
|
});
|
|
|
|
const filteredIcons = $derived.by(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return allUniqueIcons.filter((ic) => {
|
|
if (activeCategory !== 'all' && !ic.categories.includes(activeCategory)) return false;
|
|
if (!q) return true;
|
|
return ic.name.toLowerCase().includes(q) || ic.pascal.toLowerCase().includes(q);
|
|
});
|
|
});
|
|
|
|
const selectedIconName = $derived(
|
|
allUniqueIcons.find((ic) => ic.pascal === iconName)?.name ?? iconNameToFileName(iconName)
|
|
);
|
|
|
|
const categories = $derived.by(() => [
|
|
{ name: 'all', label: 'All', count: allUniqueIcons.length },
|
|
...iconCategories.map((c) => ({ name: c.label, label: c.label, count: c.icons.length }))
|
|
]);
|
|
|
|
function iconNameToFileName(value: string): string {
|
|
return value
|
|
.trim()
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
.replace(/[\s_]+/g, '-')
|
|
.toLowerCase();
|
|
}
|
|
|
|
function copyImport(pascal: string) {
|
|
const code = `import { ${pascal} } from '$uix/eidos/components/icon';`;
|
|
navigator.clipboard?.writeText(code);
|
|
copiedIcon = pascal;
|
|
if (copyTimeout) clearTimeout(copyTimeout);
|
|
copyTimeout = setTimeout(() => {
|
|
if (copiedIcon === pascal) copiedIcon = null;
|
|
copyTimeout = null;
|
|
}, 1400);
|
|
}
|
|
|
|
function selectAsLive(pascal: string) {
|
|
iconName = pascal;
|
|
tab = 'live';
|
|
if (typeof window !== 'undefined') {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
return () => {
|
|
if (copyTimeout) clearTimeout(copyTimeout);
|
|
};
|
|
});
|
|
|
|
$effect(() => {
|
|
const name = selectedIconName;
|
|
if (!name) {
|
|
Glyph = undefined;
|
|
return;
|
|
}
|
|
|
|
const current = ++glyphRequestId;
|
|
Glyph = undefined;
|
|
void loadIconByName(name).then((component) => {
|
|
if (current === glyphRequestId) Glyph = component;
|
|
});
|
|
});
|
|
|
|
$effect(() => {
|
|
if (categoriesLoading || iconCategories.length > 0) return;
|
|
categoriesLoading = true;
|
|
void loadIconCategories().then((categories) => {
|
|
iconCategories = categories;
|
|
categoriesLoading = false;
|
|
});
|
|
});
|
|
|
|
// ── Compiled morfo ────────────────────────────────────────────────────
|
|
const compiled = compileMorfo(iconMorfo);
|
|
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(
|
|
[
|
|
'<!-- Icon is an eidos-only primitive — no soma layer. -->',
|
|
"<!-- The morfo declares scope:['eidos'] for the contract only. -->",
|
|
'',
|
|
'<!-- Equivalent semantic markup (not real soma): -->',
|
|
'<svg',
|
|
' role="img"',
|
|
' aria-hidden={decorative}',
|
|
'>',
|
|
' <title>{title}</title>',
|
|
' <path d="…" />',
|
|
'</svg>'
|
|
].join('\n')
|
|
);
|
|
|
|
const eidosSnippet = $derived(
|
|
[
|
|
"<script lang='ts'>",
|
|
` import { ${iconName} } from '$uix/eidos/components/icon';`,
|
|
'</' + 'script>',
|
|
'',
|
|
`<${iconName}`,
|
|
size !== 'md' && ` size="${size}"`,
|
|
strokeWidth !== 'auto' && ` strokeWidth="${strokeWidth}"`,
|
|
color !== 'currentColor' && ` color="${color}"`,
|
|
!decorative && ' decorative={false}',
|
|
!decorative && title && ` title="${title}"`,
|
|
'/>'
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
);
|
|
</script>
|
|
|
|
<div data-uix-canvas-inner>
|
|
<header>
|
|
<div data-uix-eyebrow>Primitives · Icon</div>
|
|
<h1 data-uix-page-title>Icon</h1>
|
|
<p data-uix-page-lede>
|
|
Eidos-only SVG primitive. Wraps the 1696-glyph Lucide set plus the doctrinal <code
|
|
>IntentIcon</code
|
|
>
|
|
map (intent → canonical glyph). One morfo part, no events — the contract just enforces the
|
|
<code>aria-hidden</code> / <code>role="img"</code> coupling driven by
|
|
<code>decorative</code>.
|
|
</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>glyphs</span>1696
|
|
</span>
|
|
<span data-uix-meta-pill>
|
|
<span data-uix-meta-key>sizes</span>7
|
|
</span>
|
|
<span data-uix-meta-pill>
|
|
<span data-uix-meta-key>scope</span>eidos
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Live preview always rendered -->
|
|
<div data-uix-stage>
|
|
<div data-uix-stage-area bind:this={stageRef}>
|
|
<div
|
|
style="display: flex; align-items: center; gap: var(--uix-space-6); flex-wrap: wrap; padding: var(--uix-space-4);"
|
|
>
|
|
{#if Glyph}
|
|
{@const Comp = Glyph}
|
|
<Comp {size} {strokeWidth} {color} {decorative} title={decorative ? undefined : title} />
|
|
{/if}
|
|
<IntentIcon {intent} {loading} {size} {strokeWidth} />
|
|
</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>icon</span>
|
|
{iconName}
|
|
<span style="margin-inline-start: auto;">
|
|
<span data-uix-stage-trace-key>intent</span>
|
|
{intent}{loading ? ' · loading' : ''}
|
|
</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>6</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>
|
|
Icon is eidos-only — there is no <span data-uix-layer-badge="soma">soma</span> layer to
|
|
split props across. All knobs are visual treatment plus the a11y contract (<code
|
|
>decorative</code
|
|
>
|
|
/ <code>title</code>) that the morfo enforces.
|
|
</p>
|
|
|
|
<!-- ── Eidos props ─────────────────────────────────────────────── -->
|
|
<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>size</span>
|
|
<span data-uix-chips role="radiogroup">
|
|
{#each ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as s}
|
|
<button data-uix-chip data-active={size === s} onclick={() => (size = s as IconSize)}
|
|
>{s}</button
|
|
>
|
|
{/each}
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>strokeWidth</span>
|
|
<span data-uix-chips role="radiogroup">
|
|
{#each ['auto', 'sm', 'md', 'lg'] as w}
|
|
<button
|
|
data-uix-chip
|
|
data-active={strokeWidth === w}
|
|
onclick={() => (strokeWidth = w as IconStrokeWidth)}>{w}</button
|
|
>
|
|
{/each}
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>color <span data-uix-control-hint>{color}</span></span>
|
|
<input
|
|
type="color"
|
|
value={color === 'currentColor' ? '#3b82f6' : color}
|
|
disabled={color === 'currentColor'}
|
|
oninput={(event) => (color = event.currentTarget.value)}
|
|
/>
|
|
<button
|
|
data-uix-chip
|
|
data-active={color === 'currentColor'}
|
|
onclick={() => (color = color === 'currentColor' ? '#3b82f6' : 'currentColor')}
|
|
>{color === 'currentColor' ? 'theme' : 'theme'}</button
|
|
>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- ── A11y / morfo-driven props ───────────────────────────────── -->
|
|
<div data-uix-subsection-head>
|
|
<span data-uix-layer-badge="morfo">morfo</span> props · a11y contract
|
|
</div>
|
|
<div data-uix-controls>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label
|
|
>decorative <span data-uix-control-hint>aria-hidden</span></span
|
|
>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={decorative} />
|
|
<span data-uix-switch-label>{decorative ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label
|
|
>title <span data-uix-control-hint>required when not decorative</span></span
|
|
>
|
|
<input type="text" bind:value={title} disabled={decorative} />
|
|
</label>
|
|
</div>
|
|
|
|
<!-- ── Demo content ────────────────────────────────────────────── -->
|
|
<div data-uix-subsection-head>
|
|
Demo content <span style="font-weight: normal; color: var(--uix-text-muted);"
|
|
>· glyph + intent picker</span
|
|
>
|
|
</div>
|
|
<div data-uix-controls>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label
|
|
>icon name <span data-uix-control-hint>or pick one in Browse below</span></span
|
|
>
|
|
<input type="text" bind:value={iconName} list="icon-name-options" />
|
|
<datalist id="icon-name-options">
|
|
{#each allUniqueIcons.slice(0, 200) as ic}
|
|
<option value={ic.pascal}></option>
|
|
{/each}
|
|
</datalist>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>IntentIcon · intent</span>
|
|
<span data-uix-chips role="radiogroup">
|
|
{#each ['primary', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as i}
|
|
<button
|
|
data-uix-chip
|
|
data-active={intent === i}
|
|
onclick={() => (intent = i as Intent)}>{i}</button
|
|
>
|
|
{/each}
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>IntentIcon · loading</span>
|
|
<span data-uix-switch>
|
|
<input type="checkbox" bind:checked={loading} />
|
|
<span data-uix-switch-label>{loading ? 'on' : 'off'}</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- ── Code snippets per layer ─────────────────────────────────── -->
|
|
<div data-uix-code>
|
|
<div data-uix-code-head>
|
|
<span data-uix-layer-badge="soma">soma</span>
|
|
<span>n/a · icon is eidos-only — 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 · size, strokeWidth, color</span>
|
|
<span data-uix-code-lang>svelte</span>
|
|
</div>
|
|
<pre><code>{eidosSnippet}</code></pre>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if tab === 'live'}
|
|
<section data-uix-section>
|
|
<h2 data-uix-section-title>
|
|
Browse <span
|
|
style="font-weight: normal; color: var(--uix-text-muted); font-size: var(--uix-font-size-2);"
|
|
>· {categoriesLoading ? 'loading…' : `${allUniqueIcons.length.toLocaleString()} glyphs`}</span
|
|
>
|
|
</h2>
|
|
|
|
<!-- IntentIcon doctrinal mapping -->
|
|
<div data-uix-subsection-head>
|
|
<span data-uix-layer-badge="eidos">eidos</span> IntentIcon · doctrinal intent → glyph
|
|
<span
|
|
style="font-weight: normal; color: var(--uix-text-muted); margin-inline-start: var(--uix-space-3);"
|
|
>single source of truth at <code>$uix/eidos/components/icon</code></span
|
|
>
|
|
</div>
|
|
<div data-uix-icon-intent-grid>
|
|
{#each [{ intent: 'neutral', glyph: 'Bell' }, { intent: 'affirm', glyph: 'Check' }, { intent: 'fulfill', glyph: 'CircleCheck' }, { intent: 'risk', glyph: 'TriangleAlert' }, { intent: 'threat', glyph: 'CircleAlert' }, { intent: 'loss', glyph: 'CircleX' }] as entry (entry.intent)}
|
|
<div data-uix-icon-intent-card data-intent={entry.intent}>
|
|
<div data-uix-icon-intent-glyph>
|
|
<IntentIcon intent={entry.intent as Intent} size="lg" />
|
|
</div>
|
|
<div data-uix-icon-intent-meta>
|
|
<span data-uix-icon-intent-name>{entry.intent}</span>
|
|
<code>{entry.glyph}</code>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Toolbar: search + size -->
|
|
<div data-uix-subsection-head>
|
|
Browse · click to copy import, double-click to load in Live
|
|
</div>
|
|
<div data-uix-controls>
|
|
<label data-uix-control style="grid-column: 1 / -1;">
|
|
<span data-uix-control-label>search</span>
|
|
<input
|
|
type="search"
|
|
placeholder="Search {allUniqueIcons.length.toLocaleString()} icons by name…"
|
|
bind:value={search}
|
|
/>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label>size</span>
|
|
<span data-uix-chips role="radiogroup">
|
|
{#each ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as s}
|
|
<button
|
|
data-uix-chip
|
|
data-active={browseSize === s}
|
|
onclick={() => (browseSize = s as BrowseSize)}>{s}</button
|
|
>
|
|
{/each}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Category chips -->
|
|
<div data-uix-icon-cats>
|
|
{#each categories as cat}
|
|
<button
|
|
type="button"
|
|
data-uix-icon-cat
|
|
data-active={activeCategory === cat.name}
|
|
onclick={() => (activeCategory = cat.name)}
|
|
>
|
|
{cat.label}
|
|
<span data-uix-icon-cat-count>{cat.count}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<div data-uix-icon-stats>
|
|
Showing <strong>{filteredIcons.length.toLocaleString()}</strong>
|
|
{filteredIcons.length === 1 ? 'icon' : 'icons'}
|
|
{search ? `matching "${search}"` : ''}
|
|
{activeCategory !== 'all' ? `in ${activeCategory}` : ''}
|
|
</div>
|
|
|
|
{#if categoriesLoading && allUniqueIcons.length === 0}
|
|
<div data-uix-icon-empty>Loading icon index…</div>
|
|
{:else if filteredIcons.length === 0}
|
|
<div data-uix-icon-empty>
|
|
No icons match the current filter. Try a different search or category.
|
|
</div>
|
|
{:else}
|
|
<div data-uix-icon-grid>
|
|
{#each filteredIcons as ic (ic.pascal)}
|
|
<button
|
|
type="button"
|
|
data-uix-icon-card
|
|
data-copied={copiedIcon === ic.pascal}
|
|
title="Click to copy import; double-click to load in Live"
|
|
onclick={() => copyImport(ic.pascal)}
|
|
ondblclick={() => selectAsLive(ic.pascal)}
|
|
>
|
|
<div data-uix-icon-card-glyph>
|
|
<LazyIcon name={ic.name} size={browseSize} />
|
|
</div>
|
|
<div data-uix-icon-card-name>{ic.pascal}</div>
|
|
{#if copiedIcon === ic.pascal}
|
|
<div data-uix-icon-card-toast>copied</div>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
{#if tab === 'api'}
|
|
<section data-uix-section>
|
|
<h2 data-uix-section-title>API reference</h2>
|
|
<p data-uix-section-desc>
|
|
All public props on the Icon wrapper. Individual lucide icons forward the same surface.
|
|
</p>
|
|
|
|
<div data-uix-subsection-head>Icon</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<tr
|
|
><td class="name">size</td><td class="type"
|
|
>'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | number | string</td
|
|
><td class="default">'md'</td><td
|
|
>Width & height. Token strings map to recipe steps; number = px; arbitrary CSS
|
|
string is forwarded.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td class="name">strokeWidth</td><td class="type"
|
|
>'auto' | 'sm' | 'md' | 'lg' | number | string</td
|
|
><td class="default">'auto'</td><td>Lucide-style stroke. 'auto' scales with size.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td class="name">color</td><td class="type">string</td><td class="default"
|
|
>'currentColor'</td
|
|
><td>Forwarded to <code>color</code> attr; 'currentColor' inherits from text.</td></tr
|
|
>
|
|
<tr
|
|
><td class="name">decorative</td><td class="type">boolean</td><td class="default"
|
|
>true</td
|
|
><td
|
|
>When true, sets <code>aria-hidden</code>. When false, sets <code>role="img"</code>
|
|
and requires <code>title</code>.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td class="name">title</td><td class="type">string</td><td class="default empty"
|
|
>—</td
|
|
><td>Accessible name. Required when <code>decorative=false</code>.</td></tr
|
|
>
|
|
<tr
|
|
><td class="name">viewBox</td><td class="type">string</td><td class="default"
|
|
>'0 0 24 24'</td
|
|
><td>SVG viewBox override for custom paths.</td></tr
|
|
>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>IntentIcon</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<tr
|
|
><td class="name">intent</td><td class="type">Intent</td><td class="default empty"
|
|
>—</td
|
|
><td>Picks the doctrinal glyph for this intent from <code>INTENT_ICON</code>.</td></tr
|
|
>
|
|
<tr
|
|
><td class="name">loading</td><td class="type">boolean</td><td class="default"
|
|
>false</td
|
|
><td>Render <code>LOADING_ICON</code> instead. Spin animation is the recipe's job.</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td class="name">…rest</td><td class="type">IconProps</td><td class="default empty"
|
|
>—</td
|
|
><td>All Icon props are forwarded.</td></tr
|
|
>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if tab === 'morfo'}
|
|
<section data-uix-section>
|
|
<h2 data-uix-section-title>
|
|
<span data-uix-layer-badge="morfo">morfo</span>
|
|
· declarative contract
|
|
</h2>
|
|
<p data-uix-section-desc>
|
|
Icon is scope-eidos — there is no soma runtime. The morfo exists solely to lock the a11y
|
|
contract (<code>decorative</code> ↔
|
|
<code>aria-hidden</code> / <code>role</code>) so the recipe can assume it. Source:
|
|
<code>src/uix/morfo/components/icon.ts</code>.
|
|
</p>
|
|
|
|
<!-- ── Component header ───────────────────────────────────────── -->
|
|
<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 class="type">"{iconMorfo.name}"</td></tr>
|
|
<tr><td class="name">kebab</td><td class="type">"{iconMorfo.kebab}"</td></tr>
|
|
<tr
|
|
><td class="name">scope</td><td class="type"
|
|
>[{iconMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td class="name">parts.length</td><td class="default">{iconMorfo.parts.length}</td
|
|
></tr
|
|
>
|
|
<tr><td class="name">events.length</td><td class="default">0</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- ── Parts overview ────────────────────────────────────────── -->
|
|
<div data-uix-subsection-head>Parts</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead
|
|
><tr
|
|
><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th
|
|
>States</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.role ?? '—'}</td>
|
|
<td class="default">{part.archetype ?? '—'}</td>
|
|
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
|
|
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- ── Per-part contracts ──────────────────────────────────── -->
|
|
{#each iconMorfo.parts as rawPart}
|
|
{@const partAny = rawPart as unknown as {
|
|
kebab: string;
|
|
aria?: ReadonlyArray<{
|
|
attr: string;
|
|
value: { kind: string };
|
|
condition?: { when: string; prop?: string };
|
|
severity?: string;
|
|
}>;
|
|
}}
|
|
{@const ariaAttrs = partAny.aria ?? []}
|
|
{#if ariaAttrs.length}
|
|
<div data-uix-subsection-head>{partAny.kebab}</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead
|
|
><tr><th>aria-attr</th><th>Source kind</th><th>Condition</th><th>Severity</th></tr
|
|
></thead
|
|
>
|
|
<tbody>
|
|
{#each ariaAttrs as a}
|
|
<tr>
|
|
<td class="name">{a.attr}</td>
|
|
<td class="type">{a.value.kind}</td>
|
|
<td class="default"
|
|
>{a.condition
|
|
? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}`
|
|
: 'always'}</td
|
|
>
|
|
<td class="default">{a.severity ?? 'required'}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</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>
|
|
This morfo declares no semantic events. If the component grows user-triggered perceptual
|
|
feedback, the event must be declared in Morfo first and surfaced here.
|
|
</p>
|
|
<div data-uix-empty-state>No Sema events declared.</div>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if tab === 'recipe'}
|
|
<section data-uix-section>
|
|
<h2 data-uix-section-title>Eidos recipe</h2>
|
|
<p data-uix-section-desc>
|
|
Selectors at <code>src/uix/eidos/components/icon/icon.css</code>. Tokens bare-prefixed (<code
|
|
>--icon-*</code
|
|
>). Size / strokeWidth resolve through <code>resolveIconSize</code> /
|
|
<code>resolveIconStrokeWidth</code> rather than CSS variables.
|
|
</p>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
|
|
<tbody>
|
|
<tr
|
|
><td class="name">[data-icon]</td><td
|
|
><span data-uix-tag data-kind="soma">morfo</span></td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td class="name">[data-icon][data-loading]</td><td
|
|
><span data-uix-tag data-kind="eidos">eidos</span></td
|
|
></tr
|
|
>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if tab === 'a11y'}
|
|
<section data-uix-section>
|
|
<h2 data-uix-section-title>Accessibility</h2>
|
|
<p data-uix-section-desc>
|
|
Icons are non-interactive. The contract collapses to two cases: decorative (default — <code
|
|
>aria-hidden</code
|
|
>) and meaningful (<code>decorative=false</code> + <code>title</code>, gets
|
|
<code>role="img"</code>).
|
|
</p>
|
|
|
|
<div data-uix-subsection-head>When to flip <code>decorative</code></div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Scenario</th><th>decorative</th><th>title</th></tr></thead>
|
|
<tbody>
|
|
<tr
|
|
><td>Icon next to a text label that already names the action</td><td class="default"
|
|
>true</td
|
|
><td class="default">—</td></tr
|
|
>
|
|
<tr
|
|
><td>Standalone icon-only button (no visible label)</td><td class="default">false</td
|
|
><td class="type">required</td></tr
|
|
>
|
|
<tr
|
|
><td>Status badge / inline meaning glyph</td><td class="default">false</td><td
|
|
class="type">required</td
|
|
></tr
|
|
>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div data-uix-subsection-head>ARIA contract</div>
|
|
<div data-uix-table-wrap>
|
|
<table data-uix-table>
|
|
<thead><tr><th>Part</th><th>Attribute</th><th>Condition</th></tr></thead>
|
|
<tbody>
|
|
<tr
|
|
><td class="name">provider</td><td>aria-hidden="true"</td><td class="type"
|
|
>when decorative</td
|
|
></tr
|
|
>
|
|
<tr
|
|
><td class="name">provider</td><td>role="img"</td><td class="type"
|
|
>when !decorative</td
|
|
></tr
|
|
>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
/* IntentIcon mapping cards */
|
|
[data-uix-icon-intent-grid] {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
|
|
gap: var(--uix-space-3);
|
|
margin-block: var(--uix-space-3);
|
|
}
|
|
[data-uix-icon-intent-card] {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--uix-space-3);
|
|
padding: var(--uix-space-3);
|
|
border: 1px solid var(--uix-border-faint);
|
|
border-radius: var(--uix-radius-2);
|
|
background: var(--uix-surface-1);
|
|
}
|
|
[data-uix-icon-intent-card][data-intent='neutral'] {
|
|
color: var(--color-neutral-emph);
|
|
}
|
|
[data-uix-icon-intent-card][data-intent='affirm'] {
|
|
color: var(--color-affirm-emph);
|
|
}
|
|
[data-uix-icon-intent-card][data-intent='fulfill'] {
|
|
color: var(--color-fulfill-emph);
|
|
}
|
|
[data-uix-icon-intent-card][data-intent='risk'] {
|
|
color: var(--color-risk-emph);
|
|
}
|
|
[data-uix-icon-intent-card][data-intent='threat'] {
|
|
color: var(--color-threat-emph);
|
|
}
|
|
[data-uix-icon-intent-card][data-intent='loss'] {
|
|
color: var(--color-loss-emph);
|
|
}
|
|
[data-uix-icon-intent-glyph] {
|
|
display: grid;
|
|
place-items: center;
|
|
inline-size: 2.4rem;
|
|
block-size: 2.4rem;
|
|
flex-shrink: 0;
|
|
}
|
|
[data-uix-icon-intent-meta] {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
min-inline-size: 0;
|
|
}
|
|
[data-uix-icon-intent-name] {
|
|
font-weight: 600;
|
|
font-size: var(--uix-font-size-2);
|
|
text-transform: capitalize;
|
|
}
|
|
[data-uix-icon-intent-meta] code {
|
|
font-size: var(--uix-font-size-1);
|
|
color: var(--uix-text-muted);
|
|
}
|
|
|
|
/* Category chips */
|
|
[data-uix-icon-cats] {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem;
|
|
margin-block: var(--uix-space-3);
|
|
}
|
|
[data-uix-icon-cat] {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
padding: 0.32rem 0.7rem;
|
|
border: 1px solid var(--uix-border-faint);
|
|
border-radius: 999px;
|
|
background: var(--uix-surface-1);
|
|
color: var(--uix-text-default);
|
|
font-size: var(--uix-font-size-1);
|
|
cursor: pointer;
|
|
}
|
|
[data-uix-icon-cat][data-active='true'] {
|
|
background: var(--uix-text-default);
|
|
color: var(--uix-surface-1);
|
|
border-color: var(--uix-text-default);
|
|
}
|
|
[data-uix-icon-cat-count] {
|
|
font-variant-numeric: tabular-nums;
|
|
opacity: 0.7;
|
|
font-size: 0.78em;
|
|
}
|
|
|
|
[data-uix-icon-stats] {
|
|
color: var(--uix-text-muted);
|
|
font-size: var(--uix-font-size-1);
|
|
margin-block: var(--uix-space-2) var(--uix-space-3);
|
|
}
|
|
|
|
[data-uix-icon-empty] {
|
|
padding: var(--uix-space-6);
|
|
text-align: center;
|
|
color: var(--uix-text-muted);
|
|
border: 1px dashed var(--uix-border-faint);
|
|
border-radius: var(--uix-radius-2);
|
|
}
|
|
|
|
/* The big grid */
|
|
[data-uix-icon-grid] {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
|
|
gap: 0.5rem;
|
|
}
|
|
[data-uix-icon-card] {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: var(--uix-space-3) 0.4rem;
|
|
border: 1px solid var(--uix-border-faint);
|
|
border-radius: var(--uix-radius-2);
|
|
background: var(--uix-surface-1);
|
|
color: var(--uix-text-default);
|
|
cursor: pointer;
|
|
transition:
|
|
border-color 0.12s,
|
|
transform 0.12s;
|
|
}
|
|
[data-uix-icon-card]:hover {
|
|
border-color: var(--uix-text-default);
|
|
transform: translateY(-1px);
|
|
}
|
|
[data-uix-icon-card][data-copied='true'] {
|
|
border-color: var(--color-affirm-emph);
|
|
}
|
|
[data-uix-icon-card-glyph] {
|
|
display: grid;
|
|
place-items: center;
|
|
min-block-size: 2.6rem;
|
|
}
|
|
[data-uix-icon-card-name] {
|
|
font-size: 0.7rem;
|
|
color: var(--uix-text-muted);
|
|
text-align: center;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-inline-size: 100%;
|
|
}
|
|
[data-uix-icon-card-toast] {
|
|
position: absolute;
|
|
inset-block-start: -0.55rem;
|
|
inset-inline-end: 0.4rem;
|
|
padding: 0.1rem 0.45rem;
|
|
border-radius: 999px;
|
|
background: var(--color-affirm-emph);
|
|
color: var(--color-content-on-solid, #fff);
|
|
font-size: 0.65rem;
|
|
font-weight: 600;
|
|
}
|
|
</style>
|