@divlet/web 0.0.0-stage → 0.0.2
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.
- package/README.md +12 -2
- package/dist/LICENSE +21 -0
- package/dist/board.css +950 -0
- package/dist/board.js +427 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.html +73 -0
- package/dist/index.js +3 -0
- package/dist/lib/actions.js +472 -0
- package/dist/lib/activity.js +311 -0
- package/dist/lib/artboards.js +56 -0
- package/dist/lib/ask.js +350 -0
- package/dist/lib/attrs.js +144 -0
- package/dist/lib/board-host.d.ts +14 -0
- package/dist/lib/board-host.js +2 -0
- package/dist/lib/board-session.d.ts +53 -0
- package/dist/lib/board-session.js +24 -0
- package/dist/lib/canvas.js +1594 -0
- package/dist/lib/chat.js +156 -0
- package/dist/lib/clip.js +231 -0
- package/dist/lib/crumbbar.js +56 -0
- package/dist/lib/crumbs.js +63 -0
- package/dist/lib/css.js +238 -0
- package/dist/lib/expandbar.js +64 -0
- package/dist/lib/file-picker.js +103 -0
- package/dist/lib/help.js +273 -0
- package/dist/lib/host.js +16 -0
- package/dist/lib/http-services.js +24 -0
- package/dist/lib/http-session.js +45 -0
- package/dist/lib/http.js +17 -0
- package/dist/lib/inspector.js +857 -0
- package/dist/lib/layers.js +298 -0
- package/dist/lib/menu.js +119 -0
- package/dist/lib/palette.js +292 -0
- package/dist/lib/panels.js +27 -0
- package/dist/lib/project.js +188 -0
- package/dist/lib/resize.js +114 -0
- package/dist/lib/shortcuts.js +91 -0
- package/dist/lib/sidebar.js +85 -0
- package/dist/lib/store.js +249 -0
- package/dist/lib/suggest.js +130 -0
- package/dist/lib/toolbar.js +54 -0
- package/dist/lib/typography.js +120 -0
- package/dist/lib/util.js +177 -0
- package/package.json +37 -4
package/dist/lib/ask.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { $, el, icon, button, keys } from './util.js';
|
|
2
|
+
import { openMenu } from './menu.js';
|
|
3
|
+
import { store, on, emit, post, toast, descriptor } from './store.js';
|
|
4
|
+
import * as sidebar from './sidebar.js';
|
|
5
|
+
import * as chat from './chat.js';
|
|
6
|
+
|
|
7
|
+
/* The Agent tab: the conversation with Pi and the box to write to it. A request goes to the running Pi
|
|
8
|
+
session together with what is selected; the status line shows what Pi is doing and can stop it; Pi's
|
|
9
|
+
answers land in the conversation and its edits in History. */
|
|
10
|
+
|
|
11
|
+
const panel = $('#agent');
|
|
12
|
+
const RECENT_KEY = 'divlet:asks';
|
|
13
|
+
let sending = false;
|
|
14
|
+
let recall = -1;
|
|
15
|
+
let draft = '';
|
|
16
|
+
let watchdog = 0;
|
|
17
|
+
let returnFocus = null;
|
|
18
|
+
|
|
19
|
+
export const available = () => !!store.state?.agent?.connected;
|
|
20
|
+
export const isOpen = () => sidebar.isOpen('agent');
|
|
21
|
+
const agent = () => store.state?.agent || {};
|
|
22
|
+
|
|
23
|
+
const recent = () => { try { const list = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); return Array.isArray(list) ? list.filter(item => typeof item === 'string') : []; } catch { return []; } };
|
|
24
|
+
const remember = text => { try { localStorage.setItem(RECENT_KEY, JSON.stringify([text, ...recent().filter(item => item !== text)].slice(0, 20))); } catch {} };
|
|
25
|
+
|
|
26
|
+
/* ---- composer ------------------------------------------------------------- */
|
|
27
|
+
const input = el('textarea', 'ask-input');
|
|
28
|
+
input.rows = 1;
|
|
29
|
+
input.placeholder = 'Ask the agent to change the layout…';
|
|
30
|
+
input.spellcheck = true;
|
|
31
|
+
input.dataset.key = 'ask';
|
|
32
|
+
input.setAttribute('aria-label', 'Request for the agent');
|
|
33
|
+
const hint = el('span', 'ask-hint');
|
|
34
|
+
const stopButton = button('stop', 'Stop the agent', () => stop(), 'mini ask-stop');
|
|
35
|
+
const send = el('button', 'cta ask-send');
|
|
36
|
+
send.type = 'button';
|
|
37
|
+
send.id = 'ask-send';
|
|
38
|
+
send.append(el('span', '', 'Send'), icon('send'));
|
|
39
|
+
send.addEventListener('click', () => submit());
|
|
40
|
+
const status = el('div', 'agent-status');
|
|
41
|
+
status.setAttribute('role', 'status');
|
|
42
|
+
status.hidden = true;
|
|
43
|
+
const statusText = el('span', 'agent-status-text');
|
|
44
|
+
const statusTime = el('time', 'agent-status-time');
|
|
45
|
+
status.append(el('span', 'spinner'), statusText, statusTime, stopButton);
|
|
46
|
+
const foot = el('div', 'ask-foot');
|
|
47
|
+
/* Two chips, no labels: the session that answers this board — model and thinking level — when the host can change them. An MCP
|
|
48
|
+
client can only answer prompts, so there the chips are simply absent; nothing here is guessed or stored in the project. */
|
|
49
|
+
const sessionChips = el('div', 'agent-session');
|
|
50
|
+
const settingsError = el('div', 'agent-settings-error');
|
|
51
|
+
settingsError.hidden = true;
|
|
52
|
+
const post_ = (path, body) => post(path, body).catch(error => {
|
|
53
|
+
// A refused change is reported where it was made, never as a silent no-op.
|
|
54
|
+
settingsError.hidden = false;
|
|
55
|
+
settingsError.textContent = error?.message || 'That setting could not be applied.';
|
|
56
|
+
setTimeout(() => { settingsError.hidden = true; }, 6000);
|
|
57
|
+
paintSession();
|
|
58
|
+
return null;
|
|
59
|
+
});
|
|
60
|
+
const chip = (label, tip, options, current, choose) => {
|
|
61
|
+
const node = el('button', 'agent-chip', label);
|
|
62
|
+
node.type = 'button';
|
|
63
|
+
node.dataset.tip = tip;
|
|
64
|
+
node.setAttribute('aria-label', tip);
|
|
65
|
+
node.addEventListener('click', () => {
|
|
66
|
+
openMenu(options.map(option => ({
|
|
67
|
+
label: option.label,
|
|
68
|
+
icon: option.id === current ? 'check' : undefined,
|
|
69
|
+
run: () => { if (option.id !== current) choose(option); },
|
|
70
|
+
})), { anchor: node, label: tip, className: 'menu-agent' });
|
|
71
|
+
});
|
|
72
|
+
return node;
|
|
73
|
+
};
|
|
74
|
+
function paintSession() {
|
|
75
|
+
const controls = agent().controls;
|
|
76
|
+
const chips = [];
|
|
77
|
+
if (controls?.model?.options?.length) {
|
|
78
|
+
const current = controls.model.value;
|
|
79
|
+
const option = controls.model.options.find(item => item.id === current);
|
|
80
|
+
chips.push(chip(option?.label || current, `Model · ${current}`, controls.model.options, current, item => post_('api/agent/settings', { model: item.id })));
|
|
81
|
+
}
|
|
82
|
+
if (controls?.thinking?.options?.length) {
|
|
83
|
+
const current = controls.thinking.value;
|
|
84
|
+
chips.push(chip(current, `Thinking level · ${current}`, controls.thinking.options.map(level => ({ id: level, label: level })), current, item => post_('api/agent/settings', { thinking: item.id })));
|
|
85
|
+
}
|
|
86
|
+
sessionChips.replaceChildren(...chips);
|
|
87
|
+
// The selectors replace the key hints rather than stacking under them; Send keeps the trailing bottom corner if they wrap.
|
|
88
|
+
sessionChips.hidden = chips.length === 0;
|
|
89
|
+
hint.hidden = chips.length > 0;
|
|
90
|
+
}
|
|
91
|
+
// One slot, two occupants: a host that can change the session shows the two selectors where the key hints would be; a host that
|
|
92
|
+
// cannot (MCP) shows the hints. Pi users are assumed to know the shortcuts; the selectors carry their own tips.
|
|
93
|
+
foot.append(sessionChips, hint, send);
|
|
94
|
+
const scope = el('div', 'ask-scope');
|
|
95
|
+
scope.id = 'ask-context';
|
|
96
|
+
input.setAttribute('aria-describedby', scope.id);
|
|
97
|
+
const suggest = el('div', 'ask-suggest');
|
|
98
|
+
const composer = el('div', 'agent-composer');
|
|
99
|
+
// Order is the reading order: what the request is about, the box, then send — with the session selectors in its footer.
|
|
100
|
+
composer.append(scope, input, foot, settingsError);
|
|
101
|
+
panel.append(chat.list, suggest, status, composer);
|
|
102
|
+
|
|
103
|
+
const grow = () => { input.style.height = 'auto'; input.style.height = `${Math.min(input.scrollHeight + 2, 220)}px`; };
|
|
104
|
+
|
|
105
|
+
function context() {
|
|
106
|
+
if (!store.selected.size) return null;
|
|
107
|
+
const ids = [...store.selected];
|
|
108
|
+
const nodes = store.node?.nodeIds.length && ids.length === 1 && store.node.documentId === ids[0] ? { documentId: ids[0], nodeIds: [...store.node.nodeIds] } : undefined;
|
|
109
|
+
return { ids, nodes };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What the agent will see with the next request, so nobody has to guess what "this" means. */
|
|
113
|
+
function paintScope() {
|
|
114
|
+
const ids = [...store.selected];
|
|
115
|
+
const names = ids.map(id => descriptor(id)?.name || id);
|
|
116
|
+
const nodes = store.node?.documentId === ids[0] && ids.length === 1 ? store.node.nodeIds : [];
|
|
117
|
+
const value = el('span', 'ask-scope-v');
|
|
118
|
+
if (!ids.length) value.append(el('span', '', 'the whole board (nothing selected)'));
|
|
119
|
+
else if (ids.length > 1) value.append(el('span', '', `${ids.length} artboards`));
|
|
120
|
+
else {
|
|
121
|
+
// Only the frame name is truncated: it can be surprisingly long and the sidebar is narrow, while the selector after it
|
|
122
|
+
// tells the user what the request is about and must stay whole. The whole name stays available as a tooltip.
|
|
123
|
+
const frame = el('span', 'ask-scope-frame', names[0]);
|
|
124
|
+
frame.dataset.tip = names[0];
|
|
125
|
+
frame.title = names[0];
|
|
126
|
+
value.append(frame);
|
|
127
|
+
if (nodes.length) {
|
|
128
|
+
value.append(el('span', 'ask-scope-sep', '›'));
|
|
129
|
+
value.append(el('span', 'ask-scope-tail', nodes.length === 1 ? `#${nodes[0]}` : `${nodes.length} elements`));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
scope.replaceChildren(el('span', 'ask-scope-k', 'About'), ' ', value);
|
|
133
|
+
scope.dataset.tip = 'Your next request goes to the agent together with this selection';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A few starting points while the conversation is empty; a click only fills the box, nothing is sent. */
|
|
137
|
+
function paintSuggest() {
|
|
138
|
+
const empty = !store.recent.some(chat.isChat);
|
|
139
|
+
suggest.hidden = !empty || !available();
|
|
140
|
+
if (suggest.hidden) { suggest.replaceChildren(); return; }
|
|
141
|
+
const ids = [...store.selected];
|
|
142
|
+
const nodes = store.node?.documentId === ids[0] && ids.length === 1 ? store.node.nodeIds : [];
|
|
143
|
+
const ideas = nodes.length ? ['Tighten the spacing', 'Make it responsive', 'Improve contrast and readability']
|
|
144
|
+
: ids.length ? ['Make this page work on a phone', 'Add a footer', 'Describe what this page does']
|
|
145
|
+
: ['Create a landing page for ', 'Add a pricing section', 'Review the whole board for consistency'];
|
|
146
|
+
suggest.replaceChildren(...ideas.map(text => {
|
|
147
|
+
const chip = el('button', 'ask-idea', text.trim());
|
|
148
|
+
chip.type = 'button';
|
|
149
|
+
chip.addEventListener('click', () => { input.value = text; grow(); paintStatus(); input.focus(); input.setSelectionRange(input.value.length, input.value.length); });
|
|
150
|
+
return chip;
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function paintStatus() {
|
|
155
|
+
paintScope();
|
|
156
|
+
paintSuggest();
|
|
157
|
+
paintSession();
|
|
158
|
+
const state = agent();
|
|
159
|
+
send.querySelector('span').textContent = state.busy ? 'Queue' : 'Send';
|
|
160
|
+
send.dataset.tip = state.busy ? `The agent is working — this runs right after · ${keys('Enter')}` : `Send to the agent · ${keys('Enter')}`;
|
|
161
|
+
send.disabled = sending || !available() || !input.value.trim();
|
|
162
|
+
input.disabled = !available();
|
|
163
|
+
// The sidebar can be dragged narrow, so the texts follow its own width (sidebar.js measures it).
|
|
164
|
+
const tight = (document.documentElement.dataset.sidebar ?? 'wide') !== 'wide';
|
|
165
|
+
input.placeholder = !available() ? (tight ? 'Open from Pi (/divlet)' : 'Open this board from Pi (/divlet) to talk to it')
|
|
166
|
+
: tight ? 'Ask the agent…' : 'Ask the agent to change the layout…';
|
|
167
|
+
const keysHint = tight ? [`${keys('Enter')} send`] : [`${keys('Enter')} send`, `${keys('Shift+Enter')} new line`, '↑ previous'];
|
|
168
|
+
// Each key with its meaning is one unit: a narrow composer wraps between units, never inside one.
|
|
169
|
+
if (!available()) {
|
|
170
|
+
// Not connected: say so, and offer the exact command that connects this project in Pi.
|
|
171
|
+
const file = store.state?.project?.file;
|
|
172
|
+
const copy = button('copy', 'Copy the command that opens this project in Pi', async () => {
|
|
173
|
+
const command = `/divlet ${file || ''}`.trim();
|
|
174
|
+
try { await navigator.clipboard.writeText(command); toast(`Copied ${command} · paste it in the Pi terminal`); }
|
|
175
|
+
catch { toast(`Run ${command} in the Pi terminal`); }
|
|
176
|
+
}, 'mini ask-copy');
|
|
177
|
+
hint.replaceChildren(el('span', '', 'No agent is connected to this board'), copy);
|
|
178
|
+
} else hint.replaceChildren(...keysHint.map(text => el('span', 'hint-unit', text)));
|
|
179
|
+
// The plaque above the box already says what the agent is doing; the hint keeps the keys so nothing moves or repeats it.
|
|
180
|
+
hint.classList.toggle('busy', false);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Opened by a key typed in a preview frame: that frame keeps focus until typing pauses, so every
|
|
184
|
+
character arrives through one ordered channel (keys forwarded by the frame) and none is reordered. */
|
|
185
|
+
let handOff = 0;
|
|
186
|
+
function focusInput() {
|
|
187
|
+
clearTimeout(handOff);
|
|
188
|
+
handOff = 0;
|
|
189
|
+
if (!isOpen()) return;
|
|
190
|
+
input.focus();
|
|
191
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function toggle(open = !isOpen(), { text, fromFrame = false } = {}) {
|
|
195
|
+
if (open && !available()) {
|
|
196
|
+
toast('No agent is connected to this board. Open it from Pi with /divlet, or connect an MCP client (divlet-mcp).');
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
if (open === isOpen()) { if (open) { if (text !== undefined) { input.value = text; grow(); } input.focus(); } return open; }
|
|
200
|
+
if (open) {
|
|
201
|
+
returnFocus = document.activeElement instanceof HTMLElement && !panel.contains(document.activeElement) ? document.activeElement : null;
|
|
202
|
+
if (text !== undefined) input.value = text;
|
|
203
|
+
sidebar.show('agent');
|
|
204
|
+
if (fromFrame) handOff = setTimeout(focusInput, 220);
|
|
205
|
+
else focusInput();
|
|
206
|
+
} else {
|
|
207
|
+
clearTimeout(handOff);
|
|
208
|
+
handOff = 0;
|
|
209
|
+
sidebar.close();
|
|
210
|
+
recall = -1;
|
|
211
|
+
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
|
212
|
+
else $('#viewport').focus({ preventScroll: true });
|
|
213
|
+
}
|
|
214
|
+
return open;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The pane was shown or folded, by this module or by its tab. */
|
|
218
|
+
on('sidebar', ({ open, tab }) => {
|
|
219
|
+
placeStatus();
|
|
220
|
+
const now = open && tab === 'agent';
|
|
221
|
+
if (now) { paintStatus(); grow(); chat.render(); }
|
|
222
|
+
emit('ask-open', now);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// A narrow sidebar changes what fits: the placeholder and the key hints are re-chosen, and the textarea re-fits its value.
|
|
226
|
+
on('sidebar-width', () => { paintStatus(); grow(); });
|
|
227
|
+
|
|
228
|
+
function armWatchdog(delay = 9000, waits = 6) {
|
|
229
|
+
clearTimeout(watchdog);
|
|
230
|
+
watchdog = setTimeout(() => {
|
|
231
|
+
if (agent().busy || !available()) return;
|
|
232
|
+
// A notice the user can still act on (Undo after a delete) keeps its window: this one waits its turn instead of replacing it.
|
|
233
|
+
if (waits > 0 && document.querySelector('#toast:not([hidden]) .toast-action')) { armWatchdog(2000, waits - 1); return; }
|
|
234
|
+
toast('Pi has not started yet. Check the Pi terminal — it needs a model and an API key.');
|
|
235
|
+
}, delay);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Keys typed in a preview frame while focus is still moving to the field: keep them as text. */
|
|
239
|
+
export function typeAhead(key) {
|
|
240
|
+
if (!isOpen()) return false;
|
|
241
|
+
if (key === 'Backspace') input.value = input.value.slice(0, -1);
|
|
242
|
+
else input.value += key;
|
|
243
|
+
if (handOff) { clearTimeout(handOff); handOff = setTimeout(focusInput, 220); }
|
|
244
|
+
else focusInput();
|
|
245
|
+
grow();
|
|
246
|
+
paintStatus();
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function submit(text = input.value) {
|
|
251
|
+
const request = text.trim();
|
|
252
|
+
if (!request || sending) return null;
|
|
253
|
+
sending = true;
|
|
254
|
+
paintStatus();
|
|
255
|
+
const ctx = context();
|
|
256
|
+
try {
|
|
257
|
+
const result = await post('api/ask', { text: request, ids: ctx?.ids ?? [], nodes: ctx?.nodes });
|
|
258
|
+
remember(request);
|
|
259
|
+
if (input.value.trim() === request) input.value = '';
|
|
260
|
+
draft = '';
|
|
261
|
+
grow();
|
|
262
|
+
if (result.queued) toast('Queued — Pi takes it right after the current task');
|
|
263
|
+
if (!result.queued) armWatchdog();
|
|
264
|
+
return result;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
toast(error.message);
|
|
267
|
+
return null;
|
|
268
|
+
} finally {
|
|
269
|
+
sending = false;
|
|
270
|
+
paintStatus();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let stopping = false;
|
|
275
|
+
export function stop() {
|
|
276
|
+
stopping = true;
|
|
277
|
+
paintChip();
|
|
278
|
+
paintStatus();
|
|
279
|
+
return post('api/agent/stop', {}).catch(error => { stopping = false; paintChip(); paintStatus(); toast(error.message); });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
input.addEventListener('input', () => { grow(); paintStatus(); recall = -1; });
|
|
283
|
+
input.addEventListener('keydown', event => {
|
|
284
|
+
event.stopPropagation();
|
|
285
|
+
if (event.key === 'Escape') { event.preventDefault(); toggle(false); }
|
|
286
|
+
else if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); submit(); }
|
|
287
|
+
else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && !event.shiftKey) {
|
|
288
|
+
const list = recent();
|
|
289
|
+
const atStart = input.selectionStart === 0 && input.selectionEnd === 0;
|
|
290
|
+
// Like a shell: ↑ on an empty or untouched field (or while already recalling) walks back through requests.
|
|
291
|
+
if (event.key === 'ArrowUp' && (atStart || !input.value || recall >= 0) && recall < list.length - 1) {
|
|
292
|
+
event.preventDefault();
|
|
293
|
+
if (recall < 0) draft = input.value;
|
|
294
|
+
recall++;
|
|
295
|
+
input.value = list[recall];
|
|
296
|
+
} else if (event.key === 'ArrowDown' && recall >= 0) {
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
recall--;
|
|
299
|
+
input.value = recall < 0 ? draft : list[recall];
|
|
300
|
+
} else return;
|
|
301
|
+
grow();
|
|
302
|
+
paintStatus();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
panel.addEventListener('keydown', event => { if (event.key === 'Escape') { event.stopPropagation(); toggle(false); } });
|
|
306
|
+
|
|
307
|
+
/* ---- one activity plaque: above the composer, or above the tabs when the sidebar folds ---- */
|
|
308
|
+
function placeStatus() {
|
|
309
|
+
const floating = !!agent().busy && !sidebar.isOpen();
|
|
310
|
+
const parent = floating ? $('#agent-status-dock') : panel;
|
|
311
|
+
if (status.parentElement === parent) return;
|
|
312
|
+
const focused = status.contains(document.activeElement) ? document.activeElement : null;
|
|
313
|
+
parent.insertBefore(status, floating ? null : composer);
|
|
314
|
+
if (focused instanceof HTMLElement && (floating || isOpen())) focused.focus({ preventScroll: true });
|
|
315
|
+
}
|
|
316
|
+
function elapsed() {
|
|
317
|
+
const since = agent().since ? Date.parse(agent().since) : NaN;
|
|
318
|
+
if (!Number.isFinite(since)) return '';
|
|
319
|
+
const seconds = Math.max(0, Math.round((Date.now() - since) / 1000));
|
|
320
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function paintChip() {
|
|
324
|
+
const state = agent();
|
|
325
|
+
if (!state.busy) stopping = false;
|
|
326
|
+
status.hidden = !state.busy;
|
|
327
|
+
stopButton.hidden = !state.busy;
|
|
328
|
+
placeStatus();
|
|
329
|
+
status.classList.toggle('stopping', stopping);
|
|
330
|
+
document.body.classList.toggle('agent-busy', !!state.busy);
|
|
331
|
+
$('#open-agent').setAttribute('aria-busy', String(!!state.busy));
|
|
332
|
+
if (!state.busy) return;
|
|
333
|
+
statusText.textContent = stopping ? 'Agent · stopping…' : `Agent · ${state.activity || 'working'}${state.queued ? ` · ${state.queued} queued` : ''}`;
|
|
334
|
+
statusTime.textContent = elapsed();
|
|
335
|
+
}
|
|
336
|
+
setInterval(() => { if (!status.hidden) statusTime.textContent = elapsed(); }, 1000);
|
|
337
|
+
|
|
338
|
+
on('selection', () => { if (isOpen()) { paintScope(); paintSuggest(); } });
|
|
339
|
+
on('change', () => { if (isOpen()) paintSuggest(); });
|
|
340
|
+
on('state', () => {
|
|
341
|
+
if (agent().busy) clearTimeout(watchdog);
|
|
342
|
+
paintChip();
|
|
343
|
+
if (isOpen()) paintStatus();
|
|
344
|
+
emit('agent', agent());
|
|
345
|
+
});
|
|
346
|
+
on('change', change => {
|
|
347
|
+
if (change.kind !== 'reply') return;
|
|
348
|
+
if (sidebar.isOpen('agent')) return;
|
|
349
|
+
toast(`Pi: ${String(change.summary || '').replace(/\*\*|`/g, '')}`, { label: 'Read', run: () => { sidebar.show('agent'); chat.focus(change.seq); } });
|
|
350
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Attributes of an element: what it has, the important ones it lacks (one click to add), and
|
|
2
|
+
// suggestions for names and values that fit its tag.
|
|
3
|
+
import { el, icon } from './util.js';
|
|
4
|
+
import { attachSuggest, filterLocal } from './suggest.js';
|
|
5
|
+
|
|
6
|
+
const COMMON = [
|
|
7
|
+
['id', 'name for links (#id) and labels'], ['title', 'tooltip text'], ['aria-label', 'name read by screen readers'],
|
|
8
|
+
['role', 'what it is for assistive tech'], ['aria-hidden', 'hide from screen readers'], ['tabindex', 'keyboard focus order'],
|
|
9
|
+
['hidden', 'not shown'], ['lang', 'language of the content'], ['dir', 'text direction'],
|
|
10
|
+
];
|
|
11
|
+
const BY_TAG = {
|
|
12
|
+
a: [['href', 'where the link goes'], ['target', '_blank opens a new tab'], ['rel', 'noopener for new tabs'], ['download', 'download instead of opening']],
|
|
13
|
+
img: [['src', 'image file, e.g. assets/photo.png'], ['alt', 'what the image shows'], ['loading', 'lazy: load when visible'], ['width', 'intrinsic width'], ['height', 'intrinsic height'], ['decoding', 'async decoding']],
|
|
14
|
+
button: [['type', 'button, submit or reset'], ['disabled', 'not clickable'], ['name', 'form field name'], ['value', 'value sent with the form']],
|
|
15
|
+
input: [['type', 'text, email, checkbox…'], ['name', 'form field name'], ['placeholder', 'hint inside the field'], ['value', 'initial value'], ['required', 'must be filled'], ['disabled', 'not editable'], ['autocomplete', 'browser autofill'], ['min', 'smallest value'], ['max', 'largest value']],
|
|
16
|
+
textarea: [['name', 'form field name'], ['placeholder', 'hint inside the field'], ['rows', 'visible lines'], ['required', 'must be filled']],
|
|
17
|
+
select: [['name', 'form field name'], ['required', 'must be chosen'], ['multiple', 'several choices']],
|
|
18
|
+
option: [['value', 'value sent with the form'], ['selected', 'chosen at start']],
|
|
19
|
+
label: [['for', 'id of its field']],
|
|
20
|
+
form: [['action', 'where it is sent'], ['method', 'get or post']],
|
|
21
|
+
video: [['src', 'video file'], ['controls', 'show player controls'], ['autoplay', 'start at once'], ['muted', 'no sound'], ['loop', 'repeat'], ['poster', 'image before playing'], ['playsinline', 'play inline on phones']],
|
|
22
|
+
audio: [['src', 'audio file'], ['controls', 'show player controls'], ['loop', 'repeat']],
|
|
23
|
+
time: [['datetime', 'machine-readable date']],
|
|
24
|
+
ol: [['start', 'first number'], ['reversed', 'count down']],
|
|
25
|
+
td: [['colspan', 'columns it spans'], ['rowspan', 'rows it spans']],
|
|
26
|
+
th: [['scope', 'col or row'], ['colspan', 'columns it spans']],
|
|
27
|
+
details: [['open', 'expanded at start']],
|
|
28
|
+
nav: [['aria-label', 'name of this navigation']],
|
|
29
|
+
section: [['aria-label', 'name of this region']],
|
|
30
|
+
};
|
|
31
|
+
const IMPORTANT = { a: ['href', 'target'], img: ['src', 'alt'], button: ['type', 'aria-label'], input: ['type', 'name', 'placeholder'], textarea: ['name', 'placeholder'], label: ['for'], form: ['action', 'method'], video: ['src', 'controls'], audio: ['src', 'controls'], nav: ['aria-label'], section: ['aria-label'] };
|
|
32
|
+
const BOOLEAN = new Set(['hidden', 'disabled', 'required', 'multiple', 'selected', 'controls', 'autoplay', 'muted', 'loop', 'reversed', 'open', 'download', 'playsinline', 'checked', 'readonly']);
|
|
33
|
+
const VALUES = {
|
|
34
|
+
target: ['_blank', '_self'], rel: ['noopener noreferrer', 'nofollow', 'me'], loading: ['lazy', 'eager'], decoding: ['async', 'sync', 'auto'],
|
|
35
|
+
method: ['get', 'post'], 'aria-hidden': ['true', 'false'], dir: ['ltr', 'rtl', 'auto'], scope: ['col', 'row'],
|
|
36
|
+
autocomplete: ['on', 'off', 'name', 'email', 'tel', 'current-password', 'new-password', 'street-address'],
|
|
37
|
+
role: ['button', 'link', 'navigation', 'banner', 'main', 'contentinfo', 'region', 'list', 'listitem', 'img', 'presentation', 'dialog', 'tab', 'tablist', 'tabpanel', 'status', 'alert'],
|
|
38
|
+
};
|
|
39
|
+
const TYPES = { button: ['button', 'submit', 'reset'], input: ['text', 'email', 'password', 'number', 'search', 'tel', 'url', 'checkbox', 'radio', 'date', 'time', 'file', 'range', 'color', 'hidden'] };
|
|
40
|
+
|
|
41
|
+
const namesFor = tag => [...(BY_TAG[tag] || []), ...COMMON].filter(([name], index, all) => all.findIndex(([other]) => other === name) === index);
|
|
42
|
+
const valuesFor = (tag, name) => (name === 'type' ? TYPES[tag] || [] : VALUES[name] || []);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `node` is the element; `save(patch, label)` commits `{ attrs: { name: value | null } }`.
|
|
46
|
+
* Each row edits one attribute; the last row adds one (name, then value).
|
|
47
|
+
*/
|
|
48
|
+
export function attributesEditor(node, save) {
|
|
49
|
+
const box = el('div', 'rows attrs');
|
|
50
|
+
const attrs = node.attrs || {};
|
|
51
|
+
const known = new Map(namesFor(node.tag));
|
|
52
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
53
|
+
const line = el('div', 'kv');
|
|
54
|
+
const key = el('span', 'k', name);
|
|
55
|
+
key.dataset.tip = known.get(name) ? `${name} · ${known.get(name)}` : name;
|
|
56
|
+
const input = el('input', 'field mono');
|
|
57
|
+
input.type = 'text';
|
|
58
|
+
input.value = value;
|
|
59
|
+
input.spellcheck = false;
|
|
60
|
+
input.dataset.key = `attr:${name}`;
|
|
61
|
+
input.placeholder = BOOLEAN.has(name) ? 'on' : '';
|
|
62
|
+
input.setAttribute('aria-label', `${name} value`);
|
|
63
|
+
const commit = () => { if (input.value !== value) save({ [name]: input.value }, `${name}="${input.value.slice(0, 24)}"`); };
|
|
64
|
+
const choices = valuesFor(node.tag, name);
|
|
65
|
+
if (choices.length) attachSuggest(input, { empty: true, fetch: word => filterLocal(choices.map(item => ({ name: item })), choices.includes(word) ? '' : word), onAccept: commit });
|
|
66
|
+
input.addEventListener('keydown', event => {
|
|
67
|
+
event.stopPropagation();
|
|
68
|
+
if (event.key === 'Enter') { event.preventDefault(); input.blur(); }
|
|
69
|
+
else if (event.key === 'Escape') { input.value = value; input.blur(); }
|
|
70
|
+
});
|
|
71
|
+
input.addEventListener('change', commit);
|
|
72
|
+
const remove = el('button', 'tool mini');
|
|
73
|
+
remove.type = 'button';
|
|
74
|
+
remove.dataset.tip = `Remove ${name}`;
|
|
75
|
+
remove.setAttribute('aria-label', `Remove ${name}`);
|
|
76
|
+
remove.append(icon('close'));
|
|
77
|
+
remove.addEventListener('click', () => save({ [name]: null }, `remove ${name}`));
|
|
78
|
+
line.append(el('span', 'swatch none'), key, input, remove);
|
|
79
|
+
box.append(line);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Add: a name with suggestions for this tag, then its value.
|
|
83
|
+
const add = el('div', 'kv add');
|
|
84
|
+
const nameInput = el('input', 'field mono');
|
|
85
|
+
nameInput.type = 'text';
|
|
86
|
+
nameInput.placeholder = 'attribute';
|
|
87
|
+
nameInput.spellcheck = false;
|
|
88
|
+
nameInput.dataset.key = 'attr-new-key';
|
|
89
|
+
nameInput.setAttribute('aria-label', 'New attribute name');
|
|
90
|
+
const valueInput = el('input', 'field mono');
|
|
91
|
+
valueInput.type = 'text';
|
|
92
|
+
valueInput.placeholder = 'value';
|
|
93
|
+
valueInput.spellcheck = false;
|
|
94
|
+
valueInput.dataset.key = 'attr-new-value';
|
|
95
|
+
valueInput.setAttribute('aria-label', 'New attribute value');
|
|
96
|
+
const missing = () => namesFor(node.tag).filter(([name]) => !Object.hasOwn(attrs, name)).map(([name, hint]) => ({ name, hint }));
|
|
97
|
+
const submit = () => {
|
|
98
|
+
const name = nameInput.value.trim();
|
|
99
|
+
if (!name) return false;
|
|
100
|
+
if (!/^[A-Za-z_:][A-Za-z0-9_.:-]*$/.test(name)) { nameInput.focus(); return false; }
|
|
101
|
+
if (!valueInput.value && !BOOLEAN.has(name)) return false;
|
|
102
|
+
save({ [name]: valueInput.value }, `${name}="${valueInput.value.slice(0, 24)}"`);
|
|
103
|
+
nameInput.value = '';
|
|
104
|
+
valueInput.value = '';
|
|
105
|
+
return true;
|
|
106
|
+
};
|
|
107
|
+
const toValue = () => {
|
|
108
|
+
const name = nameInput.value.trim();
|
|
109
|
+
if (!name) return;
|
|
110
|
+
if (BOOLEAN.has(name)) { submit(); return; }
|
|
111
|
+
valueInput.placeholder = valuesFor(node.tag, name)[0] ?? known.get(name) ?? 'value';
|
|
112
|
+
valueInput.focus();
|
|
113
|
+
};
|
|
114
|
+
attachSuggest(nameInput, { fetch: word => filterLocal(missing(), word), onAccept: toValue });
|
|
115
|
+
attachSuggest(valueInput, { empty: true, fetch: word => { const choices = valuesFor(node.tag, nameInput.value.trim()); return filterLocal(choices.map(item => ({ name: item })), choices.includes(word) ? '' : word); }, onAccept: () => submit() });
|
|
116
|
+
nameInput.addEventListener('keydown', event => {
|
|
117
|
+
event.stopPropagation();
|
|
118
|
+
if (event.key === 'Enter') { event.preventDefault(); toValue(); }
|
|
119
|
+
else if (event.key === 'Escape') { nameInput.value = ''; nameInput.blur(); }
|
|
120
|
+
});
|
|
121
|
+
valueInput.addEventListener('keydown', event => {
|
|
122
|
+
event.stopPropagation();
|
|
123
|
+
if (event.key === 'Enter') { event.preventDefault(); if (submit()) nameInput.focus(); }
|
|
124
|
+
else if (event.key === 'Escape') { valueInput.value = ''; valueInput.blur(); }
|
|
125
|
+
});
|
|
126
|
+
valueInput.addEventListener('change', () => { if (nameInput.value.trim() && valueInput.value) submit(); });
|
|
127
|
+
add.append(nameInput, valueInput);
|
|
128
|
+
box.append(add);
|
|
129
|
+
|
|
130
|
+
// One click for the attributes this kind of element usually needs.
|
|
131
|
+
const wanted = (IMPORTANT[node.tag] || []).filter(name => !Object.hasOwn(attrs, name));
|
|
132
|
+
if (wanted.length) {
|
|
133
|
+
const quick = el('div', 'chips quick-attrs');
|
|
134
|
+
for (const name of wanted) {
|
|
135
|
+
const chip = el('button', 'chip', `+ ${name}`);
|
|
136
|
+
chip.type = 'button';
|
|
137
|
+
chip.dataset.tip = known.get(name) ?? name;
|
|
138
|
+
chip.addEventListener('click', () => { nameInput.value = name; toValue(); });
|
|
139
|
+
quick.append(chip);
|
|
140
|
+
}
|
|
141
|
+
box.append(quick);
|
|
142
|
+
}
|
|
143
|
+
return box;
|
|
144
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A history reader need not have authority to restore project state. Payload admission remains with the consumer. */
|
|
2
|
+
export interface BoardHistoryService {
|
|
3
|
+
readonly readRecent: () => Promise<unknown>;
|
|
4
|
+
readonly navigate?: (target: {
|
|
5
|
+
pointId: string;
|
|
6
|
+
expectedPoint: string;
|
|
7
|
+
}) => Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
/** Native source-update workflow, including the actions offered by its notification. Absent in static compositions. */
|
|
10
|
+
export interface BoardSourceUpdates {
|
|
11
|
+
readonly readStatus: () => Promise<unknown>;
|
|
12
|
+
readonly reimport: () => Promise<unknown>;
|
|
13
|
+
readonly openProject: (path: string) => Promise<unknown>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type BoardReadEvent = {
|
|
2
|
+
type: 'connection';
|
|
3
|
+
status: 'connected' | 'offline';
|
|
4
|
+
} | {
|
|
5
|
+
type: 'state';
|
|
6
|
+
value: unknown;
|
|
7
|
+
} | {
|
|
8
|
+
type: 'change';
|
|
9
|
+
value: unknown;
|
|
10
|
+
} | {
|
|
11
|
+
type: 'invalid-state';
|
|
12
|
+
};
|
|
13
|
+
/** Payloads are opaque transport data; existing consumers/admission own their model and state checks. */
|
|
14
|
+
export interface BoardReadSession {
|
|
15
|
+
readonly readState: () => Promise<unknown>;
|
|
16
|
+
readonly readFrame: (id: string) => Promise<unknown>;
|
|
17
|
+
/** Each subscription owns its cleanup. After cleanup, it delivers no further events. */
|
|
18
|
+
readonly subscribe: (listener: (event: BoardReadEvent) => void) => () => void;
|
|
19
|
+
}
|
|
20
|
+
/** Optional host capability for sharing ephemeral view selection, not committing project content. Replies are opaque board state. */
|
|
21
|
+
export interface BoardSelection {
|
|
22
|
+
readonly ids: readonly string[];
|
|
23
|
+
readonly nodes?: {
|
|
24
|
+
readonly documentId: string;
|
|
25
|
+
readonly nodeIds: readonly string[];
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export type BoardSelectionPublisher = (selection: BoardSelection) => Promise<unknown>;
|
|
29
|
+
/** Frame measurements already bounded by the preview receiver. `version` and `render` both name the shown render revision. */
|
|
30
|
+
export interface BoardObservation {
|
|
31
|
+
readonly documentId: string;
|
|
32
|
+
readonly version: number;
|
|
33
|
+
readonly render: number;
|
|
34
|
+
readonly nodes: readonly {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly rect: {
|
|
37
|
+
readonly x: number;
|
|
38
|
+
readonly y: number;
|
|
39
|
+
readonly width: number;
|
|
40
|
+
readonly height: number;
|
|
41
|
+
};
|
|
42
|
+
readonly overflowX?: boolean;
|
|
43
|
+
readonly overflowY?: boolean;
|
|
44
|
+
}[];
|
|
45
|
+
readonly contentHeight?: number;
|
|
46
|
+
readonly scrollHeight?: number;
|
|
47
|
+
}
|
|
48
|
+
export type BoardObservationPublisher = (report: BoardObservation) => Promise<unknown>;
|
|
49
|
+
/** A host-created snapshot; the caller owns project admission and public-data curation. Not an upload parser. */
|
|
50
|
+
export declare function createSnapshotReadSession({ state, frames }: {
|
|
51
|
+
state: unknown;
|
|
52
|
+
frames: ReadonlyMap<string, unknown>;
|
|
53
|
+
}): BoardReadSession;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A host-created snapshot; the caller owns project admission and public-data curation. Not an upload parser. */
|
|
2
|
+
export function createSnapshotReadSession({ state, frames }) {
|
|
3
|
+
const snapshot = structuredClone(state);
|
|
4
|
+
const models = new Map([...frames].map(([id, model]) => [id, structuredClone(model)]));
|
|
5
|
+
return Object.freeze({
|
|
6
|
+
readState: async () => structuredClone(snapshot),
|
|
7
|
+
readFrame: async (id) => {
|
|
8
|
+
if (!models.has(id))
|
|
9
|
+
throw Object.assign(new Error(`Unknown frame: ${id}`), { code: 'NOT_FOUND' });
|
|
10
|
+
return structuredClone(models.get(id));
|
|
11
|
+
},
|
|
12
|
+
subscribe(listener) {
|
|
13
|
+
let active = true;
|
|
14
|
+
queueMicrotask(() => {
|
|
15
|
+
if (!active)
|
|
16
|
+
return;
|
|
17
|
+
listener({ type: 'connection', status: 'connected' });
|
|
18
|
+
if (active)
|
|
19
|
+
listener({ type: 'state', value: structuredClone(snapshot) });
|
|
20
|
+
});
|
|
21
|
+
return () => { active = false; };
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|