anentrypoint-design 0.0.381 → 0.0.382

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.
Files changed (49) hide show
  1. package/dist/247420.css +228 -162
  2. package/dist/247420.js +24 -24
  3. package/package.json +1 -1
  4. package/src/components/content/avatar.js +31 -0
  5. package/src/components/content/charts.js +50 -0
  6. package/src/components/content/cli.js +46 -0
  7. package/src/components/content/feedback.js +77 -0
  8. package/src/components/content/fields.js +136 -0
  9. package/src/components/content/hero.js +146 -0
  10. package/src/components/content/lists.js +85 -0
  11. package/src/components/content/panel.js +79 -0
  12. package/src/components/content/row.js +115 -0
  13. package/src/components/content/table.js +97 -0
  14. package/src/components/content/views.js +81 -0
  15. package/src/components/content.js +29 -861
  16. package/src/components/editor-primitives/batch.js +60 -0
  17. package/src/components/editor-primitives/chrome.js +146 -0
  18. package/src/components/editor-primitives/collapse.js +46 -0
  19. package/src/components/editor-primitives/context-menu.js +127 -0
  20. package/src/components/editor-primitives/diagnostics.js +44 -0
  21. package/src/components/editor-primitives/focus-trap.js +26 -0
  22. package/src/components/editor-primitives/json-viewer.js +123 -0
  23. package/src/components/editor-primitives/layout.js +89 -0
  24. package/src/components/editor-primitives/modals.js +89 -0
  25. package/src/components/editor-primitives/pager.js +74 -0
  26. package/src/components/editor-primitives/property-grid.js +57 -0
  27. package/src/components/editor-primitives/shared.js +18 -0
  28. package/src/components/editor-primitives/split-panel.js +101 -0
  29. package/src/components/editor-primitives/toast.js +59 -0
  30. package/src/components/editor-primitives/tree.js +75 -0
  31. package/src/components/editor-primitives.js +35 -1048
  32. package/src/components/overlay-primitives/approval-prompt.js +38 -0
  33. package/src/components/overlay-primitives/auth-modal.js +89 -0
  34. package/src/components/overlay-primitives/command-palette.js +101 -0
  35. package/src/components/overlay-primitives/emoji-picker.js +103 -0
  36. package/src/components/overlay-primitives/floating.js +146 -0
  37. package/src/components/overlay-primitives/full-screen.js +45 -0
  38. package/src/components/overlay-primitives/menus.js +131 -0
  39. package/src/components/overlay-primitives/popover.js +51 -0
  40. package/src/components/overlay-primitives/roving-menu.js +73 -0
  41. package/src/components/overlay-primitives/settings-popover.js +85 -0
  42. package/src/components/overlay-primitives/tooltip.js +55 -0
  43. package/src/components/overlay-primitives.js +33 -855
  44. package/src/css/app-shell/chat-polish.css +39 -32
  45. package/src/css/app-shell/data-density.css +25 -21
  46. package/src/css/app-shell/files.css +37 -29
  47. package/src/css/app-shell/kits-appended.css +73 -50
  48. package/src/css/app-shell/responsive.css +47 -29
  49. package/src/kits/os/freddie-dashboard.css +2 -2
@@ -1,865 +1,33 @@
1
1
  // Content blocks: Panel, Row, RowLink, Section, Hero, Install, Receipt,
2
2
  // Changelog, WorksList, WritingList, Manifesto, Kpi, Table, HomeView,
3
3
  // ProjectView, Form. Pure factories.
4
-
5
- import * as webjsx from '../../vendor/webjsx/index.js';
6
- import { Btn, Heading, Lede, Dot, Icon, Chip } from './shell.js';
7
- const h = webjsx.createElement;
8
-
9
- // Avatar — generic identity disc: an image when `src` resolves, else a
10
- // letter fallback derived from `name`/`fallback`. Kit previously only had
11
- // scoped one-offs (chat.js `.chat-avatar`, community.js `.cm-user-avatar`)
12
- // duplicating this same letter-fallback logic; this is the reusable version.
13
- // avatarInitial — the single shared letter-fallback computation behind
14
- // Avatar and every custom-colored avatar wrapper (community.js's voice/user/
15
- // member rows use their own `--avatar-bg` CSS-variable styling and can't
16
- // drop in the Avatar element directly, but still want the SAME fallback
17
- // text, not their own independently-drifting .slice(0,n).toUpperCase()).
18
- // Empty-guards to '?' identically everywhere it's used.
19
- export function avatarInitial(name, count = 1) {
20
- return name ? String(name).trim().slice(0, count).toUpperCase() || '?' : '?';
21
- }
22
-
23
- // Avatar — the single letter-fallback/image avatar primitive. `initialsCount`
24
- // (default 1) controls how many leading characters of `name` become the
25
- // fallback letters when no `src`/`fallback` is given (community.js's
26
- // pill-shaped ServerIcon wants 2); `shape` ('circle' default, or 'square')
27
- // covers non-circular consumers without each hand-rolling its own
28
- // .slice(0,n).toUpperCase() (previously duplicated across 5+ call sites in
29
- // community.js and chat.js with drifting char-counts/empty-guards).
30
- export function Avatar({ name, src, fallback, size = 'md', shape = 'circle', initialsCount = 1, key } = {}) {
31
- const letter = fallback != null ? fallback : avatarInitial(name, initialsCount);
32
- const cls = 'ds-avatar ds-avatar-' + size + (shape === 'square' ? ' ds-avatar-square' : '');
33
- if (src) return h('img', { key, class: cls, src, alt: name || '', loading: 'lazy' });
34
- return h('span', { key, class: cls, 'aria-hidden': !!name, role: name ? 'img' : undefined, 'aria-label': name || undefined }, letter);
35
- }
36
-
37
- export function Panel({ title, count, right, style = '', class: className = '', children, kind, id }) {
38
- const cls = 'panel' + (kind ? ' panel-' + kind : '') + (className ? ' ' + className : '');
39
- return h('div', { class: cls, style, id: id || null },
40
- title != null ? h('div', { class: 'panel-head' },
41
- h('span', {}, title),
42
- right != null ? right : (count != null ? h('span', {}, String(count)) : null)
43
- ) : null,
44
- h('div', { class: 'panel-body' }, ...(Array.isArray(children) ? children : [children]))
45
- );
46
- }
47
-
48
- // Card — semantic alias of Panel; behaves identically.
49
- export const Card = Panel;
50
-
51
- // Split a title string around case-insensitive matches of `highlight`, wrapping
52
- // hits in <mark class="ds-hl">. Every segment is a keyed span so the children
53
- // array never mixes keyed VElements with bare strings (webjsx applyDiff crashes
54
- // on mixed keying).
55
- function highlightTitle(title, highlight) {
56
- const text = String(title);
57
- const needle = String(highlight).toLowerCase();
58
- if (!needle) return text;
59
- const lower = text.toLowerCase();
60
- const segs = [];
61
- let pos = 0, n = 0;
62
- while (pos <= text.length) {
63
- const hit = lower.indexOf(needle, pos);
64
- if (hit === -1) break;
65
- if (hit > pos) segs.push(h('span', { key: 'hs' + n++ }, text.slice(pos, hit)));
66
- segs.push(h('mark', { key: 'hs' + n++, class: 'ds-hl' }, text.slice(hit, hit + needle.length)));
67
- pos = hit + needle.length;
68
- }
69
- if (!segs.length) return text;
70
- if (pos < text.length) segs.push(h('span', { key: 'hs' + n++ }, text.slice(pos)));
71
- return segs;
72
- }
73
-
74
- export function Row({ code, rank, title, sub, meta, active, state = 'default', onClick, key, style, href, kind, cols, leading, trailing, target, selected, rail, expanded, highlight, actions, detail }) {
75
- // `rank` is an alias for `code` (the leading monospace index); callers use
76
- // either name. `rail` renders a thin colour bar at the row's leading edge as
77
- // a status indicator (tone: green | purple | flame | <any token>).
78
- const codeVal = code != null ? code : rank;
79
- // Support legacy active/selected props for backward compatibility
80
- const isActive = state === 'active' || (state === 'default' && (active || selected));
81
- const isLink = kind === 'link' || (href != null && !onClick);
82
- const isButton = !isLink && !!onClick;
83
- const stateCls = state === 'disabled' ? ' row-state-disabled' : (state === 'error' ? ' row-state-error' : '');
84
- // With no leading/code, the title would otherwise land in the narrow code
85
- // column and wrap; `row-nocode` collapses that column so the title gets the
86
- // full width (meta still pinned right).
87
- const noLead = codeVal == null && leading == null;
88
- const cls = 'row' + (isActive ? ' active' : '') + stateCls + (cols ? ' row-grid' : '') + (noLead && !cols ? ' row-nocode' : '') + (rail ? ' rail-' + rail : '');
89
- const isDisabled = state === 'disabled';
90
- const props = { key, class: cls, style: cols ? `${style ? style + ';' : ''}grid-template-columns:${cols}` : style };
91
- if (isLink) {
92
- props.href = href || '#';
93
- if (target) props.target = target;
94
- } else if (isButton && !isDisabled) {
95
- // Clickable div needs button semantics + keyboard activation for a11y parity.
96
- // A disabled row is inert: no click, no button role, no tab stop.
97
- props.onclick = onClick;
98
- props.role = 'button';
99
- props.tabindex = '0';
100
- props.onkeydown = (e) => {
101
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(e); }
102
- };
103
- // When the row is a disclosure toggle (host passes a boolean `expanded`),
104
- // announce its open/closed state so AT users hear "expanded/collapsed".
105
- // Omitted entirely for plain action buttons (expanded === undefined).
106
- if (expanded === true || expanded === false) props['aria-expanded'] = expanded ? 'true' : 'false';
107
- }
108
- if (isDisabled) props['aria-disabled'] = 'true';
109
- if (isActive && (isLink || isButton)) props['aria-current'] = isActive ? 'page' : null;
110
- // `highlight` wraps case-insensitive matches in the title in <mark class="ds-hl">.
111
- // The segments live inside a single wrapper span so the title's child list
112
- // never mixes keyed and unkeyed siblings.
113
- const titleNode = (highlight && typeof title === 'string')
114
- ? h('span', {}, ...[].concat(highlightTitle(title, highlight)))
115
- : title;
116
- // `actions` render ONLY when the row is expanded, as a sibling action strip
117
- // inside the row container; each button stops propagation so it never fires
118
- // the row onClick.
119
- const actionRow = (expanded === true && Array.isArray(actions) && actions.length)
120
- ? h('span', { class: 'row-actions', role: 'group', 'aria-label': 'row actions' },
121
- ...actions.map((a, i) => h('button', {
122
- key: 'ract' + i,
123
- type: 'button',
124
- class: 'row-act',
125
- title: a.title || a.label,
126
- 'aria-label': a.title || a.label,
127
- onclick: (e) => { e.stopPropagation(); a.onClick && a.onClick(e); },
128
- onkeydown: (e) => { e.stopPropagation(); },
129
- }, a.label)))
130
- : null;
131
- // Color is not the only status channel: emit a visually-hidden word for the
132
- // meaningful rail tones (error/subagent) so AT and color-blind users get the
133
- // state. green is the unremarkable default - announcing "ok" everywhere would
134
- // be AT noise - so it emits nothing.
135
- const railWord = rail === 'flame' ? 'error' : rail === 'purple' ? 'subagent' : null;
136
- // `detail` renders as a sibling block AFTER the title/meta children (its own
137
- // line via flex-basis:100% in .ds-row-detail), not inside the title span.
138
- // The same `highlight` search term that marks matches in the collapsed
139
- // title previously stopped applying the moment a row expanded - the
140
- // expanded body (often the ONLY place a match beyond the 220-char title
141
- // window is actually visible) rendered as plain unmarked text.
142
- const detailNode = (highlight && typeof detail === 'string')
143
- ? h('span', {}, ...[].concat(highlightTitle(detail, highlight)))
144
- : detail;
145
- return h(isLink ? 'a' : 'div', props,
146
- railWord ? h('span', { class: 'sr-only' }, railWord) : null,
147
- leading != null ? leading : (codeVal != null ? h('span', { class: 'code' }, codeVal) : null),
148
- h('span', { class: 'title', title: typeof title === 'string' ? title : undefined }, titleNode, sub ? h('span', { class: 'sub', title: typeof sub === 'string' ? sub : undefined }, sub) : null),
149
- trailing != null ? trailing : (meta != null ? h('span', { class: 'meta' }, meta) : null),
150
- actionRow,
151
- detail != null ? h('pre', { class: 'ds-row-detail' }, detailNode) : null);
152
- }
153
-
154
- export function RowLink({ code, title, sub, meta, href = '#', key, target }) {
155
- return Row({ code, title, sub, meta, href, kind: 'link', key, target });
156
- }
157
-
158
- // PanelFromItems — the shared 'items[] -> RowLink wrapped in Panel' mapper
159
- // every portfolio consumer theme.mjs (zellous/wireweave/thebird/247420) had
160
- // hand-rolled identically: items.map((it,i) => RowLink({code, title, sub,
161
- // meta, href})) inside a titled Panel. `keyPrefix` seeds each row's stable
162
- // key (`${keyPrefix}${i}`), matching the consumer convention of a
163
- // one-letter-per-section prefix (e.g. 'f' for features, 'm' for modules).
164
- // Field aliasing mirrors the union of shapes actually hand-rolled downstream:
165
- // title reads `title` then `name`; sub reads `sub` then `desc`; code falls
166
- // back to a zero-padded 1-based index when the item carries none. `heading`/
167
- // `count`/`style`/`kind` pass through to Panel unchanged.
168
- export function PanelFromItems({ heading, items = [], keyPrefix = 'i', count, style, kind, emptyText } = {}) {
169
- if (!items || !items.length) return emptyText != null ? h('div', { class: 'empty' }, emptyText) : null;
170
- const rows = items.map((it, i) => {
171
- const codeVal = it.code != null ? it.code : (it.rank != null ? it.rank : String(i + 1).padStart(2, '0'));
172
- return RowLink({
173
- key: keyPrefix + i,
174
- code: codeVal,
175
- title: it.title != null ? it.title : it.name,
176
- sub: it.sub != null ? it.sub : (it.desc != null ? it.desc : ''),
177
- meta: it.meta != null ? it.meta : '',
178
- href: it.href || '#'
179
- });
180
- });
181
- return Panel({ title: heading, count, style, kind, children: rows });
182
- }
183
-
184
- export function Section({ title, eyebrow, children, id }) {
185
- return h('section', { class: 'ds-section', id: id || null },
186
- eyebrow ? h('span', { class: 'eyebrow' }, eyebrow) : null,
187
- title ? h('h3', {}, title) : null,
188
- ...(Array.isArray(children) ? children : [children])
189
- );
190
- }
191
-
192
- export function Hero({ eyebrow, title, body, accent, actions, badges }) {
193
- // Eyebrow + title share the title grid-area so the named-area layout stays
194
- // intact; body occupies the wide left column, badges + actions stack in
195
- // the narrow right column so it carries real visual weight instead of
196
- // sitting empty beside the body copy.
197
- const badgeList = Array.isArray(badges) ? badges.filter(Boolean) : [];
198
- const badgeRow = badgeList.length
199
- ? h('div', { class: 'ds-hero-stats' }, ...badgeList.map((b, i) =>
200
- h('span', { key: 'hb' + i, class: 'ds-hero-stat' }, String(b && b.label != null ? b.label : b))))
201
- : null;
202
- const actionRow = actions ? h('div', { class: 'ds-hero-actions' }, ...(Array.isArray(actions) ? actions : [actions])) : null;
203
- const aside = (badgeRow || actionRow) ? h('div', { class: 'ds-hero-aside' }, badgeRow, actionRow) : null;
204
- return h('div', { class: 'ds-hero' },
205
- h('div', { class: 'ds-hero-head' },
206
- eyebrow ? h('span', { class: 'eyebrow' }, eyebrow) : null,
207
- h('h1', { class: 'ds-hero-title' }, title)
208
- ),
209
- body ? h('p', { class: 'ds-hero-body' },
210
- body,
211
- accent ? h('span', { class: 'ds-hero-accent' }, ' ' + accent) : null
212
- ) : null,
213
- aside
214
- );
215
- }
216
-
217
- // HeroFromPageData — a single factory for the "hero block driven by a page-data
218
- // object" shape that recurs, independently hand-rolled, across every flatspace
219
- // consumer theme.mjs (heading/subheading/body/badges/ctas/install all read off
220
- // a `hero` object parsed from the `__site__` JSON script tag). Consumers differ
221
- // only in which fields their content YAML populates; this factory renders every
222
- // field it is given and omits what is absent, so it is a drop-in for the
223
- // narrowest (heading+body only) or richest (badges+ctas+install) hero shape
224
- // alike. Returns null on a falsy `hero` so callers can write
225
- // `HeroFromPageData(page.hero)` unconditionally, matching the existing
226
- // `!home.hero ? null : ...` guard every hand-rolled version repeats.
227
4
  //
228
- // Shape: { heading, title, subheading, body, accent, badges, ctas, install }
229
- // heading/title — hero <h1> text (heading wins if both given)
230
- // subheading a Lede-style standalone line above `body`
231
- // body — the hero paragraph
232
- // accent — a muted trailing aside appended to `body`
233
- // badges — [{label, desc}] or [string], rendered as a stat strip
234
- // ctas — [{label, href, primary}], rendered as Btn-equivalent links
235
- // install — a single install command string, rendered as a `.cli` block
236
- export function HeroFromPageData(hero) {
237
- if (!hero) return null;
238
- const heading = hero.heading || hero.title || '';
239
- const badges = Array.isArray(hero.badges) ? hero.badges.filter(Boolean) : [];
240
- const ctas = Array.isArray(hero.ctas) ? hero.ctas.filter(Boolean) : [];
241
- const badgeRow = badges.length
242
- ? h('div', { class: 'ds-hero-stats' }, ...badges.map((b, i) =>
243
- h('span', { key: 'hb' + i, class: 'ds-hero-stat' },
244
- h('strong', { class: 'ds-hero-stat-n' }, String(b && b.label != null ? b.label : b)),
245
- (b && b.desc) ? h('span', { class: 'ds-hero-stat-l' }, String(b.desc)) : null,
246
- )))
247
- : null;
248
- const ctaRow = ctas.length
249
- ? h('div', { class: 'ds-hero-actions' }, ...ctas.map((c, i) =>
250
- h('a', {
251
- key: 'hc' + i,
252
- class: (c.primary || i === 0) ? 'btn btn-accent' : 'btn btn-ghost',
253
- href: c.href || '#',
254
- }, c.label || c.cta || 'go')))
255
- : null;
256
- const installRow = hero.install
257
- ? h('div', { class: 'cli' },
258
- h('span', { class: 'prompt' }, '$'),
259
- h('span', { class: 'cmd' }, hero.install))
260
- : null;
261
- return h('div', { class: 'ds-hero' },
262
- h('div', { class: 'ds-hero-head' },
263
- hero.eyebrow ? h('span', { class: 'eyebrow' }, hero.eyebrow) : null,
264
- h('h1', { class: 'ds-hero-title' }, heading)
265
- ),
266
- hero.subheading ? h('p', { class: 'ds-hero-body lede' }, hero.subheading) : null,
267
- hero.body ? h('p', { class: 'ds-hero-body' },
268
- hero.body,
269
- hero.accent ? h('span', { class: 'ds-hero-accent' }, ' ' + hero.accent) : null,
270
- ) : null,
271
- (badgeRow || ctaRow || installRow)
272
- ? h('div', { class: 'ds-hero-aside' }, badgeRow, installRow, ctaRow)
273
- : null,
274
- );
275
- }
276
-
277
- export function Marquee({ items = [], sep = '/' }) {
278
- // No items -> no ticker: an empty marquee still paints its border-block
279
- // rules as an unexplained full-width stripe.
280
- if (!items.length) return null;
281
- // Two identical runs make the -50% translate loop seamless. Each text and
282
- // separator is a keyed span so webjsx applyDiff never sees a primitive
283
- // sibling beside a keyed VElement.
284
- const run = (runKey) => items.flatMap((it, i) => [
285
- h('span', { class: 'ds-marquee-item', key: `${runKey}-i${i}` }, it),
286
- h('span', { class: 'ds-marquee-sep', key: `${runKey}-s${i}`, 'aria-hidden': 'true' }, sep),
287
- ]);
288
- return h('div', { class: 'ds-marquee', role: 'marquee' },
289
- h('div', { class: 'ds-marquee-track' }, ...run('a'), ...run('b'))
290
- );
291
- }
292
-
293
- export function Install({ cmd, copied, onCopy }) {
294
- return h('div', { class: 'cli' },
295
- h('span', { class: 'prompt' }, '$'),
296
- h('span', { class: 'cmd' }, cmd),
297
- h('span', { class: 'copy', onclick: () => onCopy && onCopy(cmd) }, copied ? 'copied' : 'copy')
298
- );
299
- }
300
-
301
- // CliBlock — the shared 'quickstart.lines[] -> stacked CLI block' renderer
302
- // every portfolio consumer theme.mjs (zellous/wireweave/247420) had hand-rolled
303
- // identically: lines.map((l,i) => a div per line holding a prompt span ('$' or
304
- // '#' for a comment line) and a cmd span, all wrapped in a Panel. This factory
305
- // targets the multi-line `.ds-cli-block` contract defined in gm-prose.css
306
- // (`.ds-cli-block` holding `.ds-cli-row` rows — each a prompt+cmd pair — and
307
- // `.ds-cli-comment` comment rows). `lines` is [{kind, text}] where kind: 'cmt' renders a
308
- // comment-only row (no prompt glyph); any other kind (or omitted) renders a
309
- // command row prefixed '$'. `heading` titles the wrapping Panel ('quick start'
310
- // default, matching every hand-rolled instance); pass `heading: null` to
311
- // render the bare `.ds-cli-block` block with no Panel chrome.
312
- // Note: this is a different component than the bare `.cli` single prompt+cmd
313
- // row primitive (app-shell.css; see Install() above and the per-line usage
314
- // in terminal/site quickstart renderers) — the two used to collide on the
315
- // same `.cli` class name with incompatible display models.
316
- export function CliBlock({ lines = [], heading = 'quick start', className = '' } = {}) {
317
- if (!lines || !lines.length) return null;
318
- const rows = lines.map((l, i) => {
319
- const isComment = l && l.kind === 'cmt';
320
- const text = l && l.text != null ? l.text : '';
321
- return isComment
322
- ? h('div', { key: 'q' + i, class: 'ds-cli-comment' }, text)
323
- : h('div', { key: 'q' + i, class: 'ds-cli-row' },
324
- h('span', { class: 'prompt' }, '$'),
325
- h('span', { class: 'cmd' }, text));
326
- });
327
- const body = h('div', { class: 'ds-cli-block' + (className ? ' ' + className : '') }, ...rows);
328
- return heading == null ? body : Panel({ title: heading, children: body });
329
- }
330
-
331
- export function Receipt({ rows = [], emptyText = 'nothing here yet' }) {
332
- if (!rows.length) return h('div', { class: 'empty' }, emptyText);
333
- return h('table', { class: 'kv' },
334
- h('tbody', {}, ...rows.map(([k, v], i) =>
335
- h('tr', { key: i }, h('td', {}, k), h('td', {}, v))
336
- ))
337
- );
338
- }
339
-
340
- export function Changelog({ entries = [], emptyText = 'no changelog entries yet' }) {
341
- if (!entries.length) return h('div', { class: 'empty' }, emptyText);
342
- return Panel({
343
- kind: 'wide',
344
- children: entries.map((e, i) =>
345
- h('div', { key: i, class: 'row ds-changelog-row' },
346
- h('span', { class: 'code' }, e.date),
347
- h('span', { class: 'ds-changelog-ver' }, e.ver),
348
- h('span', { class: 'title' }, e.msg)
349
- )
350
- )
351
- });
352
- }
353
-
354
- export function WorksList({ works = [], openedIndex = -1, onToggle }) {
355
- return Panel({
356
- children: works.map((w, i) => {
357
- const isOpen = openedIndex === i;
358
- return h('div', { key: i },
359
- Row({
360
- code: w.code,
361
- title: w.title, sub: w.sub,
362
- // Expand affordance: a chevron icon (down when open, right when
363
- // collapsed) separated from the meta text by a CSS gap, not a
364
- // literal +/- with a double-space.
365
- meta: h('span', { class: 'ds-works-meta' },
366
- w.meta != null ? h('span', {}, w.meta) : null,
367
- Icon(isOpen ? 'chevron-down' : 'chevron-right')),
368
- active: isOpen,
369
- expanded: isOpen,
370
- onClick: () => onToggle && onToggle(isOpen ? -1 : i)
371
- }),
372
- isOpen ? h('div', { class: 'work-detail', 'data-work-index': String(i) },
373
- h('div', { class: 'ds-prose' },
374
- h('p', { class: 'ds-work-body' }, w.body)
375
- ),
376
- h('div', { class: 'ds-work-actions' },
377
- Btn({ variant: 'primary', href: w.href || '#', children: 'open ->' }),
378
- Btn({ href: w.source || '#', children: 'source' })
379
- )
380
- ) : null
381
- );
382
- })
383
- });
384
- }
385
-
386
- export function WritingList({ posts = [] }) {
387
- return Panel({
388
- children: posts.map((p, i) =>
389
- RowLink({ key: i, code: p.date, title: p.title, meta: p.tag, href: p.href || '#' })
390
- )
391
- });
392
- }
393
-
394
- export function Manifesto({ paragraphs = [], maxWidth }) {
395
- return h('div', {
396
- class: 'ds-prose ds-manifesto',
397
- 'data-max-width': maxWidth ? String(maxWidth) : null
398
- },
399
- ...paragraphs.map((p, i) => h('p', {
400
- key: i,
401
- class: 'ds-manifesto-para' + (p.dim ? ' dim' : '')
402
- }, p.text || p))
403
- );
404
- }
405
-
406
- // items: [n, label] or [n, label, {delta, tone: 'up'|'down', spark: number[]}]
407
- // meta is optional and additive — every existing 2-tuple call site is untouched.
408
- export function Kpi({ items = [], emptyText = 'no metrics yet' }) {
409
- if (!items.length) return h('div', { class: 'empty' }, emptyText);
410
- return h('div', { class: 'kpi' }, ...items.map(([n, l, meta], i) =>
411
- h('div', { key: i, class: 'kpi-card' },
412
- h('div', { class: 'num' }, String(n)),
413
- h('div', { class: 'lbl' }, l),
414
- meta && (meta.delta != null || meta.spark)
415
- ? h('div', { class: 'kpi-foot' },
416
- meta.delta != null
417
- ? h('span', { class: 'kpi-delta kpi-delta-' + (meta.tone === 'down' ? 'down' : 'up') },
418
- Icon(meta.tone === 'down' ? 'arrow-down' : 'arrow-up', { size: 12 }),
419
- String(meta.delta))
420
- : null,
421
- meta.spark ? Sparkline({ values: meta.spark, tone: meta.tone }) : null)
422
- : null)));
423
- }
424
-
425
- // Minimal inline SVG trend line — token-stroke only, no raw color literals.
426
- export function Sparkline({ values = [], width = 72, height = 24, tone }) {
427
- if (!values.length) return null;
428
- const max = Math.max(...values), min = Math.min(...values);
429
- const span = (max - min) || 1;
430
- const step = width / (values.length - 1 || 1);
431
- const points = values.map((v, i) => [i * step, height - ((v - min) / span) * height]);
432
- const d = points.map(([x, y], i) => (i === 0 ? 'M' : 'L') + x.toFixed(1) + ',' + y.toFixed(1)).join(' ');
433
- return h('svg', { class: 'ds-sparkline ds-sparkline-' + (tone === 'down' ? 'down' : 'up'), viewBox: '0 0 ' + width + ' ' + height, width, height, 'aria-hidden': 'true' },
434
- h('path', { d, fill: 'none', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }));
435
- }
436
-
437
- // Horizontal token-only bar breakdown — e.g. revenue by channel, traffic by source.
438
- export function BarChart({ items = [], emptyText = 'no data yet' }) {
439
- if (!items.length) return h('div', { class: 'empty' }, emptyText);
440
- const max = Math.max(...items.map(it => it.value || 0)) || 1;
441
- return h('div', { class: 'ds-barchart' }, ...items.map((it, i) =>
442
- h('div', { key: i, class: 'ds-barchart-row' },
443
- h('div', { class: 'ds-barchart-label' }, it.label),
444
- h('div', { class: 'ds-barchart-track' },
445
- h('div', { class: 'ds-barchart-fill', style: '--bar-pct:' + Math.round((it.value / max) * 100) + '%' })),
446
- h('div', { class: 'ds-barchart-value' }, it.display != null ? it.display : String(it.value)))));
447
- }
448
-
449
- export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothing here yet', rowLabels, striped = false, compact = false, sortable = false, sortKey, sortDir = 'asc', onSort }) {
450
- if (!rows || rows.length === 0) return h('div', { class: 'empty' }, emptyText);
451
- // rowLabels lets callers supply a plain-text label per row when the first
452
- // cell is a vnode (so the aria-label is meaningful, not the literal 'row').
453
- const labelFor = (row, i) => {
454
- if (Array.isArray(rowLabels) && rowLabels[i] != null) return String(rowLabels[i]);
455
- const c = row[0];
456
- return c == null ? 'row' : (typeof c === 'object' ? 'row' : String(c));
457
- };
458
- // Native <table>/<tr>/<th>/<td> already carry the correct implicit ARIA
459
- // roles — explicit role="table"/row/columnheader/cell is redundant and only
460
- // risks overriding native semantics, so it is omitted.
461
- // Scroll containment lives on the component itself: a wide table used
462
- // outside a Panel must never force page-level horizontal scroll.
463
- // striped/compact are opt-in density modifiers (webgeist g-table parity) —
464
- // default false so the existing contract/visual is byte-unchanged.
465
- const wrapClass = 'ds-table-wrap' + (striped ? ' is-striped' : '') + (compact ? ' is-compact' : '');
466
- // sortable is opt-in (default false, byte-unchanged for existing callers):
467
- // a header becomes a real <button> announcing aria-sort, dispatching
468
- // onSort(headerIndex) so the HOST owns the actual row-ordering logic (this
469
- // component has no opinion on comparator/locale/type - it only renders the
470
- // control and current state). A docstudio-style dense admin table needs
471
- // sortable columns; Table previously had no way to express that at all.
472
- const thFor = (hd, i) => {
473
- if (!sortable || !onSort) return h('th', { key: i, scope: 'col' }, hd);
474
- const isActive = sortKey === i;
475
- const ariaSort = isActive ? (sortDir === 'desc' ? 'descending' : 'ascending') : 'none';
476
- return h('th', { key: i, scope: 'col', 'aria-sort': ariaSort },
477
- h('button', { type: 'button', class: 'ds-table-sort-btn' + (isActive ? ' is-active' : ''), onclick: () => onSort(i) },
478
- h('span', { class: 'ds-table-sort-label' }, hd),
479
- isActive ? Icon(sortDir === 'desc' ? 'chevron-down' : 'chevron-up', { size: 12 }) : null));
480
- };
481
- return h('div', { class: wrapClass }, h('table', {},
482
- h('thead', {}, h('tr', {}, ...headers.map((hd, i) => thFor(hd, i)))),
483
- h('tbody', {}, ...rows.map((row, i) => h('tr', {
484
- key: i,
485
- class: onRowClick ? 'clickable' : '',
486
- onclick: onRowClick ? () => onRowClick(i) : null,
487
- // Space scrolls by default — preventDefault on Space (and Enter) so
488
- // keyboard activation matches click without page jump.
489
- ...(onRowClick ? { tabindex: '0', role: 'button', 'aria-label': 'open ' + labelFor(row, i), onkeydown: (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onRowClick(i); } } } : {})
490
- }, ...row.map((c, j) => h('td', { key: j }, c == null ? '' : (typeof c === 'object' ? c : String(c)))))))));
491
- }
492
-
493
- // HealthTable — generic health-check table: given an arbitrary
494
- // `{checkName: value}` object, infers a Chip tone per row from the value's
495
- // own type (boolean true/false -> ok/miss Chip, object -> truncated JSON
496
- // string, else the raw value stringified) so a new backend health check
497
- // appears with zero per-check hardcoding at the call site. Ported from
498
- // freddie's health page (src/components/freddie.js's `health` page), which
499
- // hand-rolled this exact inference inline; promoted here as a reusable
500
- // primitive for any dashboard/monitoring surface with a health-check map
501
- // (agentgui's Live tab included). `okLabel`/`missLabel` let a caller
502
- // localize the two Chip strings; `jsonTruncate` caps the JSON.stringify
503
- // length for object-shaped values (matches freddie's own truncation width).
504
- export function HealthTable({ checks = {}, emptyText = 'no health data', okLabel = 'ok', missLabel = 'no', jsonTruncate = 60 } = {}) {
505
- const entries = Object.entries(checks);
506
- if (!entries.length) return h('div', { class: 'empty' }, emptyText);
507
- const rows = entries.map(([name, v]) => {
508
- let cell;
509
- if (typeof v === 'object' && v !== null) {
510
- const s = JSON.stringify(v);
511
- cell = h('span', { title: s.length > jsonTruncate ? s : null }, s.length > jsonTruncate ? s.slice(0, jsonTruncate) + '…' : s);
512
- } else if (v === true) cell = Chip({ tone: 'ok', children: okLabel });
513
- else if (v === false) cell = Chip({ tone: 'miss', children: missLabel });
514
- else cell = String(v);
515
- return [name, cell];
516
- });
517
- return Table({ headers: ['check', 'status'], rows });
518
- }
519
-
520
- // ProcessRegistryTable — generic long-lived-process registry table: given a
521
- // list of `{kind, key, state}`-shaped rows (any in-flight process the host
522
- // tracks — an xstate machine, an ACP/direct-runner session, a background
523
- // job), renders a uniform table. Ported from freddie's `machines` page
524
- // (src/components/freddie.js), which used this exact shape for its xstate
525
- // machine census; generalized here since the shape (kind/key/state) applies
526
- // equally to agentgui's Live tab listing in-flight agent runner processes.
527
- // `extraColumns` lets a caller append columns beyond the base three (e.g. a
528
- // stop-action button) without forking the whole table.
529
- export function ProcessRegistryTable({ processes = [], emptyText = 'no live processes', extraColumns = [] } = {}) {
530
- if (!processes.length) return h('div', { class: 'empty' }, emptyText);
531
- const headers = ['kind', 'key', 'state', ...extraColumns.map(c => c.header)];
532
- const rows = processes.map(p => [
533
- p.kind || '—', p.key || '—', p.state || '—',
534
- ...extraColumns.map(c => c.render(p))
535
- ]);
536
- return Table({ headers, rows });
537
- }
538
-
539
- export function HomeView({ state = {}, onNav, onToggleWork, works = [], posts = [], manifesto = [], currentlyShipping } = {}) {
540
- return [
541
- // The page's one deliberate kicker. 'an entrypoint' is the collective's
542
- // name-as-tagline — it says something the <h1> does not, and it is the
543
- // masthead position where a print eyebrow actually belongs. The sections
544
- // below intentionally carry NO eyebrow: each has a heading that already
545
- // names it, so a kicker there would only restate the <h3> one word
546
- // shorter. Do not add eyebrows to the sibling sections to "match" this.
547
- Hero({
548
- eyebrow: 'an entrypoint',
549
- title: 'Small, weird, useful tools — built in public.',
550
- body: '247420 is a creative collective of eight, scattered across three timezones. We have been shipping open-source tools for the web since 2018.',
551
- accent: 'Some become the future. Most don\'t. That\'s the deal.'
552
- }),
553
- // Titleless section: promoted its former eyebrow to the actual heading
554
- // rather than leaving a kicker hovering over an unnamed panel. The label
555
- // was carrying the section's only name, so it became the <h3>.
556
- currentlyShipping ? Section({
557
- title: 'currently shipping',
558
- children: Panel({
559
- kind: 'wide',
560
- children: currentlyShipping.map((row, i) => {
561
- const dotNode = Dot({ tone: row.live ? 'live' : 'idle' });
562
- dotNode.props = { ...dotNode.props, 'aria-label': row.live ? 'live status' : 'idle status' };
563
- return Row({
564
- key: i,
565
- code: dotNode,
566
- title: row.title, sub: row.sub, meta: row.meta
567
- });
568
- })
569
- })
570
- }) : null,
571
- works.length ? Section({
572
- title: 'Everything else.',
573
- children: WorksList({ works, openedIndex: state.opened ?? -1, onToggle: onToggleWork })
574
- }) : null,
575
- posts.length ? Section({
576
- title: 'When we have something to say.',
577
- children: WritingList({ posts })
578
- }) : null,
579
- manifesto.length ? Section({
580
- title: 'Eight people, three timezones, one ongoing conversation.',
581
- children: Manifesto({ paragraphs: manifesto })
582
- }) : null
583
- ].filter(Boolean);
584
- }
585
-
586
- export function ProjectView({ project = {}, copied, onCopy } = {}) {
587
- return [
588
- h('div', { class: 'ds-prose' },
589
- Heading({ level: 1, children: project.name }),
590
- Lede({ children: project.tagline })
591
- ),
592
- project.install ? [
593
- Heading({ level: 3, children: 'install' }),
594
- Install({ cmd: project.install, copied, onCopy }),
595
- ] : null,
596
- project.receipt ? [
597
- Heading({ level: 3, children: 'by the numbers' }),
598
- Receipt({ rows: project.receipt }),
599
- ] : null,
600
- project.changelog ? [
601
- Heading({ level: 3, children: 'recent releases' }),
602
- Changelog({ entries: project.changelog })
603
- ] : null
604
- ].filter(Boolean).flat();
605
- }
606
-
607
- export function PageHeader({ title, lede, eyebrow, right, compact, dense, id }) {
608
- // `compact` drops the large leading/trailing section margins so a PageHeader
609
- // used as a page's first element top-aligns cleanly without the consumer
610
- // having to !important-override the .ds-section margin. `id` lands on the
611
- // outermost section so the header can serve as a deep-link anchor.
612
- // `dense` is the content-first working-surface form: one row - a small
613
- // heading with the lede beside it, clamped to a single muted line - instead
614
- // of a display H1 over a paragraph. App surfaces (files, dashboards,
615
- // settings) should not spend 150px of fold on an intro.
616
- if (dense) {
617
- return h('section', { class: 'ds-section ds-section-compact ds-page-header-dense', id: id || null },
618
- h('div', { class: 'ds-page-header-dense-row' },
619
- ...[
620
- title != null ? h('h1', { key: 'dh' }, title) : null,
621
- lede != null ? h('span', { key: 'dl', class: 'ds-page-header-dense-lede', title: typeof lede === 'string' ? lede : null }, lede) : null,
622
- right != null ? h('div', { key: 'dr', class: 'ds-page-header-right' }, ...(Array.isArray(right) ? right : [right])) : null,
623
- ].filter(Boolean)));
624
- }
625
- return h('section', { class: 'ds-section' + (compact ? ' ds-section-compact' : ''), id: id || null },
626
- eyebrow ? h('span', { class: 'eyebrow' }, eyebrow) : null,
627
- title != null ? h('h1', {}, title) : null,
628
- lede != null ? h('p', { class: 'lede' }, lede) : null,
629
- right != null ? h('div', { class: 'ds-page-header-right' }, ...(Array.isArray(right) ? right : [right])) : null
630
- );
631
- }
632
-
633
- export function SearchInput({ value = '', placeholder = 'search…', onInput, onSubmit, name = 'q', key, label, resultCount }) {
634
- // Shared clear path — both the Escape key and the visible clear button
635
- // call this, so there is exactly one place that clears the field.
636
- const doClear = (e) => { if (onInput) onInput('', e); };
637
- const input = h('input', {
638
- key: 'i',
639
- type: 'search',
640
- name,
641
- class: 'ds-search-input',
642
- placeholder,
643
- 'aria-label': label || placeholder,
644
- value,
645
- oninput: onInput ? (e) => onInput(e.target.value, e) : null,
646
- onkeydown: (e) => {
647
- // Escape clears the field in place (stays focused) rather than
648
- // falling through to whatever ancestor Escape handler exists.
649
- if (e.key === 'Escape' && value) { e.preventDefault(); e.stopPropagation(); doClear(e); return; }
650
- // IME guard: the Enter that commits a CJK composition must not submit.
651
- if (onSubmit && e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) onSubmit(e.target.value, e);
652
- }
653
- });
654
- // Visible clear (X) button — mouse/touch users have no way to discover the
655
- // Escape-to-clear shortcut, so this surfaces the same clear path visibly.
656
- // Only rendered when there's something to clear.
657
- const clearBtn = value
658
- ? h('button', {
659
- key: 'clr', type: 'button', class: 'ds-search-clear',
660
- 'aria-label': 'clear search',
661
- onclick: doClear,
662
- }, Icon('x'))
663
- : null;
664
- // Always return the same wrapping shape regardless of whether resultCount/
665
- // clearBtn are present this render - a conditional bare-input-vs-wrapped-
666
- // span return here previously changed SearchInput's VElement type at the
667
- // SAME keyed slot from render to render (e.g. typing into an empty filter
668
- // makes resultCount go from undefined to a string), and webjsx's applyDiff
669
- // has no way to morph one element type into another in place - it produced
670
- // a corrupted merged DOM node carrying attributes from both shapes.
671
- return h('span', { key, class: 'ds-search-input-wrap' },
672
- input,
673
- clearBtn,
674
- resultCount != null ? h('span', { key: 'cnt', class: 'sr-only', role: 'status', 'aria-live': 'polite' }, resultCount) : null);
675
- }
676
-
677
- export function TextField({ label, value = '', type = 'text', placeholder = '', onInput, onChange, name, key, hint, multiline, rows = 4, maxLength, min, max, error, title, size = 'md', 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy }) {
678
- // size: 'sm' | 'md' | 'lg' — md is the base .ds-field control; sm/lg add a
679
- // wrapper modifier that snaps the control height/padding/font to --ctl-*.
680
- const sizeCls = size === 'sm' ? ' ds-field--sm' : (size === 'lg' ? ' ds-field--lg' : '');
681
- const errorId = error != null ? ((key ? key : 'tf') + '-err') : null;
682
- const describedBy = ariaDescribedBy || errorId || null;
683
- const input = multiline
684
- ? h('textarea', {
685
- key: 'i', name, rows, placeholder, value,
686
- maxlength: maxLength != null ? maxLength : null,
687
- 'aria-label': ariaLabel || null,
688
- 'aria-invalid': error != null ? 'true' : (ariaInvalid || null),
689
- 'aria-describedby': describedBy,
690
- title: title || null,
691
- oninput: onInput ? (e) => onInput(e.target.value, e) : null,
692
- onchange: onChange ? (e) => onChange(e.target.value, e) : null
693
- })
694
- : h('input', {
695
- key: 'i', type, name, placeholder, value,
696
- maxlength: maxLength != null ? maxLength : null,
697
- min: min != null ? String(min) : null,
698
- max: max != null ? String(max) : null,
699
- 'aria-label': ariaLabel || null,
700
- 'aria-invalid': error != null ? 'true' : (ariaInvalid || null),
701
- 'aria-describedby': describedBy,
702
- title: title || null,
703
- oninput: onInput ? (e) => onInput(e.target.value, e) : null,
704
- onchange: onChange ? (e) => onChange(e.target.value, e) : null
705
- });
706
- return h('label', { key, class: 'ds-field' + sizeCls },
707
- ...[
708
- label != null ? h('span', { key: 'l', class: 'ds-field-label' }, label) : null,
709
- input,
710
- error != null ? h('span', { key: 'e', id: errorId, class: 'ds-field-error', role: 'alert', 'aria-live': 'polite', 'aria-atomic': 'true' }, error) : null,
711
- maxLength != null ? h('span', { key: 'c', class: 'ds-field-count' }, String(value.length) + '/' + maxLength) : null,
712
- hint != null ? h('span', { key: 'h', class: 'ds-field-hint' }, hint) : null
713
- ].filter(Boolean)
714
- );
715
- }
716
-
717
- export function Select({ label, value = '', options = [], onChange, name, key, placeholder, hint, title, size = 'md', 'aria-label': ariaLabel }) {
718
- const sizeCls = size === 'sm' ? ' ds-field--sm' : (size === 'lg' ? ' ds-field--lg' : '');
719
- const opts = [];
720
- if (placeholder != null) opts.push(h('option', { key: '_ph', value: '', disabled: true, selected: value === '' || value == null }, placeholder));
721
- for (const o of options) {
722
- const id = typeof o === 'string' ? o : (o.value != null ? o.value : o.id);
723
- const lab = typeof o === 'string' ? o : (o.label != null ? o.label : (o.id || o.value));
724
- opts.push(h('option', { key: 'o-' + id, value: id, selected: id === value }, lab));
725
- }
726
- const select = h('select', {
727
- key: 'i', name, class: 'ds-select',
728
- // Guarantee an accessible name even when rendered without a visible label.
729
- 'aria-label': ariaLabel || (label == null ? (title || placeholder || name) : null),
730
- title,
731
- onchange: onChange ? (e) => onChange(e.target.value, e) : null
732
- }, ...opts);
733
- if (label == null && hint == null && size === 'md') return select;
734
- if (label == null && hint == null) return h('label', { key, class: 'ds-field' + sizeCls }, select);
735
- return h('label', { key, class: 'ds-field' + sizeCls },
736
- label != null ? h('span', { key: 'l', class: 'ds-field-label' }, label) : null,
737
- select,
738
- hint != null ? h('span', { key: 'h', class: 'ds-field-hint' }, hint) : null
739
- );
740
- }
741
-
742
- export function EventList({ items, events, emptyText = 'no events', rankPad = 3, loading = false, loadingText = 'loading events…' }) {
743
- const list = items || events || [];
744
- // Shape-matched skeleton rows for the slow first events fetch (the ccsniff
745
- // cold walk can take 30-90s) - a lone spinner collapses the whole pane.
746
- // Keying discipline mirrors ConversationList: a single keyed wrapper with
747
- // all-keyed siblings (webjsx applyDiff crashes on mixed keyed/unkeyed).
748
- if (loading && !list.length) {
749
- return h('section', { class: 'ds-section ds-event-list' },
750
- h('div', { key: 'st', role: 'status', 'aria-live': 'polite', class: 'ds-event-state lede' }, loadingText),
751
- ...Array.from({ length: 7 }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-event-row-skeleton', 'aria-hidden': 'true' },
752
- h('span', { key: 'r', class: 'ds-skel ds-skel-rank' }),
753
- h('span', { key: 't', class: 'ds-skel ds-skel-title' }),
754
- h('span', { key: 'm', class: 'ds-skel ds-skel-meta' }))));
755
- }
756
- if (!list.length) return h('p', { class: 'lede' }, emptyText);
757
- return h('section', { class: 'ds-section ds-event-list' },
758
- ...list.map((it, i) => Row({
759
- key: it.key || ('ev' + i),
760
- code: it.code != null ? it.code : (it.rank != null ? it.rank : String(i + 1).padStart(rankPad, '0')),
761
- title: it.title || '(empty)',
762
- sub: it.sub || '',
763
- active: it.active,
764
- onClick: it.onClick,
765
- kind: it.kind,
766
- rail: it.rail,
767
- // Forward a disclosure state when the host marks the row as a toggle,
768
- // so a clickable event row announces aria-expanded.
769
- expanded: it.expanded,
770
- detail: it.detail,
771
- actions: it.actions,
772
- highlight: it.highlight,
773
- meta: it.meta
774
- }))
775
- );
776
- }
777
-
778
- export function Form({ fields = [], submit = 'submit', onSubmit, columns = 1 }) {
779
- const cols = columns > 1 ? String(columns) : null;
780
- return h('form', { class: 'row-form', 'data-columns': cols, onsubmit: (ev) => { ev.preventDefault(); onSubmit && onSubmit(ev); } },
781
- ...fields.map((f, i) => {
782
- // Each control gets a stable id and an associated <label> so the
783
- // placeholder is no longer the only (inaccessible) name. The label
784
- // text falls back to label -> placeholder -> name.
785
- const fieldId = 'ds-form-' + (f.name || 'field') + '-' + i;
786
- const labelText = f.label != null ? f.label : (f.placeholder || f.name || '');
787
- const control = f.kind === 'textarea'
788
- ? h('textarea', { key: 'i', id: fieldId, name: f.name, placeholder: f.placeholder || '', rows: f.rows || 4, required: f.required ? true : null })
789
- : h('input', { key: 'i', id: fieldId, name: f.name, type: f.type || 'text', placeholder: f.placeholder || '', value: f.value || '', required: f.required ? true : null });
790
- return h('label', { key: i, class: 'ds-field', for: fieldId },
791
- labelText !== '' ? h('span', { key: 'l', class: 'ds-field-label' }, labelText) : null,
792
- control);
793
- }),
794
- h('button', { type: 'submit', class: 'btn-primary' }, submit));
795
- }
796
-
797
- export function Spinner({ size = 'base', tone = 'accent', label = 'loading', key } = {}) {
798
- const SIZE_CLASS = { xs: 'ds-spinner-xs', sm: 'ds-spinner-sm', base: '', lg: 'ds-spinner-lg', xl: 'ds-spinner-xl' };
799
- const sizeClass = SIZE_CLASS[size] != null ? SIZE_CLASS[size] : '';
800
- return h('div', {
801
- key, class: 'ds-spinner ' + sizeClass + ' tone-' + tone,
802
- role: 'status', 'aria-live': 'polite', 'aria-label': label
803
- },
804
- h('span', { key: '1', 'aria-hidden': 'true' }),
805
- h('span', { key: '2', 'aria-hidden': 'true' }),
806
- h('span', { key: '3', 'aria-hidden': 'true' })
807
- );
808
- }
809
-
810
- // Clamp a caller-supplied CSS length to a sane range so a raw prop like
811
- // height="9999px" can't blow out the layout. Accepts a CSS length string
812
- // (px/em/rem/%/vh/vw) or a bare number (treated as px); rejects anything else
813
- // back to the default. Numeric values are clamped to [2, 600] (px-equivalent).
814
- function clampLen(v, fallback) {
815
- if (v == null) return fallback;
816
- const s = String(v).trim();
817
- const m = /^(\d+(?:\.\d+)?)(px|em|rem|%|vh|vw)?$/.exec(s);
818
- if (!m) return fallback;
819
- const unit = m[2] || 'px';
820
- let n = parseFloat(m[1]);
821
- if (unit === '%' || unit === 'vh' || unit === 'vw') n = Math.min(100, Math.max(0, n));
822
- else n = Math.min(600, Math.max(2, n));
823
- return n + unit;
824
- }
825
-
826
- export function Skeleton({ height = '1em', width = '100%', count = 1, label = 'loading content', key } = {}) {
827
- const h_ = clampLen(height, '1em');
828
- const w_ = clampLen(width, '100%');
829
- return h('div', {
830
- key, class: 'ds-skeleton-group',
831
- role: 'status', 'aria-busy': 'true', 'aria-label': label
832
- },
833
- ...Array(count).fill(0).map((_, i) =>
834
- h('div', { key: String(i), class: 'ds-skeleton', style: `height:${h_};width:${w_};`, 'aria-hidden': 'true' })
835
- )
836
- );
837
- }
838
-
839
- // FilterPills — a role=group of pill toggle buttons for quick category filters.
840
- // `options` is [{ id, label }]; `selected` the active id; clicking a pill calls
841
- // onSelect(id). Pressed state is announced via aria-pressed.
842
- export function FilterPills({ options = [], selected, onSelect, label = 'filters' } = {}) {
843
- if (!options.length) return null;
844
- return h('div', { class: 'ds-filter-pills', role: 'group', 'aria-label': label },
845
- ...options.map((o) => h('button', {
846
- key: 'fp-' + o.id,
847
- type: 'button',
848
- class: 'ds-filter-pill' + (o.id === selected ? ' active' : ''),
849
- 'aria-pressed': o.id === selected ? 'true' : 'false',
850
- onclick: () => onSelect && onSelect(o.id),
851
- }, o.label != null ? o.label : o.id)));
852
- }
853
-
854
- export function Alert({ kind = 'info', children, onDismiss, title, key } = {}) {
855
- const icons = { info: 'info', success: 'check', warn: 'warn', error: 'x' };
856
- const cls = 'ds-alert ds-alert-' + kind;
857
- return h('div', { key, class: cls, role: 'alert' },
858
- h('span', { key: 'icon', class: 'ds-alert-icon' }, Icon(icons[kind] || 'info')),
859
- h('div', { key: 'content', class: 'ds-alert-content' },
860
- title ? h('div', { key: 'title', class: 'ds-alert-title' }, title) : null,
861
- h('div', { key: 'msg', class: 'ds-alert-message' }, ...(Array.isArray(children) ? children : [children]))
862
- ),
863
- onDismiss ? h('button', { key: 'dismiss', class: 'ds-alert-dismiss', 'aria-label': 'dismiss', onclick: onDismiss }, Icon('x')) : null
864
- );
865
- }
5
+ // This module is a barrel: every component lives in a single-responsibility
6
+ // submodule under ./content/, and the public export surface here is unchanged
7
+ // no consumer import needs to move.
8
+
9
+ import { avatarInitial, Avatar } from './content/avatar.js';
10
+ import { Row, RowLink } from './content/row.js';
11
+ import { Panel, Card, PanelFromItems, Section, Receipt, Changelog } from './content/panel.js';
12
+ import { Hero, HeroFromPageData, Marquee, Manifesto, PageHeader } from './content/hero.js';
13
+ import { Install, CliBlock } from './content/cli.js';
14
+ import { WorksList, WritingList, EventList } from './content/lists.js';
15
+ import { Kpi, Sparkline, BarChart } from './content/charts.js';
16
+ import { Table, HealthTable, ProcessRegistryTable } from './content/table.js';
17
+ import { SearchInput, TextField, Select, Form } from './content/fields.js';
18
+ import { Spinner, Skeleton, Alert, FilterPills } from './content/feedback.js';
19
+ import { HomeView, ProjectView } from './content/views.js';
20
+
21
+ export {
22
+ avatarInitial, Avatar,
23
+ Row, RowLink,
24
+ Panel, Card, PanelFromItems, Section, Receipt, Changelog,
25
+ Hero, HeroFromPageData, Marquee, Manifesto, PageHeader,
26
+ Install, CliBlock,
27
+ WorksList, WritingList, EventList,
28
+ Kpi, Sparkline, BarChart,
29
+ Table, HealthTable, ProcessRegistryTable,
30
+ SearchInput, TextField, Select, Form,
31
+ Spinner, Skeleton, Alert, FilterPills,
32
+ HomeView, ProjectView,
33
+ };