@bakapiano/ccsm 0.22.2 → 0.22.4

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 (60) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +233 -231
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +592 -592
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +187 -15
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/XtermTerminal.js +148 -14
  44. package/public/js/components/useDragSort.js +67 -67
  45. package/public/js/dialog.js +67 -67
  46. package/public/js/icons.js +212 -212
  47. package/public/js/main.js +296 -296
  48. package/public/js/pages/AboutPage.js +90 -90
  49. package/public/js/pages/ConfigurePage.js +713 -713
  50. package/public/js/pages/LaunchPage.js +421 -421
  51. package/public/js/pages/RemotePage.js +743 -743
  52. package/public/js/pages/SessionsPage.js +100 -100
  53. package/public/js/state.js +335 -335
  54. package/public/manifest.webmanifest +25 -0
  55. package/public/setup/index.html +567 -0
  56. package/scripts/dev.js +149 -149
  57. package/scripts/install.js +153 -153
  58. package/scripts/restart-helper.js +96 -96
  59. package/scripts/upgrade-helper.js +687 -687
  60. package/server.js +1807 -1807
@@ -1,128 +1,128 @@
1
- // Full-screen blocker shown on the remote browser while waiting for
2
- // the host to approve this device. Triggered by api.js setting the
3
- // pendingDevice signal in response to a 403 {pending:true}.
4
- //
5
- // While visible, polls /api/devices/me every 3s. When the server
6
- // flips status to 'approved', the next polled api() call clears
7
- // pendingDevice (api.js does that on any 2xx response), the overlay
8
- // unmounts, and the rest of the app keeps loading.
9
- //
10
- // Reuses .offline-overlay / .offline-card classes so styling matches
11
- // the existing HealthOverlay's blocking modal aesthetic.
12
-
13
- import { html } from '../html.js';
14
- import { useEffect } from 'preact/hooks';
15
- import { api, pendingDevice, loadConfig, refreshAll } from '../api.js';
16
- import { getDeviceCode } from '../backend.js';
17
- import { BrandMark } from '../icons.js';
18
-
19
- const POLL_MS = 3000;
20
-
21
- export function PendingApprovalOverlay() {
22
- const p = pendingDevice.value;
23
-
24
- useEffect(() => {
25
- if (!p) return;
26
- let stopped = false;
27
- const tick = async () => {
28
- if (stopped) return;
29
- try {
30
- // /api/devices/me is gate-exempt: returns 200 with the current
31
- // device record regardless of approval state. We inspect the
32
- // body ourselves to decide whether to dismiss the overlay.
33
- const d = await api('GET', '/api/devices/me');
34
- if (d && d.status === 'approved') {
35
- pendingDevice.value = null;
36
- // First load failed because we weren't approved yet — main.js'
37
- // boot tried /api/config and got 401, no auto-retry. Now that
38
- // we're through the gate, kick a one-shot load so config +
39
- // sessions/folders/workspaces hydrate without waiting for the
40
- // 5s tick (and config without that ever happening, since the
41
- // periodic loop doesn't include loadConfig).
42
- loadConfig().catch(() => {});
43
- refreshAll().catch(() => {});
44
- } else if (d) {
45
- // Preserve any fields already set (firstSeen survives the
46
- // overlay's repeated polls; staleToken from a previous tick
47
- // gets cleared since we just got a 200).
48
- pendingDevice.value = {
49
- ...(pendingDevice.value || {}),
50
- pending: d.status === 'pending',
51
- rejected: d.status === 'rejected',
52
- deviceId: d.id,
53
- firstSeen: d.firstSeen,
54
- staleToken: false,
55
- at: Date.now(),
56
- };
57
- }
58
- } catch (e) {
59
- // /me 401s in exactly one situation: our device record was
60
- // pruned (24h pending TTL) AND the host's current token no
61
- // longer matches the one in our localStorage. We can't
62
- // self-recover from this — a fresh share URL is needed. Surface
63
- // it so the user gets honest feedback instead of an indefinite
64
- // "Waiting for approval" loop.
65
- const msg = String(e?.message || '');
66
- if (/401/.test(msg) || /token/i.test(msg)) {
67
- pendingDevice.value = {
68
- ...(pendingDevice.value || {}),
69
- staleToken: true,
70
- pending: false,
71
- rejected: false,
72
- at: Date.now(),
73
- };
74
- }
75
- /* anything else: network blip — try again next tick */
76
- }
77
- };
78
- const id = setInterval(tick, POLL_MS);
79
- tick();
80
- return () => { stopped = true; clearInterval(id); };
81
- }, [!!p]);
82
-
83
- if (!p) return null;
84
-
85
- const rejected = !!p.rejected;
86
- const staleToken = !!p.staleToken;
87
- const firstSeen = p.firstSeen ? new Date(p.firstSeen).toLocaleTimeString() : null;
88
-
89
- return html`
90
- <div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
91
- <div class="offline-card">
92
- <div class="offline-brand"><${BrandMark} /></div>
93
- ${staleToken ? html`
94
- <h1 class="offline-title">Link expired</h1>
95
- <p class="offline-copy">
96
- This share link no longer works — the host either rotated the
97
- registration token or this device was pruned after sitting
98
- unapproved for 24 hours.
99
- </p>
100
- <p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
101
- Ask the operator to send a fresh share URL from the Remote page.
102
- </p>
103
- ` : rejected ? html`
104
- <h1 class="offline-title">Access declined</h1>
105
- <p class="offline-copy">
106
- The host machine rejected this device. If you think this was a
107
- mistake, ask the operator to re-approve from the Remote page.
108
- </p>
109
- ` : html`
110
- <h1 class="offline-title">Waiting for host approval</h1>
111
- <p class="offline-copy">
112
- The host machine got your request${firstSeen ? ` at ${firstSeen}` : ''}.
113
- Approve this device from the Remote page over there to continue.
114
- </p>
115
- ${getDeviceCode() ? html`
116
- <div class="offline-code-block">
117
- <div class="offline-code-label">Your code</div>
118
- <div class="offline-code">${getDeviceCode()}</div>
119
- <div class="offline-code-hint">Match this against what the host sees before they Approve.</div>
120
- </div>
121
- ` : null}
122
- <p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
123
- We'll auto-unlock the moment the host clicks Approve.
124
- </p>
125
- `}
126
- </div>
127
- </div>`;
128
- }
1
+ // Full-screen blocker shown on the remote browser while waiting for
2
+ // the host to approve this device. Triggered by api.js setting the
3
+ // pendingDevice signal in response to a 403 {pending:true}.
4
+ //
5
+ // While visible, polls /api/devices/me every 3s. When the server
6
+ // flips status to 'approved', the next polled api() call clears
7
+ // pendingDevice (api.js does that on any 2xx response), the overlay
8
+ // unmounts, and the rest of the app keeps loading.
9
+ //
10
+ // Reuses .offline-overlay / .offline-card classes so styling matches
11
+ // the existing HealthOverlay's blocking modal aesthetic.
12
+
13
+ import { html } from '../html.js';
14
+ import { useEffect } from 'preact/hooks';
15
+ import { api, pendingDevice, loadConfig, refreshAll } from '../api.js';
16
+ import { getDeviceCode } from '../backend.js';
17
+ import { BrandMark } from '../icons.js';
18
+
19
+ const POLL_MS = 3000;
20
+
21
+ export function PendingApprovalOverlay() {
22
+ const p = pendingDevice.value;
23
+
24
+ useEffect(() => {
25
+ if (!p) return;
26
+ let stopped = false;
27
+ const tick = async () => {
28
+ if (stopped) return;
29
+ try {
30
+ // /api/devices/me is gate-exempt: returns 200 with the current
31
+ // device record regardless of approval state. We inspect the
32
+ // body ourselves to decide whether to dismiss the overlay.
33
+ const d = await api('GET', '/api/devices/me');
34
+ if (d && d.status === 'approved') {
35
+ pendingDevice.value = null;
36
+ // First load failed because we weren't approved yet — main.js'
37
+ // boot tried /api/config and got 401, no auto-retry. Now that
38
+ // we're through the gate, kick a one-shot load so config +
39
+ // sessions/folders/workspaces hydrate without waiting for the
40
+ // 5s tick (and config without that ever happening, since the
41
+ // periodic loop doesn't include loadConfig).
42
+ loadConfig().catch(() => {});
43
+ refreshAll().catch(() => {});
44
+ } else if (d) {
45
+ // Preserve any fields already set (firstSeen survives the
46
+ // overlay's repeated polls; staleToken from a previous tick
47
+ // gets cleared since we just got a 200).
48
+ pendingDevice.value = {
49
+ ...(pendingDevice.value || {}),
50
+ pending: d.status === 'pending',
51
+ rejected: d.status === 'rejected',
52
+ deviceId: d.id,
53
+ firstSeen: d.firstSeen,
54
+ staleToken: false,
55
+ at: Date.now(),
56
+ };
57
+ }
58
+ } catch (e) {
59
+ // /me 401s in exactly one situation: our device record was
60
+ // pruned (24h pending TTL) AND the host's current token no
61
+ // longer matches the one in our localStorage. We can't
62
+ // self-recover from this — a fresh share URL is needed. Surface
63
+ // it so the user gets honest feedback instead of an indefinite
64
+ // "Waiting for approval" loop.
65
+ const msg = String(e?.message || '');
66
+ if (/401/.test(msg) || /token/i.test(msg)) {
67
+ pendingDevice.value = {
68
+ ...(pendingDevice.value || {}),
69
+ staleToken: true,
70
+ pending: false,
71
+ rejected: false,
72
+ at: Date.now(),
73
+ };
74
+ }
75
+ /* anything else: network blip — try again next tick */
76
+ }
77
+ };
78
+ const id = setInterval(tick, POLL_MS);
79
+ tick();
80
+ return () => { stopped = true; clearInterval(id); };
81
+ }, [!!p]);
82
+
83
+ if (!p) return null;
84
+
85
+ const rejected = !!p.rejected;
86
+ const staleToken = !!p.staleToken;
87
+ const firstSeen = p.firstSeen ? new Date(p.firstSeen).toLocaleTimeString() : null;
88
+
89
+ return html`
90
+ <div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
91
+ <div class="offline-card">
92
+ <div class="offline-brand"><${BrandMark} /></div>
93
+ ${staleToken ? html`
94
+ <h1 class="offline-title">Link expired</h1>
95
+ <p class="offline-copy">
96
+ This share link no longer works — the host either rotated the
97
+ registration token or this device was pruned after sitting
98
+ unapproved for 24 hours.
99
+ </p>
100
+ <p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
101
+ Ask the operator to send a fresh share URL from the Remote page.
102
+ </p>
103
+ ` : rejected ? html`
104
+ <h1 class="offline-title">Access declined</h1>
105
+ <p class="offline-copy">
106
+ The host machine rejected this device. If you think this was a
107
+ mistake, ask the operator to re-approve from the Remote page.
108
+ </p>
109
+ ` : html`
110
+ <h1 class="offline-title">Waiting for host approval</h1>
111
+ <p class="offline-copy">
112
+ The host machine got your request${firstSeen ? ` at ${firstSeen}` : ''}.
113
+ Approve this device from the Remote page over there to continue.
114
+ </p>
115
+ ${getDeviceCode() ? html`
116
+ <div class="offline-code-block">
117
+ <div class="offline-code-label">Your code</div>
118
+ <div class="offline-code">${getDeviceCode()}</div>
119
+ <div class="offline-code-hint">Match this against what the host sees before they Approve.</div>
120
+ </div>
121
+ ` : null}
122
+ <p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
123
+ We'll auto-unlock the moment the host clicks Approve.
124
+ </p>
125
+ `}
126
+ </div>
127
+ </div>`;
128
+ }
@@ -1,179 +1,179 @@
1
- // Unified picker used inside a Popover. Renders:
2
- // - optional search box (filters items by `label` + `meta`)
3
- // - scrollable item list (single or multi select)
4
- // - footer "+ New <thing>" that expands an inline form
5
- //
6
- // items: [{ id, label, meta?, disabled? }]
7
- // onSelect(id) — single select. Closes popover.
8
- // onToggle(id, on) — multi select. Doesn't close.
9
- // selectedIds: Set<string> — for multi select highlight
10
- // selectedId: string — for single select highlight
11
- // onCreate(values) — async; returns the newly-created id (selected immediately
12
- // in single mode; added to selection in multi mode)
13
- // createFields: [{ key, label, type?, placeholder?, required? }]
14
-
15
- import { html } from '../html.js';
16
- import { useState, useRef, useEffect } from 'preact/hooks';
17
- import { IconSearch, IconPlus, IconClose } from '../icons.js';
18
-
19
- export function PickerPanel({
20
- title,
21
- items,
22
- selectedId,
23
- selectedIds,
24
- multi = false,
25
- showSearch = true,
26
- emptyHint = 'Nothing yet.',
27
- createLabel = '+ New',
28
- createFields = [],
29
- onSelect,
30
- onToggle,
31
- onCreate,
32
- onClose,
33
- dnd,
34
- }) {
35
- const [q, setQ] = useState('');
36
- const [creating, setCreating] = useState(false);
37
- const [draft, setDraft] = useState(() => initialDraft(createFields));
38
- const [saving, setSaving] = useState(false);
39
- const searchRef = useRef(null);
40
-
41
- useEffect(() => {
42
- if (showSearch) searchRef.current?.focus();
43
- }, [showSearch]);
44
-
45
- const filtered = items.filter((it) => {
46
- if (!q.trim()) return true;
47
- const hay = (it.label + ' ' + (it.meta || '')).toLowerCase();
48
- return hay.includes(q.trim().toLowerCase());
49
- });
50
-
51
- const submitCreate = async (ev) => {
52
- ev?.preventDefault?.();
53
- for (const f of createFields) {
54
- if (f.required && !String(draft[f.key] || '').trim()) return;
55
- }
56
- setSaving(true);
57
- try {
58
- const id = await onCreate?.(draft);
59
- setDraft(initialDraft(createFields));
60
- setCreating(false);
61
- if (id != null) {
62
- if (multi) onToggle?.(id, true);
63
- else { onSelect?.(id); onClose?.(); }
64
- }
65
- } catch (e) {
66
- // Caller is expected to toast; we just stay open with the form
67
- console.warn(e);
68
- } finally {
69
- setSaving(false);
70
- }
71
- };
72
-
73
- return html`
74
- <div class="picker">
75
- ${title ? html`<div class="picker-title">${title}</div>` : null}
76
-
77
- ${showSearch ? html`
78
- <div class="picker-search">
79
- <span class="picker-search-icon"><${IconSearch} /></span>
80
- <input ref=${searchRef} class="picker-search-input"
81
- placeholder="Search…" value=${q}
82
- onInput=${(e) => setQ(e.target.value)} />
83
- ${q ? html`<button class="picker-search-clear" onClick=${() => setQ('')}>
84
- <${IconClose} />
85
- </button>` : null}
86
- </div>` : null}
87
-
88
- <div class="picker-list">
89
- ${filtered.length === 0 ? html`
90
- <div class="picker-empty">${q ? 'No matches.' : emptyHint}</div>
91
- ` : filtered.map((it) => {
92
- const isSel = multi ? selectedIds?.has(it.id) : selectedId === it.id;
93
- const enableDnd = dnd && !it.disabled && !it.undraggable && !q.trim();
94
- const rowProps = enableDnd ? dnd.rowProps(it.id) : {};
95
- const handleProps = enableDnd ? dnd.handleProps(it.id) : {};
96
- return html`
97
- <div key=${it.id} class=${`picker-item-wrap${enableDnd ? ' is-draggable' : ''}`} ...${rowProps} ...${handleProps}>
98
- ${enableDnd ? html`<span class="picker-item-grip" aria-hidden="true">⋮⋮</span>` : null}
99
- <button type="button"
100
- class=${`picker-item${isSel ? ' is-selected' : ''}`}
101
- disabled=${it.disabled}
102
- onClick=${() => {
103
- if (multi) onToggle?.(it.id, !isSel);
104
- else { onSelect?.(it.id); onClose?.(); }
105
- }}>
106
- ${it.icon ? html`<span class="picker-item-icon">${it.icon}</span>` : null}
107
- <span class="picker-item-label">${it.label}</span>
108
- ${it.meta ? html`<span class="picker-item-meta">${it.meta}</span>` : null}
109
- ${isSel ? html`<span class="picker-item-check">✓</span>` : null}
110
- </button>
111
- </div>`;
112
- })}
113
- </div>
114
-
115
- ${onCreate ? html`
116
- <div class="picker-create">
117
- ${!creating ? html`
118
- <button class="picker-create-toggle" type="button"
119
- onClick=${() => setCreating(true)}>
120
- <${IconPlus} />
121
- <span>${createLabel}</span>
122
- </button>
123
- ` : html`
124
- <form class="picker-create-form" onSubmit=${submitCreate}>
125
- ${createFields.map((f) => html`
126
- <label class="picker-field" key=${f.key}>
127
- <span class="picker-field-label">${f.label}</span>
128
- ${f.type === 'select' ? html`
129
- <select class="input" value=${draft[f.key] || ''}
130
- onChange=${(e) => {
131
- const next = { ...draft, [f.key]: e.target.value };
132
- const side = f.onChange?.(e.target.value, next);
133
- setDraft(side ? { ...next, ...side } : next);
134
- }}>
135
- ${(f.options || []).map((opt) => html`
136
- <option value=${opt.value}>${opt.label}</option>`)}
137
- </select>
138
- ` : f.type === 'iconRadio' ? html`
139
- <div class="icon-radio">
140
- ${(f.options || []).map((opt) => html`
141
- <button type="button" key=${opt.value}
142
- class=${`icon-radio-opt${draft[f.key] === opt.value ? ' is-active' : ''}`}
143
- onClick=${() => {
144
- const next = { ...draft, [f.key]: opt.value };
145
- const side = f.onChange?.(opt.value, next);
146
- setDraft(side ? { ...next, ...side } : next);
147
- }}>
148
- ${opt.icon ? html`<span class="icon-radio-icon">${opt.icon}</span>` : null}
149
- <span>${opt.label}</span>
150
- </button>`)}
151
- </div>
152
- ` : html`
153
- <input type=${f.type || 'text'}
154
- class=${`input${f.mono ? ' mono' : ''}`}
155
- placeholder=${f.placeholder || ''}
156
- value=${draft[f.key] || ''}
157
- onInput=${(e) => setDraft({ ...draft, [f.key]: e.target.value })}
158
- autoFocus=${f.autoFocus} />`}
159
- ${f.hint ? html`<span class="picker-field-hint">${f.hint}</span>` : null}
160
- </label>`)}
161
- <div class="picker-create-actions">
162
- <button type="button" class="action small subtle"
163
- onClick=${() => { setCreating(false); setDraft(initialDraft(createFields)); }}>
164
- Cancel
165
- </button>
166
- <button type="submit" class="action small primary" disabled=${saving}>
167
- ${saving ? 'Creating…' : 'Create'}
168
- </button>
169
- </div>
170
- </form>`}
171
- </div>` : null}
172
- </div>`;
173
- }
174
-
175
- function initialDraft(fields) {
176
- const out = {};
177
- for (const f of fields) out[f.key] = f.default ?? '';
178
- return out;
179
- }
1
+ // Unified picker used inside a Popover. Renders:
2
+ // - optional search box (filters items by `label` + `meta`)
3
+ // - scrollable item list (single or multi select)
4
+ // - footer "+ New <thing>" that expands an inline form
5
+ //
6
+ // items: [{ id, label, meta?, disabled? }]
7
+ // onSelect(id) — single select. Closes popover.
8
+ // onToggle(id, on) — multi select. Doesn't close.
9
+ // selectedIds: Set<string> — for multi select highlight
10
+ // selectedId: string — for single select highlight
11
+ // onCreate(values) — async; returns the newly-created id (selected immediately
12
+ // in single mode; added to selection in multi mode)
13
+ // createFields: [{ key, label, type?, placeholder?, required? }]
14
+
15
+ import { html } from '../html.js';
16
+ import { useState, useRef, useEffect } from 'preact/hooks';
17
+ import { IconSearch, IconPlus, IconClose } from '../icons.js';
18
+
19
+ export function PickerPanel({
20
+ title,
21
+ items,
22
+ selectedId,
23
+ selectedIds,
24
+ multi = false,
25
+ showSearch = true,
26
+ emptyHint = 'Nothing yet.',
27
+ createLabel = '+ New',
28
+ createFields = [],
29
+ onSelect,
30
+ onToggle,
31
+ onCreate,
32
+ onClose,
33
+ dnd,
34
+ }) {
35
+ const [q, setQ] = useState('');
36
+ const [creating, setCreating] = useState(false);
37
+ const [draft, setDraft] = useState(() => initialDraft(createFields));
38
+ const [saving, setSaving] = useState(false);
39
+ const searchRef = useRef(null);
40
+
41
+ useEffect(() => {
42
+ if (showSearch) searchRef.current?.focus();
43
+ }, [showSearch]);
44
+
45
+ const filtered = items.filter((it) => {
46
+ if (!q.trim()) return true;
47
+ const hay = (it.label + ' ' + (it.meta || '')).toLowerCase();
48
+ return hay.includes(q.trim().toLowerCase());
49
+ });
50
+
51
+ const submitCreate = async (ev) => {
52
+ ev?.preventDefault?.();
53
+ for (const f of createFields) {
54
+ if (f.required && !String(draft[f.key] || '').trim()) return;
55
+ }
56
+ setSaving(true);
57
+ try {
58
+ const id = await onCreate?.(draft);
59
+ setDraft(initialDraft(createFields));
60
+ setCreating(false);
61
+ if (id != null) {
62
+ if (multi) onToggle?.(id, true);
63
+ else { onSelect?.(id); onClose?.(); }
64
+ }
65
+ } catch (e) {
66
+ // Caller is expected to toast; we just stay open with the form
67
+ console.warn(e);
68
+ } finally {
69
+ setSaving(false);
70
+ }
71
+ };
72
+
73
+ return html`
74
+ <div class="picker">
75
+ ${title ? html`<div class="picker-title">${title}</div>` : null}
76
+
77
+ ${showSearch ? html`
78
+ <div class="picker-search">
79
+ <span class="picker-search-icon"><${IconSearch} /></span>
80
+ <input ref=${searchRef} class="picker-search-input"
81
+ placeholder="Search…" value=${q}
82
+ onInput=${(e) => setQ(e.target.value)} />
83
+ ${q ? html`<button class="picker-search-clear" onClick=${() => setQ('')}>
84
+ <${IconClose} />
85
+ </button>` : null}
86
+ </div>` : null}
87
+
88
+ <div class="picker-list">
89
+ ${filtered.length === 0 ? html`
90
+ <div class="picker-empty">${q ? 'No matches.' : emptyHint}</div>
91
+ ` : filtered.map((it) => {
92
+ const isSel = multi ? selectedIds?.has(it.id) : selectedId === it.id;
93
+ const enableDnd = dnd && !it.disabled && !it.undraggable && !q.trim();
94
+ const rowProps = enableDnd ? dnd.rowProps(it.id) : {};
95
+ const handleProps = enableDnd ? dnd.handleProps(it.id) : {};
96
+ return html`
97
+ <div key=${it.id} class=${`picker-item-wrap${enableDnd ? ' is-draggable' : ''}`} ...${rowProps} ...${handleProps}>
98
+ ${enableDnd ? html`<span class="picker-item-grip" aria-hidden="true">⋮⋮</span>` : null}
99
+ <button type="button"
100
+ class=${`picker-item${isSel ? ' is-selected' : ''}`}
101
+ disabled=${it.disabled}
102
+ onClick=${() => {
103
+ if (multi) onToggle?.(it.id, !isSel);
104
+ else { onSelect?.(it.id); onClose?.(); }
105
+ }}>
106
+ ${it.icon ? html`<span class="picker-item-icon">${it.icon}</span>` : null}
107
+ <span class="picker-item-label">${it.label}</span>
108
+ ${it.meta ? html`<span class="picker-item-meta">${it.meta}</span>` : null}
109
+ ${isSel ? html`<span class="picker-item-check">✓</span>` : null}
110
+ </button>
111
+ </div>`;
112
+ })}
113
+ </div>
114
+
115
+ ${onCreate ? html`
116
+ <div class="picker-create">
117
+ ${!creating ? html`
118
+ <button class="picker-create-toggle" type="button"
119
+ onClick=${() => setCreating(true)}>
120
+ <${IconPlus} />
121
+ <span>${createLabel}</span>
122
+ </button>
123
+ ` : html`
124
+ <form class="picker-create-form" onSubmit=${submitCreate}>
125
+ ${createFields.map((f) => html`
126
+ <label class="picker-field" key=${f.key}>
127
+ <span class="picker-field-label">${f.label}</span>
128
+ ${f.type === 'select' ? html`
129
+ <select class="input" value=${draft[f.key] || ''}
130
+ onChange=${(e) => {
131
+ const next = { ...draft, [f.key]: e.target.value };
132
+ const side = f.onChange?.(e.target.value, next);
133
+ setDraft(side ? { ...next, ...side } : next);
134
+ }}>
135
+ ${(f.options || []).map((opt) => html`
136
+ <option value=${opt.value}>${opt.label}</option>`)}
137
+ </select>
138
+ ` : f.type === 'iconRadio' ? html`
139
+ <div class="icon-radio">
140
+ ${(f.options || []).map((opt) => html`
141
+ <button type="button" key=${opt.value}
142
+ class=${`icon-radio-opt${draft[f.key] === opt.value ? ' is-active' : ''}`}
143
+ onClick=${() => {
144
+ const next = { ...draft, [f.key]: opt.value };
145
+ const side = f.onChange?.(opt.value, next);
146
+ setDraft(side ? { ...next, ...side } : next);
147
+ }}>
148
+ ${opt.icon ? html`<span class="icon-radio-icon">${opt.icon}</span>` : null}
149
+ <span>${opt.label}</span>
150
+ </button>`)}
151
+ </div>
152
+ ` : html`
153
+ <input type=${f.type || 'text'}
154
+ class=${`input${f.mono ? ' mono' : ''}`}
155
+ placeholder=${f.placeholder || ''}
156
+ value=${draft[f.key] || ''}
157
+ onInput=${(e) => setDraft({ ...draft, [f.key]: e.target.value })}
158
+ autoFocus=${f.autoFocus} />`}
159
+ ${f.hint ? html`<span class="picker-field-hint">${f.hint}</span>` : null}
160
+ </label>`)}
161
+ <div class="picker-create-actions">
162
+ <button type="button" class="action small subtle"
163
+ onClick=${() => { setCreating(false); setDraft(initialDraft(createFields)); }}>
164
+ Cancel
165
+ </button>
166
+ <button type="submit" class="action small primary" disabled=${saving}>
167
+ ${saving ? 'Creating…' : 'Create'}
168
+ </button>
169
+ </div>
170
+ </form>`}
171
+ </div>` : null}
172
+ </div>`;
173
+ }
174
+
175
+ function initialDraft(fields) {
176
+ const out = {};
177
+ for (const f of fields) out[f.key] = f.default ?? '';
178
+ return out;
179
+ }