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.
svelte-kit-vice/web/routes/uix/components/icon/+page.svelte

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 &amp; 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">&lt;{part.defaultElement}&gt;</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>

Powered by TurnKey Linux.