@volter/world-console 3.0.5 → 3.0.7

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.
@@ -610,11 +610,19 @@ main.app.home { max-width: none; }
610
610
  .ws-rule-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
611
611
  .ws-rule-row select { width: auto; }
612
612
  .ws-rules fieldset.choice { border: 0; padding: 0; margin: 4px 0; display: grid; gap: 6px; }
613
- /* THE BOARD: sections round their frames as Figma's do, behind them; the boards as a canvas app's pages */
614
- .canvas .react-flow__node-section { padding: 0; border: 0; background: none; pointer-events: none; }
615
- .section-box { position: relative; border: 2px dashed var(--line-strong); border-radius: 28px; background: color-mix(in srgb, var(--surface) 40%, transparent); }
616
- .section-label { position: absolute; top: 28px; left: 40px; display: inline-flex; align-items: center; gap: 14px; font: 600 40px/1.2 var(--sans); color: var(--muted); white-space: nowrap; }
617
- .section-label .mark { width: 44px; height: 44px; }
613
+ /* THE BOARD: sections are regions with a name, as a canvas app's are: a quiet fill behind the frames, the name a small tag
614
+ above the top left that keeps its size at every zoom; a selected frame or section is outlined in the accent */
615
+ .canvas .react-flow__node-section { padding: 0; border: 0; background: none; }
616
+ .section-box { width: 100%; height: 100%; border: 1px solid var(--line-strong); border-radius: 4px; background: color-mix(in srgb, var(--surface) 55%, transparent); }
617
+ .section-box.on { outline: 2px solid var(--accent); outline-offset: 0; }
618
+ .section-tag { display: flex; align-items: center; gap: 2px; }
619
+ .section-tag button { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border: 0; border-radius: 4px; background: var(--surface-2); color: var(--ink); font: 600 12px/1.4 var(--sans); }
620
+ .section-tag button.on { background: var(--accent); color: var(--surface); }
621
+ .section-tag .mark { width: 14px; height: 14px; }
622
+ .frame.picked { outline: 4px solid var(--accent-soft); outline-offset: 3px; }
623
+ .float.board-tools { display: flex; gap: 4px; padding: 4px; }
624
+ .float.board-tools button { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 13px; }
625
+ .float.board-tools kbd { font: 11px var(--mono); color: var(--muted); }
618
626
  .frame-none { height: 100%; display: grid; place-content: center; text-align: center; gap: 8px; padding: 40px; font-size: 24px; color: var(--muted); background: repeating-linear-gradient(135deg, var(--surface-2) 0 18px, var(--surface) 18px 36px); }
619
627
  .frame-none p { margin: 0; }
620
628
  .frame-label select, .frame-label button { font-size: 12px; padding: 1px 6px; }
@@ -623,10 +631,29 @@ main.app.home { max-width: none; }
623
631
  .board-tabs button.on { background: var(--accent-soft); border-color: var(--accent); }
624
632
  .board-acts { display: flex; gap: 4px; margin-top: 4px; }
625
633
  .board-acts button { font-size: 12px; padding: 2px 6px; }
626
- .layer-section { display: flex; align-items: center; gap: 6px; padding: 6px 8px 2px; font-size: 12px; font-weight: 600; color: var(--muted); }
627
- .layer-section .mark { width: 16px; height: 16px; }
634
+ .layer-list button.layer-section { font-size: 12px; font-weight: 600; }
635
+ .layer-list .mark { width: 16px; height: 16px; }
628
636
  .layer-list.inner { border-top: 0; margin: 0; padding: 0 0 0 14px; }
629
637
  .board-add { width: 100%; margin-top: 8px; font-size: 12.5px; }
630
638
  .board-hidden { margin-top: 8px; font-size: 12.5px; }
631
639
  .board-elsewhere { width: 100%; margin-top: 8px; display: flex; align-items: center; gap: 6px; font-size: 12.5px; border: 1px solid var(--line); background: transparent; border-radius: 8px; padding: 4px 8px; }
632
640
  .pad-s { padding: 6px 8px; margin: 0; font-size: 12.5px; }
641
+ .float.hint.using { display: flex; align-items: center; gap: 10px; pointer-events: auto; padding: 4px 6px 4px 12px; }
642
+ .float.hint.using button { padding: 2px 10px; font-size: 12px; }
643
+ .float.hint.using kbd { font: 11px var(--mono); color: var(--muted); }
644
+ /* THE COMPARISON: before and now side by side over the board's top right, the changes in words under them */
645
+ .float.compare-panel { max-height: calc(100vh - 32px); overflow-y: auto; padding: 0; }
646
+ .compare { display: grid; gap: 12px; padding: 12px 14px; width: 900px; max-width: calc(100vw - 320px); }
647
+ .compare-head { display: flex; align-items: center; gap: 8px; }
648
+ .compare-head .mode { flex: 1; }
649
+ .compare-sides { display: flex; gap: 16px; flex-wrap: wrap; }
650
+ .compare-sides figure { margin: 0; display: grid; gap: 6px; }
651
+ .compare-sides figcaption { font-size: 12px; font-weight: 600; color: var(--muted); }
652
+ .compare-thumb { position: relative; overflow: hidden; border: 1px solid var(--line); border-radius: 6px; background: var(--ground); }
653
+ .compare-thumb iframe { position: absolute; top: 0; left: 0; border: 0; transform-origin: 0 0; pointer-events: none; }
654
+ .compare-note { width: 420px; height: 269px; margin: 0; display: grid; place-content: center; text-align: center; padding: 16px; border: 1px dashed var(--line-strong); border-radius: 6px; font-size: 13px; }
655
+ .compare-acts { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 13px; }
656
+ .compare-acts > li { display: grid; grid-template-columns: 120px 1fr; gap: 8px; align-items: baseline; }
657
+ .compare-acts ul { margin: 4px 0 0; padding-left: 16px; }
658
+ .compare-all { font-size: 13px; }
659
+ .frame-label button.frame-changed { display: inline-flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 0 4px; color: inherit; }
@@ -3,7 +3,7 @@
3
3
  // person typed, from the same origin the host serves. The client holds no state a door did not
4
4
  // answer, and offers nothing `volter world` or `curl` could not do. Locators are roles and text.
5
5
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
- import { Background, BackgroundVariant, Controls, MiniMap, NodeToolbar, Panel, Position, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, type Node, type NodeProps } from '@xyflow/react';
6
+ import { Background, BackgroundVariant, Controls, MiniMap, NodeResizer, NodeToolbar, Panel, Position, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, type Node, type NodeProps } from '@xyflow/react';
7
7
  import { applyTheme, storedTheme, type Theme, At, Brand, ConfirmHost, MenuDismiss, confirmAct, CopyField, counted, Dialog, DrawerButton, Empty, failure as doorWords, listed, Mark, Menu, Pending, Skeleton, Toaster, toast, useDoor as useKitDoor, vendorName, when, ago, type Load } from './kit/kit.tsx';
8
8
  import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
9
9
  import { ICONS } from './brand/icons.ts';
@@ -1680,8 +1680,12 @@ type ViewSession = { state: 'pending' } | { state: 'ok'; scope: string } | { sta
1680
1680
  * come. Moving, hiding and including are kept by the World (`PUT /-/<world>/board`), for everyone who opens it. Pan by
1681
1681
  * dragging the background, move a frame by its title bar, click a frame to use it and the background or Esc to leave. */
1682
1682
  const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160; const SECTION_PAD = 72; const SECTION_HEAD = 72;
1683
- type BoardFrame = { vendor: string; id: string; kind: string; title: string; section: { id: string; title: string; aka?: string[] }; url: string; at: { site: string } | { screen: string } | null; unseen?: 'not-drawn' | 'sites-not-routed'; changes: number };
1684
- type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }> };
1683
+ /** A change behind a frame's count: the writes one request made, as words read them, and when. */
1684
+ type BoardAct = { at: string; writes: number; entries: Array<{ op: string; operation?: string; subject: { type: string; id: string }; fields: Record<string, string> }> };
1685
+ type BoardFrame = { vendor: string; id: string; kind: string; title: string; section: { id: string; title: string; aka?: string[] }; url: string; at: { site: string } | { screen: string } | null; unseen?: 'not-drawn' | 'sites-not-routed'; changes: number; /** when its first change not yet cut was made */ since?: string | null };
1686
+ /** A section of a board: its region and name, or a pack's group removed from the board (`gone`). */
1687
+ type SavedSection = { x: number; y: number; w: number; h: number; name?: string; gone?: true };
1688
+ type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }>; sections?: Record<string, SavedSection> };
1685
1689
  type BoardDoor = { frames: BoardFrame[]; boards: SavedBoard[]; version: string };
1686
1690
  const frameKey = (f: BoardFrame): string => `${f.vendor}:${f.id}`;
1687
1691
  const sectionKey = (f: BoardFrame): string => `${f.vendor}:section:${f.section.id}`;
@@ -1693,22 +1697,23 @@ const frameUrl = (name: string, f: BoardFrame): string | null => (f.at && 'site'
1693
1697
  const shownOn = (b: SavedBoard, all: BoardFrame[]): BoardFrame[] => b.include
1694
1698
  ? all.filter((f) => b.include!.includes(frameKey(f)) || sectionKeys(f).some((k) => b.include!.includes(k)))
1695
1699
  : all.filter((f) => !(b.hidden ?? []).includes(frameKey(f)));
1696
- type Screen = { world: string; frame: BoardFrame; url: string | null; active: boolean; boards: SavedBoard[]; addTo: (board: string, key: string) => void; remove: { label: string; act: (key: string) => void } | null };
1700
+ type Screen = { world: string; frame: BoardFrame; url: string | null; active: boolean; picked: boolean; boards: SavedBoard[]; addTo: (board: string, key: string) => void; remove: { label: string; act: (key: string) => void } | null; compare: (key: string) => void };
1697
1701
  function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1698
- const { world, frame, url, active, boards, addTo, remove } = data;
1702
+ const { frame, url, active, picked, boards, addTo, remove, compare } = data;
1699
1703
  const key = frameKey(frame);
1700
1704
  const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
1701
1705
  return (
1702
1706
  <>
1707
+ {/* its name always, as a canvas app labels its frames; what can be done to it once it is selected */}
1703
1708
  <NodeToolbar isVisible position={Position.Top} align="start" className="frame-label">
1704
- <Mark vendor={frame.vendor} /><span>{vendorName(frame.vendor)} · {frame.kind}</span>
1705
- {frame.changes ? <a className="frame-changed nodrag" href={`${BASE}/${world}/~/changes`} onClick={(e) => { e.preventDefault(); go([...world.split('/'), '~', 'changes']); }} title={`${counted(frame.changes, 'change')} here, not in a changeset: see them in Changes`}><Icon name="square-dot" size={14} />{frame.changes}</a> : null}
1706
- {others.length ? <select className="nodrag frame-add" value="" aria-label={`Add ${frame.title} to a board`} onChange={(e) => { if (e.target.value) addTo(e.target.value, key); }}><option value="">Add to board…</option>{others.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select> : null}
1707
- {remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
1709
+ <Mark vendor={frame.vendor} /><span>{frame.title}</span>
1710
+ {frame.changes ? <button type="button" className="frame-changed nodrag" onClick={() => compare(key)} title={`${counted(frame.changes, 'change')} here, not in a changeset: compare before and now`}><Icon name="square-dot" size={14} />{frame.changes}</button> : null}
1711
+ {picked && others.length ? <select className="nodrag frame-add" value="" aria-label={`Add ${frame.title} to a board`} onChange={(e) => { if (e.target.value) addTo(e.target.value, key); }}><option value="">Add to board…</option>{others.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select> : null}
1712
+ {picked && remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
1708
1713
  </NodeToolbar>
1709
- <div className={active ? 'frame on' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
1710
- <div className="frame-bar" title="Drag to move">
1711
- <Mark vendor={frame.vendor} /><strong>{frame.title}</strong>
1714
+ <div className={active ? 'frame on' : picked ? 'frame picked' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
1715
+ <div className="frame-bar" title="Drag to move · double-click to use">
1716
+ <Mark vendor={frame.vendor} /><strong>{vendorName(frame.vendor)} · {frame.kind}</strong>
1712
1717
  <span className="frame-url">{frame.url.replace(/^https?:\/\//, '')}</span>
1713
1718
  {url ? <a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${frame.title} in a new tab`}>↗</a> : null}
1714
1719
  </div>
@@ -1721,9 +1726,97 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1721
1726
  </>
1722
1727
  );
1723
1728
  }
1724
- type Section = { label: string; vendor: string; width: number; height: number };
1725
- function SectionBox({ data }: NodeProps<Node<Section>>): React.JSX.Element {
1726
- return <div className="section-box" style={{ width: data.width, height: data.height }}><span className="section-label"><Mark vendor={data.vendor} />{data.label}</span></div>;
1729
+ /** A section, as a canvas app's: a region of the board with a name, holding whatever frames sit in it. Its name stays the
1730
+ * same size at every zoom; selected, it can be resized from its edges, renamed (double-click the name) or removed. */
1731
+ type SectionData = { key: string; name: string; vendor: string | null; /** renamed by someone, so its name is kept */ named: boolean; picked: boolean; editable: boolean; pick: (key: string) => void; rename: (key: string) => void; remove: (key: string) => void; resized: () => void };
1732
+ function SectionBox({ data }: NodeProps<Node<SectionData>>): React.JSX.Element {
1733
+ return (
1734
+ <>
1735
+ <NodeResizer isVisible={data.picked && data.editable} minWidth={480} minHeight={360} color="var(--accent)" onResizeEnd={data.resized} />
1736
+ <NodeToolbar isVisible position={Position.Top} align="start" offset={6} className="section-tag">
1737
+ <button type="button" className={data.picked ? 'nodrag on' : 'nodrag'} onClick={() => data.pick(data.key)} onDoubleClick={() => { if (data.editable) data.rename(data.key); }} title={data.editable ? 'Double-click to rename' : undefined}>
1738
+ {data.vendor ? <Mark vendor={data.vendor} /> : null}<span>{data.name}</span>
1739
+ </button>
1740
+ {data.picked && data.editable ? <button type="button" className="ghost nodrag" onClick={() => data.remove(data.key)} aria-label={`Remove the section ${data.name}`} title="Remove the section (its frames stay)">✕</button> : null}
1741
+ </NodeToolbar>
1742
+ <div className={data.picked ? 'section-box on' : 'section-box'} />
1743
+ </>
1744
+ );
1745
+ }
1746
+ /** A frame's page at a desktop's size, shown smaller: what it looks like, not a place to use it. */
1747
+ function Thumb({ url, title }: { url: string; title: string }): React.JSX.Element {
1748
+ const width = 420;
1749
+ return <div className="compare-thumb" style={{ width, height: (width * FRAME_H) / FRAME_W }}><iframe title={title} src={url} loading="lazy" style={{ width: FRAME_W, height: FRAME_H, transform: `scale(${width / FRAME_W})` }} /></div>;
1750
+ }
1751
+ /** THE COMPARISON, as a canvas app's "compare changes": a frame's changes not yet cut, each in words, and the frame as it
1752
+ * was the moment before the first of them beside how it is now. "Before" is a read-only copy of the World as of then (a
1753
+ * branch the World makes for an hour, removed when this closes), its site read at the copy's own address. */
1754
+ function Compare({ name, token, frame, onClose, reads }: { name: string; token: string; frame: BoardFrame; onClose: () => void; /** the board's reads so far: its changes are read again with each */ reads: number }): React.JSX.Element {
1755
+ const [before, setBefore] = useState<{ state: 'opening' } | { state: 'ok'; url: string; at: string; /** a vendor's page: opened as a page of its own, never framed */ opens?: boolean } | { state: 'none'; why: string }>({ state: 'opening' });
1756
+ // its changes, read for this comparison alone
1757
+ const listed = useDoor<{ total: number; acts: BoardAct[] }>(token, `/-/${name}/board/changes?frame=${encodeURIComponent(frameKey(frame))}`, reads);
1758
+ useEffect(() => {
1759
+ if (!frame.since) { setBefore({ state: 'none', why: 'Nothing here has changed since the World was last cut.' }); return; }
1760
+ if (!frame.at) { setBefore({ state: 'none', why: `${vendorName(frame.vendor)}'s twin draws no page for this ${frame.kind} yet; its changes are listed below.` }); return; }
1761
+ const shown = frame.at;
1762
+ const at = new Date(Date.parse(frame.since) - 1).toISOString();
1763
+ let made: string | null = null; let closed = false;
1764
+ // a copy is made once the comparison has stayed open a moment: one passed through on the way to another makes none
1765
+ const wait = setTimeout(() => void (async () => {
1766
+ const r = await door(token, `/-/${name}/branches`, { method: 'POST', body: JSON.stringify({ at: { instant: at }, ttl: 3600 }), signal: AbortSignal.timeout(60000) }).catch(() => null);
1767
+ if (!r?.ok) { setBefore({ state: 'none', why: `The World made no copy of itself as it was then${r ? `: ${await failure(r)}` : ''}.` }); return; }
1768
+ const b = (await r.json()) as { name: string; origin?: string; readToken?: string };
1769
+ made = b.name;
1770
+ if (closed) { void door(token, `/-/${name}/branches/${b.name}`, { method: 'DELETE' }).catch(() => null); return; }
1771
+ const o = httpOrigin(b.origin);
1772
+ if (!o) { setBefore({ state: 'none', why: 'This World shows sites only at its own local address.' }); return; }
1773
+ // a site at the copy's own address, framed; a vendor's page in the copy is behind the copy's session, which opens
1774
+ // only on the copy's own pages (another site's frame is refused it), so it opens as a page of its own: the copy's
1775
+ // hand-off page (served-world, `session/hand-off`) opens the session from the read token, then goes to the page
1776
+ if ('site' in shown) { const u = new URL(frame.url); setBefore({ state: 'ok', url: `${o.protocol}//${u.hostname}.${o.host}${u.pathname}${u.search}`, at }); return; }
1777
+ if (!b.readToken) { setBefore({ state: 'none', why: 'The copy of the World gave no read token to open its pages with.' }); return; }
1778
+ setBefore({ state: 'ok', url: `${o.origin}/-/${b.name}/session/hand-off#${new URLSearchParams({ token: b.readToken, land: `/${b.name}/${frame.vendor}${shown.screen}` }).toString()}`, at, opens: true });
1779
+ })(), 500);
1780
+ return () => { closed = true; clearTimeout(wait); if (made) void door(token, `/-/${name}/branches/${made}`, { method: 'DELETE' }).catch(() => null); };
1781
+ }, [name, token, frame.vendor, frame.id, frame.since]);
1782
+ const now = frameUrl(name, frame);
1783
+ return (
1784
+ <div className="compare" role="dialog" aria-label={`Changes to ${frame.title}`}>
1785
+ <div className="compare-head">
1786
+ <Mark vendor={frame.vendor} /><strong>{frame.title}</strong>
1787
+ <span className="mode">{counted(frame.changes, 'change')} not yet cut{frame.since ? `, since ${when(frame.since)}` : ''}</span>
1788
+ <button type="button" className="ghost" onClick={onClose} aria-label="Close the comparison">✕</button>
1789
+ </div>
1790
+ <div className="compare-sides">
1791
+ <figure>
1792
+ <figcaption>Before{before.state === 'ok' ? ` · as of ${when(before.at)}` : ''}</figcaption>
1793
+ {before.state === 'ok' ? (before.opens
1794
+ ? <p className="mode compare-note"><a href={before.url} target="_blank" rel="noopener noreferrer">Open {frame.title} as it was →</a><br />A read-only copy of the World, kept while this comparison is open.</p>
1795
+ : <Thumb url={before.url} title={`${frame.title} before`} />) : before.state === 'opening' ? <p className="mode compare-note" role="status">Opening the World as it was…</p> : <p className="mode compare-note">{before.why}</p>}
1796
+ </figure>
1797
+ <figure>
1798
+ <figcaption>Now</figcaption>
1799
+ {now ? <Thumb url={now} title={`${frame.title} now`} /> : <p className="mode compare-note">{vendorName(frame.vendor)}'s twin draws no page for this {frame.kind} yet.</p>}
1800
+ </figure>
1801
+ </div>
1802
+ {listed.state === 'error' ? <p role="alert" className="mode">Its changes could not be read: {listed.error}</p> : null}
1803
+ <ol className="compare-acts">
1804
+ {(listed.state === 'ok' ? listed.data.acts : []).map((a) => {
1805
+ const first = a.entries[0]!; const words = said({ ...first, service: frame.vendor, id: '', occurredAt: a.at } as unknown as Entry);
1806
+ return (
1807
+ <li key={`${a.at}:${first.subject.type}:${first.subject.id}`}>
1808
+ <span className="mode">{when(a.at)}</span>
1809
+ <details><summary><strong>{words.what}</strong>{words.label ? ` · ${words.label}` : ''}{a.writes > 1 ? <span className="mode"> and {counted(a.writes - 1, 'more write')}</span> : null}</summary>
1810
+ <ul>{a.entries.map((e, i) => <li key={i}><code>{e.operation ?? e.op}</code> <span className="mode">{e.subject.type} {shown(e as unknown as Entry)}</span></li>)}{a.writes > a.entries.length ? <li className="mode">and {counted(a.writes - a.entries.length, 'more write')}</li> : null}</ul>
1811
+ </details>
1812
+ </li>
1813
+ );
1814
+ })}
1815
+ </ol>
1816
+ {listed.state === 'ok' && listed.data.total > listed.data.acts.length ? <p className="mode">and {counted(listed.data.total - listed.data.acts.length, 'earlier change')}, in Changes</p> : null}
1817
+ <a className="compare-all" href={`${BASE}/${name}/~/changes`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changes']); }}>Every change in the World →</a>
1818
+ </div>
1819
+ );
1727
1820
  }
1728
1821
  const FRAME_TYPES = { screen: ScreenFrame, section: SectionBox };
1729
1822
  function Canvas(props: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: ViewSession; retry: () => void; snapshot: Snapshot | null; back: () => void; /** a session that writes, never a snapshot: only it arranges the boards */ canWrite: boolean }): React.JSX.Element {
@@ -1745,6 +1838,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
1745
1838
  const [loaded, setLoaded] = useState<Load<BoardDoor>>({ state: 'loading' });
1746
1839
  // the last read that did not answer, said beside the board it leaves on screen
1747
1840
  const [stale, setStale] = useState<string | null>(null);
1841
+ const [reads, setReads] = useState(0);
1748
1842
  // reads in the order they went out: an earlier one answering after a later one is not taken
1749
1843
  const sent = useRef(0); const taken = useRef(0);
1750
1844
  /** The World's board read again (its frames always, its boards when no save moved since the read went out). */
@@ -1760,7 +1854,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
1760
1854
  setStale(error);
1761
1855
  return;
1762
1856
  }
1763
- setStale(null);
1857
+ setStale(null); setReads((n) => n + 1);
1764
1858
  if (at === generation.current && pending.current === 0) held.current = { boards: data.boards, version: data.version };
1765
1859
  setLoaded({ state: 'ok', data });
1766
1860
  }, [token, name]);
@@ -1807,15 +1901,33 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
1807
1901
  const change = (id: string, edit: (b: SavedBoard) => SavedBoard): void => { void save((list) => list.map((b) => (b.id === id ? edit(b) : b))); };
1808
1902
  const shown = shownOn(board, all);
1809
1903
  const elsewhere = all.filter((f) => f.changes && !shown.includes(f)).reduce((n, f) => n + f.changes, 0);
1904
+ // a frame in use takes the pointer (double-click, as a canvas app enters a frame); one selected shows what can be done to it
1810
1905
  const [active, setActive] = useState<string | null>(null);
1906
+ const [picked, setPicked] = useState<string | null>(null);
1907
+ // the frame whose changes are being compared
1908
+ const [comparing, setComparing] = useState<string | null>(null);
1811
1909
  const flow = useReactFlow();
1812
- /** Each frame's place: where it was put on this board, else after its section's last frame, a new section under all. */
1813
- const placed = useCallback((): Array<Node<Screen>> => {
1910
+ const others = boards.filter((b) => b.include);
1911
+ const addTo = (id: string, key: string): void => change(id, (b) => ({ ...b, include: [...(b.include ?? []), key] }));
1912
+ const removeFrame = { label: board.include ? 'Take it off this board' : 'Hide it', act: (key: string): void => { setPicked(null); change(board.id, (b) => (b.include ? { ...b, include: b.include.filter((k) => k !== key) } : { ...b, hidden: [...(b.hidden ?? []), key] })); } };
1913
+ const rect = (n: Node): { x: number; y: number; w: number; h: number } => ({ x: n.position.x, y: n.position.y, w: n.width ?? n.measured?.width ?? FRAME_W, h: n.height ?? n.measured?.height ?? FRAME_H });
1914
+ /** The section a frame sits in, as on a canvas: the smallest whose region holds the frame's middle, the newest of equals
1915
+ * (a section drawn around a frame takes it). */
1916
+ const sectionOf = (frame: Node, all: Node[]): Node | undefined => {
1917
+ const r = rect(frame); const cx = r.x + r.w / 2; const cy = r.y + r.h / 2;
1918
+ return all.filter((n) => n.type === 'section').map((s, i) => ({ s, i })).filter(({ s }) => { const q = rect(s); return cx >= q.x && cx <= q.x + q.w && cy >= q.y && cy <= q.y + q.h; })
1919
+ .sort((a, b) => rect(a.s).w * rect(a.s).h - rect(b.s).w * rect(b.s).h || b.i - a.i)[0]?.s;
1920
+ };
1921
+ /** The board laid out: each frame where it was put on this board, else after its pack's group, a new group under all;
1922
+ * each of the packs' groups a section around its frames until it is moved, resized, renamed or removed, and the
1923
+ * sections people drew where they drew them. */
1924
+ const layout = useCallback((): Node[] => {
1814
1925
  const at = new Map<string, { x: number; y: number }>();
1815
1926
  for (const f of shown) { const p = board.placed?.[frameKey(f)]; if (p) at.set(frameKey(f), p); }
1927
+ // a new group goes under the frames placed so far; a section someone draws never moves what is already laid out
1816
1928
  let bottom = Math.max(-FRAME_GAP, ...[...at.values()].map((p) => p.y + FRAME_H));
1817
- const sections = [...new Set(shown.map(sectionKey))];
1818
- for (const s of sections) {
1929
+ const groups = [...new Set(shown.map(sectionKey))];
1930
+ for (const s of groups) {
1819
1931
  const members = shown.filter((f) => sectionKey(f) === s);
1820
1932
  const placedOnes = members.filter((f) => at.has(frameKey(f))).map((f) => at.get(frameKey(f))!);
1821
1933
  let x = placedOnes.length ? Math.max(...placedOnes.map((p) => p.x)) + FRAME_W + FRAME_GAP : 0;
@@ -1823,42 +1935,137 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
1823
1935
  for (const f of members) if (!at.has(frameKey(f))) { at.set(frameKey(f), { x, y }); x += FRAME_W + FRAME_GAP; }
1824
1936
  bottom = Math.max(bottom, y + FRAME_H);
1825
1937
  }
1826
- const others = boards.filter((b) => b.include);
1827
- const addTo = (id: string, key: string): void => change(id, (b) => ({ ...b, include: [...(b.include ?? []), key] }));
1828
- const remove = { label: board.include ? 'Take it off this board' : 'Hide it', act: (key: string): void => change(board.id, (b) => (b.include ? { ...b, include: b.include.filter((k) => k !== key) } : { ...b, hidden: [...(b.hidden ?? []), key] })) };
1829
- return shown.map((f) => ({ id: frameKey(f), type: 'screen', dragHandle: '.frame-bar', draggable: editable, position: at.get(frameKey(f))!, data: { world: name, frame: f, url: frameUrl(name, f), active: false, boards: editable ? others : [], addTo, remove: !editable || (board.include && !board.include.includes(frameKey(f))) ? null : remove } }));
1938
+ const kept = board.sections ?? {};
1939
+ const section = (key: string, r: { x: number; y: number; w: number; h: number }, name: string, vendor: string | null, named: boolean): Node => ({ id: `sec:${key}`, type: 'section', position: { x: r.x, y: r.y }, width: r.w, height: r.h, zIndex: 0, draggable: editable, data: { key, name, vendor, named } });
1940
+ const sections: Node[] = [];
1941
+ for (const s of groups) {
1942
+ const f = shown.find((x) => sectionKey(x) === s)!;
1943
+ // a pack's group as people left it, under whichever id it was known by when they did (an account adopted since)
1944
+ const keyed = sectionKeys(f).find((k) => kept[k]) ?? s;
1945
+ const own = kept[keyed];
1946
+ if (own?.gone) continue;
1947
+ const ps = shown.filter((x) => sectionKey(x) === s).map((x) => at.get(frameKey(x))!);
1948
+ const around = { x: Math.min(...ps.map((p) => p.x)) - SECTION_PAD, y: Math.min(...ps.map((p) => p.y)) - SECTION_PAD, w: 0, h: 0 };
1949
+ around.w = Math.max(...ps.map((p) => p.x)) + FRAME_W + SECTION_PAD - around.x; around.h = Math.max(...ps.map((p) => p.y)) + FRAME_H + SECTION_PAD - around.y;
1950
+ // a group people arranged still takes its new frames: its region reaches over the ones placed after it
1951
+ const unplacedOf = shown.filter((x) => sectionKey(x) === s && !board.placed?.[frameKey(x)]).map((x) => at.get(frameKey(x))!);
1952
+ const r = own ? { ...own } : around;
1953
+ if (own) for (const p of unplacedOf) { r.w = Math.max(r.w, p.x + FRAME_W + SECTION_PAD - r.x); r.h = Math.max(r.h, p.y + FRAME_H + SECTION_PAD - r.y); }
1954
+ sections.push(section(keyed, r, own?.name ?? `${vendorName(f.vendor)} · ${f.section.title}`, f.vendor, Boolean(own?.name)));
1955
+ }
1956
+ for (const [k, r] of Object.entries(kept)) if (k.startsWith('user:') && !r.gone) sections.push(section(k, r, r.name ?? 'Section', null, true));
1957
+ // frames above the sections they sit in; a section's name tag above the board, where it can be clicked
1958
+ // a frame's size given, never left to be measured again: a board laid out anew keeps showing what did not change size
1959
+ const frames: Node[] = shown.map((f) => ({ id: frameKey(f), type: 'screen', zIndex: 1, width: FRAME_W, height: FRAME_H, dragHandle: '.frame-bar', draggable: editable, position: at.get(frameKey(f))!, data: { world: name, frame: f, url: frameUrl(name, f) } }));
1960
+ return [...sections, ...frames];
1830
1961
  // biome-ignore lint/correctness/useExhaustiveDependencies: the frames, the board and its places are what change it
1831
- }, [JSON.stringify(shown), JSON.stringify(board), JSON.stringify(boards.map((b) => [b.id, b.name, b.include])), name, editable]);
1832
- const [nodes, setNodes, onNodesChange] = useNodesState<Node<Screen>>([]);
1962
+ }, [JSON.stringify(shown), JSON.stringify(board), name, editable]);
1963
+ const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
1833
1964
  // a board opened is fitted to its own frames, once they are set: each board keeps its own layout
1834
1965
  const fitted = useRef('');
1835
1966
  useEffect(() => {
1836
- const next = placed();
1837
- setNodes(next.map((n) => ({ ...n, data: { ...n.data, active: n.id === active } })));
1838
- if (next.length && fitted.current !== board.id) { fitted.current = board.id; requestAnimationFrame(() => { void flow.fitView({ padding: 0.08, duration: 0 }); }); }
1839
- }, [placed, setNodes]);
1840
- useEffect(() => { setNodes((ns) => ns.map((n) => (n.data.active === (n.id === active) ? n : { ...n, data: { ...n.data, active: n.id === active } }))); }, [active, setNodes]);
1841
- useEffect(() => { const on = (e: KeyboardEvent): void => { if (e.key === 'Escape') setActive(null); }; document.addEventListener('keydown', on); return () => document.removeEventListener('keydown', on); }, []);
1842
- // each section a box around its frames as they stand, behind them: it grows as a frame is moved
1843
- const boxes = useMemo((): Array<Node<Section>> => {
1844
- const by = new Map<string, Array<Node<Screen>>>();
1845
- for (const n of nodes) { const k = sectionKey(n.data.frame); by.set(k, [...(by.get(k) ?? []), n]); }
1846
- return [...by].map(([k, ns]) => {
1847
- const x = Math.min(...ns.map((n) => n.position.x)) - SECTION_PAD; const y = Math.min(...ns.map((n) => n.position.y)) - SECTION_PAD - SECTION_HEAD;
1848
- const f = ns[0]!.data.frame;
1849
- const width = Math.max(...ns.map((n) => n.position.x)) + FRAME_W + SECTION_PAD - x; const height = Math.max(...ns.map((n) => n.position.y)) + FRAME_H + SECTION_PAD - y;
1850
- // its size given, never measured: a box is derived from its frames, so React Flow's measurements of it are not kept
1851
- return { id: `box:${k}`, type: 'section', position: { x, y }, width, height, zIndex: -1, draggable: false, selectable: false, data: { vendor: f.vendor, label: `${vendorName(f.vendor)} · ${f.section.title}`, width, height } };
1967
+ const next = layout();
1968
+ setNodes(next);
1969
+ if (next.length && fitted.current !== board.id) { fitted.current = board.id; requestAnimationFrame(() => { void flow.fitView({ padding: { top: '72px', left: '272px', right: '40px', bottom: '72px' }, duration: 0 }); }); }
1970
+ }, [layout, setNodes]);
1971
+ /** What was just arranged, kept on this board beside what was kept before: the frames moved, and the sections moved,
1972
+ * resized or renamed (a pack's group is kept only once someone arranges it; its name only once someone renames it). */
1973
+ const keep = (frames: Record<string, { x: number; y: number }>, sections: string[] = [], names: Record<string, string> = {}): void => {
1974
+ const regions = Object.fromEntries(sections.map((key) => {
1975
+ const n = flow.getNode(`sec:${key}`); if (!n) return [key, undefined];
1976
+ const r = rect(n); const d = n.data as { name: string; named?: boolean };
1977
+ const name = names[key] ?? (d.named ? d.name : undefined);
1978
+ return [key, { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h), ...(name ? { name: name.slice(0, 80) } : {}) }];
1979
+ }).filter(([, v]) => v !== undefined));
1980
+ const placed = Object.fromEntries(Object.entries(frames).map(([k, p]) => [k, { x: Math.round(p.x), y: Math.round(p.y) }]));
1981
+ change(board.id, (b) => ({ ...b, placed: { ...(b.placed ?? {}), ...placed }, sections: { ...(b.sections ?? {}), ...regions } }));
1982
+ };
1983
+ // a section carries what is in it as it is moved: the frames whose middle it holds, and the sections inside it
1984
+ const carrying = useRef<{ id: string; from: { x: number; y: number }; members: Array<{ id: string; x: number; y: number }> } | null>(null);
1985
+ const onDragStart = (_e: unknown, node: Node): void => {
1986
+ if (node.type !== 'section') { carrying.current = null; return; }
1987
+ const ns = flow.getNodes(); const q = rect(node);
1988
+ const holds = (n: Node): boolean => {
1989
+ const r = rect(n);
1990
+ if (n.type === 'screen') { const cx = r.x + r.w / 2; const cy = r.y + r.h / 2; return cx >= q.x && cx <= q.x + q.w && cy >= q.y && cy <= q.y + q.h; }
1991
+ return n.id !== node.id && r.x >= q.x && r.y >= q.y && r.x + r.w <= q.x + q.w && r.y + r.h <= q.y + q.h;
1992
+ };
1993
+ carrying.current = { id: node.id, from: { ...node.position }, members: ns.filter(holds).map((n) => ({ id: n.id, x: n.position.x, y: n.position.y })) };
1994
+ };
1995
+ const onDrag = (_e: unknown, node: Node): void => {
1996
+ const c = carrying.current; if (!c || c.id !== node.id) return;
1997
+ const dx = node.position.x - c.from.x; const dy = node.position.y - c.from.y;
1998
+ const by = new Map(c.members.map((m) => [m.id, m]));
1999
+ setNodes((ns) => ns.map((n) => { const m = by.get(n.id); return m ? { ...n, position: { x: m.x + dx, y: m.y + dy } } : n; }));
2000
+ };
2001
+ /** A drag ended: where it ended, from the drag itself, for the node and everything it carried; and each group a moved
2002
+ * frame belongs to kept as it now stands (its other frames, and its section), so nothing moves unless someone moves it. */
2003
+ const onDragStop = (_e: unknown, node: Node): void => {
2004
+ const c = carrying.current; carrying.current = null;
2005
+ const dx = node.position.x - (c?.from.x ?? node.position.x); const dy = node.position.y - (c?.from.y ?? node.position.y);
2006
+ const moved = node.type === 'screen' ? [{ id: node.id, x: node.position.x, y: node.position.y }] : (c?.members ?? []).map((m) => ({ ...m, x: m.x + dx, y: m.y + dy }));
2007
+ requestAnimationFrame(() => {
2008
+ const ns = flow.getNodes();
2009
+ const frames = new Map(moved.filter((m) => !m.id.startsWith('sec:')).map((m) => [m.id, { x: m.x, y: m.y }]));
2010
+ const sections = new Set([...(node.type === 'section' ? [(node.data as { key: string }).key] : []), ...moved.filter((m) => m.id.startsWith('sec:')).map((m) => m.id.slice(4))]);
2011
+ // a group by every id its section was known by (its kept settings may be under an adopted account's earlier one)
2012
+ const groups = new Set(ns.filter((n) => frames.has(n.id)).flatMap((n) => sectionKeys((n.data as Screen).frame)));
2013
+ for (const n of ns) {
2014
+ if (n.type === 'screen' && sectionKeys((n.data as Screen).frame).some((k) => groups.has(k)) && !frames.has(n.id)) frames.set(n.id, { x: n.position.x, y: n.position.y });
2015
+ if (n.type === 'section' && groups.has((n.data as SectionData).key)) sections.add((n.data as SectionData).key);
2016
+ }
2017
+ keep(Object.fromEntries(frames), [...sections]);
2018
+ });
2019
+ };
2020
+ /** A pack's group's frames where they now stand: kept with its section when the section is arranged, so they stay in it. */
2021
+ const groupFrames = (key: string): Record<string, { x: number; y: number }> =>
2022
+ Object.fromEntries(flow.getNodes().filter((n) => n.type === 'screen' && sectionKeys((n.data as Screen).frame).includes(key)).map((n) => [n.id, { x: n.position.x, y: n.position.y }]));
2023
+ const renameSection = (key: string): void => {
2024
+ const n = flow.getNode(`sec:${key}`); const now = (n?.data as { name?: string } | undefined)?.name ?? 'Section';
2025
+ const next = window.prompt('Name this section', now)?.trim(); if (!next) return;
2026
+ setNodes((ns) => ns.map((x) => (x.id === `sec:${key}` ? { ...x, data: { ...x.data, name: next.slice(0, 80), named: true } } : x)));
2027
+ requestAnimationFrame(() => keep(groupFrames(key), [key], { [key]: next }));
2028
+ };
2029
+ /** A section removed: its frames stay where they are; a pack's group is kept as removed, so it does not come back. */
2030
+ const removeSection = (key: string): void => {
2031
+ setPicked(null);
2032
+ const r = flow.getNode(`sec:${key}`);
2033
+ change(board.id, (b) => {
2034
+ const rest = Object.fromEntries(Object.entries(b.sections ?? {}).filter(([k]) => k !== key));
2035
+ return { ...b, sections: key.startsWith('user:') ? rest : { ...rest, [key]: { ...(r ? { x: Math.round(r.position.x), y: Math.round(r.position.y), w: Math.round(rect(r).w), h: Math.round(rect(r).h) } : { x: 0, y: 0, w: 0, h: 0 }), gone: true } } };
1852
2036
  });
1853
- }, [nodes]);
1854
- // once one frame is moved the board's layout is its people's: every frame's place is kept, a new frame added after
1855
- const keep = (): void => change(board.id, (b) => ({ ...b, placed: { ...(b.placed ?? {}), ...Object.fromEntries(nodes.map((n) => [n.id, { x: Math.round(n.position.x), y: Math.round(n.position.y) }])) } }));
1856
- /** A layer's row: bring that frame to the middle of the window, and use it. */
1857
- const focus = (key: string): void => {
1858
- setActive(key);
1859
- const n = flow.getNode(key); if (!n) return;
1860
- const zoom = Math.min((window.innerWidth - 300) / FRAME_W, (window.innerHeight - 120) / FRAME_H, 1);
1861
- void flow.setCenter(n.position.x + FRAME_W / 2 - 130 / zoom, n.position.y + FRAME_H / 2, { zoom, duration: 400 });
2037
+ };
2038
+ /** A new section: around the selected frame, else in the middle of the window. */
2039
+ const addSection = (): void => {
2040
+ const sel = picked ? flow.getNode(picked) : undefined;
2041
+ const middle = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
2042
+ const r = sel?.type === 'screen' ? { x: sel.position.x - SECTION_PAD, y: sel.position.y - SECTION_PAD, w: FRAME_W + SECTION_PAD * 2, h: FRAME_H + SECTION_PAD * 2 } : { x: middle.x - 900, y: middle.y - 600, w: 1800, h: 1200 };
2043
+ const key = `user:${Date.now().toString(36)}`;
2044
+ const count = Object.keys(board.sections ?? {}).filter((k) => k.startsWith('user:')).length + 1;
2045
+ setPicked(`sec:${key}`);
2046
+ change(board.id, (b) => ({ ...b, sections: { ...(b.sections ?? {}), [key]: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h), name: `Section ${count}` } } }));
2047
+ };
2048
+ // what each node shows: in use, selected, and what can be done to it
2049
+ const shownNodes = nodes.map((n) => (n.type === 'section'
2050
+ ? { ...n, data: { ...n.data, picked: picked === n.id, editable, pick: (k: string) => setPicked(`sec:${k}`), rename: renameSection, remove: removeSection, resized: () => requestAnimationFrame(() => { const k = (n.data as { key: string }).key; keep(groupFrames(k), [k]); }) } }
2051
+ : { ...n, data: { ...n.data, active: active === n.id, picked: picked === n.id, boards: editable ? others : [], addTo, remove: !editable || (board.include && !board.include.includes(n.id)) ? null : removeFrame, compare: (k: string) => { setPicked(k); setComparing(k); } } }));
2052
+ // the keys a canvas app answers: Esc leaves a frame, then the selection; Delete removes what is selected; ⇧S draws a section
2053
+ useEffect(() => {
2054
+ const on = (e: KeyboardEvent): void => {
2055
+ if ((e.target as HTMLElement | null)?.closest('input, textarea, select, [contenteditable]')) return;
2056
+ if (e.key === 'Escape') { if (active) setActive(null); else setPicked(null); return; }
2057
+ if (!editable || active) return;
2058
+ if ((e.key === 'Delete' || e.key === 'Backspace') && picked) { e.preventDefault(); if (picked.startsWith('sec:')) removeSection(picked.slice(4)); else if (!board.include || board.include.includes(picked)) removeFrame.act(picked); else toast('It is on this board with its whole group: take the group off in the list to remove it', 'error'); return; }
2059
+ if (e.key === 'S' && e.shiftKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); addSection(); }
2060
+ };
2061
+ document.addEventListener('keydown', on); return () => document.removeEventListener('keydown', on);
2062
+ });
2063
+ /** A layer's row: bring that frame or section to the middle of the window, selected. */
2064
+ const focus = (id: string): void => {
2065
+ setPicked(id);
2066
+ const n = flow.getNode(id); if (!n) return;
2067
+ const r = rect(n);
2068
+ void flow.fitBounds({ x: r.x, y: r.y, width: r.w, height: r.h }, { padding: 0.15, duration: 400 });
1862
2069
  };
1863
2070
  const addBoard = (): void => {
1864
2071
  const id = `board-${Date.now().toString(36)}`;
@@ -1867,17 +2074,24 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
1867
2074
  };
1868
2075
  const rename = (): void => { const n = window.prompt('Name this board', board.name)?.trim(); if (n) change(board.id, (b) => ({ ...b, name: n.slice(0, 80) })); };
1869
2076
  const drop = (): void => { const gone = board.id; setBoardId('everything'); void save((list) => list.filter((b) => b.id !== gone)); };
1870
- const sections = [...new Map(all.map((f) => [sectionKey(f), f])).values()];
1871
- const missing = board.include ? [...sections.filter((f) => !sectionKeys(f).some((k) => board.include!.includes(k))).map((f) => ({ key: sectionKey(f), label: `${vendorName(f.vendor)} · ${f.section.title} (all)` })), ...all.filter((f) => !shown.includes(f)).map((f) => ({ key: frameKey(f), label: f.title }))] : [];
2077
+ const groups = [...new Map(all.map((f) => [sectionKey(f), f])).values()];
2078
+ const missing = board.include ? [...groups.filter((f) => !sectionKeys(f).some((k) => board.include!.includes(k))).map((f) => ({ key: sectionKey(f), label: `${vendorName(f.vendor)} · ${f.section.title} (all)` })), ...all.filter((f) => !shown.includes(f)).map((f) => ({ key: frameKey(f), label: f.title }))] : [];
1872
2079
  const hidden = board.include ? [] : all.filter((f) => !shown.includes(f));
2080
+ // the layers as the canvas has them: each section with the frames in it, then the frames in none
2081
+ const sectionNodes = nodes.filter((n) => n.type === 'section');
2082
+ const frameNodes = nodes.filter((n) => n.type === 'screen');
2083
+ const inSection = new Map(frameNodes.map((n) => [n.id, sectionOf(n, nodes)?.id ?? null]));
2084
+ const frameRow = (n: Node): React.JSX.Element => { const f = (n.data as Screen).frame; return <li key={n.id}><button type="button" className={picked === n.id ? 'on' : undefined} onClick={() => focus(n.id)} onDoubleClick={() => setActive(n.id)}><Mark vendor={f.vendor} /><span>{f.title}</span>{f.changes ? <span className="layer-changed" title={`${counted(f.changes, 'change')}, not in a changeset: its mark on the board compares before and now`}><Icon name="square-dot" size={14} />{f.changes}</span> : null}</button></li>; };
1873
2085
  const dark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
1874
2086
  const ready = session.state === 'ok' && loaded.state === 'ok';
1875
2087
  return (
1876
2088
  <div className="canvas full" role="region" aria-label="The World's board">
1877
- <ReactFlow nodes={ready ? [...boxes, ...nodes] as Node[] : []} onNodesChange={(cs) => onNodesChange(cs.filter((c) => !('id' in c) || !String(c.id).startsWith('box:')) as never)} nodeTypes={FRAME_TYPES} onNodeDragStop={keep}
1878
- onNodeClick={(_e, n) => { if (!n.id.startsWith('box:')) setActive(n.id); }} onPaneClick={() => setActive(null)}
2089
+ <ReactFlow nodes={ready ? shownNodes : []} onNodesChange={onNodesChange} nodeTypes={FRAME_TYPES}
2090
+ onNodeDragStart={onDragStart} onNodeDrag={onDrag} onNodeDragStop={onDragStop}
2091
+ onNodeClick={(_e, n) => { if (n.id !== active) setPicked(n.id); }} onNodeDoubleClick={(_e, n) => { if (n.type === 'screen') { setPicked(n.id); setActive(n.id); } }}
2092
+ onPaneClick={() => { setActive(null); setPicked(null); }}
1879
2093
  minZoom={0.03} maxZoom={1.5} nodesConnectable={false} elementsSelectable={false}
1880
- panOnScroll={false} zoomOnDoubleClick={false} colorMode={dark ? 'dark' : 'light'} proOptions={{ hideAttribution: true }}>
2094
+ panOnScroll zoomOnPinch zoomOnScroll={false} zoomOnDoubleClick={false} colorMode={dark ? 'dark' : 'light'} proOptions={{ hideAttribution: true }}>
1881
2095
  <Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
1882
2096
  <Panel position="top-left" className="float layers">
1883
2097
  <div className="float-head">
@@ -1885,22 +2099,21 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
1885
2099
  <span className="float-title"><strong>{name.split('/')[1]}</strong><span className="mode">{name.split('/')[0]}{snapshot ? ' · snapshot' : ''}</span></span>
1886
2100
  </div>
1887
2101
  <div className="board-tabs" role="tablist" aria-label="Boards">
1888
- {boards.map((b) => <button key={b.id} type="button" role="tab" aria-selected={b.id === board.id} className={b.id === board.id ? 'on' : undefined} onClick={() => setBoardId(b.id)} onDoubleClick={editable && b.id === board.id && b.include ? rename : undefined}>{b.name}</button>)}
2102
+ {boards.map((b) => <button key={b.id} type="button" role="tab" aria-selected={b.id === board.id} className={b.id === board.id ? 'on' : undefined} onClick={() => { setPicked(null); setActive(null); setBoardId(b.id); }} onDoubleClick={editable && b.id === board.id && b.include ? rename : undefined}>{b.name}</button>)}
1889
2103
  {editable ? <button type="button" className="ghost" onClick={addBoard} aria-label="A new board">+</button> : null}
1890
2104
  </div>
1891
2105
  {board.include && editable ? <div className="board-acts"><button type="button" className="ghost" onClick={rename}>Rename</button><button type="button" className="ghost" onClick={drop}>Delete board</button></div> : null}
1892
- <ul className="layer-list" aria-label="Frames">
1893
- {[...new Set(shown.map(sectionKey))].map((s) => {
1894
- const members = shown.filter((f) => sectionKey(f) === s);
2106
+ <ul className="layer-list" aria-label="Layers">
2107
+ {sectionNodes.map((s) => {
2108
+ const d = s.data as SectionData;
1895
2109
  return (
1896
- <li key={s}>
1897
- <span className="layer-section"><Mark vendor={members[0]!.vendor} />{vendorName(members[0]!.vendor)} · {members[0]!.section.title}</span>
1898
- <ul className="layer-list inner">
1899
- {members.map((f) => <li key={frameKey(f)}><button type="button" className={active === frameKey(f) ? 'on' : undefined} onClick={() => focus(frameKey(f))}><span>{f.title}</span>{f.changes ? <span className="layer-changed" title={`${counted(f.changes, 'change')}, not in a changeset`}><Icon name="square-dot" size={14} />{f.changes}</span> : null}</button></li>)}
1900
- </ul>
2110
+ <li key={s.id}>
2111
+ <button type="button" className={picked === s.id ? 'layer-section on' : 'layer-section'} onClick={() => focus(s.id)} onDoubleClick={() => { if (editable) renameSection(d.key); }}>{d.vendor ? <Mark vendor={d.vendor} /> : <Icon name="layout-grid" size={14} />}<span>{d.name}</span></button>
2112
+ <ul className="layer-list inner">{frameNodes.filter((n) => inSection.get(n.id) === s.id).map(frameRow)}</ul>
1901
2113
  </li>
1902
2114
  );
1903
2115
  })}
2116
+ {frameNodes.filter((n) => !inSection.get(n.id)).map(frameRow)}
1904
2117
  </ul>
1905
2118
  {board.include && !shown.length ? <p className="mode pad-s">An empty board: add frames from Everything, or below.</p> : null}
1906
2119
  {missing.length && editable ? <select className="board-add" value="" aria-label="Add to this board" onChange={(e) => { const k = e.target.value; if (k) change(board.id, (b) => ({ ...b, include: [...(b.include ?? []), k] })); }}><option value="">Add to this board…</option>{missing.map((m) => <option key={m.key} value={m.key}>{m.label}</option>)}</select> : null}
@@ -1911,7 +2124,12 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
1911
2124
  {session.state === 'pending' ? <p className="mode" role="status">Opening…</p> : null}
1912
2125
  {session.state === 'error' ? <p role="alert">The World did not open its screens: {session.error} <button type="button" onClick={retry}>Try again</button></p> : null}
1913
2126
  </Panel>
1914
- <Panel position="top-center" className="float hint mode">{active ? `Using ${shown.find((f) => frameKey(f) === active)?.title ?? active} · Esc to leave` : 'Drag to pan · scroll to zoom · click a frame to use it'}</Panel>
2127
+ {/* the way out of a frame's live page, which keeps the keys it has (Esc reaches the board only from outside it) */}
2128
+ {active ? <Panel position="top-center" className="float hint using"><span className="mode">Using {shown.find((f) => frameKey(f) === active)?.title ?? active}</span><button type="button" onClick={() => setActive(null)}>Done <kbd>Esc</kbd></button></Panel>
2129
+ : <Panel position="top-center" className="float hint mode">Scroll to pan · pinch or ⌘ scroll to zoom · double-click a frame to use it</Panel>}
2130
+ {/* the board's own tools, along the bottom as a canvas app keeps them */}
2131
+ {editable ? <Panel position="bottom-center" className="float board-tools"><button type="button" className="ghost" onClick={addSection} title="Section (⇧S): around the selected frame, else in the middle"><Icon name="layout-grid" size={16} /><span>Section</span><kbd>⇧S</kbd></button></Panel> : null}
2132
+ {comparing && shown.some((f) => frameKey(f) === comparing) ? <Panel position="top-right" className="float compare-panel"><Compare key={comparing} name={name} token={token} frame={shown.find((f) => frameKey(f) === comparing)!} onClose={() => setComparing(null)} reads={reads} /></Panel> : null}
1915
2133
  <Controls showInteractive={false} position="bottom-left" />
1916
2134
  <MiniMap pannable zoomable nodeBorderRadius={8} position="bottom-right" />
1917
2135
  </ReactFlow>