@divlet/web 0.0.0-stage → 0.0.1

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 (44) hide show
  1. package/README.md +12 -2
  2. package/dist/LICENSE +21 -0
  3. package/dist/board.css +948 -0
  4. package/dist/board.js +427 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.html +73 -0
  7. package/dist/index.js +3 -0
  8. package/dist/lib/actions.js +472 -0
  9. package/dist/lib/activity.js +311 -0
  10. package/dist/lib/artboards.js +56 -0
  11. package/dist/lib/ask.js +350 -0
  12. package/dist/lib/attrs.js +144 -0
  13. package/dist/lib/board-host.d.ts +14 -0
  14. package/dist/lib/board-host.js +2 -0
  15. package/dist/lib/board-session.d.ts +53 -0
  16. package/dist/lib/board-session.js +24 -0
  17. package/dist/lib/canvas.js +1594 -0
  18. package/dist/lib/chat.js +156 -0
  19. package/dist/lib/clip.js +231 -0
  20. package/dist/lib/crumbbar.js +56 -0
  21. package/dist/lib/crumbs.js +63 -0
  22. package/dist/lib/css.js +238 -0
  23. package/dist/lib/expandbar.js +64 -0
  24. package/dist/lib/file-picker.js +103 -0
  25. package/dist/lib/help.js +273 -0
  26. package/dist/lib/host.js +16 -0
  27. package/dist/lib/http-services.js +24 -0
  28. package/dist/lib/http-session.js +45 -0
  29. package/dist/lib/http.js +17 -0
  30. package/dist/lib/inspector.js +857 -0
  31. package/dist/lib/layers.js +298 -0
  32. package/dist/lib/menu.js +119 -0
  33. package/dist/lib/palette.js +292 -0
  34. package/dist/lib/panels.js +27 -0
  35. package/dist/lib/project.js +178 -0
  36. package/dist/lib/resize.js +114 -0
  37. package/dist/lib/shortcuts.js +91 -0
  38. package/dist/lib/sidebar.js +85 -0
  39. package/dist/lib/store.js +249 -0
  40. package/dist/lib/suggest.js +130 -0
  41. package/dist/lib/toolbar.js +54 -0
  42. package/dist/lib/typography.js +120 -0
  43. package/dist/lib/util.js +177 -0
  44. package/package.json +37 -4
package/dist/board.js ADDED
@@ -0,0 +1,427 @@
1
+ import { $, el, icon, button, clamp, keys, shortcutKey, uiScale } from './lib/util.js';
2
+ import { store, on, emit, documents, descriptor, apiUrl, request, receiveState, setConnection, selectArtboards, selectNodes, undo, redo, toast } from './lib/store.js';
3
+ import * as canvas from './lib/canvas.js';
4
+ import { readSession } from './lib/host.js';
5
+ import './lib/inspector.js';
6
+ import { loadClientPlugins } from './lib/panels.js';
7
+ import * as layers from './lib/layers.js';
8
+ import * as activity from './lib/activity.js';
9
+ import * as sidebar from './lib/sidebar.js';
10
+ import * as actions from './lib/actions.js';
11
+ import * as help from './lib/help.js';
12
+ import * as shortcuts from './lib/shortcuts.js';
13
+ import * as ask from './lib/ask.js';
14
+ import * as palette from './lib/palette.js';
15
+ import * as clip from './lib/clip.js';
16
+ import * as project from './lib/project.js';
17
+ import './lib/crumbbar.js';
18
+ import './lib/expandbar.js';
19
+ import { resizable } from './lib/resize.js';
20
+ import { openMenu, menuOpen, closeMenu } from './lib/menu.js';
21
+
22
+ const { entries } = canvas;
23
+ const viewport = $('#viewport');
24
+ // Opening a sidebar over the selected artboard brings that artboard back into the clear part of the canvas.
25
+ on('sidebar', ({ open }) => { if (open) setTimeout(() => canvas.revealSelection(), 180); });
26
+
27
+ /* ---- dock ----------------------------------------------------------------- */
28
+ const dock = $('#dock');
29
+ // The dock wraps on narrow screens; the inspector sits on top of its real height, never under it.
30
+ new ResizeObserver(() => {
31
+ // A hidden dock measures 0: fall back to the stylesheet's one-row height rather than letting the inspector sit on nothing.
32
+ const height = Math.round(dock.getBoundingClientRect().height / uiScale());
33
+ if (height > 0) document.documentElement.style.setProperty('--dock-h', `${height}px`);
34
+ else document.documentElement.style.removeProperty('--dock-h');
35
+ }).observe(dock);
36
+ const modeButtons = [['select', 'select', 'Select artboards and elements · V'], ['interact', 'play', 'Interact with the page · P']].map(([mode, name, tip]) => {
37
+ const node = button(name, tip, () => canvas.setMode(mode), 'mode');
38
+ node.dataset.mode = mode;
39
+ return node;
40
+ });
41
+ const zoomOut = button('minus', 'Zoom out · −', () => canvas.zoomAt(1 / 1.25, undefined, undefined, { animate: true }), 'zoom-step');
42
+ const zoomValue = el('button', 'zoom');
43
+ zoomValue.id = 'zoom-value';
44
+ zoomValue.type = 'button';
45
+ zoomValue.dataset.tip = 'Zoom options';
46
+ zoomValue.setAttribute('aria-label', 'Zoom options');
47
+ zoomValue.setAttribute('aria-haspopup', 'menu');
48
+ zoomValue.addEventListener('click', () => openMenu([
49
+ ...[0.25, 0.5, 1, 2].map(k => ({ label: `${k * 100}%`, checked: Math.abs(canvas.camera.k - k) < 0.005, keys: k === 1 ? '0' : undefined, run: () => canvas.zoomTo(k) })),
50
+ '-',
51
+ { label: 'Zoom in', icon: 'plus', keys: '+', run: () => canvas.zoomAt(1.25, undefined, undefined, { animate: true }) },
52
+ { label: 'Zoom out', icon: 'minus', keys: '−', run: () => canvas.zoomAt(1 / 1.25, undefined, undefined, { animate: true }) },
53
+ { label: 'Fit everything', icon: 'fit', keys: keys('Shift+1'), disabled: !documents().length, run: () => canvas.fit(null) },
54
+ { label: 'Zoom to selection', icon: 'fit', keys: keys('Shift+2'), disabled: !store.selected.size, run: () => fitSelection() },
55
+ ], { anchor: zoomValue, label: 'Zoom' }));
56
+ const zoomIn = button('plus', 'Zoom in · +', () => canvas.zoomAt(1.25, undefined, undefined, { animate: true }), 'zoom-step');
57
+ const undoButton = button('undo', `Undo · ${keys('Mod+Z')}`, () => undo());
58
+ const redoButton = button('redo', `Redo · ${keys('Mod+Shift+Z')}`, () => redo());
59
+ const helpButton = button('gear', `Settings · ${keys('Mod')} ,`, () => help.toggle());
60
+ helpButton.id = 'dock-help';
61
+ const siteLink = el('a', 'tool');
62
+ siteLink.id = 'open-site';
63
+ siteLink.href = './site/';
64
+ siteLink.target = '_blank';
65
+ siteLink.rel = 'noopener';
66
+ siteLink.setAttribute('aria-label', 'Open site');
67
+ siteLink.hidden = true;
68
+ const sep = () => el('i', 'sep');
69
+ // Bottom right: pointer modes, zoom, undo/redo and settings. Fit and zoom-to-selection live in the zoom menu and shortcuts.
70
+ dock.append(...modeButtons, sep(), zoomOut, zoomValue, zoomIn, sep(), undoButton, redoButton, sep(), siteLink, helpButton);
71
+
72
+ const paletteButton = $('#palette-open');
73
+ paletteButton.prepend(icon('search'));
74
+ $('#palette-keys').textContent = keys('Mod+K');
75
+ paletteButton.dataset.tip = `Search commands, artboards and elements · ${keys('Mod+K')}`;
76
+ paletteButton.addEventListener('click', () => palette.toggle(true));
77
+ on('palette', open => palette.toggle(open));
78
+
79
+ on('mode', mode => {
80
+ for (const node of modeButtons) { node.classList.toggle('on', node.dataset.mode === mode); node.setAttribute('aria-pressed', String(node.dataset.mode === mode)); }
81
+ document.body.dataset.canvasMode = mode;
82
+ });
83
+ canvas.setMode('select');
84
+
85
+ function paintHistory() {
86
+ const head = store.state?.history;
87
+ undoButton.disabled = !head?.canUndo;
88
+ redoButton.disabled = !head?.canRedo;
89
+ undoButton.dataset.tip = head?.undoLabel ? `Undo ${head.undoLabel} · ${keys('Mod+Z')}` : 'Nothing to undo';
90
+ redoButton.dataset.tip = head?.redoLabel ? `Redo ${head.redoLabel} · ${keys('Mod+Shift+Z')}` : 'Nothing to redo';
91
+ }
92
+
93
+ /* The corner: panels on the left of the search, the project indicator that turns into Back. */
94
+ $('#status-dot').append(icon('divlet'));
95
+ $('#open-layers .tab-mark').prepend(icon('layers'));
96
+ $('#open-history .tab-mark').prepend(icon('clock'));
97
+ $('#open-agent .tab-mark').prepend(icon('sparkle'));
98
+ $('#open-layers').addEventListener('click', () => sidebar.toggle('layers'));
99
+ $('#open-history').addEventListener('click', () => sidebar.toggle('activity'));
100
+ $('#open-agent').addEventListener('click', () => sidebar.toggle('agent'));
101
+ $('#expanded-back').prepend(icon('back'));
102
+ $('#status').setAttribute('aria-haspopup', 'menu');
103
+ $('#status').addEventListener('click', () => {
104
+ openMenu([
105
+ { heading: String(store.state?.project?.name || 'Project') },
106
+ { label: 'Open project…', icon: 'folder', keys: keys('Mod+O'), run: () => project.show('open') },
107
+ { label: 'Save a copy as…', icon: 'download', disabled: !project.canShowCopy(), keys: keys('Mod+Shift+S'), run: () => project.show('save') },
108
+ '-',
109
+ { label: 'Copy project folder path', icon: 'copy', run: () => actions.copyText(String(store.state?.projectDir || ''), 'Folder path copied') },
110
+ ], { anchor: $('#status'), label: 'Project' });
111
+ });
112
+ $('#empty-new').addEventListener('click', () => actions.newArtboard());
113
+
114
+ /* ---- site link ------------------------------------------------------------ */
115
+ siteLink.append(icon('site'));
116
+
117
+ on('state', state => {
118
+ $('#loading').hidden = true;
119
+ $('#status').setAttribute('aria-label', 'Project status');
120
+ $('#empty').hidden = state.documents.length !== 0;
121
+ siteLink.hidden = !state.site;
122
+ if (state.site) { siteLink.href = state.site.url; siteLink.dataset.tip = `Open “${state.site.title || 'site'}” · ${state.site.routes.length} page${state.site.routes.length === 1 ? '' : 's'}`; }
123
+ if (store.expanded) {
124
+ const doc = descriptor(store.expanded.entry.doc.id);
125
+ if (!doc) canvas.closeExpanded({ reset: false });
126
+ else {
127
+ $('#expanded-title').textContent = doc.name || doc.id;
128
+ if (state.site) store.expanded.entry.iframe?.contentWindow.postMessage({ type: 'divlet:site-state', site: { version: state.site.version } }, '*');
129
+ }
130
+ }
131
+ const source = String(state.project?.file || '').replaceAll('\\', '/');
132
+ const filename = source.split('/').at(-1);
133
+ const folder = String(state.projectDir || state.cwd || '').replaceAll('\\', '/').split('/').filter(Boolean).at(-1) || 'divlet';
134
+ const label = filename && filename !== 'project.divlet' ? filename.replace(/\.(?:divlet|pen)$/i, '') : folder;
135
+ // Named sources must remain distinguishable when several projects share a folder.
136
+ document.title = `${label} · divlet`;
137
+ $('#status').dataset.project = label;
138
+ paintHistory();
139
+ });
140
+
141
+ /* ---- toast ---------------------------------------------------------------- */
142
+ const toastNode = $('#toast');
143
+ let toastTimer;
144
+ let toastId = null;
145
+ const hideToast = () => { clearTimeout(toastTimer); toastNode.hidden = true; toastId = null; };
146
+ let toastDuration = 0;
147
+ on('toast', ({ message, action, sticky, id }) => {
148
+ const text = el('span', 'toast-msg', message);
149
+ toastNode.replaceChildren(text);
150
+ if (action) {
151
+ const run = el('button', 'toast-action', action.label);
152
+ run.type = 'button';
153
+ run.addEventListener('click', () => { hideToast(); action.run(); });
154
+ toastNode.append(run);
155
+ }
156
+ const close = el('button', 'toast-close');
157
+ close.type = 'button';
158
+ close.setAttribute('aria-label', 'Dismiss');
159
+ close.append(icon('close'));
160
+ close.addEventListener('click', hideToast);
161
+ toastNode.append(close);
162
+ toastNode.hidden = false;
163
+ toastNode.classList.remove('pop');
164
+ void toastNode.offsetWidth;
165
+ toastNode.classList.add('pop');
166
+ toastId = id ?? null;
167
+ $('#sr').textContent = message;
168
+ clearTimeout(toastTimer);
169
+ toastDuration = sticky ? 0 : action ? 7000 : 4000;
170
+ if (toastDuration) toastTimer = setTimeout(hideToast, toastDuration);
171
+ });
172
+ on('toast-clear', id => { if (toastId === id) hideToast(); });
173
+ toastNode.addEventListener('pointerenter', () => clearTimeout(toastTimer));
174
+ toastNode.addEventListener('pointerleave', () => { if (toastDuration && !toastNode.hidden) toastTimer = setTimeout(hideToast, 2500); });
175
+
176
+ /* ---- context menus -------------------------------------------------------- */
177
+ on('context', ({ kind, id, x, y, anchor, world }) => {
178
+ const position = anchor ? { anchor } : { x, y };
179
+ if (kind === 'artboard') actions.artboardMenu(id, position);
180
+ else if (kind === 'node') actions.nodeMenu(id, position);
181
+ else actions.canvasMenu(position, world);
182
+ });
183
+
184
+ /* ---- selection commands --------------------------------------------------- */
185
+ const nodeSelection = () => (store.node?.nodeIds.length ? store.node : null);
186
+
187
+ function deleteSelection() {
188
+ const node = nodeSelection();
189
+ if (node) return actions.deleteNodes(node.documentId, node.nodeIds);
190
+ if (store.selected.size) return actions.deleteArtboards([...store.selected]);
191
+ }
192
+
193
+ function duplicateSelection() {
194
+ const node = nodeSelection();
195
+ if (node) return actions.duplicateNodes(node.documentId, node.nodeIds);
196
+ if (store.selected.size) return actions.duplicateArtboards([...store.selected]);
197
+ }
198
+
199
+ function fitSelection() {
200
+ const node = nodeSelection();
201
+ if (node) canvas.fitNode(node.documentId, node.nodeIds[0]);
202
+ else if (store.selected.size) canvas.fit([...store.selected]);
203
+ else canvas.fit(null);
204
+ }
205
+ on('fit-selection', fitSelection);
206
+
207
+ /* ---- keyboard ------------------------------------------------------------- */
208
+ /** Keys that also navigate focus (Enter, Tab, arrows) are only ours when the canvas — not a control — has focus. */
209
+ const onCanvas = event => !event.target || event.target === document.body || event.target === viewport || event.target.classList?.contains('artboard');
210
+
211
+ function handleKey(event) {
212
+ const prevent = () => event.preventDefault?.();
213
+ const key = shortcutKey(event);
214
+ const mod = event.ctrlKey || event.metaKey;
215
+ if (store.expanded) { if (key === 'escape') { prevent(); canvas.closeExpanded(); } return true; }
216
+ // A preview frame may still have focus for a moment after the Ask Pi field opened.
217
+ if (event.fromFrame && ask.isOpen() && !mod && !event.altKey && (String(event.key).length === 1 || event.key === 'Backspace')) return ask.typeAhead(event.key);
218
+ if (event.fromFrame && ask.isOpen() && key === 'enter' && !event.shiftKey) { ask.submit(); return true; }
219
+ if (project.isOpen()) return false;
220
+ if (help.isOpen()) { if (key === 'escape') { prevent(); help.toggle(false); return true; } return false; }
221
+ if (shortcuts.isOpen()) { if (key === 'escape' || key === '?') { prevent(); shortcuts.toggle(false); return true; } return false; }
222
+ if (palette.isOpen()) { if (key === 'escape') { prevent(); palette.toggle(false); return true; } return false; }
223
+ if (menuOpen()) { if (key === 'escape') { prevent(); closeMenu(); return true; } return false; }
224
+ if (mod && event.altKey && (key === '=' || key === '+' || key === '-')) { prevent(); help.stepUiSize(key === '-' ? -1 : 1); return true; }
225
+ if (mod && !event.altKey && !event.shiftKey && (key === ',' || event.code === 'Comma')) { prevent(); help.toggle(true); return true; }
226
+ if (mod && !event.altKey && key === 'o') { prevent(); project.show('open'); return true; }
227
+ if (mod && event.shiftKey && !event.altKey && key === 's') { prevent(); project.show('save'); return true; }
228
+ if (mod && !event.altKey && key === 'k') { prevent(); palette.toggle(true); return true; }
229
+ // Copy, cut and paste from the board run through the native clipboard events (see clip.js);
230
+ // keys forwarded by a preview frame have no clipboard event, so they act on the board's own copy.
231
+ if (mod && !event.altKey && (key === 'c' || key === 'x' || key === 'v')) {
232
+ if (!event.fromFrame) return false;
233
+ if (key === 'v') clip.paste();
234
+ else clip.copySelection({ cut: key === 'x' });
235
+ return true;
236
+ }
237
+ const node = nodeSelection();
238
+ const single = store.selected.size === 1 ? [...store.selected][0] : null;
239
+ if (mod) {
240
+ if (key === 'z') { prevent(); event.shiftKey ? redo() : undo(); }
241
+ else if (key === 'y') { prevent(); redo(); }
242
+ else if (key === 'd') { prevent(); duplicateSelection(); }
243
+ else if (key === 'a') { prevent(); selectArtboards(documents().map(doc => doc.id)); }
244
+ else if (key === 'g') {
245
+ prevent();
246
+ if (!node) return true;
247
+ if (event.shiftKey) actions.unwrapNode(node.documentId, node.nodeIds[0]);
248
+ else actions.wrapNodes(node.documentId, node.nodeIds);
249
+ } else if (key === '=' || key === '+') { prevent(); canvas.zoomAt(1.25, undefined, undefined, { animate: true }); }
250
+ else if (key === '-' || key === '_') { prevent(); canvas.zoomAt(1 / 1.25, undefined, undefined, { animate: true }); }
251
+ else if (key === '0') { prevent(); canvas.zoomTo(1); }
252
+ else return false;
253
+ return true;
254
+ }
255
+ // The menu key (or Shift+F10) opens the actions of what is selected, next to it.
256
+ if (key === 'contextmenu' || (event.shiftKey && key === 'f10')) {
257
+ prevent();
258
+ if (node) actions.nodeMenu(node.documentId, canvas.anchorPoint(node.documentId, node.nodeIds[0]));
259
+ else if (single) actions.artboardMenu(single, canvas.anchorPoint(single, null));
260
+ else return false;
261
+ return true;
262
+ }
263
+ if (event.altKey && (key === 'arrowup' || key === 'arrowdown') && node) { prevent(); actions.moveNode(node.documentId, node.nodeIds[0], key === 'arrowup' ? -1 : 1); return true; }
264
+ if (event.altKey) return false;
265
+ if (event.shiftKey && (event.code === 'Digit1' || key === '!')) { prevent(); canvas.fit(null); return true; }
266
+ if (event.shiftKey && (event.code === 'Digit2' || key === '@')) { prevent(); fitSelection(); return true; }
267
+ if (key === 'enter' && onCanvas(event)) {
268
+ prevent();
269
+ if (event.shiftKey) { if (!actions.selectParent() && store.selected.size) selectArtboards([]); }
270
+ else actions.selectChild();
271
+ return true;
272
+ }
273
+ if (key === 'tab' && onCanvas(event) && documents().length) { prevent(); actions.selectSibling(event.shiftKey ? -1 : 1); return true; }
274
+ switch (key) {
275
+ case 'v': case 'i': canvas.setMode('select'); break;
276
+ case 'p': canvas.setMode('interact'); break;
277
+ case 'e': if (single) canvas.openExpanded(single); break;
278
+ case 'f': fitSelection(); break;
279
+ case 'a': prevent(); ask.toggle(true, { fromFrame: !!event.fromFrame }); break;
280
+ case 'r': if (single && !node) actions.renameArtboard(single); else return false; break;
281
+ case '0': canvas.zoomTo(1); break;
282
+ case '+': case '=': canvas.zoomAt(1.25, undefined, undefined, { animate: true }); break;
283
+ case '-': case '_': canvas.zoomAt(1 / 1.25, undefined, undefined, { animate: true }); break;
284
+ case 'n': actions.newArtboard(); break;
285
+ case 'l': sidebar.toggle('layers'); break;
286
+ case 'h': sidebar.toggle('activity'); break;
287
+ case '?': shortcuts.toggle(true); break;
288
+ case 'delete': case 'backspace': prevent(); deleteSelection(); break;
289
+ case 'escape':
290
+ if (ask.isOpen()) ask.toggle(false);
291
+ else if (node) { if (!actions.selectParent()) selectNodes(node.documentId, []); }
292
+ else if (store.selected.size) selectArtboards([]);
293
+ else if (store.mode !== 'select') canvas.setMode('select');
294
+ else if (sidebar.isOpen()) sidebar.close();
295
+ else return false;
296
+ break;
297
+ case 'arrowleft': case 'arrowright': case 'arrowup': case 'arrowdown':
298
+ // With an element selected the arrows walk the tree: ↑↓ siblings, ← parent, → first child.
299
+ if (node && onCanvas(event)) {
300
+ prevent();
301
+ if (key === 'arrowup') actions.selectSibling(-1);
302
+ else if (key === 'arrowdown') actions.selectSibling(1);
303
+ else if (key === 'arrowleft') actions.selectParent();
304
+ else actions.selectChild({ edit: false });
305
+ break;
306
+ }
307
+ if (node || !store.selected.size || !onCanvas(event)) return false;
308
+ prevent();
309
+ { const step = event.shiftKey ? 10 : 1; canvas.nudge(key === 'arrowleft' ? -step : key === 'arrowright' ? step : 0, key === 'arrowup' ? -step : key === 'arrowdown' ? step : 0); }
310
+ break;
311
+ default: return false;
312
+ }
313
+ return true;
314
+ }
315
+ canvas.setKeyHandler(handleKey);
316
+ window.addEventListener('keydown', event => {
317
+ const target = event.target;
318
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target?.isContentEditable) return;
319
+ if (event.code === 'Space' || (event.repeat && /^(Delete|Backspace|Enter)$/.test(event.key))) return;
320
+ if ((event.key === 'Enter' || event.key === ' ') && target?.closest?.('button, summary, a[href]')) return;
321
+ handleKey(event);
322
+ });
323
+
324
+ /* ---- images and paste from preview frames --------------------------------- */
325
+ on('images', async ({ documentId, files, nodeId }) => {
326
+ if (nodeId && cachedModelHas(documentId, nodeId)) selectNodes(documentId, [nodeId]);
327
+ else if (!(store.selected.size === 1 && store.selected.has(documentId) && (!store.node || store.node.documentId === documentId))) selectArtboards([documentId]);
328
+ clip.addImages(files, documentId);
329
+ });
330
+ on('frame-paste', ({ text, files }) => clip.paste({ text, files }));
331
+ on('override-text', ({ documentId, instance, address, content }) => actions.overrideText(documentId, instance, address, content));
332
+ function cachedModelHas(documentId, nodeId) { return !!store.models.get(documentId)?.nodes[nodeId]; }
333
+
334
+ /* ---- live connection ------------------------------------------------------ */
335
+ let offlineTimer = 0;
336
+ let wasOffline = false;
337
+ on('connection', value => {
338
+ if (value === 'offline') {
339
+ if (!offlineTimer && !wasOffline) offlineTimer = setTimeout(() => { wasOffline = true; toast('Connection lost — reconnecting. The preview is kept as it was.', null, { sticky: true, id: 'offline' }); }, 2500);
340
+ } else if (value === 'connected') {
341
+ clearTimeout(offlineTimer);
342
+ offlineTimer = 0;
343
+ if (wasOffline) { wasOffline = false; toast.clear('offline'); toast('Reconnected'); }
344
+ }
345
+ });
346
+
347
+ // Plugins' client modules register their inspector panels before the first project state is shown.
348
+ const bootstrap = readSession.readState().then(async info => { await loadClientPlugins(info.clientPlugins, apiUrl); return info; });
349
+ const stopWatching = readSession.subscribe(event => {
350
+ if (event.type === 'connection') {
351
+ setConnection(event.status);
352
+ if (event.status === 'connected') activity.loadRecent();
353
+ return;
354
+ }
355
+ if (event.type === 'state' || event.type === 'invalid-state') {
356
+ bootstrap.catch(() => {}).then(() => {
357
+ try {
358
+ if (event.type === 'invalid-state') throw new Error('Invalid project update');
359
+ receiveState(event.value); setConnection('connected');
360
+ } catch { toast('Received an invalid project update. The current preview is retained.'); }
361
+ });
362
+ return;
363
+ }
364
+ try {
365
+ const change = event.value;
366
+ activity.pushChange(change);
367
+ emit('change', change);
368
+ const entry = entries.get(change.documentId);
369
+ if (change.actor !== 'user' || change.kind === 'undo' || change.kind === 'redo') canvas.flashNodes(change.documentId, change.render ?? 0, change.touched);
370
+ if (entry && change.actor !== 'user') { entry.el.classList.add('touched'); setTimeout(() => entry.el.classList.remove('touched'), 1800); }
371
+ } catch { /* ignore malformed change */ }
372
+ });
373
+ bootstrap.then(info => { receiveState(info); project.checkImport(); }).catch(error => {
374
+ $('#loading').hidden = true;
375
+ setConnection('offline');
376
+ toast(error.message);
377
+ });
378
+
379
+ // Several boards can be open in tabs: the tab says which project this one is (the file name, or the folder for project.divlet).
380
+ on('state', () => {
381
+ const parts = String(store.state?.project?.file || '').split(/[\\/]/).filter(Boolean);
382
+ const stem = (parts.at(-1) || '').replace(/\.divlet$/i, '');
383
+ const name = stem === 'project' && parts.length > 1 ? parts.at(-2) : stem;
384
+ document.title = name ? `${name} · divlet` : 'divlet';
385
+ });
386
+ on('state', () => { if (!document.activeElement || document.activeElement === document.body) viewport.focus({ preventScroll: true }); });
387
+ window.addEventListener('pagehide', () => { stopWatching(); canvas.disposeCanvas(); });
388
+ window.addEventListener('pageshow', event => { if (event.persisted) location.reload(); });
389
+
390
+ /* ---- hints ---------------------------------------------------------------- */
391
+ const tip = el('div', 'tip');
392
+ tip.setAttribute('role', 'tooltip');
393
+ document.body.append(tip);
394
+ let tipTimer = 0;
395
+ function showTip(target) {
396
+ const code = target.dataset.tipCode;
397
+ tip.classList.toggle('code', !!code);
398
+ if (code) tip.replaceChildren(el('b', 'tip-title', target.dataset.tip), el('pre', 'tip-code', code));
399
+ else tip.textContent = target.dataset.tip;
400
+ tip.classList.add('show');
401
+ const r = target.getBoundingClientRect();
402
+ const w = tip.offsetWidth * uiScale();
403
+ const h = tip.offsetHeight * uiScale();
404
+ const above = r.bottom + h + 14 > innerHeight || (r.top > innerHeight / 2 && r.top > h + 14);
405
+ tip.style.left = `${clamp(r.left + r.width / 2 - w / 2, 6, innerWidth - w - 6)}px`;
406
+ tip.style.top = `${clamp(above ? r.top - h - 8 : r.bottom + 8, 6, innerHeight - h - 6)}px`;
407
+ }
408
+ const hideTip = () => { clearTimeout(tipTimer); tip.classList.remove('show'); };
409
+ document.addEventListener('pointerover', event => {
410
+ const target = event.target.closest?.('[data-tip]');
411
+ clearTimeout(tipTimer);
412
+ if (target && event.pointerType !== 'touch' && target.dataset.tip && !target.closest('.menu')) tipTimer = setTimeout(() => showTip(target), tip.classList.contains('show') ? 0 : 280);
413
+ else hideTip();
414
+ });
415
+ document.addEventListener('focusin', event => { const target = event.target.closest?.('[data-tip]'); if (target?.dataset.tip && target.matches(':focus-visible')) showTip(target); });
416
+ document.addEventListener('focusout', hideTip);
417
+ document.addEventListener('pointerdown', hideTip, true);
418
+ document.addEventListener('keydown', hideTip, true);
419
+ window.addEventListener('scroll', hideTip, true);
420
+
421
+
422
+ /* Panel widths: the inspector and the sidebar can be dragged wider or narrower. */
423
+ // The inspector is as wide as the toolbar under it until the user drags its edge; a double-click on the edge returns to that.
424
+ resizable($('#inspector'), { side: 'right', key: 'inspector', cssVar: '--inspector-w', min: 280, max: 640, follow: dock, fallback: () => Math.round(dock.getBoundingClientRect().width / uiScale()) || 336 });
425
+ resizable($('#sidebar'), { side: 'left', key: 'sidebar', cssVar: '--sidebar-w', min: 240, max: 560, fallback: 300 });
426
+
427
+ on('edit-step', info => actions.stepEdit(info));
@@ -0,0 +1,4 @@
1
+ export declare const clientRoot: URL;
2
+ export { createSnapshotReadSession } from './lib/board-session.ts';
3
+ export type { BoardReadSession, BoardReadEvent, BoardSelection, BoardSelectionPublisher, BoardObservation, BoardObservationPublisher } from './lib/board-session.ts';
4
+ export type { BoardHistoryService, BoardSourceUpdates } from './lib/board-host.ts';
@@ -0,0 +1,73 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>divlet</title>
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20color%3D%22%235b5bf0%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.5%22%20stroke-linejoin%3D%22round%22%20stroke-linecap%3D%22round%22%3E%3Cpath%20d%3D%22M8%204.5%202.5%2010%208%2015.5%22%20fill%3D%22none%22%2F%3E%3Cpath%20d%3D%22M10.5%204.5H12L17.5%2010%2012%2015.5h-1.5Z%22%20fill%3D%22currentColor%22%20stroke%3D%22none%22%2F%3E%3C%2Fsvg%3E">
9
+ <script>try { const theme = localStorage.getItem('divlet:theme'); if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme; const ui = Number(localStorage.getItem('divlet:ui')); if (ui >= 1 && ui <= 2) document.documentElement.style.setProperty('--ui', String(ui)); } catch {}
10
+ // Layouts react to the width the interface really has: the window divided by the interface size.
11
+ const fit = () => { const w = innerWidth / (Number.parseFloat(document.documentElement.style.getPropertyValue('--ui')) || 1); for (const t of [720, 560, 440, 420]) document.documentElement.classList.toggle('w' + t, w <= t); };
12
+ fit(); addEventListener('resize', fit); window.__divletFit = fit;</script>
13
+ <link rel="stylesheet" href="./board.css">
14
+ <script type="module" src="./board.js"></script>
15
+ </head>
16
+ <body>
17
+ <main id="viewport" class="viewport mode-board" tabindex="0" aria-label="Canvas">
18
+ <div id="stage" class="stage"></div>
19
+ <div id="guides" class="guides"></div>
20
+ <div id="hover-badge" class="hover-badge" aria-hidden="true" hidden></div>
21
+ <div id="marquee" class="marquee" hidden></div>
22
+ <div id="empty" class="empty" hidden>
23
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M8 4.5 2.5 10 8 15.5" fill="none"/><path d="M10.5 4.5H12L17.5 10 12 15.5h-1.5Z" fill="currentColor" stroke="none"/></svg>
24
+ <p>An empty canvas</p>
25
+ <button id="empty-new" class="cta" type="button">New artboard <kbd>N</kbd></button>
26
+ <span class="hint">or ask Pi to design something</span>
27
+ </div>
28
+ <div id="loading" class="loading"><i></i></div>
29
+ </main>
30
+
31
+ <div id="ui">
32
+ <!-- Top left: the logo/project button and search. Together with the sidebar and its tabs it reads as one column that stays when the sidebar folds. -->
33
+ <header id="brand" class="brand chrome">
34
+ <button id="status" class="status" type="button" aria-label="Project status"><span id="status-dot" class="logo" data-kind="connecting" aria-hidden="true"></span><span id="project-name" class="project">DIVLET</span></button>
35
+ <button id="palette-open" class="tool search-pill" type="button" aria-label="Search and commands"><span class="search-pill-text">Search</span><kbd id="palette-keys">Ctrl K</kbd></button>
36
+ </header>
37
+
38
+ <aside id="sidebar" class="panel left chrome" aria-label="Layers, history and agent" hidden>
39
+ <section id="layers" class="pane" role="region" aria-labelledby="open-layers"></section>
40
+ <section id="activity" class="pane" role="region" aria-labelledby="open-history" hidden></section>
41
+ <section id="agent" class="pane agent-pane" role="region" aria-labelledby="open-agent" hidden></section>
42
+ </aside>
43
+ <!-- The tabs sit under the sidebar. A tab that is already open folds the sidebar; the strip itself always stays. -->
44
+ <div id="sidebar-foot" class="sidebar-foot chrome">
45
+ <div id="agent-status-dock" class="agent-status-dock"></div>
46
+ <nav id="side-tabs" class="side-tabs chrome" role="tablist" aria-label="Sidebar">
47
+ <button id="open-layers" class="tab" type="button" role="tab" aria-label="Layers" aria-pressed="false" aria-controls="layers" data-tip="Artboards and elements · L"><span class="tab-mark"></span><span class="tab-copy"><span class="tool-label">Layers</span></span></button>
48
+ <button id="open-history" class="tab" type="button" role="tab" aria-label="History" aria-pressed="false" aria-controls="activity" data-tip="History of changes · H"><span class="tab-mark"></span><span class="tab-copy"><span class="tool-label">History</span><b id="diag-count" class="badge" hidden>0</b></span></button>
49
+ <button id="open-agent" class="tab" type="button" role="tab" aria-label="Agent" aria-pressed="false" aria-controls="agent" data-tip="Talk to the agent · A"><span class="tab-mark"></span><span class="tab-copy"><span class="tool-label">Agent</span><b id="agent-count" class="badge" hidden>0</b></span></button>
50
+ </nav>
51
+ </div>
52
+ <aside id="inspector" class="panel right chrome" aria-label="Inspector" hidden></aside>
53
+
54
+ <nav id="crumbs" class="crumbs crumbs-bar chrome" aria-label="Selected element path" hidden></nav>
55
+ <nav id="dock" class="dock chrome" aria-label="Tools"></nav>
56
+ <span id="live-count" class="sr-only">0/0</span>
57
+ <div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
58
+ <div id="sr" class="sr-only" role="status" aria-live="polite"></div>
59
+
60
+ <div id="palette" class="scrim chrome palette-scrim" hidden>
61
+ <section class="dialog palette" role="dialog" aria-modal="true" aria-label="Search and commands"></section>
62
+ </div>
63
+
64
+ <div id="help" class="scrim chrome" hidden>
65
+ <section class="dialog" role="dialog" aria-modal="true" aria-labelledby="help-title"></section>
66
+ </div>
67
+
68
+ <section id="expanded-preview" class="expanded-bar" role="dialog" aria-modal="true" aria-labelledby="expanded-title" hidden>
69
+ <button id="expanded-back" class="expanded-back" type="button" aria-label="Back to the canvas" data-tip="Back to the canvas · Esc · drag to move"><span id="expanded-title"></span></button>
70
+ </section>
71
+ </div>
72
+ </body>
73
+ </html>
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // The client package owns its assets and portable host ports, not a Node runtime or model writer.
2
+ export const clientRoot = new URL('./', import.meta.url);
3
+ export { createSnapshotReadSession } from "./lib/board-session.js";