@volter/world-console 3.0.4 → 3.0.6

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.
@@ -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';
@@ -619,7 +619,7 @@ function WorldView({ name, token, twin, tab, arg, sub, admin, snapshot, parentTo
619
619
  const retry = (): void => { setScope('pending'); setAttempt((a) => a + 1); };
620
620
  // ALL SCREENS is a canvas app's own page: the whole window, the World's pages a click away (Figma's, tldraw's)
621
621
  if (current === 'canvas' || (current === 'vendors' && !chosen) || (tab === 'screens' && !arg)) {
622
- return <Canvas name={name} token={token} twins={twins} session={session} retry={retry} snapshot={snapshot} back={() => go(name.split('/'))} />;
622
+ return <Canvas name={name} token={token} twins={twins} session={session} retry={retry} snapshot={snapshot} back={() => go(name.split('/'))} canWrite={canWrite && !snapshot} />;
623
623
  }
624
624
  // EVERY World opens as its workspace: on this machine, on a host, and a snapshot of one (read only, a way back to live)
625
625
  const tabOf = (t?: string): SettingsTab => (SETTINGS_TABS.find(([k]) => k === t)?.[0] ?? 'account');
@@ -1187,12 +1187,11 @@ function Workspace({ name, token, view, sub, arg, win, who, snapshot, onReturn,
1187
1187
 
1188
1188
  <div className="ws-body">
1189
1189
  <nav className="ws-side" aria-label={`${name} places`}>
1190
- {/* the screens: one way in, saying how many there are and whose; the canvas lists them along its left */}
1191
- {withScreens.length ? (
1192
- <a href={`${BASE}/${name}/~/screens`} className={view === 'screen' ? 'ws-screens-entry on' : 'ws-screens-entry'} onClick={to(['screens'])} title={`${counted(withScreens.length, 'screen')}: ${listed(withScreens.map(vendorName))}`}>
1193
- <Icon name="layout-grid" size={16} /><span>Screens</span>
1194
- <span className="ws-screens-marks">{withScreens.slice(0, 3).map((v) => <Mark key={v} vendor={v} />)}{withScreens.length > 3 ? <span className="more">+{withScreens.length - 3}</span> : null}</span>
1195
- <span className="count">{withScreens.length}</span>
1190
+ {/* the board: one way in, saying whose things are on it; the board lists its frames along its left */}
1191
+ {vendors.length ? (
1192
+ <a href={`${BASE}/${name}/~/screens`} className={view === 'screen' ? 'ws-screens-entry on' : 'ws-screens-entry'} onClick={to(['screens'])} title={`The board: ${listed(vendors.map(vendorName))}`}>
1193
+ <Icon name="layout-grid" size={16} /><span>Board</span>
1194
+ <span className="ws-screens-marks">{vendors.slice(0, 3).map((v) => <Mark key={v} vendor={v} />)}{vendors.length > 3 ? <span className="more">+{vendors.length - 3}</span> : null}</span>
1196
1195
  </a>
1197
1196
  ) : null}
1198
1197
  <p className="side-group">Workspace</p>
@@ -1674,101 +1673,456 @@ type RequestReport = { hours: number; requests: number; journalFailures?: number
1674
1673
 
1675
1674
 
1676
1675
  type ViewSession = { state: 'pending' } | { state: 'ok'; scope: string } | { state: 'error'; error: string };
1677
- /** THE CANVAS: every vendor's own screens as frames on one surface, the way a canvas app holds its frames (React
1678
- * Flow; Figma's and tldraw's feel). Pan by dragging the background, zoom with the wheel or the controls, move a frame
1679
- * by its title bar (kept in this browser), click a frame to use that vendor's screens and the background or Esc to
1680
- * leave it, so a frame never captures the pan. A frame is its vendor's site at a desktop's size. */
1681
- const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160;
1682
- type Screen = { world: string; vendor: string; url: string; active: boolean; open: (vendor: string) => void; /** its records changed and not yet cut: the screen's mark, as a changed file's */ changed: number };
1676
+ /** THE BOARD: the World's things as frames on one surface, the way a Figma file holds its frames (React Flow; Figma's
1677
+ * and tldraw's feel). Each vendor's pack lays out its default frames (a Worker's site, a repository's page), grouped in
1678
+ * sections by the vendor's own containers; the World says where each opens and how many changes it holds not yet cut
1679
+ * (`GET /-/<world>/board`). The first board is everything; others hold what they include, a section with its frames to
1680
+ * come. Moving, hiding and including are kept by the World (`PUT /-/<world>/board`), for everyone who opens it. Pan by
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
+ const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160; const SECTION_PAD = 72; const SECTION_HEAD = 72;
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> };
1689
+ type BoardDoor = { frames: BoardFrame[]; boards: SavedBoard[]; version: string };
1690
+ const frameKey = (f: BoardFrame): string => `${f.vendor}:${f.id}`;
1691
+ const sectionKey = (f: BoardFrame): string => `${f.vendor}:section:${f.section.id}`;
1692
+ /** Every name a frame's section answers to on a board: its id, and those it was known by (an account a deploy adopted). */
1693
+ const sectionKeys = (f: BoardFrame): string[] => [f.section.id, ...(f.section.aka ?? [])].map((id) => `${f.vendor}:section:${id}`);
1694
+ /** Where a frame opens in this World: a site at its own origin, a vendor page at the twin's place, or nowhere yet. */
1695
+ const frameUrl = (name: string, f: BoardFrame): string | null => (f.at && 'site' in f.at ? f.at.site : f.at ? `${WORLDS}/${name}/${f.vendor}${f.at.screen}` : null);
1696
+ /** The frames a board shows: everything but what it hides, or what it includes (a frame, or a section's every frame). */
1697
+ const shownOn = (b: SavedBoard, all: BoardFrame[]): BoardFrame[] => b.include
1698
+ ? all.filter((f) => b.include!.includes(frameKey(f)) || sectionKeys(f).some((k) => b.include!.includes(k)))
1699
+ : all.filter((f) => !(b.hidden ?? []).includes(frameKey(f)));
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 };
1683
1701
  function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1684
- const { world, vendor, url, active, open, changed } = data;
1702
+ const { frame, url, active, picked, boards, addTo, remove, compare } = data;
1703
+ const key = frameKey(frame);
1704
+ const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
1685
1705
  return (
1686
1706
  <>
1707
+ {/* its name always, as a canvas app labels its frames; what can be done to it once it is selected */}
1687
1708
  <NodeToolbar isVisible position={Position.Top} align="start" className="frame-label">
1688
- <Mark vendor={vendor} /><span>{vendorName(vendor)}</span>
1689
- {changed ? <a className="frame-changed nodrag" href={`${BASE}/${world}/~/changes`} onClick={(e) => { e.preventDefault(); go([...world.split('/'), '~', 'changes']); }} title={`${counted(changed, 'record')} changed here, not in a changeset: see them in Changes`}><Icon name="square-dot" size={14} />{changed}</a> : 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}
1690
1713
  </NodeToolbar>
1691
- <div className={active ? 'frame on' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
1692
- <div className="frame-bar" title="Drag to move">
1693
- <Mark vendor={vendor} /><strong>{vendorName(vendor)}</strong>
1694
- <span className="frame-url">{url.replace(/^https?:\/\//, '')}</span>
1695
- <a className="nodrag" href={`${BASE}/${world}/~/screens/${vendor}`} onClick={(e) => { e.preventDefault(); open(vendor); }}>Open</a>
1696
- <a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${vendorName(vendor)} in a new tab`}>↗</a>
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>
1717
+ <span className="frame-url">{frame.url.replace(/^https?:\/\//, '')}</span>
1718
+ {url ? <a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${frame.title} in a new tab`}>↗</a> : null}
1697
1719
  </div>
1698
1720
  <div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
1699
- <iframe title={`${vendor} screens`} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
1721
+ {url
1722
+ ? <iframe title={frame.title} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
1723
+ : <div className="frame-none"><p><strong>{frame.unseen === 'sites-not-routed' ? `This World shows ${frame.title} only at its own local address.` : `${vendorName(frame.vendor)}'s twin draws no page for this ${frame.kind} yet.`}</strong></p><p className="mode">{frame.url}</p></div>}
1700
1724
  </div>
1701
1725
  </div>
1702
1726
  </>
1703
1727
  );
1704
1728
  }
1705
- const FRAME_TYPES = { screen: ScreenFrame };
1706
- function Canvas(props: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: ViewSession; retry: () => void; snapshot: Snapshot | null; back: () => void }): React.JSX.Element {
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 } | { 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 || !('site' in frame.at)) { setBefore({ state: 'none', why: `A ${vendorName(frame.vendor)} page as it was needs a signed-in copy of the World; its changes are listed below.` }); return; }
1761
+ const at = new Date(Date.parse(frame.since) - 1).toISOString();
1762
+ let made: string | null = null; let closed = false;
1763
+ // a copy is made once the comparison has stayed open a moment: one passed through on the way to another makes none
1764
+ const wait = setTimeout(() => void (async () => {
1765
+ const r = await door(token, `/-/${name}/branches`, { method: 'POST', body: JSON.stringify({ at: { instant: at }, ttl: 3600 }), signal: AbortSignal.timeout(60000) }).catch(() => null);
1766
+ if (!r?.ok) { setBefore({ state: 'none', why: `The World made no copy of itself as it was then${r ? `: ${await failure(r)}` : ''}.` }); return; }
1767
+ const b = (await r.json()) as { name: string; origin?: string };
1768
+ made = b.name;
1769
+ if (closed) { void door(token, `/-/${name}/branches/${b.name}`, { method: 'DELETE' }).catch(() => null); return; }
1770
+ const o = httpOrigin(b.origin);
1771
+ if (!o) { setBefore({ state: 'none', why: 'This World shows sites only at its own local address.' }); return; }
1772
+ const u = new URL(frame.url);
1773
+ setBefore({ state: 'ok', url: `${o.protocol}//${u.hostname}.${o.host}${u.pathname}${u.search}`, at });
1774
+ })(), 500);
1775
+ return () => { closed = true; clearTimeout(wait); if (made) void door(token, `/-/${name}/branches/${made}`, { method: 'DELETE' }).catch(() => null); };
1776
+ }, [name, token, frame.vendor, frame.id, frame.since]);
1777
+ const now = frameUrl(name, frame);
1778
+ return (
1779
+ <div className="compare" role="dialog" aria-label={`Changes to ${frame.title}`}>
1780
+ <div className="compare-head">
1781
+ <Mark vendor={frame.vendor} /><strong>{frame.title}</strong>
1782
+ <span className="mode">{counted(frame.changes, 'change')} not yet cut{frame.since ? `, since ${when(frame.since)}` : ''}</span>
1783
+ <button type="button" className="ghost" onClick={onClose} aria-label="Close the comparison">✕</button>
1784
+ </div>
1785
+ <div className="compare-sides">
1786
+ <figure>
1787
+ <figcaption>Before{before.state === 'ok' ? ` · as of ${when(before.at)}` : ''}</figcaption>
1788
+ {before.state === 'ok' ? <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>}
1789
+ </figure>
1790
+ <figure>
1791
+ <figcaption>Now</figcaption>
1792
+ {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>}
1793
+ </figure>
1794
+ </div>
1795
+ {listed.state === 'error' ? <p role="alert" className="mode">Its changes could not be read: {listed.error}</p> : null}
1796
+ <ol className="compare-acts">
1797
+ {(listed.state === 'ok' ? listed.data.acts : []).map((a) => {
1798
+ const first = a.entries[0]!; const words = said({ ...first, service: frame.vendor, id: '', occurredAt: a.at } as unknown as Entry);
1799
+ return (
1800
+ <li key={`${a.at}:${first.subject.type}:${first.subject.id}`}>
1801
+ <span className="mode">{when(a.at)}</span>
1802
+ <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>
1803
+ <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>
1804
+ </details>
1805
+ </li>
1806
+ );
1807
+ })}
1808
+ </ol>
1809
+ {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}
1810
+ <a className="compare-all" href={`${BASE}/${name}/~/changes`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changes']); }}>Every change in the World →</a>
1811
+ </div>
1812
+ );
1813
+ }
1814
+ const FRAME_TYPES = { screen: ScreenFrame, section: SectionBox };
1815
+ 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 {
1707
1816
  return <ReactFlowProvider><CanvasSurface {...props} /></ReactFlowProvider>;
1708
1817
  }
1709
- /** The canvas fills the window, as Figma's, tldraw's and Miro's do: the way back and the frames' list float at the top
1710
- * left (a canvas app's layers), zoom at the bottom left, the minimap at the bottom right. */
1711
- function CanvasSurface({ name, token, twins, session, retry, snapshot, back }: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: ViewSession; retry: () => void; snapshot: Snapshot | null; back: () => void }): React.JSX.Element {
1712
- // each screen's changes not yet cut, by vendor: the records, as Changes counts them
1713
- const diff = useDoor<Diff>(token, `/-/${name}/diff`);
1714
- const changed = useMemo((): Record<string, number> => {
1715
- const by: Record<string, Set<string>> = {};
1716
- if (diff.state === 'ok') for (const c of diff.data.changes) (by[c.twin] ??= new Set()).add(`${c.entry.subject.type}:${c.entry.subject.id}`);
1717
- return Object.fromEntries(Object.entries(by).map(([v, rs]) => [v, rs.size]));
1718
- }, [diff]);
1719
- const changedAt = (v: string): number => changed[v] ?? 0;
1720
- const urls = new Map((twins.state === 'ok' ? twins.data.twins : []).flatMap((t) => { const at = screenUrl(name, t); return at ? [[t.twin, at] as const] : []; }));
1721
- const frames = [...urls.keys()];
1722
- const saved = `volter.canvas.${name}`;
1818
+ /** The canvas fills the window, as Figma's, tldraw's and Miro's do: the way back, the boards and the frames' list float at
1819
+ * the top left (a canvas app's pages and layers), zoom at the bottom left, the minimap at the bottom right. */
1820
+ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: ViewSession; retry: () => void; snapshot: Snapshot | null; back: () => void; canWrite: boolean }): React.JSX.Element {
1821
+ // the boards as this page holds them: the World's as last read or saved, with this page's edits not yet saved on top.
1822
+ // Edits are saved one after another, each made from the version the last save left; one the World refuses (another
1823
+ // person saved first) is dropped and the World's boards are shown, said in a toast
1824
+ const held = useRef<{ boards: SavedBoard[]; version: string } | null>(null);
1825
+ const pending = useRef(0);
1826
+ const queue = useRef<Promise<void>>(Promise.resolve());
1827
+ // a save's generation: every save moves it when it starts and when it settles, and a read of the board takes its
1828
+ // boards only if none moved it while the read was out, so an older read never replaces a save's answer
1829
+ const generation = useRef(0);
1830
+ const [, rerender] = useState(0);
1831
+ const [loaded, setLoaded] = useState<Load<BoardDoor>>({ state: 'loading' });
1832
+ // the last read that did not answer, said beside the board it leaves on screen
1833
+ const [stale, setStale] = useState<string | null>(null);
1834
+ const [reads, setReads] = useState(0);
1835
+ // reads in the order they went out: an earlier one answering after a later one is not taken
1836
+ const sent = useRef(0); const taken = useRef(0);
1837
+ /** The World's board read again (its frames always, its boards when no save moved since the read went out). */
1838
+ const readBoard = useCallback(async (): Promise<void> => {
1839
+ const at = generation.current; const mine = ++sent.current;
1840
+ const r = await door(token, `/-/${name}/board`, { signal: AbortSignal.timeout(15000) }).catch(() => null);
1841
+ const data = r?.ok ? ((await r.json().catch(() => null)) as BoardDoor | null) : null;
1842
+ if (mine < taken.current) return;
1843
+ taken.current = mine;
1844
+ if (!data?.frames || !data.boards || !data.version) {
1845
+ const error = r ? `the World answered ${r.status}` : 'the World did not answer';
1846
+ setLoaded((l) => (l.state === 'ok' ? l : { state: 'error', status: r?.status ?? 0, error }));
1847
+ setStale(error);
1848
+ return;
1849
+ }
1850
+ setStale(null); setReads((n) => n + 1);
1851
+ if (at === generation.current && pending.current === 0) held.current = { boards: data.boards, version: data.version };
1852
+ setLoaded({ state: 'ok', data });
1853
+ }, [token, name]);
1854
+ // read now and every ten seconds while the page is shown: another person's arrangement arrives as the World's changes do
1855
+ useEffect(() => { void readBoard(); const id = setInterval(() => { if (!document.hidden) void readBoard(); }, 10000); return () => clearInterval(id); }, [readBoard]);
1856
+ const all = loaded.state === 'ok' ? loaded.data.frames : [];
1857
+ const boards = held.current?.boards ?? [{ id: 'everything', name: 'Everything' }];
1858
+ const [boardId, setBoardId] = useState('everything');
1859
+ const board = boards.find((b) => b.id === boardId) ?? boards[0]!;
1860
+ const editable = canWrite && !snapshot;
1861
+ /** An edit of the boards: shown at once, saved after the edits before it, made from the boards as they then stand. */
1862
+ const save = useCallback((edit: (all: SavedBoard[]) => SavedBoard[]): Promise<void> => {
1863
+ if (!held.current) { toast('The board is being read again: make that change once it shows', 'error'); return Promise.resolve(); }
1864
+ held.current = { ...held.current, boards: edit(held.current.boards) };
1865
+ pending.current += 1; generation.current += 1; rerender((n) => n + 1);
1866
+ // each save settles whatever happens, so the next always runs: one the World refuses, or that never arrives, drops
1867
+ // this page's unsaved edits for the World's boards, read again
1868
+ const run = queue.current.then(async () => {
1869
+ const at = held.current;
1870
+ if (!at) return;
1871
+ try {
1872
+ // a save that never answers is a failed one, so the saves after it still run
1873
+ const r = await door(token, `/-/${name}/board`, { method: 'PUT', body: JSON.stringify({ boards: at.boards, version: at.version }), signal: AbortSignal.timeout(15000) });
1874
+ const answer = (await r.json().catch(() => null)) as (Partial<BoardDoor> & { error?: string }) | null;
1875
+ if (r.ok && answer?.boards && answer.version) held.current = { boards: pending.current > 1 && held.current ? held.current.boards : answer.boards, version: answer.version };
1876
+ else if (r.status === 409 && answer?.boards && answer.version) { held.current = { boards: answer.boards, version: answer.version }; toast('Someone else changed the board first: it is shown as they left it', 'error'); }
1877
+ else throw new Error(answer?.error ?? `the World answered ${r.status}`);
1878
+ } catch (e) {
1879
+ toast(`The board was not kept: ${e instanceof Error ? e.message : String(e)}`, 'error');
1880
+ // the World's boards as they stand now, read for this, never an older read the page still holds
1881
+ held.current = null;
1882
+ const r = await door(token, `/-/${name}/board`, { signal: AbortSignal.timeout(15000) }).catch(() => null);
1883
+ const now = r?.ok ? ((await r.json().catch(() => null)) as BoardDoor | null) : null;
1884
+ if (now?.boards && now.version) held.current = { boards: now.boards, version: now.version };
1885
+ }
1886
+ }).finally(() => {
1887
+ pending.current -= 1; generation.current += 1; rerender((n) => n + 1);
1888
+ // a page left without the boards (the recovery read failed too) reads them once the saves are done
1889
+ if (pending.current === 0 && !held.current) void readBoard();
1890
+ });
1891
+ queue.current = run;
1892
+ return run;
1893
+ }, [token, name, readBoard]);
1894
+ const change = (id: string, edit: (b: SavedBoard) => SavedBoard): void => { void save((list) => list.map((b) => (b.id === id ? edit(b) : b))); };
1895
+ const shown = shownOn(board, all);
1896
+ const elsewhere = all.filter((f) => f.changes && !shown.includes(f)).reduce((n, f) => n + f.changes, 0);
1897
+ // 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
1723
1898
  const [active, setActive] = useState<string | null>(null);
1724
- const [layers, setLayers] = useState(true);
1899
+ const [picked, setPicked] = useState<string | null>(null);
1900
+ // the frame whose changes are being compared
1901
+ const [comparing, setComparing] = useState<string | null>(null);
1725
1902
  const flow = useReactFlow();
1726
- const open = useCallback((v: string): void => go([...name.split('/'), '~', 'screens', v]), [name]);
1727
- const cols = Math.max(1, Math.ceil(Math.sqrt(frames.length)));
1728
- const placed = useCallback((): Array<Node<Screen>> => {
1729
- let kept: Record<string, { x: number; y: number }> = {};
1730
- try { kept = JSON.parse(localStorage.getItem(saved) ?? '{}') as Record<string, { x: number; y: number }>; } catch { /* a convenience */ }
1731
- return frames.map((v, i) => ({ id: v, type: 'screen', dragHandle: '.frame-bar', position: kept[v] ?? { x: (i % cols) * (FRAME_W + FRAME_GAP), y: Math.floor(i / cols) * (FRAME_H + FRAME_GAP) }, data: { world: name, vendor: v, url: urls.get(v)!, active: false, open, changed: changed[v] ?? 0 } }));
1732
- // biome-ignore lint/correctness/useExhaustiveDependencies: the frames' names are what change it
1733
- }, [frames.join(','), name, cols, open, changed]);
1734
- const [nodes, setNodes, onNodesChange] = useNodesState<Node<Screen>>([]);
1735
- useEffect(() => { setNodes(placed()); }, [placed, setNodes]);
1736
- useEffect(() => { setNodes((all) => all.map((n) => (n.data.active === (n.id === active) ? n : { ...n, data: { ...n.data, active: n.id === active } }))); }, [active, setNodes]);
1737
- useEffect(() => { const on = (e: KeyboardEvent): void => { if (e.key === 'Escape') setActive(null); }; document.addEventListener('keydown', on); return () => document.removeEventListener('keydown', on); }, []);
1738
- const keep = (): void => { try { localStorage.setItem(saved, JSON.stringify(Object.fromEntries(nodes.map((n) => [n.id, n.position])))); } catch { /* a convenience */ } };
1739
- /** A layer's row: bring that frame to the middle of the window, and use it. */
1740
- const focus = (v: string): void => {
1741
- setActive(v);
1742
- const n = flow.getNode(v); if (!n) return;
1743
- // the frame in the room the floating panels leave, at the largest zoom that shows it whole
1744
- const zoom = Math.min((window.innerWidth - 300) / FRAME_W, (window.innerHeight - 120) / FRAME_H, 1);
1745
- void flow.setCenter(n.position.x + FRAME_W / 2 - 130 / zoom, n.position.y + FRAME_H / 2, { zoom, duration: 400 });
1903
+ const others = boards.filter((b) => b.include);
1904
+ const addTo = (id: string, key: string): void => change(id, (b) => ({ ...b, include: [...(b.include ?? []), key] }));
1905
+ 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] })); } };
1906
+ 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 });
1907
+ /** The section a frame sits in, as on a canvas: the smallest whose region holds the frame's middle, the newest of equals
1908
+ * (a section drawn around a frame takes it). */
1909
+ const sectionOf = (frame: Node, all: Node[]): Node | undefined => {
1910
+ const r = rect(frame); const cx = r.x + r.w / 2; const cy = r.y + r.h / 2;
1911
+ 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; })
1912
+ .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;
1913
+ };
1914
+ /** The board laid out: each frame where it was put on this board, else after its pack's group, a new group under all;
1915
+ * each of the packs' groups a section around its frames until it is moved, resized, renamed or removed, and the
1916
+ * sections people drew where they drew them. */
1917
+ const layout = useCallback((): Node[] => {
1918
+ const at = new Map<string, { x: number; y: number }>();
1919
+ for (const f of shown) { const p = board.placed?.[frameKey(f)]; if (p) at.set(frameKey(f), p); }
1920
+ // a new group goes under the frames placed so far; a section someone draws never moves what is already laid out
1921
+ let bottom = Math.max(-FRAME_GAP, ...[...at.values()].map((p) => p.y + FRAME_H));
1922
+ const groups = [...new Set(shown.map(sectionKey))];
1923
+ for (const s of groups) {
1924
+ const members = shown.filter((f) => sectionKey(f) === s);
1925
+ const placedOnes = members.filter((f) => at.has(frameKey(f))).map((f) => at.get(frameKey(f))!);
1926
+ let x = placedOnes.length ? Math.max(...placedOnes.map((p) => p.x)) + FRAME_W + FRAME_GAP : 0;
1927
+ const y = placedOnes.length ? Math.min(...placedOnes.map((p) => p.y)) : bottom + FRAME_GAP + SECTION_PAD * 2 + SECTION_HEAD;
1928
+ for (const f of members) if (!at.has(frameKey(f))) { at.set(frameKey(f), { x, y }); x += FRAME_W + FRAME_GAP; }
1929
+ bottom = Math.max(bottom, y + FRAME_H);
1930
+ }
1931
+ const kept = board.sections ?? {};
1932
+ 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 } });
1933
+ const sections: Node[] = [];
1934
+ for (const s of groups) {
1935
+ const f = shown.find((x) => sectionKey(x) === s)!;
1936
+ // a pack's group as people left it, under whichever id it was known by when they did (an account adopted since)
1937
+ const keyed = sectionKeys(f).find((k) => kept[k]) ?? s;
1938
+ const own = kept[keyed];
1939
+ if (own?.gone) continue;
1940
+ const ps = shown.filter((x) => sectionKey(x) === s).map((x) => at.get(frameKey(x))!);
1941
+ 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 };
1942
+ 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;
1943
+ // a group people arranged still takes its new frames: its region reaches over the ones placed after it
1944
+ const unplacedOf = shown.filter((x) => sectionKey(x) === s && !board.placed?.[frameKey(x)]).map((x) => at.get(frameKey(x))!);
1945
+ const r = own ? { ...own } : around;
1946
+ 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); }
1947
+ sections.push(section(keyed, r, own?.name ?? `${vendorName(f.vendor)} · ${f.section.title}`, f.vendor, Boolean(own?.name)));
1948
+ }
1949
+ for (const [k, r] of Object.entries(kept)) if (k.startsWith('user:') && !r.gone) sections.push(section(k, r, r.name ?? 'Section', null, true));
1950
+ // frames above the sections they sit in; a section's name tag above the board, where it can be clicked
1951
+ // a frame's size given, never left to be measured again: a board laid out anew keeps showing what did not change size
1952
+ 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) } }));
1953
+ return [...sections, ...frames];
1954
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the frames, the board and its places are what change it
1955
+ }, [JSON.stringify(shown), JSON.stringify(board), name, editable]);
1956
+ const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
1957
+ // a board opened is fitted to its own frames, once they are set: each board keeps its own layout
1958
+ const fitted = useRef('');
1959
+ useEffect(() => {
1960
+ const next = layout();
1961
+ setNodes(next);
1962
+ 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 }); }); }
1963
+ }, [layout, setNodes]);
1964
+ /** What was just arranged, kept on this board beside what was kept before: the frames moved, and the sections moved,
1965
+ * resized or renamed (a pack's group is kept only once someone arranges it; its name only once someone renames it). */
1966
+ const keep = (frames: Record<string, { x: number; y: number }>, sections: string[] = [], names: Record<string, string> = {}): void => {
1967
+ const regions = Object.fromEntries(sections.map((key) => {
1968
+ const n = flow.getNode(`sec:${key}`); if (!n) return [key, undefined];
1969
+ const r = rect(n); const d = n.data as { name: string; named?: boolean };
1970
+ const name = names[key] ?? (d.named ? d.name : undefined);
1971
+ 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) } : {}) }];
1972
+ }).filter(([, v]) => v !== undefined));
1973
+ const placed = Object.fromEntries(Object.entries(frames).map(([k, p]) => [k, { x: Math.round(p.x), y: Math.round(p.y) }]));
1974
+ change(board.id, (b) => ({ ...b, placed: { ...(b.placed ?? {}), ...placed }, sections: { ...(b.sections ?? {}), ...regions } }));
1975
+ };
1976
+ // a section carries what is in it as it is moved: the frames whose middle it holds, and the sections inside it
1977
+ const carrying = useRef<{ id: string; from: { x: number; y: number }; members: Array<{ id: string; x: number; y: number }> } | null>(null);
1978
+ const onDragStart = (_e: unknown, node: Node): void => {
1979
+ if (node.type !== 'section') { carrying.current = null; return; }
1980
+ const ns = flow.getNodes(); const q = rect(node);
1981
+ const holds = (n: Node): boolean => {
1982
+ const r = rect(n);
1983
+ 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; }
1984
+ 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;
1985
+ };
1986
+ 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 })) };
1987
+ };
1988
+ const onDrag = (_e: unknown, node: Node): void => {
1989
+ const c = carrying.current; if (!c || c.id !== node.id) return;
1990
+ const dx = node.position.x - c.from.x; const dy = node.position.y - c.from.y;
1991
+ const by = new Map(c.members.map((m) => [m.id, m]));
1992
+ setNodes((ns) => ns.map((n) => { const m = by.get(n.id); return m ? { ...n, position: { x: m.x + dx, y: m.y + dy } } : n; }));
1993
+ };
1994
+ /** A drag ended: where it ended, from the drag itself, for the node and everything it carried; and each group a moved
1995
+ * frame belongs to kept as it now stands (its other frames, and its section), so nothing moves unless someone moves it. */
1996
+ const onDragStop = (_e: unknown, node: Node): void => {
1997
+ const c = carrying.current; carrying.current = null;
1998
+ const dx = node.position.x - (c?.from.x ?? node.position.x); const dy = node.position.y - (c?.from.y ?? node.position.y);
1999
+ 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 }));
2000
+ requestAnimationFrame(() => {
2001
+ const ns = flow.getNodes();
2002
+ const frames = new Map(moved.filter((m) => !m.id.startsWith('sec:')).map((m) => [m.id, { x: m.x, y: m.y }]));
2003
+ 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))]);
2004
+ // a group by every id its section was known by (its kept settings may be under an adopted account's earlier one)
2005
+ const groups = new Set(ns.filter((n) => frames.has(n.id)).flatMap((n) => sectionKeys((n.data as Screen).frame)));
2006
+ for (const n of ns) {
2007
+ 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 });
2008
+ if (n.type === 'section' && groups.has((n.data as SectionData).key)) sections.add((n.data as SectionData).key);
2009
+ }
2010
+ keep(Object.fromEntries(frames), [...sections]);
2011
+ });
1746
2012
  };
2013
+ /** A pack's group's frames where they now stand: kept with its section when the section is arranged, so they stay in it. */
2014
+ const groupFrames = (key: string): Record<string, { x: number; y: number }> =>
2015
+ 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 }]));
2016
+ const renameSection = (key: string): void => {
2017
+ const n = flow.getNode(`sec:${key}`); const now = (n?.data as { name?: string } | undefined)?.name ?? 'Section';
2018
+ const next = window.prompt('Name this section', now)?.trim(); if (!next) return;
2019
+ setNodes((ns) => ns.map((x) => (x.id === `sec:${key}` ? { ...x, data: { ...x.data, name: next.slice(0, 80), named: true } } : x)));
2020
+ requestAnimationFrame(() => keep(groupFrames(key), [key], { [key]: next }));
2021
+ };
2022
+ /** A section removed: its frames stay where they are; a pack's group is kept as removed, so it does not come back. */
2023
+ const removeSection = (key: string): void => {
2024
+ setPicked(null);
2025
+ const r = flow.getNode(`sec:${key}`);
2026
+ change(board.id, (b) => {
2027
+ const rest = Object.fromEntries(Object.entries(b.sections ?? {}).filter(([k]) => k !== key));
2028
+ 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 } } };
2029
+ });
2030
+ };
2031
+ /** A new section: around the selected frame, else in the middle of the window. */
2032
+ const addSection = (): void => {
2033
+ const sel = picked ? flow.getNode(picked) : undefined;
2034
+ const middle = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
2035
+ 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 };
2036
+ const key = `user:${Date.now().toString(36)}`;
2037
+ const count = Object.keys(board.sections ?? {}).filter((k) => k.startsWith('user:')).length + 1;
2038
+ setPicked(`sec:${key}`);
2039
+ 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}` } } }));
2040
+ };
2041
+ // what each node shows: in use, selected, and what can be done to it
2042
+ const shownNodes = nodes.map((n) => (n.type === 'section'
2043
+ ? { ...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]); }) } }
2044
+ : { ...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); } } }));
2045
+ // the keys a canvas app answers: Esc leaves a frame, then the selection; Delete removes what is selected; ⇧S draws a section
2046
+ useEffect(() => {
2047
+ const on = (e: KeyboardEvent): void => {
2048
+ if ((e.target as HTMLElement | null)?.closest('input, textarea, select, [contenteditable]')) return;
2049
+ if (e.key === 'Escape') { if (active) setActive(null); else setPicked(null); return; }
2050
+ if (!editable || active) return;
2051
+ 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; }
2052
+ if (e.key === 'S' && e.shiftKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); addSection(); }
2053
+ };
2054
+ document.addEventListener('keydown', on); return () => document.removeEventListener('keydown', on);
2055
+ });
2056
+ /** A layer's row: bring that frame or section to the middle of the window, selected. */
2057
+ const focus = (id: string): void => {
2058
+ setPicked(id);
2059
+ const n = flow.getNode(id); if (!n) return;
2060
+ const r = rect(n);
2061
+ void flow.fitBounds({ x: r.x, y: r.y, width: r.w, height: r.h }, { padding: 0.15, duration: 400 });
2062
+ };
2063
+ const addBoard = (): void => {
2064
+ const id = `board-${Date.now().toString(36)}`;
2065
+ setBoardId(id);
2066
+ void save((list) => [...list, { id, name: `Board ${list.length}`, include: [] }]);
2067
+ };
2068
+ 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) })); };
2069
+ const drop = (): void => { const gone = board.id; setBoardId('everything'); void save((list) => list.filter((b) => b.id !== gone)); };
2070
+ const groups = [...new Map(all.map((f) => [sectionKey(f), f])).values()];
2071
+ 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 }))] : [];
2072
+ const hidden = board.include ? [] : all.filter((f) => !shown.includes(f));
2073
+ // the layers as the canvas has them: each section with the frames in it, then the frames in none
2074
+ const sectionNodes = nodes.filter((n) => n.type === 'section');
2075
+ const frameNodes = nodes.filter((n) => n.type === 'screen');
2076
+ const inSection = new Map(frameNodes.map((n) => [n.id, sectionOf(n, nodes)?.id ?? null]));
2077
+ 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>; };
1747
2078
  const dark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
1748
- const ready = session.state === 'ok' && twins.state === 'ok';
2079
+ const ready = session.state === 'ok' && loaded.state === 'ok';
1749
2080
  return (
1750
- <div className="canvas full" role="region" aria-label="Every vendor's screens">
1751
- <ReactFlow nodes={ready ? nodes : []} onNodesChange={onNodesChange} nodeTypes={FRAME_TYPES} onNodeDragStop={keep}
1752
- onNodeClick={(_e, n) => setActive(n.id)} onPaneClick={() => setActive(null)}
1753
- fitView fitViewOptions={{ padding: { top: '64px', left: '272px', right: '32px', bottom: '32px' } }} minZoom={0.05} maxZoom={1.5} nodesConnectable={false} elementsSelectable={false}
1754
- panOnScroll={false} zoomOnDoubleClick={false} colorMode={dark ? 'dark' : 'light'} proOptions={{ hideAttribution: true }}>
2081
+ <div className="canvas full" role="region" aria-label="The World's board">
2082
+ <ReactFlow nodes={ready ? shownNodes : []} onNodesChange={onNodesChange} nodeTypes={FRAME_TYPES}
2083
+ onNodeDragStart={onDragStart} onNodeDrag={onDrag} onNodeDragStop={onDragStop}
2084
+ onNodeClick={(_e, n) => { if (n.id !== active) setPicked(n.id); }} onNodeDoubleClick={(_e, n) => { if (n.type === 'screen') { setPicked(n.id); setActive(n.id); } }}
2085
+ onPaneClick={() => { setActive(null); setPicked(null); }}
2086
+ minZoom={0.03} maxZoom={1.5} nodesConnectable={false} elementsSelectable={false}
2087
+ panOnScroll zoomOnPinch zoomOnScroll={false} zoomOnDoubleClick={false} colorMode={dark ? 'dark' : 'light'} proOptions={{ hideAttribution: true }}>
1755
2088
  <Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
1756
2089
  <Panel position="top-left" className="float layers">
1757
2090
  <div className="float-head">
1758
2091
  <button type="button" className="back" onClick={back} aria-label={`Back to ${name}`}>←</button>
1759
2092
  <span className="float-title"><strong>{name.split('/')[1]}</strong><span className="mode">{name.split('/')[0]}{snapshot ? ' · snapshot' : ''}</span></span>
1760
- <button type="button" className="ghost" aria-expanded={layers} aria-label={layers ? 'Hide the screens list' : 'Show the screens list'} onClick={() => setLayers(!layers)}>{layers ? '▾' : '▸'}</button>
1761
2093
  </div>
1762
- {layers ? (
1763
- <ul className="layer-list" aria-label="Screens">
1764
- {frames.map((v) => <li key={v}><button type="button" className={active === v ? 'on' : undefined} onClick={() => focus(v)}><Mark vendor={v} /><span>{vendorName(v)}</span>{changedAt(v) ? <span className="layer-changed" title={`${counted(changedAt(v), 'record')} changed, not in a changeset`}><Icon name="square-dot" size={14} />{changedAt(v)}</span> : null}</button></li>)}
1765
- </ul>
1766
- ) : null}
1767
- <Pending load={twins} what="the vendors" />
2094
+ <div className="board-tabs" role="tablist" aria-label="Boards">
2095
+ {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>)}
2096
+ {editable ? <button type="button" className="ghost" onClick={addBoard} aria-label="A new board">+</button> : null}
2097
+ </div>
2098
+ {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}
2099
+ <ul className="layer-list" aria-label="Layers">
2100
+ {sectionNodes.map((s) => {
2101
+ const d = s.data as SectionData;
2102
+ return (
2103
+ <li key={s.id}>
2104
+ <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>
2105
+ <ul className="layer-list inner">{frameNodes.filter((n) => inSection.get(n.id) === s.id).map(frameRow)}</ul>
2106
+ </li>
2107
+ );
2108
+ })}
2109
+ {frameNodes.filter((n) => !inSection.get(n.id)).map(frameRow)}
2110
+ </ul>
2111
+ {board.include && !shown.length ? <p className="mode pad-s">An empty board: add frames from Everything, or below.</p> : null}
2112
+ {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}
2113
+ {hidden.length ? <details className="board-hidden"><summary>Hidden ({hidden.length})</summary><ul className="layer-list">{hidden.map((f) => <li key={frameKey(f)}>{editable ? <button type="button" onClick={() => change(board.id, (b) => ({ ...b, hidden: (b.hidden ?? []).filter((k) => k !== frameKey(f)) }))}><span>{f.title}</span><span className="mode">Show</span></button> : <span className="pad-s">{f.title}</span>}</li>)}</ul></details> : null}
2114
+ {elsewhere ? <button type="button" className="board-elsewhere" onClick={() => setBoardId('everything')}><Icon name="square-dot" size={14} />{counted(elsewhere, 'change')} not on this board</button> : null}
2115
+ <Pending load={loaded} what="the board" />
2116
+ {stale && loaded.state === 'ok' ? <p role="alert" className="pad-s">The board could not be read again ({stale}): it shows what was last read.</p> : null}
1768
2117
  {session.state === 'pending' ? <p className="mode" role="status">Opening…</p> : null}
1769
2118
  {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}
1770
2119
  </Panel>
1771
- <Panel position="top-center" className="float hint mode">{active ? `Using ${vendorName(active)} · Esc to leave` : 'Drag to pan · scroll to zoom · click a screen to use it'}</Panel>
2120
+ {/* the way out of a frame's live page, which keeps the keys it has (Esc reaches the board only from outside it) */}
2121
+ {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>
2122
+ : <Panel position="top-center" className="float hint mode">Scroll to pan · pinch or ⌘ scroll to zoom · double-click a frame to use it</Panel>}
2123
+ {/* the board's own tools, along the bottom as a canvas app keeps them */}
2124
+ {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}
2125
+ {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}
1772
2126
  <Controls showInteractive={false} position="bottom-left" />
1773
2127
  <MiniMap pannable zoomable nodeBorderRadius={8} position="bottom-right" />
1774
2128
  </ReactFlow>