@divlet/web 0.0.2 → 0.0.3

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/dist/board.css CHANGED
@@ -718,6 +718,8 @@ html.w440 .shortcut-group dd { text-align: left; padding-bottom: 5px; white-spac
718
718
  .crumbs-bar .crumb { height: 24px; flex: none; max-width: 100%; }
719
719
  .crumbs-bar .crumb.home { flex: 0 1 auto; min-width: 0; }
720
720
  .crumb.current { background: var(--field); color: var(--ink); }
721
+ /* The next level after a selected artboard: a step you can take, quieter than the current crumb. */
722
+ .crumb.root-entry { color: var(--muted); }
721
723
  .ask-more { padding: 0 6px; font: 11px var(--mono); color: var(--muted); }
722
724
  .ask-input { width: 100%; min-height: 40px; max-height: 220px; padding: 9px 11px; border: 0; border-radius: var(--radius-inner); background: var(--field); color: var(--ink); font: 13.5px/1.45 var(--font); resize: none; outline: none; }
723
725
  .ask-input:focus { outline-offset: 1px; outline: var(--focus-ring) solid var(--accent); }
package/dist/board.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { $, el, icon, button, clamp, keys, shortcutKey, uiScale } from './lib/util.js';
2
2
  import { store, on, emit, documents, descriptor, apiUrl, request, receiveState, setConnection, selectArtboards, selectNodes, undo, redo, toast } from './lib/store.js';
3
3
  import * as canvas from './lib/canvas.js';
4
- import { readSession } from './lib/host.js';
4
+ import { readSession, native } from './lib/host.js';
5
5
  import './lib/inspector.js';
6
6
  import { loadClientPlugins } from './lib/panels.js';
7
7
  import * as layers from './lib/layers.js';
@@ -103,10 +103,12 @@ $('#status').setAttribute('aria-haspopup', 'menu');
103
103
  $('#status').addEventListener('click', () => {
104
104
  openMenu([
105
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') },
106
+ // A browser host has no filesystem: opening, copying and folder paths belong to the local host.
107
+ native && { label: 'Open project…', icon: 'folder', keys: keys('Mod+O'), run: () => project.show('open') },
108
+ native && { label: 'Save a copy as…', icon: 'download', disabled: !project.canShowCopy(), keys: keys('Mod+Shift+S'), run: () => project.show('save') },
109
+ native && '-',
110
+ native && { label: 'Copy project folder path', icon: 'copy', run: () => actions.copyText(String(store.state?.projectDir || ''), 'Folder path copied') },
111
+ !native && { label: 'Download project file', icon: 'download', run: () => actions.downloadProject() },
110
112
  ], { anchor: $('#status'), label: 'Project' });
111
113
  });
112
114
  $('#empty-new').addEventListener('click', () => actions.newArtboard());
@@ -124,7 +126,7 @@ on('state', state => {
124
126
  const doc = descriptor(store.expanded.entry.doc.id);
125
127
  if (!doc) canvas.closeExpanded({ reset: false });
126
128
  else {
127
- $('#expanded-title').textContent = doc.name || doc.id;
129
+ $('#expanded-title').textContent = store.expanded.title || doc.name || doc.id;
128
130
  if (state.site) store.expanded.entry.iframe?.contentWindow.postMessage({ type: 'divlet:site-state', site: { version: state.site.version } }, '*');
129
131
  }
130
132
  }
@@ -223,8 +225,8 @@ function handleKey(event) {
223
225
  if (menuOpen()) { if (key === 'escape') { prevent(); closeMenu(); return true; } return false; }
224
226
  if (mod && event.altKey && (key === '=' || key === '+' || key === '-')) { prevent(); help.stepUiSize(key === '-' ? -1 : 1); return true; }
225
227
  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 (native && mod && !event.altKey && key === 'o') { prevent(); project.show('open'); return true; }
229
+ if (native && mod && event.shiftKey && !event.altKey && key === 's') { prevent(); project.show('save'); return true; }
228
230
  if (mod && !event.altKey && key === 'k') { prevent(); palette.toggle(true); return true; }
229
231
  // Copy, cut and paste from the board run through the native clipboard events (see clip.js);
230
232
  // keys forwarded by a preview frame have no clipboard event, so they act on the board's own copy.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export declare const clientRoot: URL;
2
2
  export { createSnapshotReadSession } from './lib/board-session.ts';
3
3
  export type { BoardReadSession, BoardReadEvent, BoardSelection, BoardSelectionPublisher, BoardObservation, BoardObservationPublisher } from './lib/board-session.ts';
4
- export type { BoardHistoryService, BoardSourceUpdates } from './lib/board-host.ts';
4
+ export type { BoardHistoryService, BoardSourceUpdates, BoardWriter, BoardPreviews, BoardComponents, BoardExports, BoardHostComposition } from './lib/board-host.ts';
@@ -4,6 +4,7 @@ import * as canvas from './canvas.js';
4
4
  import { openMenu } from './menu.js';
5
5
  import * as clip from './clip.js';
6
6
  import { newArtboard, duplicateArtboards, deleteArtboards } from './artboards.js';
7
+ import { components as componentSource, exports as exportSource, native } from './host.js';
7
8
  export { newArtboard, duplicateArtboards, deleteArtboards };
8
9
 
9
10
  /* Command facade for the toolbar, keyboard, context menus, inspector and layers.
@@ -41,22 +42,44 @@ export async function copyHtml(documentId, nodeIds) {
41
42
  const model = await modelOf(documentId);
42
43
  const ids = nodeIds.filter(id => model.nodes[id]);
43
44
  text = clip.htmlOf(model, ids);
44
- } else text = (await request(`api/export/${encodeURIComponent(documentId)}`)).html;
45
+ } else if (exportSource) text = (await exportSource.page(documentId)).html;
46
+ else throw new Error('This host cannot export HTML.');
45
47
  if (!document.hasFocus()) document.querySelector('#viewport')?.focus({ preventScroll: true });
46
48
  await navigator.clipboard.writeText(text);
47
49
  toast(nodeIds?.length ? 'HTML copied' : `Page HTML copied · ${Math.max(1, Math.round(text.length / 1024))} KB with CSS`);
48
50
  } catch (error) { toast(error?.name === 'NotAllowedError' ? 'The clipboard is not available here' : error.message); }
49
51
  }
50
52
 
53
+ /** Save the browser host's copy of the project as a .divlet file the user can open with the local host. */
54
+ export async function downloadProject() {
55
+ try {
56
+ const { text, fileName } = await exportSource.project();
57
+ const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
58
+ const link = Object.assign(document.createElement('a'), { href: url, download: fileName });
59
+ document.body.append(link);
60
+ link.click();
61
+ link.remove();
62
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
63
+ toast(`Downloading ${fileName} · open it with npx -y @divlet/cli ${fileName}`);
64
+ } catch (error) { toast(error?.message || 'This host cannot save the project.'); }
65
+ }
66
+
51
67
  /** Save an artboard as a standalone HTML file. */
52
- export function downloadHtml(documentId) {
68
+ export async function downloadHtml(documentId) {
53
69
  const link = document.createElement('a');
54
- link.href = apiUrl(`api/export/${encodeURIComponent(documentId)}?download=1`);
70
+ let blob = null;
71
+ if (native) link.href = apiUrl(`api/export/${encodeURIComponent(documentId)}?download=1`);
72
+ else {
73
+ // A browser host builds the file itself; nothing is requested from a server.
74
+ try { const page = await exportSource.page(documentId); blob = URL.createObjectURL(new Blob([page.html], { type: 'text/html' })); link.href = blob; }
75
+ catch (error) { toast(error.message); return; }
76
+ }
55
77
  const fileName = descriptor(documentId)?.htmlPath?.split(/[\\/]/).at(-1) || `${documentId}.html`;
56
78
  link.download = fileName;
57
79
  document.body.append(link);
58
80
  link.click();
59
81
  link.remove();
82
+ if (blob) setTimeout(() => URL.revokeObjectURL(blob), 1000);
60
83
  toast(`Downloading ${fileName}`);
61
84
  }
62
85
 
@@ -338,7 +361,7 @@ export async function selectSibling(step) {
338
361
  export async function insertMenu(documentId, position) {
339
362
  if (!documentId) { toast('Select an artboard first'); return; }
340
363
  let components = [];
341
- try { components = (await request('api/components')).components; } catch {}
364
+ try { if (componentSource) components = (await componentSource.list()).components; } catch {}
342
365
  openMenu([
343
366
  { heading: store.node?.documentId === documentId ? 'Add after or inside the selection' : `Add to “${nameOf(documentId)}”` },
344
367
  ...insertionPresets().map(item => (item === '-' ? '-' : { label: item[2], icon: item[1], run: () => insertElement(documentId, item[0], item[2]) })),
@@ -1,5 +1,6 @@
1
1
  // Artboard lifecycle commands; selection, reveal, grouping and failure feedback stay with each accepted operation.
2
- import { descriptor, post, undo, toast, newGroup, selectArtboards, whenDocument, leave } from './store.js';
2
+ import { descriptor, undo, toast, newGroup, selectArtboards, whenDocument, leave } from './store.js';
3
+ import { writer } from './host.js';
3
4
  import * as canvas from './canvas.js';
4
5
 
5
6
  export async function newArtboard({ x, y, width, height, name } = {}) {
@@ -8,7 +9,7 @@ export async function newArtboard({ x, y, width, height, name } = {}) {
8
9
  if (name) body.name = name;
9
10
  if (Number.isFinite(x) && Number.isFinite(y)) Object.assign(body, { x: Math.round(x), y: Math.round(y) });
10
11
  try {
11
- const result = await post('api/artboards', body);
12
+ const result = await writer.createArtboard(body);
12
13
  await whenDocument(result.documentId);
13
14
  selectArtboards([result.documentId]);
14
15
  canvas.reveal(result.documentId);
@@ -21,7 +22,7 @@ export async function duplicateArtboards(ids) {
21
22
  const group = newGroup();
22
23
  const created = [];
23
24
  for (const from of ids) {
24
- try { created.push((await post('api/artboards', { from, group, expectedVersion: descriptor(from)?.version, label: ids.length > 1 ? `duplicate ${ids.length} artboards` : 'duplicate artboard' })).documentId); }
25
+ try { created.push((await writer.createArtboard({ from, group, expectedVersion: descriptor(from)?.version, label: ids.length > 1 ? `duplicate ${ids.length} artboards` : 'duplicate artboard' })).documentId); }
25
26
  catch (error) { toast(error.message); }
26
27
  }
27
28
  if (!created.length) return;
@@ -49,7 +50,7 @@ export async function deleteArtboards(ids) {
49
50
  }
50
51
  leave(order.map(doc => doc.id));
51
52
  for (const doc of order) {
52
- try { await post('api/artboards/delete', { documentId: doc.id, group, expectedVersion: doc.version, label: order.length > 1 ? `delete ${order.length} artboards` : `delete “${doc.name}”` }); removed.push(doc); }
53
+ try { await writer.deleteArtboard({ documentId: doc.id, group, expectedVersion: doc.version, label: order.length > 1 ? `delete ${order.length} artboards` : `delete “${doc.name}”` }); removed.push(doc); }
53
54
  catch (error) { leave([doc.id], true); toast(error.message); }
54
55
  }
55
56
  if (removed.length) toast(removed.length > 1 ? `${removed.length} artboards deleted` : `“${removed[0].name}” deleted`, { label: 'Undo', run: () => undo() });
package/dist/lib/ask.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { $, el, icon, button, keys } from './util.js';
2
2
  import { openMenu } from './menu.js';
3
3
  import { store, on, emit, post, toast, descriptor } from './store.js';
4
+ import { native } from './host.js';
4
5
  import * as sidebar from './sidebar.js';
5
6
  import * as chat from './chat.js';
6
7
 
@@ -16,7 +17,10 @@ let draft = '';
16
17
  let watchdog = 0;
17
18
  let returnFocus = null;
18
19
 
19
- export const available = () => !!store.state?.agent?.connected;
20
+ /** A board request needs a host that relays conversation; agents of a browser host work through its MCP tools only. */
21
+ export const available = () => !!store.state?.agent?.connected && store.state.agent.conversation !== false;
22
+ /** The command an MCP host runs to connect an agent to this page (browser hosts only). */
23
+ const connectCommand = () => `npx -y @divlet/mcp --connect ${new URL('./', location.href).href}`;
20
24
  export const isOpen = () => sidebar.isOpen('agent');
21
25
  const agent = () => store.state?.agent || {};
22
26
 
@@ -162,11 +166,19 @@ function paintStatus() {
162
166
  input.disabled = !available();
163
167
  // The sidebar can be dragged narrow, so the texts follow its own width (sidebar.js measures it).
164
168
  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')
169
+ input.placeholder = !native ? (tight ? 'Agents connect over MCP' : 'Agents work on this page through its MCP tools')
170
+ : !available() ? (tight ? 'Open from Pi (/divlet)' : 'Open this board from Pi (/divlet) to talk to it')
166
171
  : tight ? 'Ask the agent…' : 'Ask the agent to change the layout…';
167
172
  const keysHint = tight ? [`${keys('Enter')} send`] : [`${keys('Enter')} send`, `${keys('Shift+Enter')} new line`, '↑ previous'];
168
173
  // Each key with its meaning is one unit: a narrow composer wraps between units, never inside one.
169
- if (!available()) {
174
+ if (!native) {
175
+ // A browser host: no conversation relay; offer the command that connects an MCP host to this very page.
176
+ const copy = button('copy', 'Copy the MCP command that connects an agent to this page', async () => {
177
+ try { await navigator.clipboard.writeText(connectCommand()); toast('Copied · register it as an MCP server in your agent'); }
178
+ catch { toast(connectCommand()); }
179
+ }, 'mini ask-copy');
180
+ hint.replaceChildren(el('span', '', store.state?.agent?.connected ? 'An agent is working through this page' : 'Connect an agent to this page'), copy);
181
+ } else if (!available()) {
170
182
  // Not connected: say so, and offer the exact command that connects this project in Pi.
171
183
  const file = store.state?.project?.file;
172
184
  const copy = button('copy', 'Copy the command that opens this project in Pi', async () => {
@@ -193,7 +205,7 @@ function focusInput() {
193
205
 
194
206
  export function toggle(open = !isOpen(), { text, fromFrame = false } = {}) {
195
207
  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).');
208
+ toast(native ? 'No agent is connected to this board. Open it from Pi with /divlet, or connect an MCP client (divlet-mcp).' : `Agents connect to this page over MCP: ${connectCommand()}`);
197
209
  return false;
198
210
  }
199
211
  if (open === isOpen()) { if (open) { if (text !== undefined) { input.value = text; grow(); } input.focus(); } return open; }
@@ -324,7 +336,8 @@ function paintChip() {
324
336
  const state = agent();
325
337
  if (!state.busy) stopping = false;
326
338
  status.hidden = !state.busy;
327
- stopButton.hidden = !state.busy;
339
+ // Only a host that relays the conversation can stop its agent; a browser host's agent is stopped by its own host.
340
+ stopButton.hidden = !state.busy || !native;
328
341
  placeStatus();
329
342
  status.classList.toggle('stopping', stopping);
330
343
  document.body.classList.toggle('agent-busy', !!state.busy);
@@ -12,3 +12,57 @@ export interface BoardSourceUpdates {
12
12
  readonly reimport: () => Promise<unknown>;
13
13
  readonly openProject: (path: string) => Promise<unknown>;
14
14
  }
15
+ /** The board's project writes. Each method takes the request body the native route takes and resolves with its accepted result. */
16
+ export interface BoardWriter {
17
+ readonly patch: (body: unknown) => Promise<unknown>;
18
+ readonly edit: (body: unknown) => Promise<unknown>;
19
+ readonly undo: (body: unknown) => Promise<unknown>;
20
+ readonly redo: (body: unknown) => Promise<unknown>;
21
+ readonly createArtboard: (body: unknown) => Promise<unknown>;
22
+ readonly deleteArtboard: (body: unknown) => Promise<unknown>;
23
+ }
24
+ /** Where frame previews come from: an iframe address carrying the bridge token, and later renders delivered over its channel. */
25
+ export interface BoardPreviews {
26
+ readonly frameUrl: (id: string, options: {
27
+ readonly revision?: number;
28
+ readonly bridge: string;
29
+ }) => string;
30
+ readonly readRender: (id: string, options?: {
31
+ readonly signal?: AbortSignal;
32
+ }) => Promise<unknown>;
33
+ /** Called when a preview iframe is discarded, so a host can release what `frameUrl` allocated. */
34
+ readonly release?: (id: string, url: string) => void;
35
+ }
36
+ /** Clean standalone HTML of one frame (`{ html, fileName }`), for Copy/Download HTML. */
37
+ export interface BoardExports {
38
+ readonly page: (id: string) => Promise<unknown>;
39
+ /** The whole project source (`{ text, fileName }`), where the host's copy is not already a file the user owns. */
40
+ readonly project?: () => Promise<unknown>;
41
+ }
42
+ /** The project's reusable components, for insertion menus. Absent hosts offer no component insertion list. */
43
+ export interface BoardComponents {
44
+ readonly list: () => Promise<unknown>;
45
+ }
46
+ /**
47
+ * A complete alternative composition, published before the board starts at `globalThis[Symbol.for('divlet.board-host/1')]`.
48
+ * Absent members are absent capabilities; nothing falls back to the native HTTP routes. `label` names the host in the chrome.
49
+ */
50
+ export interface BoardHostComposition {
51
+ readonly label: string;
52
+ readonly readSession: import('./board-session.ts').BoardReadSession;
53
+ readonly writer: BoardWriter;
54
+ readonly previews: BoardPreviews;
55
+ readonly components?: BoardComponents;
56
+ readonly exports?: BoardExports;
57
+ readonly historyService?: BoardHistoryService;
58
+ readonly shareSelection?: import('./board-session.ts').BoardSelectionPublisher;
59
+ readonly publishObservations?: import('./board-session.ts').BoardObservationPublisher;
60
+ /**
61
+ * What a visitor with no saved view of this project sees first: `expand` opens that frame as a readable page, and `title`
62
+ * names the way back to the board. Absent: the board fits all frames, as the native host does.
63
+ */
64
+ readonly initialView?: {
65
+ readonly expand: string;
66
+ readonly title?: string;
67
+ };
68
+ }
@@ -1,5 +1,5 @@
1
1
  import { $, $$, el, icon, clamp, round } from './util.js';
2
- import { publishObservations } from './host.js';
2
+ import { publishObservations, previews, initialView } from './host.js';
3
3
  import { store, emit, on, documents, descriptor, request, post, apiUrl, commit, edit, idle, undo, newGroup, selectArtboards, selectNodes, toast, viewState, saveViewState } from './store.js';
4
4
 
5
5
  const PREFETCH_TARGET = 8;
@@ -437,7 +437,11 @@ export function reconcileArtboards() {
437
437
  initialFit = true;
438
438
  const saved = viewState(store.state.projectId);
439
439
  if (saved && Number.isFinite(saved.k) && Number.isFinite(saved.x) && Number.isFinite(saved.y)) setCamera(saved);
440
- else fit(null, { animate: false });
440
+ else {
441
+ fit(null, { animate: false });
442
+ // A first visit to a published board reads the page itself; the board is one step back.
443
+ if (initialView?.expand && descriptor(initialView.expand)) requestAnimationFrame(() => openExpanded(initialView.expand, undefined, { title: initialView.title }));
444
+ }
441
445
  }
442
446
  updateHandles();
443
447
  applyCamera();
@@ -499,6 +503,8 @@ function deliverEdit(entry) {
499
503
  entry.port?.postMessage({ type: 'divlet:edit', nodeId: pending.nodeId });
500
504
  }
501
505
 
506
+ /** An expanded frame browses its published page (no delivery) unless the host has none, where it reads the live preview. */
507
+ const browsingPublished = entry => store.expanded?.entry === entry && !!entry.doc.viewUrl;
502
508
  const needsEagerLoad = entry => store.expanded?.entry === entry || store.selected.has(entry.doc.id);
503
509
 
504
510
  function inspectFlag() { return !store.expanded && store.mode === 'select'; }
@@ -514,7 +520,7 @@ function mount(entry, { revision = false } = {}) {
514
520
  frame.referrerPolicy = 'no-referrer';
515
521
  // The browser owns when an off-screen page loads; frames are never torn down and rebuilt by scrolling or zooming.
516
522
  frame.loading = needsEagerLoad(entry) ? 'eager' : 'lazy';
517
- frame.src = apiUrl(`preview/${encodeURIComponent(entry.doc.id)}?${revision ? `revision=${entry.doc.render}&` : ''}bridge=${entry.bridgeToken}`);
523
+ frame.src = previews.frameUrl(entry.doc.id, { revision: revision ? entry.doc.render : undefined, bridge: entry.bridgeToken });
518
524
  frame.addEventListener('load', () => {
519
525
  if (entry.iframe !== frame) return;
520
526
  entry.loaded = true;
@@ -522,7 +528,7 @@ function mount(entry, { revision = false } = {}) {
522
528
  entry.port?.postMessage({ type: 'divlet:inspect', enabled: inspectFlag() });
523
529
  syncNodeSelection(entry);
524
530
  syncWork(entry);
525
- if (store.expanded?.entry === entry) return;
531
+ if (browsingPublished(entry)) return;
526
532
  updateFrame(entry);
527
533
  });
528
534
  entry.iframe = frame;
@@ -555,6 +561,7 @@ function unmount(entry) {
555
561
  entry.abort?.abort();
556
562
  entry.abort = null;
557
563
  entry.port?.close();
564
+ previews.release?.(entry.doc.id, entry.iframe.src);
558
565
  entry.iframe.remove();
559
566
  Object.assign(entry, { iframe: null, port: null, bridgeToken: null, loaded: false, renderedVersion: 0, awaitingVersion: 0, pendingFetch: false, latestReport: null });
560
567
  clearTimeout(entry.reportTimer);
@@ -567,15 +574,15 @@ async function updateFrame(entry) {
567
574
  // While a pill is being dragged the page shows its own inline preview; delivering a render would replace the DOM under
568
575
  // the pointer. The model still moves (the drag commits live) and this frame catches up when the drag ends.
569
576
  if (gesture?.type === 'space' && gesture.id === entry.doc.id) return;
570
- if (store.expanded?.entry === entry || !entry.iframe || !entry.port || !entry.loaded || entry.pendingFetch || entry.awaitingVersion >= entry.doc.render || entry.renderedVersion >= entry.doc.render) return;
577
+ if (browsingPublished(entry) || !entry.iframe || !entry.port || !entry.loaded || entry.pendingFetch || entry.awaitingVersion >= entry.doc.render || entry.renderedVersion >= entry.doc.render) return;
571
578
  const frame = entry.iframe;
572
579
  const generation = entry.deliveryGeneration;
573
580
  entry.pendingFetch = true;
574
581
  entry.abort = new AbortController();
575
582
  setFrameStatus(entry, 'updating');
576
583
  try {
577
- const rendered = await request(`api/render/${encodeURIComponent(entry.doc.id)}`, { signal: entry.abort.signal });
578
- if (entry.iframe !== frame || generation !== entry.deliveryGeneration || store.expanded?.entry === entry || rendered.documentId !== entry.doc.id || rendered.render < entry.renderedVersion) return;
584
+ const rendered = await previews.readRender(entry.doc.id, { signal: entry.abort.signal });
585
+ if (entry.iframe !== frame || generation !== entry.deliveryGeneration || browsingPublished(entry) || rendered.documentId !== entry.doc.id || rendered.render < entry.renderedVersion) return;
579
586
  entry.awaitingVersion = rendered.render;
580
587
  entry.port?.postMessage({ type: 'divlet:update', documentId: entry.doc.id, version: rendered.render, html: rendered.html });
581
588
  } catch (error) {
@@ -706,10 +713,10 @@ function cancelDelivery(entry) {
706
713
  entry.reportTimer = null;
707
714
  }
708
715
 
709
- export function openExpanded(documentId, focusTarget) {
716
+ export function openExpanded(documentId, focusTarget, { title } = {}) {
710
717
  const entry = entries.get(documentId);
711
718
  if (!entry || store.expanded) return;
712
- store.expanded = { entry, mode: store.mode, focusTarget, camera: { ...camera } };
719
+ store.expanded = { entry, mode: store.mode, focusTarget, camera: { ...camera }, title };
713
720
  cancelDelivery(entry);
714
721
  store.node = null;
715
722
  selectArtboards([documentId]);
@@ -729,11 +736,11 @@ export function openExpanded(documentId, focusTarget) {
729
736
  entry.bridgeToken = crypto.randomUUID();
730
737
  entry.loaded = false;
731
738
  entry.iframe.src = viewUrl;
732
- } else entry.port?.postMessage({ type: 'divlet:inspect', enabled: false });
739
+ } else { entry.port?.postMessage({ type: 'divlet:inspect', enabled: false }); updateFrame(entry); }
733
740
  const overlay = $('#expanded-preview');
734
741
  overlay.hidden = false;
735
742
  overlay.setAttribute('aria-owns', entry.iframe.id);
736
- $('#expanded-title').textContent = entry.doc.name || entry.doc.id;
743
+ $('#expanded-title').textContent = title || entry.doc.name || entry.doc.id;
737
744
  setChromeInert(true, entry);
738
745
  document.body.classList.add('is-expanded');
739
746
  $('#expanded-back').focus({ preventScroll: true });
package/dist/lib/clip.js CHANGED
@@ -1,4 +1,4 @@
1
- import { store, documents, descriptor, edit, toast, cachedModel, loadModel, selectNodes, parentOf, canContain, apiUrl, request } from './store.js';
1
+ import { store, capable, documents, descriptor, edit, toast, cachedModel, loadModel, selectNodes, parentOf, canContain, apiUrl, request } from './store.js';
2
2
  import * as canvas from './canvas.js';
3
3
  import { duplicateArtboards } from './artboards.js';
4
4
 
@@ -198,6 +198,7 @@ async function upload(file, documentId, group) {
198
198
 
199
199
  /** Store images in the project and add an <img> for each at the paste/drop target. */
200
200
  export async function addImages(files, documentId) {
201
+ if (!capable('assets')) { toast('Images need the local divlet host: this browser copy stores no image files.'); return; }
201
202
  if (documentId && !(store.selected.size === 1 && store.selected.has(documentId))) {
202
203
  const { selectArtboards } = await import('./store.js');
203
204
  selectArtboards([documentId]);
@@ -6,8 +6,9 @@ import { openMenu } from './menu.js';
6
6
  import { selectRoot } from './actions.js';
7
7
 
8
8
  /**
9
- * Fill the bottom path for `ctx` ({ ids, nodes? }). A selected artboard collapses to its root-entry icon;
10
- * selecting that root opens the path. Container crumbs select ancestors; the artboard crumb folds it again.
9
+ * Fill the bottom path for `ctx` ({ ids, nodes? }). The artboard is always the first crumb; with only the artboard selected
10
+ * it is the current one, followed by a `root` crumb that previews the next level and selects the root element.
11
+ * Container crumbs select ancestors; the artboard crumb selects the whole artboard again.
11
12
  * `after()` runs after activation; `repaint()` is called when element data arrives late.
12
13
  */
13
14
  export function pathInto(container, ctx, { after, repaint, all = false } = {}) {
@@ -23,10 +24,14 @@ export function pathInto(container, ctx, { after, repaint, all = false } = {}) {
23
24
  const sep = () => el('i', 'crumb-sep');
24
25
  if (!ctx.nodes) {
25
26
  if (ctx.ids.length === 1) {
26
- const root = crumb('', () => selectRoot(ctx.ids[0]), `Select root element · ${descriptor(ctx.ids[0])?.name || ctx.ids[0]}`, 'root-entry');
27
+ const id = ctx.ids[0];
28
+ const home = crumb('', () => {}, 'The selected artboard', 'home current');
29
+ home.append(icon('artboard'), el('span', '', descriptor(id)?.name || id));
30
+ // The one exception to "the path ends at the selection": the next level is shown, so the way into the page is visible.
31
+ const root = crumb('', () => selectRoot(id), `Select the root element of ${descriptor(id)?.name || id}`, 'root-entry');
27
32
  root.setAttribute('aria-label', 'Select root element');
28
- root.append(icon('inspect'));
29
- container.append(root);
33
+ root.append(icon('inspect'), el('span', '', 'root'));
34
+ container.append(home, sep(), root);
30
35
  } else container.append(crumb(`${ctx.ids.length} artboards`, () => {}, 'These artboards', 'current'));
31
36
  return;
32
37
  }
package/dist/lib/help.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { $, el, icon } from './util.js';
3
3
  import { filePicker } from './file-picker.js';
4
4
  import { on, emit, store, post, request, receiveState } from './store.js';
5
+ import { native } from './host.js';
5
6
  import * as canvas from './canvas.js';
6
7
 
7
8
  const scrim = $('#help');
@@ -230,7 +231,8 @@ function render() {
230
231
  status.setAttribute('role', 'status');
231
232
  // The status keeps its reserved line above the buttons, so its text never moves them: the actions stay in the bottom-right.
232
233
  output.append(label, field, picker, hint, status, actions);
233
- dialog.replaceChildren(head, block(appearance), block(scale), block(handleRow), block(output));
234
+ // The build output directory is a filesystem setting of the local host; a browser host has none.
235
+ dialog.replaceChildren(head, block(appearance), block(scale), block(handleRow), ...(native ? [block(output)] : []));
234
236
  if (isOpen() && hadFocus) dialog.querySelector('button:not(:disabled), input:not(:disabled)')?.focus({ preventScroll: true });
235
237
  }
236
238
 
package/dist/lib/host.js CHANGED
@@ -1,16 +1,36 @@
1
- // The native board's composition point. Alternate hosts implement the read-session contract, not synthetic HTTP routes.
1
+ // @ts-check
2
+ // The board's composition point. The native host composes its HTTP/SSE adapters here; an alternative host publishes a complete
3
+ // `BoardHostComposition` before the board starts, and then no native route is used or synthesized.
2
4
  import { createJsonTransport } from './http.js';
3
5
  import { createHttpReadSession, createHttpSelectionPublisher, createHttpObservationPublisher } from './http-session.js';
4
- import { createHttpHistoryService, createHttpSourceUpdates } from './http-services.js';
6
+ import { createHttpHistoryService, createHttpSourceUpdates, createHttpWriter, createHttpPreviews, createHttpComponents, createHttpExports } from './http-services.js';
7
+
8
+ /** @type {import('@divlet/web/host').BoardHostComposition | undefined} */
9
+ const composed = /** @type {any} */ (globalThis)[Symbol.for('divlet.board-host/1')];
10
+ /** True when the board talks to the local divlet host over its token-scoped routes. */
11
+ export const native = !composed;
12
+ /** The host's display name, for chrome that says where the project lives. */
13
+ export const hostLabel = composed?.label ?? 'local';
5
14
 
6
15
  export const base = new URL('./', location.href);
7
16
  export const { apiUrl, request, post } = createJsonTransport(base);
8
- export const readSession = createHttpReadSession({ request, events: () => new EventSource(apiUrl('events')) });
17
+ /** @type {import('@divlet/web/session').BoardReadSession} */
18
+ export const readSession = composed ? composed.readSession : createHttpReadSession({ request, events: () => new EventSource(apiUrl('events')) });
9
19
  /** @type {import('@divlet/web/session').BoardSelectionPublisher | undefined} */
10
- export const shareSelection = createHttpSelectionPublisher(post);
20
+ export const shareSelection = composed ? composed.shareSelection : createHttpSelectionPublisher(post);
11
21
  /** @type {import('@divlet/web/session').BoardObservationPublisher | undefined} */
12
- export const publishObservations = createHttpObservationPublisher(post);
22
+ export const publishObservations = composed ? composed.publishObservations : createHttpObservationPublisher(post);
13
23
  /** @type {import('@divlet/web/host').BoardHistoryService | undefined} */
14
- export const historyService = createHttpHistoryService({ request, post });
24
+ export const historyService = composed ? composed.historyService : createHttpHistoryService({ request, post });
15
25
  /** @type {import('@divlet/web/host').BoardSourceUpdates | undefined} */
16
- export const sourceUpdates = createHttpSourceUpdates({ request, post });
26
+ export const sourceUpdates = composed ? undefined : createHttpSourceUpdates({ request, post });
27
+ /** @type {import('@divlet/web/host').BoardWriter} */
28
+ export const writer = composed ? composed.writer : createHttpWriter(post);
29
+ /** @type {import('@divlet/web/host').BoardPreviews} */
30
+ export const previews = composed ? composed.previews : createHttpPreviews({ apiUrl, request });
31
+ /** @type {import('@divlet/web/host').BoardComponents | undefined} */
32
+ export const components = composed ? composed.components : createHttpComponents(request);
33
+ /** @type {import('@divlet/web/host').BoardExports | undefined} */
34
+ export const exports = composed ? composed.exports : createHttpExports(request);
35
+ /** @type {import('@divlet/web/host').BoardHostComposition['initialView']} */
36
+ export const initialView = composed?.initialView;
@@ -22,3 +22,36 @@ export function createHttpSourceUpdates({ request, post }) {
22
22
  };
23
23
  return Object.freeze(service);
24
24
  }
25
+
26
+ /** @param {Post} post @returns {import('@divlet/web/host').BoardWriter} */
27
+ export function createHttpWriter(post) {
28
+ /** @type {import('@divlet/web/host').BoardWriter} */
29
+ const writer = {
30
+ patch: body => post('api/patch', body),
31
+ edit: body => post('api/edit', body),
32
+ undo: body => post('api/undo', body),
33
+ redo: body => post('api/redo', body),
34
+ createArtboard: body => post('api/artboards', body),
35
+ deleteArtboard: body => post('api/artboards/delete', body),
36
+ };
37
+ return Object.freeze(writer);
38
+ }
39
+
40
+ /**
41
+ * @param {{ apiUrl: (path: string) => string, request: (path: string, options?: RequestInit) => Promise<unknown> }} transport
42
+ * @returns {import('@divlet/web/host').BoardPreviews}
43
+ */
44
+ export function createHttpPreviews({ apiUrl, request }) {
45
+ /** @type {import('@divlet/web/host').BoardPreviews} */
46
+ const previews = {
47
+ frameUrl: (id, { revision, bridge }) => apiUrl(`preview/${encodeURIComponent(id)}?${revision ? `revision=${revision}&` : ''}bridge=${bridge}`),
48
+ readRender: (id, { signal } = {}) => request(`api/render/${encodeURIComponent(id)}`, signal ? { signal } : {}),
49
+ };
50
+ return Object.freeze(previews);
51
+ }
52
+
53
+ /** @param {Request} request @returns {import('@divlet/web/host').BoardExports} */
54
+ export const createHttpExports = request => Object.freeze({ page: (/** @type {string} */ id) => request(`api/export/${encodeURIComponent(id)}`) });
55
+
56
+ /** @param {Request} request @returns {import('@divlet/web/host').BoardComponents} */
57
+ export const createHttpComponents = request => Object.freeze({ list: () => request('api/components') });
@@ -1,10 +1,11 @@
1
1
  import { $, el, icon, button, clamp, round, debounce, keys, pressGuard } from './util.js';
2
- import { store, on, documents, descriptor, loadModel, cachedModel, commit, edit, toast, newGroup, selectNodes, parentOf, textChild, whenVersion, request, apiUrl, receiveState } from './store.js';
2
+ import { store, capable, on, documents, descriptor, loadModel, cachedModel, commit, edit, toast, newGroup, selectNodes, parentOf, textChild, whenVersion, request, apiUrl, receiveState } from './store.js';
3
3
  import { fit, rectOf, postToFrame, measuredBox } from './canvas.js';
4
4
  import * as actions from './actions.js';
5
5
  import { openMenu, menuAnchor, closeMenu } from './menu.js';
6
6
  import { overflowBar } from './toolbar.js';
7
7
  import { panelSections } from './panels.js';
8
+ import { components as componentSource } from './host.js';
8
9
  import { fontFamilyControl } from './typography.js';
9
10
  import { attributesEditor } from './attrs.js';
10
11
  import { formatCss, highlightCss } from './css.js';
@@ -478,7 +479,8 @@ function imageEditor(doc, node) {
478
479
  upload.dataset.tip = 'Add an image file to divlet/assets — or drop it onto the artboard';
479
480
  upload.addEventListener('click', () => uploadInto(doc, node));
480
481
  const buttons = el('div', 'image-buttons');
481
- buttons.append(replace, upload);
482
+ // Choosing or uploading project images needs a host that stores asset bytes; elsewhere the source stays a plain field.
483
+ if (capable('assets')) buttons.append(replace, upload);
482
484
  const alt = textField('image-alt', node.attrs.alt ?? '', value => refreshed(commit(doc.id, { nodes: { [node.id]: { attrs: { alt: value } } } }, { label: `alt text of ${node.id}` })), { placeholder: 'Alt text (describe the image)', label: 'Alt text' });
483
485
  side.append(el('span', 'image-src', node.attrs.src || 'No source yet'), buttons);
484
486
  box.append(preview, side);
@@ -491,9 +493,9 @@ function imageEditor(doc, node) {
491
493
  let componentsCache = { revision: -1, list: [], loading: false };
492
494
  function componentList() {
493
495
  const revision = store.state?.revision ?? 0;
494
- if (componentsCache.revision !== revision && !componentsCache.loading) {
496
+ if (componentSource && componentsCache.revision !== revision && !componentsCache.loading) {
495
497
  componentsCache.loading = true;
496
- request('api/components').then(result => { componentsCache = { revision, list: result.components, loading: false }; schedule(); }).catch(() => { componentsCache.loading = false; });
498
+ componentSource.list().then(result => { componentsCache = { revision, list: result.components, loading: false }; schedule(); }).catch(() => { componentsCache.loading = false; });
497
499
  }
498
500
  return componentsCache.list;
499
501
  }