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
@@ -0,0 +1,60 @@
1
+ // ---------------------------------------------------------------------------
2
+ // BatchProgressLabel — sequential batch-operation progress readout: "label
3
+ // (i/n)" while in flight. Ported from docstudio's sequential upload badge
4
+ // and bulk-action progress button (both show a live count against a total
5
+ // while processing one item at a time). Pure display — the host owns the
6
+ // actual queue/loop; this only renders its current {done, total} state.
7
+ // done === 0 renders the bare label with no count suffix (nothing has
8
+ // happened yet); done === total renders as complete with no live-region
9
+ // churn once settled.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import * as webjsx from '../../../vendor/webjsx/index.js';
13
+ const h = webjsx.createElement;
14
+
15
+ export function BatchProgressLabel({ label = 'Processing', done = 0, total = 0, key } = {}) {
16
+ const inFlight = total > 0 && done < total;
17
+ const suffix = total > 0 ? ` (${done}/${total})` : '';
18
+ return h('span', { key, class: 'ds-ep-batchprogress', role: 'status', 'aria-live': inFlight ? 'polite' : 'off' },
19
+ label + suffix);
20
+ }
21
+
22
+ // formatBatchOutcome — pure string helper for the aggregate toast shown once
23
+ // a batch settles: "N/total succeeded" plus a truncated failed-name list
24
+ // when any items failed, matching docstudio's attach-bar/bulk-action
25
+ // aggregation copy. Handles all three outcome shapes (all-succeed, all-fail,
26
+ // partial) and truncates a long failed-name list ("...and N more") instead
27
+ // of growing the toast unboundedly for a big batch.
28
+ export function formatBatchOutcome({ succeeded = 0, total = 0, failedNames = [], maxNames = 3 } = {}) {
29
+ if (total === 0) return '';
30
+ if (failedNames.length === 0) return `${succeeded}/${total} succeeded`;
31
+ const shown = failedNames.slice(0, maxNames).join(', ');
32
+ const more = failedNames.length > maxNames ? ` and ${failedNames.length - maxNames} more` : '';
33
+ return `${succeeded}/${total} succeeded; failed: ${shown}${more}`;
34
+ }
35
+
36
+ // runBatchSequential — pure async orchestration companion to
37
+ // BatchProgressLabel/formatBatchOutcome: runs `items` through `fn` one at a
38
+ // time (never in parallel, matching docstudio's rate-limited bulk-action
39
+ // runner), never aborting on a single item's failure. `onProgress({done,
40
+ // total})` fires after each item settles so a host can drive
41
+ // BatchProgressLabel live; the final `{succeeded, total, failedNames}`
42
+ // return shape feeds formatBatchOutcome directly. `fn` receives (item, index)
43
+ // and may reject/throw — a rejection is recorded as a failure keyed by
44
+ // `item.name != null ? item.name : String(item)`, never re-thrown.
45
+ export async function runBatchSequential(items = [], fn, onProgress) {
46
+ const total = items.length;
47
+ let succeeded = 0;
48
+ const failedNames = [];
49
+ for (let i = 0; i < total; i += 1) {
50
+ const item = items[i];
51
+ try {
52
+ await fn(item, i);
53
+ succeeded += 1;
54
+ } catch (err) {
55
+ failedNames.push(item && item.name != null ? item.name : String(item));
56
+ }
57
+ if (onProgress) onProgress({ done: i + 1, total });
58
+ }
59
+ return { succeeded, total, failedNames };
60
+ }
@@ -0,0 +1,146 @@
1
+ // Editor chrome — the action/navigation bars that frame an editor surface:
2
+ // Toolbar (three-slot), ToolbarRow (flat wrapping row), Tabs (WAI-ARIA tabs
3
+ // with a sliding underline), IconButtonGroup (segmented toggle row). Pure
4
+ // factories, h-based, theme-token driven. All visuals route through CSS
5
+ // classes defined in editor-primitives.css; no hex/rgba literals appear here.
6
+
7
+ import * as webjsx from '../../../vendor/webjsx/index.js';
8
+ import { Icon } from '../shell.js';
9
+ import { kids } from './shared.js';
10
+ const h = webjsx.createElement;
11
+
12
+ export function Toolbar({ leading = [], trailing = [], dense = false, children } = {}) {
13
+ const cls = 'ds-ep-toolbar' + (dense ? ' dense' : '');
14
+ return h('div', { class: cls, role: 'toolbar' },
15
+ h('div', { class: 'ds-ep-toolbar-leading' }, ...kids(leading)),
16
+ children != null ? h('div', { class: 'ds-ep-toolbar-center' }, ...kids(children)) : null,
17
+ h('div', { class: 'ds-ep-toolbar-trailing' }, ...kids(trailing))
18
+ );
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // ToolbarRow — a flat, wrapping row of arbitrary action nodes (buttons,
23
+ // inputs, chips) with no leading/center/trailing slot structure. Toolbar's
24
+ // three-slot split is the wrong shape when a caller just wants "this row of
25
+ // controls, left to right, wrapping on narrow viewports" — the exact shape
26
+ // gmsniff's panels.js hand-rolled as a bare '.gm-toolbar' div because Toolbar
27
+ // didn't cover it. Accepts children as varargs or a single array.
28
+ // ---------------------------------------------------------------------------
29
+ export function ToolbarRow(...actions) {
30
+ const flat = actions.length === 1 && Array.isArray(actions[0]) ? actions[0] : actions;
31
+ return h('div', { class: 'ds-ep-toolbar-row', role: 'toolbar' }, ...kids(flat));
32
+ }
33
+
34
+ export function Tabs({ items = [], active, onChange, children, 'aria-label': ariaLabel, onClose, scroll = false } = {}) {
35
+ // Roving tabindex + arrow nav per WAI-ARIA tabs pattern.
36
+ // Only the active tab is in the tab order; arrows move focus + activate.
37
+ const activeIdx = Math.max(0, items.findIndex(it => it.id === active));
38
+ const onTabKeyDown = (e, idx) => {
39
+ let next = null;
40
+ if (e.key === 'ArrowRight') next = (idx + 1) % items.length;
41
+ else if (e.key === 'ArrowLeft') next = (idx - 1 + items.length) % items.length;
42
+ else if (e.key === 'Home') next = 0;
43
+ else if (e.key === 'End') next = items.length - 1;
44
+ if (next == null) return;
45
+ e.preventDefault();
46
+ const nextId = items[next]?.id;
47
+ if (nextId && onChange) onChange(nextId);
48
+ // Move focus on next paint (so the newly rendered active button gets focus)
49
+ queueMicrotask(() => {
50
+ const head = e.currentTarget?.parentElement;
51
+ const btn = head?.querySelectorAll('[role="tab"]')[next];
52
+ if (btn) btn.focus();
53
+ });
54
+ };
55
+ // Position the sliding underline from the active tab's geometry. The ref is
56
+ // on the OUTER .ds-ep-tabs (position:relative) — the indicator is its child,
57
+ // NOT the flex head's (an abspos child of a horizontal flex row mis-sizes to
58
+ // 0 in Chromium). Runs on every render (ref fires after applyDiff) so a tab
59
+ // change re-measures; the CSS transition animates the move. left/width come
60
+ // from the active tab; top sits the bar on the head's bottom edge.
61
+ const positionSlider = (root) => {
62
+ if (!root) return;
63
+ const head = root.querySelector('.ds-ep-tabs-head');
64
+ const active = root.querySelector('.ds-ep-tab.active');
65
+ const ind = root.querySelector('.ds-ep-tab-indicator');
66
+ if (!head || !active || !ind) return;
67
+ // Size via left+right insets, NOT width: an abspos element's inline
68
+ // `width` computes to 0 in some flex-sibling layouts (Chromium), but
69
+ // left+right insets size reliably. left/right animate via the CSS
70
+ // transition on .ds-ep-tab-indicator.
71
+ const rootW = root.offsetWidth;
72
+ const l = active.offsetLeft, w = active.offsetWidth;
73
+ ind.style.left = l + 'px';
74
+ ind.style.right = Math.max(0, rootW - l - w) + 'px';
75
+ ind.style.top = (head.offsetTop + head.offsetHeight - 2) + 'px';
76
+ head.classList.add('has-slider');
77
+ };
78
+ // scroll=true: tabs size to content (min/max-width) instead of stretching
79
+ // equally (flex:1) — the shape pi-web's TabBar uses for an open-file strip
80
+ // where tab count is unbounded and overflow-x scroll (already on
81
+ // .ds-ep-tabs-head) needs real per-tab widths to have something to scroll.
82
+ // onClose: per-item close affordance — a close button plus middle-click
83
+ // (auxclick button 1) to close, matching pi-web's TabBar. Opt-in: passing
84
+ // onClose without scroll still renders close buttons on the flex:1 tabs.
85
+ const closable = typeof onClose === 'function';
86
+ return h('div', { class: 'ds-ep-tabs', ref: positionSlider },
87
+ h('div', { class: 'ds-ep-tabs-head' + (scroll ? ' scroll' : ''), role: 'tablist', 'aria-label': ariaLabel || 'tabs' },
88
+ ...items.map((it, idx) => h('span', {
89
+ key: it.id,
90
+ class: 'ds-ep-tab-wrap' + (it.id === active ? ' active' : ''),
91
+ onmousedown: closable ? (e) => { if (e.button === 1) e.preventDefault(); } : null,
92
+ onauxclick: closable ? (e) => {
93
+ if (e.button !== 1) return;
94
+ e.preventDefault();
95
+ e.stopPropagation();
96
+ onClose(it.id);
97
+ } : null
98
+ },
99
+ h('button', {
100
+ type: 'button',
101
+ class: 'ds-ep-tab' + (it.id === active ? ' active' : ''),
102
+ role: 'tab',
103
+ id: 'tab-' + it.id,
104
+ title: typeof it.label === 'string' ? it.label : undefined,
105
+ 'aria-selected': it.id === active ? 'true' : 'false',
106
+ 'aria-controls': 'tabpanel-' + it.id,
107
+ 'aria-label': typeof it.label === 'string' ? it.label : ('tab ' + (idx + 1)),
108
+ tabindex: idx === activeIdx ? '0' : '-1',
109
+ onclick: () => onChange && onChange(it.id),
110
+ onkeydown: (e) => onTabKeyDown(e, idx)
111
+ }, it.label),
112
+ closable ? h('button', {
113
+ type: 'button',
114
+ class: 'ds-ep-tab-close',
115
+ title: 'Close',
116
+ 'aria-label': 'Close ' + (typeof it.label === 'string' ? it.label : 'tab'),
117
+ onclick: (e) => { e.stopPropagation(); onClose(it.id); }
118
+ }, Icon('x', { size: 14 })) : null
119
+ ))
120
+ ),
121
+ // The sliding underline — child of the outer column (see positionSlider).
122
+ // Keyed + decorative. Renders at 0-width until positioned (no-JS: hidden).
123
+ h('span', { key: '__ind', class: 'ds-ep-tab-indicator', 'aria-hidden': 'true' }),
124
+ h('div', {
125
+ class: 'ds-ep-tabs-body',
126
+ role: 'tabpanel',
127
+ id: active ? 'tabpanel-' + active : undefined,
128
+ 'aria-labelledby': active ? 'tab-' + active : undefined,
129
+ tabindex: '0'
130
+ }, ...kids(children))
131
+ );
132
+ }
133
+
134
+ export function IconButtonGroup({ items = [], value, onChange, dense = false } = {}) {
135
+ return h('div', { class: 'ds-ep-btngrp' + (dense ? ' dense' : ''), role: 'group' },
136
+ ...items.map((it) => h('button', {
137
+ key: it.id,
138
+ type: 'button',
139
+ class: 'ds-ep-btngrp-btn' + (it.id === value ? ' active' : ''),
140
+ title: it.title || it.label || it.id,
141
+ 'aria-pressed': it.id === value ? 'true' : 'false',
142
+ disabled: it.disabled ? 'disabled' : null,
143
+ onclick: () => { if (!it.disabled && onChange) onChange(it.id); }
144
+ }, it.glyph != null ? it.glyph : it.label))
145
+ );
146
+ }
@@ -0,0 +1,46 @@
1
+ // Progressive disclosure — Collapse (a single controlled toggle panel) and
2
+ // CollapseGroup (the accordion wrapper enforcing single-open-at-a-time).
3
+ // Both are controlled: the caller owns `expanded`/`openId` state, same
4
+ // pattern as Drawer/Dialog's `open`/`onClose` — no internal state.
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ const h = webjsx.createElement;
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Collapse — progressive-disclosure toggle panel (screen-real-estate
11
+ // density: property inspectors, nested settings, FAQ-style panels).
12
+ // Controlled component: caller owns `expanded` state and passes `onToggle`,
13
+ // same pattern as Drawer/Dialog `open`/`onClose` above — no internal state.
14
+ // ---------------------------------------------------------------------------
15
+ export function Collapse({ title, expanded = false, onToggle, children, key } = {}) {
16
+ return h('div', { key, class: 'ds-ep-collapse' + (expanded ? ' is-expanded' : '') },
17
+ h('button', {
18
+ type: 'button', class: 'ds-ep-collapse-head',
19
+ 'aria-expanded': expanded ? 'true' : 'false',
20
+ onclick: () => { if (onToggle) onToggle(!expanded); },
21
+ },
22
+ h('span', { class: 'ds-ep-collapse-chevron' }, expanded ? 'v' : '>'),
23
+ h('span', { class: 'ds-ep-collapse-title' }, title)),
24
+ expanded ? h('div', { class: 'ds-ep-collapse-body' }, children) : null);
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // CollapseGroup — accordion wrapper enforcing single-open-at-a-time when
29
+ // `accordion=true` (default false — group just lays out children, each
30
+ // Collapse still individually controlled). `openId`/`onOpenChange` drive the
31
+ // accordion; `items` is [{id, title, children}].
32
+ // ---------------------------------------------------------------------------
33
+ export function CollapseGroup({ items = [], openId, onOpenChange, accordion = false, key } = {}) {
34
+ return h('div', { key, class: 'ds-ep-collapse-group' },
35
+ ...items.map((it) => Collapse({
36
+ key: it.id,
37
+ title: it.title,
38
+ expanded: accordion ? it.id === openId : Boolean(it.expanded),
39
+ onToggle: (next) => {
40
+ if (!onOpenChange) return;
41
+ if (accordion) onOpenChange(next ? it.id : null);
42
+ else onOpenChange(it.id, next);
43
+ },
44
+ children: it.children,
45
+ })));
46
+ }
@@ -0,0 +1,127 @@
1
+ // Context menu — the right-click/long-press menu surface: ContextMenu (the
2
+ // viewport-clamped, keyboard-navigable menu itself) plus useContextMenu, the
3
+ // helper that wires right-click + long-press on a target element and hands
4
+ // the caller an {x, y, items} payload to render it from.
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ const h = webjsx.createElement;
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // ContextMenu — items, anchor {x,y}, onClose. Viewport-clamped. Keyboard nav.
11
+ // ---------------------------------------------------------------------------
12
+ export function ContextMenu({ items = [], anchor = { x: 0, y: 0 }, onClose } = {}) {
13
+ let rootEl = null;
14
+ const close = () => { if (onClose) onClose(); };
15
+ const select = (it) => {
16
+ if (it.disabled || it.separator) return;
17
+ if (it.onSelect) it.onSelect();
18
+ close();
19
+ };
20
+ const onKey = (e) => {
21
+ const btns = rootEl ? [...rootEl.querySelectorAll('button[data-ix]')] : [];
22
+ const active = document.activeElement;
23
+ const idx = btns.indexOf(active);
24
+ if (e.key === 'Escape') { e.preventDefault(); close(); }
25
+ else if (e.key === 'ArrowDown') { e.preventDefault(); (btns[(idx + 1) % btns.length] || btns[0])?.focus(); }
26
+ else if (e.key === 'ArrowUp') { e.preventDefault(); (btns[(idx - 1 + btns.length) % btns.length] || btns[0])?.focus(); }
27
+ else if (e.key === 'Enter' && idx >= 0) { e.preventDefault(); btns[idx].click(); }
28
+ };
29
+ return h('div', {
30
+ class: 'ds-ep-ctxmenu-backdrop',
31
+ onmousedown: (e) => { if (e.target === e.currentTarget) close(); },
32
+ oncontextmenu: (e) => { e.preventDefault(); close(); },
33
+ },
34
+ h('div', {
35
+ class: 'ds-ep-ctxmenu',
36
+ role: 'menu',
37
+ tabindex: '-1',
38
+ onkeydown: onKey,
39
+ ref: (el) => {
40
+ if (!el) {
41
+ // Unmount: unhook the resize re-clamp bound on mount.
42
+ if (rootEl && rootEl._dsCtxClampOff) { rootEl._dsCtxClampOff(); }
43
+ rootEl = null;
44
+ return;
45
+ }
46
+ rootEl = el;
47
+ // Position at the anchor immediately, then clamp once layout has
48
+ // settled — measuring synchronously in ref reads a zero-size box
49
+ // (children not yet painted), so the clamp must run post-layout.
50
+ const ax = anchor.x || 0, ay = anchor.y || 0;
51
+ el.style.left = ax + 'px';
52
+ el.style.top = ay + 'px';
53
+ const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
54
+ const clamp = () => {
55
+ const vw = window.innerWidth, vh = window.innerHeight;
56
+ const r = el.getBoundingClientRect();
57
+ let x = ax, y = ay;
58
+ // Touch: keep the menu clear of the lifting finger — nudge
59
+ // below the touch point, or open above when it fits and the
60
+ // anchor sits in the lower half (lift-off would otherwise
61
+ // activate the first item).
62
+ if (coarse) {
63
+ y = ay + 10;
64
+ if (ay > vh / 2 && ay - r.height >= 4) y = ay - r.height;
65
+ }
66
+ if (x + r.width > vw) x = Math.max(4, vw - r.width - 4);
67
+ if (y + r.height > vh) y = Math.max(4, vh - r.height - 4);
68
+ el.style.left = x + 'px';
69
+ el.style.top = y + 'px';
70
+ };
71
+ requestAnimationFrame(clamp);
72
+ // Re-clamp on resize/orientation change for the menu's lifetime.
73
+ window.addEventListener('resize', clamp);
74
+ el._dsCtxClampOff = () => { window.removeEventListener('resize', clamp); el._dsCtxClampOff = null; };
75
+ // setTimeout(0), not queueMicrotask: the triggering contextmenu/click
76
+ // event's own default focus can otherwise win the race and leave focus
77
+ // outside the menu, breaking keyboard arrow-nav/Escape.
78
+ setTimeout(() => { el.querySelector('button[data-ix]')?.focus(); }, 0);
79
+ }
80
+ },
81
+ ...items.map((it, i) => it.separator
82
+ ? h('div', { key: 'sep' + i, class: 'ds-ep-ctxmenu-sep', role: 'separator' })
83
+ : h('button', {
84
+ key: i, type: 'button', role: 'menuitem',
85
+ 'data-ix': String(i),
86
+ class: 'ds-ep-ctxmenu-item' + (it.danger ? ' danger' : '') + (it.disabled ? ' disabled' : ''),
87
+ disabled: it.disabled ? 'disabled' : null,
88
+ onclick: () => select(it),
89
+ },
90
+ it.icon != null ? h('span', { class: 'ds-ep-ctxmenu-icon' }, it.icon) : null,
91
+ h('span', { class: 'ds-ep-ctxmenu-label' }, it.label)
92
+ ))
93
+ )
94
+ );
95
+ }
96
+
97
+ // Helper: wires right-click + long-press to a target ref. Caller manages state.
98
+ export function useContextMenu(targetEl, items, openCb) {
99
+ if (!targetEl) return () => {};
100
+ let touchTimer = null, lastOpen = 0;
101
+ // Android fires the native contextmenu event on long-press AND our 500ms
102
+ // touch timer — dedupe so the menu opens once, not twice (open/flicker).
103
+ const open = (x, y) => {
104
+ if (Date.now() - lastOpen < 700) return;
105
+ lastOpen = Date.now();
106
+ if (openCb) openCb({ x, y, items });
107
+ };
108
+ const onCtx = (e) => { e.preventDefault(); open(e.clientX, e.clientY); };
109
+ const onTouchStart = (e) => {
110
+ const t = e.touches && e.touches[0]; if (!t) return;
111
+ touchTimer = setTimeout(() => { touchTimer = null; open(t.clientX, t.clientY); }, 500);
112
+ };
113
+ const cancel = () => { if (touchTimer) { clearTimeout(touchTimer); touchTimer = null; } };
114
+ targetEl.addEventListener('contextmenu', onCtx);
115
+ targetEl.addEventListener('touchstart', onTouchStart, { passive: true });
116
+ targetEl.addEventListener('touchmove', cancel, { passive: true });
117
+ targetEl.addEventListener('touchend', cancel);
118
+ targetEl.addEventListener('touchcancel', cancel);
119
+ return () => {
120
+ targetEl.removeEventListener('contextmenu', onCtx);
121
+ targetEl.removeEventListener('touchstart', onTouchStart);
122
+ targetEl.removeEventListener('touchmove', cancel);
123
+ targetEl.removeEventListener('touchend', cancel);
124
+ targetEl.removeEventListener('touchcancel', cancel);
125
+ cancel();
126
+ };
127
+ }
@@ -0,0 +1,44 @@
1
+ // ---------------------------------------------------------------------------
2
+ // InfoRow / InfoSection / DiagnosticsPanel — static debug/system-info
3
+ // readouts: a bordered section of label + monospace-value rows. Ported from
4
+ // docstudio's diagnostics page (auth/streaming/service-worker state, recent
5
+ // client errors, environment facts). Distinct from PropertyGrid, which is
6
+ // for EDITABLE properties — these rows are read-only display, never inputs.
7
+ // `data == null` (not yet loaded) renders a loading placeholder row instead
8
+ // of an empty section, so a panel never flashes an empty bordered box before
9
+ // its first data arrives; `onRefresh` renders a trailing refresh button that
10
+ // reuses the section's own header row rather than shifting layout beneath it.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import * as webjsx from '../../../vendor/webjsx/index.js';
14
+ const h = webjsx.createElement;
15
+
16
+ export function InfoRow({ label, value, key } = {}) {
17
+ return h('div', { key, class: 'ds-ep-inforow' },
18
+ h('span', { class: 'ds-ep-inforow-label' }, label),
19
+ h('span', { class: 'ds-ep-inforow-value' }, value == null || value === '' ? '—' : String(value)));
20
+ }
21
+
22
+ export function InfoSection({ title, rows, key } = {}) {
23
+ const children = [
24
+ title ? h('h3', { key: 'title', class: 'ds-ep-infosection-title' }, title) : null,
25
+ rows == null
26
+ ? h('div', { key: 'body-loading', class: 'ds-ep-infosection-loading', role: 'status' }, 'Loading…')
27
+ : h('div', { key: 'body-rows', class: 'ds-ep-infosection-rows' }, ...rows.map((r, i) => InfoRow({ ...r, key: r.key != null ? r.key : i })))
28
+ ].filter(Boolean);
29
+ return h('section', { key, class: 'ds-ep-infosection' }, ...children);
30
+ }
31
+
32
+ export function DiagnosticsPanel({ title = 'Diagnostics', sections = [], onRefresh, refreshing = false, key } = {}) {
33
+ const headChildren = [
34
+ h('h2', { key: 'title', class: 'ds-ep-diagnostics-title' }, title),
35
+ onRefresh ? h('button', {
36
+ key: 'refresh', type: 'button', class: 'ds-ep-diagnostics-refresh', disabled: refreshing, 'aria-busy': refreshing ? 'true' : 'false',
37
+ onclick: () => onRefresh()
38
+ }, refreshing ? 'Refreshing…' : 'Refresh') : null
39
+ ].filter(Boolean);
40
+ return h('div', { key, class: 'ds-ep-diagnostics' },
41
+ h('div', { key: 'head', class: 'ds-ep-diagnostics-head' }, ...headChildren),
42
+ ...sections.map((s, i) => InfoSection({ ...s, key: s.key || i }))
43
+ );
44
+ }
@@ -0,0 +1,26 @@
1
+ // ---------------------------------------------------------------------------
2
+ // FocusTrap — wraps subtree, traps Tab/Shift+Tab. Mount/unmount lifecycle is
3
+ // managed via DOM-level keydown listener attached when first focused.
4
+ // ---------------------------------------------------------------------------
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ import { kids, FOCUSABLE_SEL, trapTabKey } from './shared.js';
8
+ const h = webjsx.createElement;
9
+
10
+ export function FocusTrap({ children } = {}) {
11
+ return h('div', {
12
+ class: 'ds-ep-focustrap',
13
+ tabindex: '-1',
14
+ ref: (el) => {
15
+ if (!el || el._dsTrap) return;
16
+ el._dsTrap = true;
17
+ el.addEventListener('keydown', (e) => trapTabKey(el, e));
18
+ // Auto-focus first focusable
19
+ queueMicrotask(() => {
20
+ const first = el.querySelector(FOCUSABLE_SEL);
21
+ if (first) first.focus();
22
+ else el.focus();
23
+ });
24
+ }
25
+ }, ...kids(children));
26
+ }
@@ -0,0 +1,123 @@
1
+ // ---------------------------------------------------------------------------
2
+ // JsonViewer — monospace data preview (max-height + scroll), generalizing
3
+ // gmsniff's gm-json. Accepts a pre-stringified string OR any value
4
+ // (objects/arrays get JSON.stringify(v, null, 2); null/undefined render the
5
+ // empty-state text rather than the literal string "undefined"/"null").
6
+ //
7
+ // mode selects rendering; 'plain' is the historical contract (children[0] is
8
+ // the raw text string, verbatim for string input) and stays the default so
9
+ // every existing consumer is untouched:
10
+ // 'plain' — flat <pre>, raw text.
11
+ // 'highlight' — flat <pre>, text tokenized into ds-ep-json-* spans
12
+ // (key/string/number/boolean/null). A string that does not
13
+ // parse as JSON falls back to plain text — arbitrary prose is
14
+ // never falsely tokenized.
15
+ // 'tree' — collapsible <details> tree per nested object/array, open
16
+ // above treeDepth (default 2), each summary carrying a
17
+ // child-count tag. Scalars/unparseable input fall back to
18
+ // 'highlight'/plain respectively.
19
+ // copyable=true wraps the viewer with a copy-to-clipboard button (transient
20
+ // copied/failed feedback, no dependencies).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ import * as webjsx from '../../../vendor/webjsx/index.js';
24
+ const h = webjsx.createElement;
25
+
26
+ const JSON_NUM_CHARS = '0123456789eE+.-';
27
+
28
+ // Linear single-pass scan — no regex, no backtracking, safe on truncated
29
+ // input (an unterminated string just consumes to end-of-text).
30
+ function tokenizeJson(text) {
31
+ const toks = [];
32
+ let i = 0, plain = '';
33
+ const flush = () => { if (plain) { toks.push(['', plain]); plain = ''; } };
34
+ while (i < text.length) {
35
+ const c = text[i];
36
+ if (c === '"') {
37
+ const start = i;
38
+ i++;
39
+ while (i < text.length && text[i] !== '"') { if (text[i] === '\\') i++; i++; }
40
+ i = Math.min(i + 1, text.length);
41
+ let j = i;
42
+ while (j < text.length && (text[j] === ' ' || text[j] === '\t' || text[j] === '\n' || text[j] === '\r')) j++;
43
+ flush();
44
+ toks.push([text[j] === ':' ? 'k' : 's', text.slice(start, i)]);
45
+ continue;
46
+ }
47
+ if (c === '-' || (c >= '0' && c <= '9')) {
48
+ const start = i;
49
+ i++;
50
+ while (i < text.length && JSON_NUM_CHARS.includes(text[i])) i++;
51
+ flush();
52
+ toks.push(['n', text.slice(start, i)]);
53
+ continue;
54
+ }
55
+ if (text.startsWith('true', i)) { flush(); toks.push(['b', 'true']); i += 4; continue; }
56
+ if (text.startsWith('false', i)) { flush(); toks.push(['b', 'false']); i += 5; continue; }
57
+ if (text.startsWith('null', i)) { flush(); toks.push(['z', 'null']); i += 4; continue; }
58
+ plain += c; i++;
59
+ }
60
+ flush();
61
+ return toks;
62
+ }
63
+
64
+ function highlightJsonSpans(text) {
65
+ return tokenizeJson(text).map(([t, s]) => t ? h('span', { class: 'ds-ep-json-' + t }, s) : s);
66
+ }
67
+
68
+ function jsonTreeNode(key, val, depth, treeDepth) {
69
+ const keyParts = key != null ? [h('span', { class: 'ds-ep-json-k' }, JSON.stringify(key)), ': '] : [];
70
+ if (val !== null && typeof val === 'object') {
71
+ const isArr = Array.isArray(val);
72
+ const entries = isArr ? val.map((v) => [null, v]) : Object.entries(val);
73
+ if (!entries.length) return h('div', { class: 'ds-ep-json-leaf' }, ...keyParts, isArr ? '[]' : '{}');
74
+ const tag = isArr ? '[' + entries.length + ']' : '{' + entries.length + '}';
75
+ return h('details', { class: 'ds-ep-json-node', open: depth < treeDepth ? true : null },
76
+ h('summary', { class: 'ds-ep-json-sum' }, ...keyParts, h('span', { class: 'ds-ep-json-tag' }, tag)),
77
+ h('div', { class: 'ds-ep-json-kids' }, ...entries.map(([k, v]) => jsonTreeNode(k, v, depth + 1, treeDepth))));
78
+ }
79
+ const t = typeof val === 'string' ? 's' : typeof val === 'number' ? 'n' : typeof val === 'boolean' ? 'b' : 'z';
80
+ return h('div', { class: 'ds-ep-json-leaf' }, ...keyParts, h('span', { class: 'ds-ep-json-' + t }, JSON.stringify(val) ?? String(val)));
81
+ }
82
+
83
+ function jsonCopyButton(text) {
84
+ return h('button', {
85
+ type: 'button', class: 'ds-ep-json-copy', title: 'copy JSON', 'aria-label': 'copy JSON',
86
+ onclick: (e) => {
87
+ const btn = e.currentTarget;
88
+ const show = (label, ok) => {
89
+ btn.textContent = label;
90
+ btn.classList.toggle('copied', ok);
91
+ setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('copied'); }, 1200);
92
+ };
93
+ try {
94
+ navigator.clipboard.writeText(text).then(() => show('copied', true), () => show('failed', false));
95
+ } catch {
96
+ show('failed', false);
97
+ }
98
+ },
99
+ }, 'copy');
100
+ }
101
+
102
+ export function JsonViewer({ value, emptyText = 'no data', maxHeight, mode = 'plain', copyable = false, treeDepth = 2 } = {}) {
103
+ let text, parsed;
104
+ let knownJson = false;
105
+ if (value == null) text = null;
106
+ else if (typeof value === 'string') text = value;
107
+ else { try { text = JSON.stringify(value, null, 2); knownJson = text != null; parsed = value; } catch { text = String(value); } }
108
+ if (!text) return h('div', { class: 'ds-ep-json ds-ep-json-empty' }, emptyText);
109
+ const style = maxHeight ? ('max-height:' + maxHeight) : null;
110
+ if (!knownJson && (mode === 'highlight' || mode === 'tree')) {
111
+ try { parsed = JSON.parse(text); knownJson = true; } catch { /* swallow: not JSON — render plain */ }
112
+ }
113
+ let body;
114
+ if (mode === 'tree' && knownJson && parsed !== null && typeof parsed === 'object') {
115
+ body = h('div', { class: 'ds-ep-json ds-ep-json-tree', style }, jsonTreeNode(null, parsed, 0, treeDepth));
116
+ } else if ((mode === 'highlight' || mode === 'tree') && knownJson) {
117
+ body = h('pre', { class: 'ds-ep-json ds-ep-json-hl', style }, ...highlightJsonSpans(text));
118
+ } else {
119
+ body = h('pre', { class: 'ds-ep-json', style }, text);
120
+ }
121
+ if (!copyable) return body;
122
+ return h('div', { class: 'ds-ep-json-wrap' }, jsonCopyButton(text), body);
123
+ }