anentrypoint-design 0.0.380 → 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 (68) hide show
  1. package/app-shell.css +16 -0
  2. package/chat.css +102 -62
  3. package/colors_and_type.css +66 -7
  4. package/community.css +104 -70
  5. package/dist/247420.css +1123 -465
  6. package/dist/247420.js +32 -29
  7. package/package.json +1 -1
  8. package/src/components/content/avatar.js +31 -0
  9. package/src/components/content/charts.js +50 -0
  10. package/src/components/content/cli.js +46 -0
  11. package/src/components/content/feedback.js +77 -0
  12. package/src/components/content/fields.js +136 -0
  13. package/src/components/content/hero.js +146 -0
  14. package/src/components/content/lists.js +85 -0
  15. package/src/components/content/panel.js +79 -0
  16. package/src/components/content/row.js +115 -0
  17. package/src/components/content/table.js +97 -0
  18. package/src/components/content/views.js +81 -0
  19. package/src/components/content.js +29 -852
  20. package/src/components/editor-primitives/batch.js +60 -0
  21. package/src/components/editor-primitives/chrome.js +146 -0
  22. package/src/components/editor-primitives/collapse.js +46 -0
  23. package/src/components/editor-primitives/context-menu.js +127 -0
  24. package/src/components/editor-primitives/diagnostics.js +44 -0
  25. package/src/components/editor-primitives/focus-trap.js +26 -0
  26. package/src/components/editor-primitives/json-viewer.js +123 -0
  27. package/src/components/editor-primitives/layout.js +89 -0
  28. package/src/components/editor-primitives/modals.js +89 -0
  29. package/src/components/editor-primitives/pager.js +74 -0
  30. package/src/components/editor-primitives/property-grid.js +57 -0
  31. package/src/components/editor-primitives/shared.js +18 -0
  32. package/src/components/editor-primitives/split-panel.js +101 -0
  33. package/src/components/editor-primitives/toast.js +59 -0
  34. package/src/components/editor-primitives/tree.js +75 -0
  35. package/src/components/editor-primitives.js +35 -1048
  36. package/src/components/freddie/helpers.js +7 -0
  37. package/src/components/freddie.js +21 -21
  38. package/src/components/overlay-primitives/approval-prompt.js +38 -0
  39. package/src/components/overlay-primitives/auth-modal.js +89 -0
  40. package/src/components/overlay-primitives/command-palette.js +101 -0
  41. package/src/components/overlay-primitives/emoji-picker.js +103 -0
  42. package/src/components/overlay-primitives/floating.js +146 -0
  43. package/src/components/overlay-primitives/full-screen.js +45 -0
  44. package/src/components/overlay-primitives/menus.js +131 -0
  45. package/src/components/overlay-primitives/popover.js +51 -0
  46. package/src/components/overlay-primitives/roving-menu.js +73 -0
  47. package/src/components/overlay-primitives/settings-popover.js +85 -0
  48. package/src/components/overlay-primitives/tooltip.js +55 -0
  49. package/src/components/overlay-primitives.js +33 -855
  50. package/src/css/app-shell/base.css +11 -1
  51. package/src/css/app-shell/catalog-theme.css +7 -0
  52. package/src/css/app-shell/chat-polish.css +45 -33
  53. package/src/css/app-shell/data-density.css +46 -24
  54. package/src/css/app-shell/files.css +40 -32
  55. package/src/css/app-shell/hero-content.css +1 -1
  56. package/src/css/app-shell/kits-appended.css +456 -79
  57. package/src/css/app-shell/panel-row.css +6 -1
  58. package/src/css/app-shell/plugins-config.css +1 -1
  59. package/src/css/app-shell/responsive.css +47 -29
  60. package/src/css/app-shell/responsive2-workspace.css +17 -12
  61. package/src/css/app-shell/states-interactions.css +9 -4
  62. package/src/kits/os/freddie-dashboard.css +2 -2
  63. package/src/kits/os/launcher.css +1 -1
  64. package/src/kits/os/theme.css +359 -283
  65. package/src/kits/os/wm.css +2 -2
  66. package/src/kits/slides/deck-stage-style.js +5 -2
  67. package/src/kits/spoint/host-join-lobby.css +1 -1
  68. package/src/kits/spoint/loading-screen.css +1 -1
@@ -1,856 +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
- Hero({
542
- eyebrow: 'an entrypoint',
543
- title: 'Small, weird, useful tools — built in public.',
544
- body: '247420 is a creative collective of eight, scattered across three timezones. We have been shipping open-source tools for the web since 2018.',
545
- accent: 'Some become the future. Most don\'t. That\'s the deal.'
546
- }),
547
- currentlyShipping ? Section({
548
- eyebrow: 'currently shipping',
549
- children: Panel({
550
- kind: 'wide',
551
- children: currentlyShipping.map((row, i) => {
552
- const dotNode = Dot({ tone: row.live ? 'live' : 'idle' });
553
- dotNode.props = { ...dotNode.props, 'aria-label': row.live ? 'live status' : 'idle status' };
554
- return Row({
555
- key: i,
556
- code: dotNode,
557
- title: row.title, sub: row.sub, meta: row.meta
558
- });
559
- })
560
- })
561
- }) : null,
562
- works.length ? Section({
563
- eyebrow: 'works', title: 'Everything else.',
564
- children: WorksList({ works, openedIndex: state.opened ?? -1, onToggle: onToggleWork })
565
- }) : null,
566
- posts.length ? Section({
567
- eyebrow: 'writing', title: 'When we have something to say.',
568
- children: WritingList({ posts })
569
- }) : null,
570
- manifesto.length ? Section({
571
- eyebrow: 'who\'s here', title: 'Eight people, three timezones, one ongoing conversation.',
572
- children: Manifesto({ paragraphs: manifesto })
573
- }) : null
574
- ].filter(Boolean);
575
- }
576
-
577
- export function ProjectView({ project = {}, copied, onCopy } = {}) {
578
- return [
579
- h('div', { class: 'ds-prose' },
580
- Heading({ level: 1, children: project.name }),
581
- Lede({ children: project.tagline })
582
- ),
583
- project.install ? [
584
- Heading({ level: 3, children: 'install' }),
585
- Install({ cmd: project.install, copied, onCopy }),
586
- ] : null,
587
- project.receipt ? [
588
- Heading({ level: 3, children: 'by the numbers' }),
589
- Receipt({ rows: project.receipt }),
590
- ] : null,
591
- project.changelog ? [
592
- Heading({ level: 3, children: 'recent releases' }),
593
- Changelog({ entries: project.changelog })
594
- ] : null
595
- ].filter(Boolean).flat();
596
- }
597
-
598
- export function PageHeader({ title, lede, eyebrow, right, compact, dense, id }) {
599
- // `compact` drops the large leading/trailing section margins so a PageHeader
600
- // used as a page's first element top-aligns cleanly without the consumer
601
- // having to !important-override the .ds-section margin. `id` lands on the
602
- // outermost section so the header can serve as a deep-link anchor.
603
- // `dense` is the content-first working-surface form: one row - a small
604
- // heading with the lede beside it, clamped to a single muted line - instead
605
- // of a display H1 over a paragraph. App surfaces (files, dashboards,
606
- // settings) should not spend 150px of fold on an intro.
607
- if (dense) {
608
- return h('section', { class: 'ds-section ds-section-compact ds-page-header-dense', id: id || null },
609
- h('div', { class: 'ds-page-header-dense-row' },
610
- ...[
611
- title != null ? h('h1', { key: 'dh' }, title) : null,
612
- lede != null ? h('span', { key: 'dl', class: 'ds-page-header-dense-lede', title: typeof lede === 'string' ? lede : null }, lede) : null,
613
- right != null ? h('div', { key: 'dr', class: 'ds-page-header-right' }, ...(Array.isArray(right) ? right : [right])) : null,
614
- ].filter(Boolean)));
615
- }
616
- return h('section', { class: 'ds-section' + (compact ? ' ds-section-compact' : ''), id: id || null },
617
- eyebrow ? h('span', { class: 'eyebrow' }, eyebrow) : null,
618
- title != null ? h('h1', {}, title) : null,
619
- lede != null ? h('p', { class: 'lede' }, lede) : null,
620
- right != null ? h('div', { class: 'ds-page-header-right' }, ...(Array.isArray(right) ? right : [right])) : null
621
- );
622
- }
623
-
624
- export function SearchInput({ value = '', placeholder = 'search…', onInput, onSubmit, name = 'q', key, label, resultCount }) {
625
- // Shared clear path — both the Escape key and the visible clear button
626
- // call this, so there is exactly one place that clears the field.
627
- const doClear = (e) => { if (onInput) onInput('', e); };
628
- const input = h('input', {
629
- key: 'i',
630
- type: 'search',
631
- name,
632
- class: 'ds-search-input',
633
- placeholder,
634
- 'aria-label': label || placeholder,
635
- value,
636
- oninput: onInput ? (e) => onInput(e.target.value, e) : null,
637
- onkeydown: (e) => {
638
- // Escape clears the field in place (stays focused) rather than
639
- // falling through to whatever ancestor Escape handler exists.
640
- if (e.key === 'Escape' && value) { e.preventDefault(); e.stopPropagation(); doClear(e); return; }
641
- // IME guard: the Enter that commits a CJK composition must not submit.
642
- if (onSubmit && e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) onSubmit(e.target.value, e);
643
- }
644
- });
645
- // Visible clear (X) button — mouse/touch users have no way to discover the
646
- // Escape-to-clear shortcut, so this surfaces the same clear path visibly.
647
- // Only rendered when there's something to clear.
648
- const clearBtn = value
649
- ? h('button', {
650
- key: 'clr', type: 'button', class: 'ds-search-clear',
651
- 'aria-label': 'clear search',
652
- onclick: doClear,
653
- }, Icon('x'))
654
- : null;
655
- // Always return the same wrapping shape regardless of whether resultCount/
656
- // clearBtn are present this render - a conditional bare-input-vs-wrapped-
657
- // span return here previously changed SearchInput's VElement type at the
658
- // SAME keyed slot from render to render (e.g. typing into an empty filter
659
- // makes resultCount go from undefined to a string), and webjsx's applyDiff
660
- // has no way to morph one element type into another in place - it produced
661
- // a corrupted merged DOM node carrying attributes from both shapes.
662
- return h('span', { key, class: 'ds-search-input-wrap' },
663
- input,
664
- clearBtn,
665
- resultCount != null ? h('span', { key: 'cnt', class: 'sr-only', role: 'status', 'aria-live': 'polite' }, resultCount) : null);
666
- }
667
-
668
- 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 }) {
669
- // size: 'sm' | 'md' | 'lg' — md is the base .ds-field control; sm/lg add a
670
- // wrapper modifier that snaps the control height/padding/font to --ctl-*.
671
- const sizeCls = size === 'sm' ? ' ds-field--sm' : (size === 'lg' ? ' ds-field--lg' : '');
672
- const errorId = error != null ? ((key ? key : 'tf') + '-err') : null;
673
- const describedBy = ariaDescribedBy || errorId || null;
674
- const input = multiline
675
- ? h('textarea', {
676
- key: 'i', name, rows, placeholder, value,
677
- maxlength: maxLength != null ? maxLength : null,
678
- 'aria-label': ariaLabel || null,
679
- 'aria-invalid': error != null ? 'true' : (ariaInvalid || null),
680
- 'aria-describedby': describedBy,
681
- title: title || null,
682
- oninput: onInput ? (e) => onInput(e.target.value, e) : null,
683
- onchange: onChange ? (e) => onChange(e.target.value, e) : null
684
- })
685
- : h('input', {
686
- key: 'i', type, name, placeholder, value,
687
- maxlength: maxLength != null ? maxLength : null,
688
- min: min != null ? String(min) : null,
689
- max: max != null ? String(max) : null,
690
- 'aria-label': ariaLabel || null,
691
- 'aria-invalid': error != null ? 'true' : (ariaInvalid || null),
692
- 'aria-describedby': describedBy,
693
- title: title || null,
694
- oninput: onInput ? (e) => onInput(e.target.value, e) : null,
695
- onchange: onChange ? (e) => onChange(e.target.value, e) : null
696
- });
697
- return h('label', { key, class: 'ds-field' + sizeCls },
698
- ...[
699
- label != null ? h('span', { key: 'l', class: 'ds-field-label' }, label) : null,
700
- input,
701
- error != null ? h('span', { key: 'e', id: errorId, class: 'ds-field-error', role: 'alert', 'aria-live': 'polite', 'aria-atomic': 'true' }, error) : null,
702
- maxLength != null ? h('span', { key: 'c', class: 'ds-field-count' }, String(value.length) + '/' + maxLength) : null,
703
- hint != null ? h('span', { key: 'h', class: 'ds-field-hint' }, hint) : null
704
- ].filter(Boolean)
705
- );
706
- }
707
-
708
- export function Select({ label, value = '', options = [], onChange, name, key, placeholder, hint, title, size = 'md', 'aria-label': ariaLabel }) {
709
- const sizeCls = size === 'sm' ? ' ds-field--sm' : (size === 'lg' ? ' ds-field--lg' : '');
710
- const opts = [];
711
- if (placeholder != null) opts.push(h('option', { key: '_ph', value: '', disabled: true, selected: value === '' || value == null }, placeholder));
712
- for (const o of options) {
713
- const id = typeof o === 'string' ? o : (o.value != null ? o.value : o.id);
714
- const lab = typeof o === 'string' ? o : (o.label != null ? o.label : (o.id || o.value));
715
- opts.push(h('option', { key: 'o-' + id, value: id, selected: id === value }, lab));
716
- }
717
- const select = h('select', {
718
- key: 'i', name, class: 'ds-select',
719
- // Guarantee an accessible name even when rendered without a visible label.
720
- 'aria-label': ariaLabel || (label == null ? (title || placeholder || name) : null),
721
- title,
722
- onchange: onChange ? (e) => onChange(e.target.value, e) : null
723
- }, ...opts);
724
- if (label == null && hint == null && size === 'md') return select;
725
- if (label == null && hint == null) return h('label', { key, class: 'ds-field' + sizeCls }, select);
726
- return h('label', { key, class: 'ds-field' + sizeCls },
727
- label != null ? h('span', { key: 'l', class: 'ds-field-label' }, label) : null,
728
- select,
729
- hint != null ? h('span', { key: 'h', class: 'ds-field-hint' }, hint) : null
730
- );
731
- }
732
-
733
- export function EventList({ items, events, emptyText = 'no events', rankPad = 3, loading = false, loadingText = 'loading events…' }) {
734
- const list = items || events || [];
735
- // Shape-matched skeleton rows for the slow first events fetch (the ccsniff
736
- // cold walk can take 30-90s) - a lone spinner collapses the whole pane.
737
- // Keying discipline mirrors ConversationList: a single keyed wrapper with
738
- // all-keyed siblings (webjsx applyDiff crashes on mixed keyed/unkeyed).
739
- if (loading && !list.length) {
740
- return h('section', { class: 'ds-section ds-event-list' },
741
- h('div', { key: 'st', role: 'status', 'aria-live': 'polite', class: 'ds-event-state lede' }, loadingText),
742
- ...Array.from({ length: 7 }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-event-row-skeleton', 'aria-hidden': 'true' },
743
- h('span', { key: 'r', class: 'ds-skel ds-skel-rank' }),
744
- h('span', { key: 't', class: 'ds-skel ds-skel-title' }),
745
- h('span', { key: 'm', class: 'ds-skel ds-skel-meta' }))));
746
- }
747
- if (!list.length) return h('p', { class: 'lede' }, emptyText);
748
- return h('section', { class: 'ds-section ds-event-list' },
749
- ...list.map((it, i) => Row({
750
- key: it.key || ('ev' + i),
751
- code: it.code != null ? it.code : (it.rank != null ? it.rank : String(i + 1).padStart(rankPad, '0')),
752
- title: it.title || '(empty)',
753
- sub: it.sub || '',
754
- active: it.active,
755
- onClick: it.onClick,
756
- kind: it.kind,
757
- rail: it.rail,
758
- // Forward a disclosure state when the host marks the row as a toggle,
759
- // so a clickable event row announces aria-expanded.
760
- expanded: it.expanded,
761
- detail: it.detail,
762
- actions: it.actions,
763
- highlight: it.highlight,
764
- meta: it.meta
765
- }))
766
- );
767
- }
768
-
769
- export function Form({ fields = [], submit = 'submit', onSubmit, columns = 1 }) {
770
- const cols = columns > 1 ? String(columns) : null;
771
- return h('form', { class: 'row-form', 'data-columns': cols, onsubmit: (ev) => { ev.preventDefault(); onSubmit && onSubmit(ev); } },
772
- ...fields.map((f, i) => {
773
- // Each control gets a stable id and an associated <label> so the
774
- // placeholder is no longer the only (inaccessible) name. The label
775
- // text falls back to label -> placeholder -> name.
776
- const fieldId = 'ds-form-' + (f.name || 'field') + '-' + i;
777
- const labelText = f.label != null ? f.label : (f.placeholder || f.name || '');
778
- const control = f.kind === 'textarea'
779
- ? h('textarea', { key: 'i', id: fieldId, name: f.name, placeholder: f.placeholder || '', rows: f.rows || 4, required: f.required ? true : null })
780
- : h('input', { key: 'i', id: fieldId, name: f.name, type: f.type || 'text', placeholder: f.placeholder || '', value: f.value || '', required: f.required ? true : null });
781
- return h('label', { key: i, class: 'ds-field', for: fieldId },
782
- labelText !== '' ? h('span', { key: 'l', class: 'ds-field-label' }, labelText) : null,
783
- control);
784
- }),
785
- h('button', { type: 'submit', class: 'btn-primary' }, submit));
786
- }
787
-
788
- export function Spinner({ size = 'base', tone = 'accent', label = 'loading', key } = {}) {
789
- const SIZE_CLASS = { xs: 'ds-spinner-xs', sm: 'ds-spinner-sm', base: '', lg: 'ds-spinner-lg', xl: 'ds-spinner-xl' };
790
- const sizeClass = SIZE_CLASS[size] != null ? SIZE_CLASS[size] : '';
791
- return h('div', {
792
- key, class: 'ds-spinner ' + sizeClass + ' tone-' + tone,
793
- role: 'status', 'aria-live': 'polite', 'aria-label': label
794
- },
795
- h('span', { key: '1', 'aria-hidden': 'true' }),
796
- h('span', { key: '2', 'aria-hidden': 'true' }),
797
- h('span', { key: '3', 'aria-hidden': 'true' })
798
- );
799
- }
800
-
801
- // Clamp a caller-supplied CSS length to a sane range so a raw prop like
802
- // height="9999px" can't blow out the layout. Accepts a CSS length string
803
- // (px/em/rem/%/vh/vw) or a bare number (treated as px); rejects anything else
804
- // back to the default. Numeric values are clamped to [2, 600] (px-equivalent).
805
- function clampLen(v, fallback) {
806
- if (v == null) return fallback;
807
- const s = String(v).trim();
808
- const m = /^(\d+(?:\.\d+)?)(px|em|rem|%|vh|vw)?$/.exec(s);
809
- if (!m) return fallback;
810
- const unit = m[2] || 'px';
811
- let n = parseFloat(m[1]);
812
- if (unit === '%' || unit === 'vh' || unit === 'vw') n = Math.min(100, Math.max(0, n));
813
- else n = Math.min(600, Math.max(2, n));
814
- return n + unit;
815
- }
816
-
817
- export function Skeleton({ height = '1em', width = '100%', count = 1, label = 'loading content', key } = {}) {
818
- const h_ = clampLen(height, '1em');
819
- const w_ = clampLen(width, '100%');
820
- return h('div', {
821
- key, class: 'ds-skeleton-group',
822
- role: 'status', 'aria-busy': 'true', 'aria-label': label
823
- },
824
- ...Array(count).fill(0).map((_, i) =>
825
- h('div', { key: String(i), class: 'ds-skeleton', style: `height:${h_};width:${w_};`, 'aria-hidden': 'true' })
826
- )
827
- );
828
- }
829
-
830
- // FilterPills — a role=group of pill toggle buttons for quick category filters.
831
- // `options` is [{ id, label }]; `selected` the active id; clicking a pill calls
832
- // onSelect(id). Pressed state is announced via aria-pressed.
833
- export function FilterPills({ options = [], selected, onSelect, label = 'filters' } = {}) {
834
- if (!options.length) return null;
835
- return h('div', { class: 'ds-filter-pills', role: 'group', 'aria-label': label },
836
- ...options.map((o) => h('button', {
837
- key: 'fp-' + o.id,
838
- type: 'button',
839
- class: 'ds-filter-pill' + (o.id === selected ? ' active' : ''),
840
- 'aria-pressed': o.id === selected ? 'true' : 'false',
841
- onclick: () => onSelect && onSelect(o.id),
842
- }, o.label != null ? o.label : o.id)));
843
- }
844
-
845
- export function Alert({ kind = 'info', children, onDismiss, title, key } = {}) {
846
- const icons = { info: 'info', success: 'check', warn: 'warn', error: 'x' };
847
- const cls = 'ds-alert ds-alert-' + kind;
848
- return h('div', { key, class: cls, role: 'alert' },
849
- h('span', { key: 'icon', class: 'ds-alert-icon' }, Icon(icons[kind] || 'info')),
850
- h('div', { key: 'content', class: 'ds-alert-content' },
851
- title ? h('div', { key: 'title', class: 'ds-alert-title' }, title) : null,
852
- h('div', { key: 'msg', class: 'ds-alert-message' }, ...(Array.isArray(children) ? children : [children]))
853
- ),
854
- onDismiss ? h('button', { key: 'dismiss', class: 'ds-alert-dismiss', 'aria-label': 'dismiss', onclick: onDismiss }, Icon('x')) : null
855
- );
856
- }
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
+ };