anentrypoint-design 0.0.384 → 0.0.385

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ // The classic single-sidebar app frame: Topbar, Crumb, Side, Status and the
2
+ // AppShell that composes them, plus the pure-DOM sidebar-drawer toggle (the
3
+ // shell is stateless chrome, so drawer open/closed lives as a class on
4
+ // .app-body read by the @container(max-width:900px) query).
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ import { trapTab } from '../overlay-primitives.js';
8
+ import { Brand, Glyph } from './atoms.js';
9
+ import { Icon } from './icons.js';
10
+ const h = webjsx.createElement;
11
+
12
+ export function Topbar({ brand = '247420', leaf = '', items = [], active = '', onNav, search } = {}) {
13
+ return h('header', { class: 'app-topbar', role: 'banner' },
14
+ Brand({ name: brand, leaf }),
15
+ search ? h('label', { class: 'app-search' },
16
+ h('span', { class: 'icon', 'aria-hidden': 'true' }, 'search'),
17
+ // `search` is either a plain placeholder string (renders the
18
+ // default uncontrolled input) or a caller-built VElement (has
19
+ // .type/.props — e.g. a controlled <input> wired to app state)
20
+ // rendered as-is. Stringifying a VElement into placeholder/
21
+ // aria-label previously produced literal "[object Object]" text.
22
+ (search && typeof search === 'object' && 'type' in search)
23
+ ? search
24
+ : h('input', { type: 'search', name: 'q', placeholder: search, 'aria-label': `search ${search}` })
25
+ ) : null,
26
+ h('nav', { 'aria-label': 'main navigation' }, ...items.map(([label, href]) => {
27
+ const cleanLabel = String(label).replace(' ->', '');
28
+ return h('a', {
29
+ key: label,
30
+ href,
31
+ class: active === cleanLabel ? 'active' : '',
32
+ 'aria-current': active === cleanLabel ? 'page' : null,
33
+ onclick: (e) => {
34
+ if (!String(href).startsWith('http') && onNav) {
35
+ e.preventDefault();
36
+ onNav(cleanLabel);
37
+ }
38
+ }
39
+ }, label);
40
+ }))
41
+ );
42
+ }
43
+
44
+ export function Crumb({ trail = [], leaf = '', right } = {}) {
45
+ const parts = [];
46
+ trail.forEach((t, i) => {
47
+ parts.push(h('span', { key: 't' + i }, t));
48
+ parts.push(h('span', { key: 's' + i, class: 'sep' }, '/'));
49
+ });
50
+ parts.push(h('span', { key: 'leaf', class: 'leaf' }, leaf));
51
+ if (right) parts.push(h('span', { key: 'r', class: 'crumb-right' }, ...(Array.isArray(right) ? right : [right])));
52
+ return h('div', { class: 'app-crumb' }, ...parts);
53
+ }
54
+
55
+ // ArrowUp/ArrowDown/Home/End move focus between sidebar links without
56
+ // altering tabindex -- every link stays naturally Tab-reachable (a plain
57
+ // link list, not a role=tablist), arrows are a same-list quick-nav shortcut
58
+ // layered on top, mirroring the roving-nav affordance Tabs already has
59
+ // (editor-primitives.js) but without roving-tabindex's activate-on-move
60
+ // semantics, since a nav link's "activation" is a real navigation the user
61
+ // should still choose deliberately with Enter/click.
62
+ function onSideLinkKeyDown(e) {
63
+ let dir = 0;
64
+ if (e.key === 'ArrowDown') dir = 1;
65
+ else if (e.key === 'ArrowUp') dir = -1;
66
+ else if (e.key === 'Home' || e.key === 'End') dir = e.key === 'Home' ? 'first' : 'last';
67
+ else return;
68
+ const side = e.currentTarget.closest('.app-side');
69
+ if (!side) return;
70
+ const links = Array.from(side.querySelectorAll('a'));
71
+ const curIdx = links.indexOf(e.currentTarget);
72
+ if (curIdx === -1) return;
73
+ e.preventDefault();
74
+ let nextIdx;
75
+ if (dir === 'first') nextIdx = 0;
76
+ else if (dir === 'last') nextIdx = links.length - 1;
77
+ else nextIdx = (curIdx + dir + links.length) % links.length;
78
+ const next = links[nextIdx];
79
+ if (next) next.focus();
80
+ }
81
+
82
+ export function Side({ sections = [] } = {}) {
83
+ return h('aside', { class: 'app-side', role: 'navigation', 'aria-label': 'sidebar navigation' }, ...sections.map(sec => {
84
+ const groupId = 'side-group-' + String(sec.group).replace(/\W+/g, '-').toLowerCase();
85
+ // Each section is a group labelled by its heading, so AT users hear the
86
+ // heading as the group name instead of an orphan heading.
87
+ return h('div', { class: 'app-side-group', key: sec.group, role: 'group', 'aria-labelledby': groupId },
88
+ h('h2', { class: 'group', id: groupId }, sec.group),
89
+ ...sec.items.map((item, i) => {
90
+ const { glyph, label, href = '#', active, count, color, onClick } = item;
91
+ const countLabel = (count != null && count !== 0 && count !== '0') ? ` (${count})` : '';
92
+ return h('a', {
93
+ key: sec.group + i,
94
+ href,
95
+ class: active ? 'active' : '',
96
+ 'aria-current': active ? 'page' : null,
97
+ 'aria-label': label + countLabel,
98
+ onclick: onClick,
99
+ onkeydown: onSideLinkKeyDown
100
+ },
101
+ glyph != null ? Glyph({ children: glyph, color }) : h('span', { class: 'glyph', 'aria-hidden': 'true' }),
102
+ h('span', {}, label),
103
+ (count != null && count !== 0 && count !== '0') ? h('span', { class: 'count', 'aria-hidden': 'true' }, String(count)) : null
104
+ );
105
+ })
106
+ );
107
+ }));
108
+ }
109
+
110
+ export function Status({ left = [], right = [] } = {}) {
111
+ return h('footer', { class: 'app-status', role: 'contentinfo' },
112
+ ...left.map((t, i) => h('span', { key: 'l' + i, class: 'item' }, t)),
113
+ h('span', { key: 'spread', class: 'spread', 'aria-hidden': 'true' }),
114
+ ...right.map((t, i) => h('span', { key: 'r' + i, class: 'item' }, t))
115
+ );
116
+ }
117
+
118
+ // Toggle the sidebar drawer. Pure-DOM because AppShell is stateless chrome; the
119
+ // class lives on .app-body and is read by the @container(max-width:900px) query.
120
+ // `fromEl` scopes the toggle to the shell that owns the clicked control — without
121
+ // it, document.querySelector grabs the FIRST .app-body on the page, so a second
122
+ // dashboard instance (multiple thebird WM windows) would toggle the wrong drawer.
123
+ function toggleSide(open, fromEl) {
124
+ const shell = (fromEl && fromEl.closest && fromEl.closest('.app')) || document;
125
+ const body = shell.querySelector('.app-body');
126
+ if (!body) return;
127
+ const next = open != null ? open : !body.classList.contains('side-open');
128
+ body.classList.toggle('side-open', next);
129
+ const btn = shell.querySelector('.app-side-toggle');
130
+ if (btn) btn.setAttribute('aria-expanded', next ? 'true' : 'false');
131
+ // Keyboard parity with toggleWsDrawer: Esc dismisses the drawer and Tab is
132
+ // trapped inside it while it overlays the content behind the scrim.
133
+ if (body._dsSideKey) { document.removeEventListener('keydown', body._dsSideKey); body._dsSideKey = null; }
134
+ if (next) {
135
+ const drawer = shell.querySelector('.app-side-shell');
136
+ const focusable = drawer && drawer.querySelector('button, a, input, [tabindex]');
137
+ if (focusable) try { focusable.focus(); } catch (_) { /* swallow: focus() can throw on a detached/hidden element, drawer still opens */ }
138
+ const onKey = (e) => {
139
+ if (e.key === 'Escape') { toggleSide(false, btn || body); if (btn) try { btn.focus(); } catch (_) { /* swallow: focus() can throw on a detached/hidden element */ } return; }
140
+ if (drawer) trapTab(drawer, e);
141
+ };
142
+ body._dsSideKey = onKey;
143
+ document.addEventListener('keydown', onKey);
144
+ }
145
+ }
146
+
147
+ // Ref on the .app root: re-sync the toggle's aria-expanded from the live
148
+ // .side-open class (applyDiff re-renders reset the attribute to 'false'), and
149
+ // arm a ResizeObserver that closes a stuck-open drawer when the shell grows
150
+ // past the 900px container breakpoint (the drawer CSS stops applying there,
151
+ // but the class would otherwise persist and reappear on the next shrink).
152
+ function syncAppSide(el) {
153
+ if (!el) return;
154
+ const body = el.querySelector('.app-body');
155
+ const btn = el.querySelector('.app-side-toggle');
156
+ if (btn && body) btn.setAttribute('aria-expanded', body.classList.contains('side-open') ? 'true' : 'false');
157
+ if (!el._dsSideRO && typeof ResizeObserver !== 'undefined') {
158
+ el._dsSideRO = new ResizeObserver((entries) => {
159
+ const w = entries[0] && entries[0].contentRect.width;
160
+ const b = el.querySelector('.app-body');
161
+ if (w > 900 && b && b.classList.contains('side-open')) toggleSide(false, el);
162
+ });
163
+ el._dsSideRO.observe(el);
164
+ }
165
+ }
166
+
167
+ export function AppShell({ topbar, crumb, side, main, status, narrow } = {}) {
168
+ const hasSide = Boolean(side);
169
+ const sideNode = hasSide ? side : h('aside', { class: 'app-side', 'aria-hidden': 'true' });
170
+ // Topbar and crumb used to stack as two separate chrome bars — a "double
171
+ // title bar". When both are present, fold them into one sticky row:
172
+ // brand + nav (topbar) and breadcrumb + right slot (crumb) share a single
173
+ // band so the chrome reads as one bar, not two. Either prop alone still
174
+ // renders on its own (consumers that pass only a topbar are unaffected).
175
+ const chrome = (topbar && crumb)
176
+ ? h('div', { class: 'app-chrome' }, topbar, crumb)
177
+ : (topbar || crumb || null);
178
+ return h('div', { class: 'app', ref: syncAppSide },
179
+ h('a', { href: '#app-main', class: 'skip-link' }, 'skip to main content'),
180
+ hasSide ? h('button', {
181
+ class: 'app-side-toggle', type: 'button',
182
+ 'aria-label': 'toggle navigation', 'aria-expanded': 'false', 'aria-controls': 'app-side-shell',
183
+ onclick: (e) => toggleSide(null, e.currentTarget),
184
+ }, Icon('menu')) : null,
185
+ chrome,
186
+ h('div', { class: 'app-body' + (hasSide ? '' : ' no-side') },
187
+ h('div', { class: 'app-side-scrim', 'aria-hidden': 'true', onclick: (e) => toggleSide(false, e.currentTarget) }),
188
+ h('div', { class: 'app-side-shell', id: 'app-side-shell', onclick: (e) => { if (e.target.closest('a')) toggleSide(false, e.currentTarget); } }, sideNode),
189
+ // tabindex=-1 so the skip-link (href="#app-main") actually moves
190
+ // keyboard focus into the main region, not just scroll to it.
191
+ h('main', { class: 'app-main' + (narrow ? ' narrow' : ''), id: 'app-main', tabindex: '-1' }, ...(Array.isArray(main) ? main : [main]))
192
+ ),
193
+ status || null
194
+ );
195
+ }
@@ -0,0 +1,165 @@
1
+ // Chrome atoms: the smallest pure label/control factories the shell and every
2
+ // higher-level component build on — Brand, Chip, Btn, IconButton, Badge, Pill,
3
+ // Glyph, Heading, Lede, Dot, Rail. Props in, webjsx vnode out; all visuals ride
4
+ // class names defined in app-shell.css.
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ import { Icon } from './icons.js';
8
+ const h = webjsx.createElement;
9
+
10
+ /**
11
+ * The wordmark used in Topbar/AppShell headers.
12
+ *
13
+ * @param {Object} [props]
14
+ * @param {string} [props.name='247420'] - the brand text.
15
+ * @param {*} [props.leaf] - optional trailing breadcrumb-style leaf, rendered after a " / " separator.
16
+ * @returns {*} webjsx vnode
17
+ */
18
+ export function Brand({ name = '247420', leaf } = {}) {
19
+ return h('span', { class: 'brand' }, name,
20
+ leaf ? h('span', { class: 'slash' }, ' / ') : null,
21
+ leaf || null);
22
+ }
23
+
24
+ /**
25
+ * A small pill/tag label.
26
+ *
27
+ * @param {Object} props
28
+ * @param {string} [props.tone=''] - semantic color tone (empty = neutral).
29
+ * @param {'sm'|'md'|'lg'} [props.size='md']
30
+ * @param {boolean} [props.tag=false] - true renders a rectangular sentence-case variant for dense data (drops the all-caps pill styling). Orthogonal to tone.
31
+ * @param {Function} [props.onRemove] - if given, renders a trailing dismiss (x) button that calls onRemove() on click. Omitted entirely (no button) when not supplied.
32
+ * @param {*} props.children
33
+ * @returns {*} webjsx vnode
34
+ */
35
+ export function Chip({ tone = '', size = 'md', tag = false, onRemove, children }) {
36
+ const sizeCls = size === 'sm' ? ' chip--sm' : (size === 'lg' ? ' chip--lg' : '');
37
+ return h('span', { class: 'chip' + sizeCls + (tag ? ' chip--tag' : '') + (tone ? ' tone-' + tone : '') + (onRemove ? ' ds-chip-removable' : '') },
38
+ children,
39
+ onRemove ? h('button', { type: 'button', class: 'ds-chip-remove-btn', 'aria-label': 'Remove', onclick: (e) => { e.stopPropagation(); onRemove(); } }, Icon('x')) : null);
40
+ }
41
+
42
+ /**
43
+ * The standard button/link factory. Renders an `<a>` when `href` is given,
44
+ * otherwise a `<button>`.
45
+ *
46
+ * @param {Object} props
47
+ * @param {string} [props.href] - if present, renders as a link instead of a button.
48
+ * @param {'default'|'primary'|'ghost'|'danger'} [props.variant='default']
49
+ * @param {'sm'|'md'|'lg'} [props.size='md']
50
+ * @param {*} props.children
51
+ * @param {Function} [props.onClick]
52
+ * @param {string} [props['aria-label']]
53
+ * @param {boolean} [props.primary] - legacy alias for variant:'primary', kept for backward compatibility.
54
+ * @param {boolean} [props.ghost] - legacy alias for variant:'ghost'.
55
+ * @param {boolean} [props.danger] - legacy alias for variant:'danger'.
56
+ * @param {boolean} [props.disabled]
57
+ * @param {string} [props.class] - extra class name(s) appended to the generated class list.
58
+ * @param {*} [props.key]
59
+ * @returns {*} webjsx vnode
60
+ */
61
+ export function Btn({ href, variant = 'default', size = 'md', children, onClick, 'aria-label': ariaLabel, primary, ghost, danger, disabled, class: className, key }) {
62
+ // Support legacy primary/ghost props for backward compatibility, but prefer variant
63
+ const resolvedVariant = variant !== 'default' ? variant : (primary ? 'primary' : (ghost ? 'ghost' : (danger ? 'danger' : 'default')));
64
+ // size: 'sm' | 'md' | 'lg' — md is the base .btn rule (no class); sm/lg add a
65
+ // modifier that snaps height/padding/font to the --ctl-* ladder. Unknown
66
+ // sizes fall back to md so a typo never drops the button's base styling.
67
+ const sizeCls = size === 'sm' ? ' btn-sm' : (size === 'lg' ? ' btn-lg' : '');
68
+ const cls = (resolvedVariant === 'primary' ? 'btn-primary' : (resolvedVariant === 'ghost' ? 'btn-ghost' : (resolvedVariant === 'danger' ? 'btn-primary danger' : 'btn')))
69
+ + sizeCls
70
+ + (disabled ? ' is-disabled' : '')
71
+ + (className ? ' ' + className : '');
72
+ const onclick = (e) => {
73
+ if (disabled) { e.preventDefault(); return; }
74
+ if (onClick) onClick(e);
75
+ };
76
+ const ariaName = ariaLabel || (typeof children === 'string' ? children : undefined);
77
+
78
+ // A real navigational href renders an anchor; everything else is an action
79
+ // button and renders a native <button> (correct semantics + keyboard
80
+ // activation for free, no role=button / href="#" scroll-jump hack).
81
+ // children may be a string OR an array of vnodes (e.g. icon + label); spread
82
+ // arrays so each vnode is a real child - passing the array as a single child
83
+ // produces a nested array webjsx applyDiff cannot key-diff (reading 'key').
84
+ const kids = Array.isArray(children) ? children : [children];
85
+ const isLink = href != null && href !== '' && href !== '#';
86
+ if (isLink) {
87
+ return h('a', {
88
+ key,
89
+ class: cls, href,
90
+ 'aria-label': ariaName,
91
+ 'aria-disabled': disabled ? 'true' : null,
92
+ tabindex: disabled ? '-1' : null,
93
+ onclick
94
+ }, ...kids);
95
+ }
96
+ return h('button', {
97
+ key,
98
+ type: 'button', class: cls,
99
+ disabled: disabled ? true : null,
100
+ 'aria-label': ariaName,
101
+ onclick
102
+ }, ...kids);
103
+ }
104
+
105
+ export function IconButton({ icon, onClick, title, size = 'base', variant = 'ghost', disabled = false }) {
106
+ const cls = 'ds-icon-btn ds-icon-btn-' + variant + ' ds-icon-btn-' + size + (disabled ? ' is-disabled' : '');
107
+ return h('button', {
108
+ type: 'button',
109
+ class: cls,
110
+ title,
111
+ 'aria-label': title,
112
+ disabled: disabled ? true : null,
113
+ onclick: (e) => { if (disabled) { e.preventDefault(); return; } if (onClick) onClick(e); }
114
+ }, Glyph({ children: icon, size }));
115
+ }
116
+
117
+ export function Badge({ children, variant = 'default', tone = 'neutral', size = 'md' }) {
118
+ // size: 'sm' | 'md' | 'lg' — md is the base 18px badge.
119
+ const sizeCls = size === 'sm' ? ' ds-badge--sm' : (size === 'lg' ? ' ds-badge--lg' : '');
120
+ return h('span', { class: 'ds-badge ds-badge-' + variant + sizeCls + ' tone-' + tone }, children);
121
+ }
122
+
123
+ // Pill — plain non-interactive label chip for tag-like annotations (a phase
124
+ // name, an id, a subsystem tag). Distinct from Chip (status-tone indicator),
125
+ // Badge (count/variant marker), and FilterPills (interactive toggle-group):
126
+ // Pill renders no button, carries no pressed/active state, just a small
127
+ // rounded label. tone is a semantic keyword ('' | 'accent' | 'muted'),
128
+ // never a raw color — every visual rides colors_and_type.css tokens.
129
+ export function Pill({ tone = '', children, key } = {}) {
130
+ return h('span', { key, class: 'ds-pill' + (tone ? ' tone-' + tone : '') }, children);
131
+ }
132
+
133
+ export function Glyph({ children, color, size = 'base', label } = {}) {
134
+ // Font-size is var-driven per size class (--glyph-size-{size}) so themes can
135
+ // retune glyph scale; inline fallback keeps sizing if the SDK CSS hasn't
136
+ // loaded yet. Size class is the stable hook (glyph-sm / glyph-base / glyph-lg).
137
+ const fallback = size === 'sm' ? '11px' : (size === 'lg' ? '16px' : '13px');
138
+ const cls = 'glyph glyph-' + size;
139
+ const style = `font-size:var(--glyph-size-${size}, ${fallback})` + (color ? `;color:${color}` : '');
140
+ // Decorative by default (screen readers skip the glyph char). Pass `label`
141
+ // to expose an accessible name instead.
142
+ return h('span', label
143
+ ? { class: cls, style, role: 'img', 'aria-label': label }
144
+ : { class: cls, style, 'aria-hidden': 'true' }, children);
145
+ }
146
+
147
+ export function Heading({ level = 1, children, style = '', class: className = '', 'aria-level': ariaLevel }) {
148
+ return h('h' + level, { class: className || null, style, 'aria-level': ariaLevel != null ? String(ariaLevel) : null }, children);
149
+ }
150
+
151
+ export function Lede({ children }) {
152
+ return h('p', { class: 'lede' }, children);
153
+ }
154
+
155
+ export function Dot({ tone = 'on' }) {
156
+ const isOn = tone === 'on' || tone === 'live';
157
+ const cls = 'ds-dot ' + (isOn ? 'ds-dot-on' : 'ds-dot-off');
158
+ const statusLabel = isOn ? 'on status indicator' : 'off status indicator';
159
+ // Drawn as a CSS circle (.ds-dot) — no decorative text glyph.
160
+ return h('span', { class: cls, role: 'img', 'aria-label': statusLabel });
161
+ }
162
+
163
+ export function Rail({ tone = 'green' }) {
164
+ return h('span', { class: 'ds-rail tone-' + tone, 'aria-hidden': 'true' });
165
+ }
@@ -0,0 +1,123 @@
1
+ // Monochrome inline-SVG icon set. Single source for the line-icon vocabulary
2
+ // AGENTS.md mandates in place of decorative unicode glyphs: extend ICON_PATHS
3
+ // to add a name (an out-of-set name renders an EMPTY span, a silent bug).
4
+ // Two renderers share one attribute contract — Icon() for webjsx render
5
+ // scopes, iconMarkup() for raw-DOM consumers that assign innerHTML.
6
+
7
+ import * as webjsx from '../../../vendor/webjsx/index.js';
8
+ const h = webjsx.createElement;
9
+
10
+ // Monochrome inline-SVG icons (stroke=currentColor) so chrome reads as one
11
+ // coherent line-icon set instead of multicolor OS emoji. 16px box, 1.6 stroke.
12
+ export const ICON_PATHS = {
13
+ lock: '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
14
+ mic: '<path d="M12 3a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>',
15
+ 'mic-off': '<path d="M9 9v2a3 3 0 0 0 4.5 2.6M15 11V6a3 3 0 0 0-5.9-.8"/><path d="M5 11a7 7 0 0 0 11.5 5.4M12 18v3"/><path d="m4 4 16 16"/>',
16
+ speaker: '<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/>',
17
+ 'speaker-off': '<path d="M11 5 6 9H3v6h3l5 4z"/><path d="m17 9 4 6M21 9l-4 6"/>',
18
+ camera: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3z"/>',
19
+ screen: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
20
+ phone: '<path d="M5 4h3l2 5-2 1a11 11 0 0 0 5 5l1-2 5 2v3a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z"/>',
21
+ members: '<circle cx="9" cy="8" r="3"/><path d="M3 20a6 6 0 0 1 12 0M16 6a3 3 0 0 1 0 6M21 20a6 6 0 0 0-4-5.7"/>',
22
+ menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
23
+ settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1 7 17M17 7l2.1-2.1"/>',
24
+ paperclip: '<path d="M21 11.5 12.5 20a5 5 0 0 1-7-7l8-8a3.5 3.5 0 0 1 5 5l-8 8a2 2 0 0 1-3-3l7.5-7.5"/>',
25
+ smile: '<circle cx="12" cy="12" r="9"/><path d="M8 14a4 4 0 0 0 8 0"/><path d="M9 9h.01M15 9h.01"/>',
26
+ 'more-horizontal': '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
27
+ 'arrow-up': '<path d="M12 19V5M5 12l7-7 7 7"/>',
28
+ send: '<path d="M22 2 11 13M22 2l-7 20-4-9-9-4z"/>',
29
+ hash: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>',
30
+ megaphone: '<path d="M3 11v2a1 1 0 0 0 1 1h2l5 4V6L6 10H4a1 1 0 0 0-1 1z"/><path d="M15 8a4 4 0 0 1 0 8M18 5a8 8 0 0 1 0 14"/>',
31
+ forum: '<path d="M4 5h13a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/>',
32
+ page: '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M8 13h8M8 17h6"/>',
33
+ thread: '<path d="M5 6h14M5 11h14M5 16h8"/><circle cx="17" cy="17" r="3"/>',
34
+ // status / control icons (replace decorative text glyphs at the source)
35
+ check: '<path d="M20 6 9 17l-5-5"/>',
36
+ 'check-check': '<path d="M18 6 7 17l-3-3"/><path d="m22 10-7.5 7.5L13 16"/>',
37
+ 'chevron-right': '<path d="m9 6 6 6-6 6"/>',
38
+ 'chevron-down': '<path d="m6 9 6 6 6-6"/>',
39
+ 'chevron-up': '<path d="m6 15 6-6 6 6"/>',
40
+ 'arrow-down': '<path d="M12 5v14M5 12l7 7 7-7"/>',
41
+ 'arrow-right': '<path d="M5 12h14M12 5l7 7-7 7"/>',
42
+ x: '<path d="M18 6 6 18M6 6l12 12"/>',
43
+ plus: '<path d="M12 5v14M5 12h14"/>',
44
+ play: '<path d="M6 4v16l14-8z"/>',
45
+ pause: '<path d="M8 5v14M16 5v14"/>',
46
+ refresh: '<path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/>',
47
+ circle: '<circle cx="12" cy="12" r="9"/>',
48
+ 'circle-dot': '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3" fill="currentColor"/>',
49
+ dot: '<circle cx="12" cy="12" r="4" fill="currentColor"/>',
50
+ square: '<rect x="4" y="4" width="16" height="16" rx="2"/>',
51
+ activity: '<path d="M3 12h4l3 8 4-16 3 8h4"/>',
52
+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
53
+ help: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 4.9.8c0 1.7-2.4 2-2.4 3.7M12 17h.01"/>',
54
+ warn: '<path d="M10.3 4 2.7 17a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/>',
55
+ // file-type icons (replace the FILE_GLYPHS unicode set)
56
+ 'file-pdf': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h6"/>',
57
+ 'file-zip': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="M11 4v3M11 9v3M11 14v3"/>',
58
+ 'file-video': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="m10 12 4 2.5L10 17z"/>',
59
+ 'file-audio': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="M9 17v-3l4-1v3"/><circle cx="8" cy="17" r="1"/><circle cx="12" cy="16" r="1"/>',
60
+ 'file-sheet': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="M8 13h8M8 17h8M12 11v8"/>',
61
+ 'file-code': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="m10 12-2 2 2 2M14 12l2 2-2 2"/>',
62
+ 'file-text': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="M8 13h8M8 17h6"/>',
63
+ file: '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/>',
64
+ pencil: '<path d="M4 20h4L19 9a2 2 0 0 0-3-3L5 17z"/><path d="M14 6l3 3"/>',
65
+ 'skip-forward': '<path d="M5 5v14l9-7z"/><path d="M19 5v14"/>',
66
+ 'chevron-left': '<path d="m15 6-6 6 6 6"/>',
67
+ trash: '<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/>',
68
+ 'external-link': '<path d="M14 4h6v6M20 4l-9 9M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6"/>',
69
+ // theme-toggle icons (replace decorative sun/moon/contrast text glyphs)
70
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
71
+ moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>',
72
+ contrast: '<circle cx="12" cy="12" r="9"/><path d="M12 3v18a9 9 0 0 0 0-18z" fill="currentColor"/>',
73
+ // file-browser icons (replace folder/file emoji + arrow glyphs in fs apps)
74
+ folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
75
+ 'folder-open': '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2H5l-2 9z"/><path d="M3 18l2-9h17l-2 9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
76
+ // density-picker icons (list / compact / thumbnail view modes)
77
+ rows: '<path d="M4 6h16M4 12h16M4 18h16"/>',
78
+ 'rows-tight': '<path d="M4 5h16M4 9h16M4 13h16M4 17h16"/>',
79
+ grid: '<rect x="4" y="4" width="7" height="7" rx="1"/><rect x="13" y="4" width="7" height="7" rx="1"/><rect x="4" y="13" width="7" height="7" rx="1"/><rect x="13" y="13" width="7" height="7" rx="1"/>',
80
+ 'file-image': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><circle cx="9.5" cy="12.5" r="1.5"/><path d="M18 19l-4-4-3 3-2-2-3 3"/>',
81
+ link: '<path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/>',
82
+ upload: '<path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/>',
83
+ download: '<path d="M12 4v12M7 11l5 5 5-5"/><path d="M5 20h14"/>',
84
+ 'corner-up-left': '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 5 5v6"/>',
85
+ // clipboard/copy — for the per-block code copy + message copy action, so the
86
+ // copy affordance reads as copy, not the lined-document `page` glyph.
87
+ copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/>',
88
+ clipboard: '<rect x="8" y="4" width="8" height="4" rx="1"/><path d="M8 6H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-2"/>'
89
+ };
90
+
91
+ // The single SVG attribute contract (viewBox/stroke/linecap…) shared by both
92
+ // the markup-string and the vnode renderers below, so the icon shape is defined
93
+ // once. Insertion order is the serialized attribute order iconMarkup emits.
94
+ function iconAttrs(name, size) {
95
+ return {
96
+ class: 'ds-icon ds-icon-' + name,
97
+ width: String(size), height: String(size), viewBox: '0 0 24 24',
98
+ fill: 'none', stroke: 'currentColor', 'stroke-width': 'var(--ds-icon-stroke, 1.6)',
99
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true',
100
+ };
101
+ }
102
+ // Normalize the (name) | ({name,size}) call shapes both renderers accept.
103
+ function iconArgs(name, size) {
104
+ if (name && typeof name === 'object') ({ name, size = 16 } = name);
105
+ return { name, size };
106
+ }
107
+ // Raw-DOM consumers (no webjsx render in scope) need the SVG as a markup string
108
+ // rather than an h() vnode. Same path table + attr contract as Icon(); use
109
+ // innerHTML = iconMarkup(name). Keeps the icon paths upstream so raw-DOM call
110
+ // sites never reintroduce decorative glyph literals.
111
+ export function iconMarkup(name, { size = 16 } = {}) {
112
+ ({ name, size } = iconArgs(name, size));
113
+ const inner = ICON_PATHS[name];
114
+ if (!inner) return '';
115
+ const attrs = Object.entries(iconAttrs(name, size)).map(([k, v]) => `${k}="${v}"`).join(' ');
116
+ return `<svg ${attrs}>${inner}</svg>`;
117
+ }
118
+ export function Icon(name, { size = 16 } = {}) {
119
+ ({ name, size } = iconArgs(name, size));
120
+ const inner = ICON_PATHS[name];
121
+ if (!inner) return h('span', { class: 'glyph', 'aria-hidden': 'true' }, '');
122
+ return h('svg', { ...iconAttrs(name, size), dangerouslySetInnerHTML: { __html: inner } });
123
+ }