registry:ui + registry:lib · the icon system
icons — one pipeline, two faces, one component
Every glyph in jixoai ships from one generator with two consumption faces. The component face: <Icon name> renders the generated set through a closed IconName union — a typo is a compile error, the inline core paints synchronously (SSR-safe), lazy overflow reserves its box and warms through preloadIcons. The slot face: the --jx-icon-* CSS vocabulary the form sheet paints through mask and background-image, with an ink matrix for UA-shadow pseudos that reject author paint. The vite plugin feeds both — jixoai({ icons }) with a library face and a provider face that share nothing but the safety checker.
install
npx jixoai-ui add icon — or point shadcn add at the item URL: https://ui.jixoai.com/r/icon.json
$npx jixoai-ui add icon
https://ui.jixoai.com/r/icon.json
install
npx jixoai-ui add icon-set — or point shadcn add at the item URL: https://ui.jixoai.com/r/icon-set.json
$npx jixoai-ui add icon-set
https://ui.jixoai.com/r/icon-set.json
component face
The Icon component — <Icon name="…" />
The named-glyph renderer every component shares: name is the generated IconName union (a wrong name never ships — it fails the compile), the component owns the whole svg root (currentColor by artwork nature, aria-hidden baked in), and size / strokeWidth are props, never wrapper CSS. The full API table, the interactive playground and the async-paths walkthrough live on the component's own page.
the size ladder — one prop, four cells
size and strokeWidth are props, never wrapper CSS; the inline core paints synchronously, SSR-safe. The drawer's usage file is this ladder's exact markup (unrolled to literal cells so the sample stands alone).
size=12size=16size=24size=32<script lang="ts">
import Icon from '@ui/icon';
</script>
<div class={cx(rt.flex, rt.wrap, rt.itemsEnd, rt.justifyCenter, rt.gapX10, rt.gapY20)} data-icon-size-demo="">
<div class={cx(rt.col8, rt.itemsCenter)}>
<Icon name="eye" size={12} />
<code class={cx(rt.note11, rt.fontMono)}>size={12}</code>
</div>
<div class={cx(rt.col8, rt.itemsCenter)}>
<Icon name="eye" size={16} />
<code class={cx(rt.note11, rt.fontMono)}>size={16}</code>
</div>
<div class={cx(rt.col8, rt.itemsCenter)}>
<Icon name="eye" size={24} />
<code class={cx(rt.note11, rt.fontMono)}>size={24}</code>
</div>
<div class={cx(rt.col8, rt.itemsCenter)}>
<Icon name="eye" size={32} />
<code class={cx(rt.note11, rt.fontMono)}>size={32}</code>
</div>
</div>named library
The named library — ICON_NAMES
54 names, generated. This grid walks ICON_NAMES itself: a glyph added to the library config — or a channel ref scanned from this site's own source — regenerates icon-set.gen.ts and appears here with ZERO page edit — the grid can never lie about the set. The prefixed names below (md:/ph:/rx:/lucide:) are the scanner's own findings: the literals live in this page's source. Every cell renders the real component (the same <Icon name> above); the label is the IconName you type.
-
arrowRight<Icon name="arrowRight" /> -
arrowLeft<Icon name="arrowLeft" /> -
rotateCcw<Icon name="rotateCcw" /> -
copy<Icon name="copy" /> -
chevronDown<Icon name="chevronDown" /> -
chevronRight<Icon name="chevronRight" /> -
x<Icon name="x" /> -
externalLink<Icon name="externalLink" /> -
check<Icon name="check" /> -
lucide:check<Icon name="lucide:check" /> -
folder<Icon name="folder" /> -
folderOpen<Icon name="folderOpen" /> -
file<Icon name="file" /> -
fileCode<Icon name="fileCode" /> -
fileText<Icon name="fileText" /> -
braces<Icon name="braces" /> -
palette<Icon name="palette" /> -
plus<Icon name="plus" /> -
minus<Icon name="minus" /> -
ellipsis<Icon name="ellipsis" /> -
gripVertical<Icon name="gripVertical" /> -
calendar<Icon name="calendar" /> -
clock<Icon name="clock" /> -
pipette<Icon name="pipette" /> -
sun<Icon name="sun" /> -
moon<Icon name="moon" /> -
monitor<Icon name="monitor" /> -
languages<Icon name="languages" /> -
image<Icon name="image" /> -
fileVideo<Icon name="fileVideo" /> -
fileAudio<Icon name="fileAudio" /> -
upload<Icon name="upload" /> -
chevronLeft<Icon name="chevronLeft" /> -
eye<Icon name="eye" /> -
eyeOff<Icon name="eyeOff" /> -
search<Icon name="search" /> -
link<Icon name="link" /> -
phone<Icon name="phone" /> -
mail<Icon name="mail" /> -
type<Icon name="type" /> -
lucide:gauge<Icon name="lucide:gauge" /> -
lucide:git-compare-arrows<Icon name="lucide:git-compare-arrows" /> -
lucide:git-fork<Icon name="lucide:git-fork" /> -
lucide:git-merge<Icon name="lucide:git-merge" /> -
lucide:git-pull-request<Icon name="lucide:git-pull-request" /> -
lucide:layers<Icon name="lucide:layers" /> -
lucide:rows3<Icon name="lucide:rows3" /> -
lucide:shapes<Icon name="lucide:shapes" /> -
lucide:squircle<Icon name="lucide:squircle" /> -
md:copy_all<Icon name="md:copy_all" /> -
copy2<Icon name="copy2" /> -
md:home<Icon name="md:home" /> -
ph:atom<Icon name="ph:atom" /> -
rx:system:add-line<Icon name="rx:system:add-line" />
Generated by the root gen:icons script (the repo's
single writer — the verify:icons gate fails the build
the moment the committed artifact drifts from the library config), mirrored byte-identical
into $lib/icon-set.gen. Names resolve synchronously
through getIcon() in the inline core; the full union
rides IconName, the iterable rides ICON_NAMES.
plugin · library face
jixoai({ icons: { library } }) — the generator's knobs
The library face answers what <Icon name> can render. Sources arrive five ways — an inline SVG literal, { file } (the plugin owns ALL file I/O), a lucide: ref resolved at build time so the emitted artifact carries zero lucide references, a channel ref (md:/ph:/rx:/your own) resolving ONE icon from a registered channel, or a font file whose glyph outline is extracted at build time. Same-name entries OVERRIDE built-ins; every icon crosses the raw safety checker before svgo. Without the icons option the plugin never loads; provider and library are independent — either alone is legal.
// vite.config.ts — the library face (this site's own wiring, condensed)
import { sveltekit } from '@sveltejs/kit/vite';
import { jixoai } from '@jixoai/ui-vite-plugin';
import { md } from '@jixoai/ui-vite-plugin/icons/md';
import { ph } from '@jixoai/ui-vite-plugin/icons/ph';
import { rx } from '@jixoai/ui-vite-plugin/icons/rx';
export default {
plugins: [
sveltekit(),
...jixoai({
icons: {
library: {
includeDefaults: true, // the 38 built-ins (default)
channels: [md(), ph(), rx()], // the shipped channels — one import each
icons: { // add + override — every source form
brand: 'lucide:zap', // a lucide ref — resolved at BUILD time
logo: { file: 'assets/logo.svg' }, // a .svg file — the plugin owns the I/O
spark: '<svg xmlns="…">…</svg>', // an inline literal — RAW safety-checked
home: 'md:home', // a channel ref — outlined/400/FILL-0 Material
glyph: { font: 'brand.woff2', code: 0xE002 }, // a font glyph, extracted
},
maxChunkBytes: 20480, // RAW module bytes per chunk (default)
chunking: 'auto', // 'single' = one chunk, no splitting
inlineFirstChunk: true, // chunk 0 inline → sync getIcon (default)
optimize: true, // svgo pass (default)
write: false, // the repo's gen:icons script is the writer
},
},
}),
],
};
/* the artifact it generates (default config → the plugin-free tier):
import { getIcon, loadIcon, preloadIcons, ICON_NAMES, type IconName }
from '$lib/icon-set.gen'; */| Option | Default | What it does |
|---|---|---|
| includeDefaults | true | the 38 built-in lucide manifest in manifest order; false with no lucide: sources never touches the lucide import at all |
| channels | [] | register icon channels — each contributes a prefixed ref form (md:/ph:/rx:/your own) resolving ONE icon from the channel's optional peer package at build time. Channel instances come from defineIconChannel (…/icons/channel) or the shipped factories (…/icons/md · …/icons/ph · …/icons/rx); lucide needs NO entry (it is the default-registered channel). Enabled prefixes = lucide ∪ channels'; a disabled or unknown prefix fails at startup listing the enabled set |
| icons | {} | add + override named sources (same name = override; names match /^[a-z][A-Za-z0-9]*$/); customs pack after the built-ins in config insertion order |
| maxChunkBytes | 20480 | serialized entry bytes per chunk module (key + payload) — RAW, non-gzip; the greedy packer opens chunk K+1 when the next icon would exceed it |
| chunking | 'auto' | 'auto' = budgeted chunks; 'single' = one chunk, no splitting |
| inlineFirstChunk | true | chunk 0 rides inline in the artifact so getIcon() answers synchronously; false = the all-lazy escape hatch |
| optimize | true | svgo after the raw safety check (floatPrecision 3, geometry-preserving — pinned a no-op on lucide); never runs on the slot face |
| output | 'src/lib/icon-set.gen.ts' | artifact write target, project-root-relative — ONLY used when write is on |
| write | false | consumer opt-in; the vite adapter serves virtual chunks and drift-warns instead. In THIS repo the root gen:icons script is the ONLY writer (the single-writer law) |
write stays false everywhere in this repo: the vite adapter serves the lazy chunks as virtual modules
(virtual:jixoai-icons/chunk/K) and drift-warns in dev —
the root gen:icons script is the one writer of the
canonical artifact (the single-writer law). Consumer apps may opt into write: true + their own output for dev ergonomics.
channels
Icon libraries as channels — one import, one ref each
library.channels registers icon channels; each channel contributes a prefixed reference form that resolves exactly ONE icon at build time through the same safety→svgo→extract pipeline — no bulk bundling, per-icon nature detection. Every channel package is an optional peer: absent installs fail loudly with the npm install line, and referencing a prefix you have not enabled is a named startup error listing the enabled set. The shipped channels import from their OWN sub-entries of the same package — lucide is the default-registered one (zero-import) and yours ride the very same defineIconChannel base.
| Channel | Ref form | Sub-entry | Package (optional peer) | License | Default mapping |
|---|---|---|---|---|---|
| material | md:home | …/icons/md | @material-symbols/svg-400 | Apache-2.0 | outlined · weight 400 · FILL 0 (knobs: weight/style/fill) |
| phosphor | ph:atom | …/icons/ph | @phosphor-icons/core | MIT | assets/regular (knob: weight thin…duotone) |
| remix | rx:system:add-line | …/icons/rx | remixicon | Apache-2.0 | icons/<Category>/ — the name carries its category prefix |
| lucide (default-registered) | lucide:zap | …/icons/lucide | lucide | ISC | the 38-name manifest + lucide: refs — always wired, scans like any channel |
| yours (defineIconChannel) | myco:logo | …/icons/channel | your own svg package | — | ONE defineIconChannel call — the section below |
// registering the shipped channels: one import each, frozen defaults
import { md } from '@jixoai/ui-vite-plugin/icons/md';
import { ph } from '@jixoai/ui-vite-plugin/icons/ph';
import { rx } from '@jixoai/ui-vite-plugin/icons/rx';
library: { channels: [md(), ph(), rx()] } // lucide: needs NO entry
// or the knobs (one weight = ONE peer package)
import { md } from '@jixoai/ui-vite-plugin/icons/md';
library: { channels: [md({ weight: 400, style: 'rounded', fill: true })] }
// referencing a prefix you have NOT enabled is a named startup error
// listing the enabled set — "fa:home" fails, teaching the fixRendered live, from THIS site's own build — not stock artwork. The cells below are the
real <Icon name> component resolving channel refs the
scanner collected out of this very page's source (and the vite config you would read
alongside it registers the channels for real). The last pair is the equivalence law,
visible: the scanned lucide:check cell renders the
SAME glyph as the built-in check cell beside it —
one payload, an EQUIVALENCES row chaining the lookups. The source is the reference:
an AI reading apps/www/vite.config.ts + this file sees the capability exercised
end-to-end.
the channel gallery — live from this site's own build
Rendered by the real <Icon name> component resolving channel refs the scanner collected out of this very page's source: three shipped channels, the lucide built-in, and the equivalence law visible — lucide:check renders the SAME glyph as check beside it. The drawer's usage file is this gallery's exact markup, static literals and all.
md:home material · outlined/400md:copy_all material · snake_caseph:atom phosphor · regularrx:system:add-line remix · category-prefixedcheck lucide · built-inlucide:check lucide · equivalence → checkas form, live: the left cell writes the full literal, the right
resolves the alias — ONE packed payload, three legal spellings (the artifact's ALIASES row)<script lang="ts">
import Icon from '@ui/icon';
</script>
<div class={cx(rt.col12)} data-channel-gallery="">
<!-- STATIC literals by law: the scanner collects static
name="…" attributes only — a dynamic name={expr} here
would be unpacked at runtime (the runtime lane), not
scanned. These six cells ARE the dogfood. -->
<div class={cx(rt.wrap12)}>
<div class={cx(rt.col8, rt.frame60, rt.bgCard40, rt.itemsCenter, rt.px16, rt.py12)} data-channel-cell="md:home">
<Icon name="md:home" size={24} />
<code class={cx(rt.note11, rt.fontMono)}>md:home</code>
<span class={cx(rt.inkMuted, rt.text10, rt.upper, rt.track14)}>material · outlined/400</span>
</div>
<div class={cx(rt.col8, rt.frame60, rt.bgCard40, rt.itemsCenter, rt.px16, rt.py12)} data-channel-cell="md:copy_all">
<Icon name="md:copy_all" size={24} />
<code class={cx(rt.note11, rt.fontMono)}>md:copy_all</code>
<span class={cx(rt.inkMuted, rt.text10, rt.upper, rt.track14)}>material · snake_case</span>
</div>
<div class={cx(rt.col8, rt.frame60, rt.bgCard40, rt.itemsCenter, rt.px16, rt.py12)} data-channel-cell="ph:atom">
<Icon name="ph:atom" size={24} />
<code class={cx(rt.note11, rt.fontMono)}>ph:atom</code>
<span class={cx(rt.inkMuted, rt.text10, rt.upper, rt.track14)}>phosphor · regular</span>
</div>
<div class={cx(rt.col8, rt.frame60, rt.bgCard40, rt.itemsCenter, rt.px16, rt.py12)} data-channel-cell="rx:system:add-line">
<Icon name="rx:system:add-line" size={24} />
<code class={cx(rt.note11, rt.fontMono)}>rx:system:add-line</code>
<span class={cx(rt.inkMuted, rt.text10, rt.upper, rt.track14)}>remix · category-prefixed</span>
</div>
<div class={cx(rt.col8, rt.frame60, rt.bgCard40, rt.itemsCenter, rt.px16, rt.py12)} data-channel-cell="check">
<Icon name="check" size={24} />
<code class={cx(rt.note11, rt.fontMono)}>check</code>
<span class={cx(rt.inkMuted, rt.text10, rt.upper, rt.track14)}>lucide · built-in</span>
</div>
<div class={cx(rt.col8, rt.framePrimary40, rt.bgCard40, rt.itemsCenter, rt.px16, rt.py12)} data-channel-cell="lucide:check">
<Icon name="lucide:check" size={24} />
<code class={cx(rt.note11, rt.fontMono)}>lucide:check</code>
<span class={cx(rt.inkMuted, rt.text10, rt.upper, rt.track14)}>lucide · equivalence → check</span>
</div>
</div>
<div class={cx(rt.rowC12, rt.panel, rt.wrap)} data-alias-demo="">
<Icon name="md:copy_all as copy2" size={20} />
<Icon name="copy2" size={20} />
<span class={cx(rt.bodyMuted)}>
the <code class={cx(rt.inkAccent)}>as</code> form, live: the left cell writes the full literal, the right
resolves the alias — ONE packed payload, three legal spellings (the artifact's ALIASES row)
</span>
</div>
</div>not shipped — and why
tabler and hugeicons ship no per-icon SVG source package on npm (tabler's SVGs are repo-only; hugeicons is JS data, not files) — a channel needs a node-resolvable .svg per icon, so both are out until that changes.
SF Symbols is rejected on licensing Apple's system-provided image terms restrict the glyphs to Apple-platform apps and prohibit SVG export or redistribution. Shipping a channel that extracts or redistributes them would breach those terms, so the pipeline deliberately offers no SF Symbols lane; use Apple's own APIs on Apple platforms.
define your own channel
defineIconChannel — one call replaces per-icon config
The channel is the product: the same base every shipped lane rides is public. ONE defineIconChannel call — an id, a prefix, an optional peer package, and a resolveFile mapping — registers a myco: namespace that works everywhere a built-in prefix does: config refs, source scanning, the IconName template union, and the enabled-prefix laws. The channel LOCATES the artwork (node resolution); the plugin still owns the READ and the shared safety→svgo→extract pipeline — your channel inherits the built-ins' guarantees by construction.
// vite.config.ts — your own channel, from the consumer's own assets
import { jixoai } from '@jixoai/ui-vite-plugin';
import { defineIconChannel, resolvePeerFile } from '@jixoai/ui-vite-plugin/icons/channel';
const myco = defineIconChannel({
id: 'myco', // /^[a-z][a-z0-9-]*$/, unique among registered
prefix: 'myco', // /^[a-z][a-z0-9]*$/ — becomes the myco: namespace
peerPackage: 'my-icons', // optional peer — named in install-hint errors
resolveFile: (ref) => resolvePeerFile('my-icons', `svgs/${ref}.svg`),
defaultsNote: 'my-icons svgs/ (outlined)',
});
export default {
plugins: [/* … */ jixoai({ icons: { library: { channels: [myco] } } })],
};
// that single registration buys, for the myco: prefix:
// - config refs — icons: { logo: 'myco:logo' }
// - source scanning — a static myco-prefixed name attribute in any
// .svelte/.ts/.js/.html file packs with ZERO library.icons entry
// - template union — IconName gains a myco-backtick template member
// - the enabled-prefix + uniqueness laws, exactly like a shipped one
// the channel LOCATES (node resolution to the ABSOLUTE svg path); the
// adapter READS through the plugin's provider context — mime law +
// watchFile HMR — and every icon crosses the shared RAW safety → svgo
// → extract pipeline. A custom channel inherits the built-ins'
// guarantees by construction; it can never smuggle unvetted bytes.Grammar: the prefix matches /^[a-z][a-z0-9]*$/ and the
id /^[a-z][a-z0-9-]*$/; both lucide spellings are reserved (the default-registered channel owns them), and duplicate ids or
prefixes fail at startup naming both entries. v1 channels are FILE-BACKED
(resolveFile → .svg); lucide's IconNode lane is the
one documented built-in asymmetry. Sub-entry imports stay pure: the …/icons/channel graph reaches no lucide/svgo/opentype
code, so configuring channels costs nothing at import time.
real-world proof
The battle-tested reference — examples/hmos-icons
A committed consumer project, not a fixture: plain Vite + Svelte, the registry icon component, and one channel over a REAL third-party set — an 11-file HarmonyOS subset (local archive, Sketch-export artwork). A gate installs it for real (the file: dependency on the built plugin), builds it twice (client + SSR), and asserts the FINAL OUTPUT only. Building it forced two production fixes worth reading before you ship your own channel.
// examples/hmos-icons/src/lib/icons/hmos.ts — the entire integration
import { fileURLToPath } from 'node:url';
import { defineIconChannel } from '@jixoai/ui-vite-plugin/icons/channel';
export const hmos = () =>
defineIconChannel({
id: 'hmos',
prefix: 'hmos',
// the absolute-path contract: one ref, one absolute svg path
resolveFile: (ref) => fileURLToPath(new URL(`../../../icons/${ref}.svg`, import.meta.url)),
});what the exercise taught (both shipped as laws)
- Sketch-export artwork is the norm, not the edge. HarmonyOS-class sets are defs + mask + use, and svgo collapses each to
defs + usewhile minifying EVERY icon's id to the same short token. Two laws came out of that: the RAW safety gate passes same-document fragment references (#frag,xlink:href,xmlnsdeclarations) and rejects only external ones; and packed ids scope under the canonical name at pack time, so two such icons on one page can never cross-resolve their use elements. - Static name literals are the scan surface. The example's preview column keeps literals in source; the moment a page moves to dynamic name expressions, the icons silently leave the packed set. The derived columns (nature, viewBox, payload, loading lane) come from the artifact at runtime — the loading column honestly OBSERVES the 20KiB budget split instead of hardcoding it.
- Assert the final output, never intermediates. The gate cross-checks the built bundle against an independent svgo re-optimization of the SOURCE svgs (not the committed artifact), executes the SSR bundle and asserts the rendered document (real paths, id uniqueness, reserved boxes for lazy names), and byte-compares the regenerated artifact. Config refs also carry what scanning cannot — the example's editGroup ref points at a filename with a space.
| Read the source | What it proves |
|---|---|
| examples/hmos-icons/ | the whole consumer — README (provenance), vite.config.ts (write: true wiring), the icon table page |
| …/src/lib/icons/hmos.ts | the entire channel integration (quoted above) |
| …/src/App.svelte | static literals + runtime-derived columns; the budget split visible on the page |
| packages/vite-plugin/test/icons/library/example-hmos.test.ts | the E2E gate — real install, dual builds, final-output assertions |
the prefix compiler
md:copy_all — scanned names, as aliases, three tiers of safety
Registering a channel turns on a source scanner: static name="md:…" literals in your .svelte/.ts/.js/.html files enter the set with ZERO configuration — and lucide: literals scan the same way (the default-registered channel rides the identical lane). The scanner has two entries that always agree — an eager project walk at build start (and inside gen:icons, so the committed artifact and the dev server can never diverge) and a dev transform collector whose findings refresh the artifact like a config edit. Unknown prefixes (fa:) are ignored fail-safe: they fail at the TYPE level instead. A ref the channel cannot resolve fails the build by name — never a silently blank glyph.
<!-- with channels: [md()] registered — write prefixed names
directly, NO library.icons declaration needed -->
<Icon name="md:copy_all" /> <!-- scanned → packs under the canonical -->
<Icon name="md:copy_all as copy2" /> <!-- the as form: alias + canonical, ONE payload -->
<Icon name="copy2" /> <!-- the alias resolves the SAME icon -->
<Icon name="lucide:check" /> <!-- lucide scans too — default-registered -->
<Icon name={`md:${iconKey}`} /> <!-- dynamic: NOT scanned — the runtime lane -->| Tier | What it checks | How it fails |
|---|---|---|
| compile | the prefix | IconName gains a `md:${string}` template member for every ENABLED channel (lucide included) — an "fa:…" name is a type error with no runtime story |
| build | the concrete name | the scanner collects the static literal; the channel resolver must answer it or the build fails BY NAME (never a silently blank glyph) |
| runtime | dynamic composition | getIcon() returns null, the component renders its reserved box and warns once; loadIcon() names both possible causes |
// the artifact the compiler generates (sources are NEVER rewritten)
export const ALIASES: Readonly<Record<string, string>> = {
copy2: 'md:copy_all', // one table row per alias — never a second payload
};
// a scanned lucide:X whose X is ALREADY packed dedupes the same way —
// through a SEPARATE compiler-generated table:
export const EQUIVALENCES: Readonly<Record<string, string>> = {
'lucide:check': 'check', // the built-in's artwork — ONE payload, chained lookups
};
// canonicalOf chains ALIASES then EQUIVALENCES: getIcon('md:copy_all'),
// getIcon('copy2'), the full literal, getIcon('lucide:check') and an
// aliased equivalence all resolve their ONE packed payloadThe as form declares a local alias: 双键并存 — both keys
coexist forever, your sources are never rewritten, and the scanned keys
ride alongside hand-written ones. The payload packs exactly once under the
canonical md:copy_all key; each alias costs exactly one
ALIASES-table row in the budget, and ICON_NAMES emits it adjacent to its ref. A scanned lucide:X whose X is
already packed dedupes identically through the separate EQUIVALENCES table — the
gallery's lucide:check cell is that row, rendered. Collisions
fail the build by name: an alias shadowing a declared name, two refs claiming one alias,
or an alias breaking the camelCase law. Dynamic composition
(name={`md:${iconKey}`}) is intentionally unscanned
— it rides the runtime lane above: getIcon() answers null, the component renders its
reserved box, and loadIcon() explains both possible causes (artifact drift or a
dynamic/never-scanned name).
font sources
A font file is an icon source
The missing lane from the original icon-direction brief, landed build-time-only: { font, code } extracts the codepoint's glyph outline and { font, liga } tries the ligature lookup (best-effort — opentype.js GSUB coverage is thin). The extracted outline normalizes through the exact contain-fit math the slot face's fontIconProvider uses, enters the artifact as fill-nature artwork, and the runtime stays pure SVG — SSR, print, no-JS and chunking guarantees unchanged.
// a font FILE is an icon source — the outline is EXTRACTED at build
// time (opentype.js + the same contain-fit math as the slot face) and
// the artifact stays pure SVG: no font bytes reach the browser
icons: {
brand: { font: 'brand.woff2', code: 0xE002 }, // codepoint (cmap)
logo: { font: 'brand.woff2', liga: 'md-logo' }, // ligature, best-effort
legacy: { font: 'old/icon.ttf', code: 0x41 }, // ttf/otf directly
}
// woff2 decompresses through the same loadSource lane (wawoff2);
// woff1 stays the hard error. A codepoint missing from cmap, a blank
// outline, or an unresolvable ligature fails BY NAME (the miss lists
// the font's resolvable ligatures when the parser exposes them).
// The gen:icons script twin is svg-only: font sources are a
// named vite-plugin-only lane there.The font file joins watchFile like any { file } source — an edit re-extracts on the
next HMR refresh. Codepoint mapping is the primary lane; a ligature the parser cannot
resolve fails by name, listing the font's resolvable ligatures when the parser exposes
them (else the glyph-name/cmap hint) — never a silent blank glyph.
install tiers
Two tiers — plugin-free by default
The default tier installs and builds standalone: the committed artifact is the default-config output (38 built-ins, one inline chunk, ZERO lazy chunks), so the two registry items carry zero npm dependencies and zero virtual imports. The overflow tier kicks in the moment your config emits lazy chunks — and there the plugin becomes a documented PREREQUISITE, failing with one named build error instead of a silent 404.
default — plugin-free
npx jixoai-ui add icon (pulls icon-set + the theme) lands files that build with
zero npm deps and zero virtual imports — every getIcon() call answers synchronously.
overflow — plugin prerequisite
Past the budget (big libraries, inlineFirstChunk: false)
the artifact emits lazy imports of virtual:jixoai-icons/chunk/* — wiring jixoai({ icons: { library } }) from @jixoai/ui-vite-plugin becomes REQUIRED.
[jixoai/icon-set] virtual:jixoai-icons/chunk/* imported but no icons library is configured — wire jixoai({ icons: { library } }) in your vite plugins (see the icon-set item docs)The sentinel fires from the plugin's resolver when the plugin IS present but the library
face is not configured, and again at runtime from the artifact's LAZY catch (with the
original error as cause). Plugin absent entirely:
vite's generic unresolved-import error is unavoidable — wire the plugin first.
async semantics
Inline core, lazy overflow
The default case is synchronous: chunk 0 rides inline in the artifact, so SSR paints core glyphs in the server HTML and hydration matches with zero wiring. Lazy chunks exist only past the budget — they load through cached promises into a FIXED reserved box whose pending and rejected markup are identical (hydration never rewrites it), and preloadIcons warms them ahead of a mount.
chunk 0 (inline) ── getIcon(name) answers synchronously
SSR paints the glyph in the server HTML,
hydration matches — ZERO wiring owed
chunks 1+ (lazy) ── loadIcon(name) dynamic-imports
virtual:jixoai-icons/chunk/K (cached promise);
pending renders the FIXED reserved box:
<span data-jx-icon-pending aria-hidden="true"
style="display:inline-block;width:16px;height:16px">
pending AND rejected render the IDENTICAL span, so
hydration never rewrites the box; a failed chunk
warns once per chunk per session — the box stays.
preloadIcons(['folderOpen', 'fileVideo']) // warm lazy chunks ahead of a mount| chunking | inlineFirstChunk | Layout | Artifact imports |
|---|---|---|---|
| 'auto' (default) | true (default) | budgeted chunks; chunk 0 inline | lazy for chunks 1..N; N=0 → zero virtual imports |
| 'auto' | false | budgeted chunks; all lazy | lazy imports for every chunk |
| 'single' | true (default) | one chunk, fully inline | zero virtual imports |
| 'single' | false | one lazy chunk with everything | exactly one lazy import |
slot face
--jx-icon-* — the mask / currentColor law
The second face paints its icons as CSS, no component: one alpha-only URI per glyph, declared on the vocabulary sheet (the encoded stroke is an ALPHA SOURCE ONLY). Author-painted rules theme through mask + background-color: currentColor; UA-shadow pseudos that reject author mask paint take the -ink variants, and .dark / .jx-light flip the whole set to white/black ink. The glyph column below consumes the vocabulary face itself — a slot that changes geometry changes here with zero edit.
| Glyph | Slot | Consumer | Technique | Overridable |
|---|---|---|---|---|
| --jx-icon-calendar | input[type=date] ::-webkit-calendar-picker-indicator | background-image | calendar | |
| --jx-icon-calendar-ink | UA-shadow pseudos — the dark/light matrix twin | background-image | calendar · derived | |
| --jx-icon-clock | input[type=time] ::-webkit-calendar-picker-indicator | background-image | clock | |
| --jx-icon-clock-ink | UA-shadow pseudos — the dark/light matrix twin | background-image | clock · derived | |
| --jx-icon-chevron | native select · .jx-color-picker-chevron | mask + background-image | chevron | |
| --jx-icon-palette | .jx-color-shell::after — the color input wrapper | mask | palette | |
| --jx-icon-clear | input × button · ::-webkit-search-cancel-button | mask + background-image | clear | |
| --jx-icon-mail | input[type=email] inline-start ornament | background-image | ||
| --jx-icon-search | input[type=search] inline-start ornament | background-image | search | |
| --jx-icon-check | combobox selection check (combobox.css) | mask | check | |
| --jx-icon-valid-ink | [aria-invalid='false'] state ink | background-image | check · derived | |
| --jx-icon-invalid-ink | [aria-invalid='true'] state ink | background-image | invalid · ink-only |
The Overridable column names the plugin CONCEPT slot:
covering a concept re-bakes every variable it owns (the derived-ink law below). The palette variable exists only as an inline mask fallback
(no :root line — the wrapper paints var(--jx-icon-palette, …)), so its glyph column mirrors
that same embedding expression.
plugin · slot face
The slot pipeline — providers and concept slots
The same jixoai({ icons }) call opens a second, independent face: a provider answers every concept slot with structured artwork, the serializer is the only code that generates CSS, and the safety checker runs before serialization in both modes — no unvalidated SVG ever reaches output. Opt-in with one feature flag; without the icons option the plugin never loads.
| Provider | Source | Nature |
|---|---|---|
| lucideIconProvider() | the lucide npm IconNode data — zero I/O, the standard defaults | stroke · 24×24 grid |
| svgIconProvider({ dir }) | your .svg files — {slot}.svg per slot, per-slot filename overrides | file-based · HMR |
| fontIconProvider({ font, codepoints }) | glyph outlines extracted from any TTF/OTF (WOFF2 decompressed for you) | fill · font units |
| mixinIconProvider(base, { slot: factory }) | a base provider plus per-slot overrides — override first, null falls through | composition |
Providers never touch the filesystem — the plugin owns ALL file I/O and hands loaded bytes
(and HMR watches) through a ProviderContext. safety defaults to warn: a rejected icon logs and serves the
standard layer's inline fallback (warn means don't crash the build, NOT let unvalidated
content through); pass { mode: 'error' } — with
optional maxBytes (10KB) / maxPathCommands (500) limits — to fail instead, e.g. for
HTTP-sourced artwork.
override ONE concept slot (e.g. icons: { calendar: mySvg })
│
▼ the serializer re-bakes the whole family from YOUR artwork
@layer theme {
:root { --jx-icon-calendar: url(… your glyph, #000 ink …); }
:root { --jx-icon-calendar-ink: url(… your glyph, #000 ink …); }
.dark { calendar + calendar-ink re-baked with #fff ink; }
.jx-light { calendar + calendar-ink re-baked with #000 ink; }
}
── the concept map (9 concepts ⇔ the variable faces) ──────────────
calendar → calendar + calendar-ink clock → clock + clock-ink
check → check + valid-ink invalid → invalid-ink (no plain)
chevron · palette · clear · mail · search → their single plain face
──────────────────────────────────────────────────────────────────
plain + ink + the dark/light matrix re-bake TOGETHER — an overridden
plain beside a stock ink is impossible by construction. // vite.config.ts — this site's own wiring: BOTH faces, one plugin call
import { jixoai } from '@jixoai/ui-vite-plugin';
import { lucideIconProvider } from '@jixoai/ui-vite-plugin/icons';
export default {
plugins: [
sveltekit(),
...jixoai({
icons: {
provider: lucideIconProvider(), // the SLOT face — --jx-icon-* vocabulary
safety: { mode: 'warn' }, // shared by both faces
library: { includeDefaults: true }, // the LIBRARY face — <Icon name>
},
}),
],
};
/* app.css — the slot face's ONLY injection path: a virtual CSS module
@import 'virtual:jixoai-icons'; */virtual:jixoai-icons is the slot face's only injection
path: the plugin serves it as a virtual CSS module
(@layer theme custom properties) for your CSS entry.
Watched sources invalidate the virtual module — HMR re-runs the provider with fresh
bytes.
live demo
One wrapper, one override — a real repaint
The vocabulary is plain CSS custom properties, so a scope can re-define one: the right box lives inside a wrapper that redefines --jx-icon-search (a style attribute — the same seam a plugin override writes at sheet scale). Both boxes paint through the identical mask expression; only the variable's value differs. No screenshots, no fakes — the browser repaints the pair live.
default — :root value
mask: var(--jx-icon-search)overridden — wrapper scope
style="--jx-icon-search: url(…)"Dogfood: this very site runs BOTH faces through the jixoai({ icons }) pipeline — the slot face
paints the default box on the left in production (the site's build smoke-asserts it renders
byte-equal to the shipped sheet), and the library face generated the very icon-set.gen the grid above walks.