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.
730 lines
27 KiB
730 lines
27 KiB
<script lang="ts">
|
|
import * as IconLib from '$uix/eidos/components/icon';
|
|
import {
|
|
IntentIcon,
|
|
iconCategories,
|
|
type IconSize,
|
|
type IconStrokeWidth
|
|
} from '$uix/eidos/components/icon';
|
|
import { compileMorfo } from '$uix/morfo';
|
|
import { iconMorfo } from '@/uix/morfo/components/icon';
|
|
import { getActiveUix } from '$active-uix';
|
|
import type { Intent } from '$uix/intent';
|
|
import type { Component } from 'svelte';
|
|
|
|
const uix = getActiveUix();
|
|
|
|
type Tab = 'live' | 'gallery' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
|
let tab = $state<Tab>('live');
|
|
|
|
function getIcon(pascal: string): Component | undefined {
|
|
return (IconLib as unknown as Record<string, Component>)[pascal];
|
|
}
|
|
|
|
// ── 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);
|
|
|
|
const Glyph = $derived(getIcon(iconName));
|
|
|
|
// ── Gallery state ────────────────────────────────────────────────────
|
|
let search = $state('');
|
|
let activeCategory = $state<string>('all');
|
|
type GallerySize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
|
|
let gallerySize = $state<GallerySize>('md');
|
|
let copiedIcon = $state<string | null>(null);
|
|
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// 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 categories = $derived.by(() => [
|
|
{ name: 'all', label: 'All', count: allUniqueIcons.length },
|
|
...iconCategories.map((c) => ({ name: c.label, label: c.label, count: c.icons.length }))
|
|
]);
|
|
|
|
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);
|
|
};
|
|
});
|
|
|
|
// ── Compiled morfo ────────────────────────────────────────────────────
|
|
const compiled = compileMorfo(iconMorfo);
|
|
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
|
|
|
// ── 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>
|
|
<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>icon</span> {iconName}
|
|
<span style="margin-inline-start: auto;">
|
|
<span data-uix-stage-trace-key>intent</span> {intent}{loading ? ' · loading' : ''}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs (no Sema — 0 events) -->
|
|
<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 === 'gallery'} onclick={() => (tab = 'gallery')}>
|
|
Gallery <span data-uix-tab-count>{filteredIcons.length}</span>
|
|
</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 === '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" bind:value={color} disabled={color === 'currentColor'} />
|
|
<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 the Gallery tab</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 === 'gallery'}
|
|
<section data-uix-section>
|
|
<h2 data-uix-section-title>Gallery <span style="font-weight: normal; color: var(--uix-text-muted); font-size: var(--uix-font-size-2);">· {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={gallerySize === s} onclick={() => (gallerySize = s as GallerySize)}>{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 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)}
|
|
{@const Comp = getIcon(ic.pascal)}
|
|
{#if Comp}
|
|
<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>
|
|
<Comp size={gallerySize} />
|
|
</div>
|
|
<div data-uix-icon-card-name>{ic.pascal}</div>
|
|
{#if copiedIcon === ic.pascal}
|
|
<div data-uix-icon-card-toast>copied</div>
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
{/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 === '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>
|