@volter/world-console 3.0.4 → 3.0.5

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,3 +610,23 @@ 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; }
618
+ .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
+ .frame-none p { margin: 0; }
620
+ .frame-label select, .frame-label button { font-size: 12px; padding: 1px 6px; }
621
+ .board-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 0 0; padding: 8px 0 0; border-top: 1px solid var(--line); }
622
+ .board-tabs button { border: 1px solid var(--line); background: transparent; border-radius: 999px; padding: 2px 10px; font-size: 12.5px; color: var(--ink); }
623
+ .board-tabs button.on { background: var(--accent-soft); border-color: var(--accent); }
624
+ .board-acts { display: flex; gap: 4px; margin-top: 4px; }
625
+ .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; }
628
+ .layer-list.inner { border-top: 0; margin: 0; padding: 0 0 0 14px; }
629
+ .board-add { width: 100%; margin-top: 8px; font-size: 12.5px; }
630
+ .board-hidden { margin-top: 8px; font-size: 12.5px; }
631
+ .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
+ .pad-s { padding: 6px 8px; margin: 0; font-size: 12.5px; }
@@ -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,245 @@ 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
+ 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 }> };
1685
+ type BoardDoor = { frames: BoardFrame[]; boards: SavedBoard[]; version: string };
1686
+ const frameKey = (f: BoardFrame): string => `${f.vendor}:${f.id}`;
1687
+ const sectionKey = (f: BoardFrame): string => `${f.vendor}:section:${f.section.id}`;
1688
+ /** Every name a frame's section answers to on a board: its id, and those it was known by (an account a deploy adopted). */
1689
+ const sectionKeys = (f: BoardFrame): string[] => [f.section.id, ...(f.section.aka ?? [])].map((id) => `${f.vendor}:section:${id}`);
1690
+ /** Where a frame opens in this World: a site at its own origin, a vendor page at the twin's place, or nowhere yet. */
1691
+ 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);
1692
+ /** The frames a board shows: everything but what it hides, or what it includes (a frame, or a section's every frame). */
1693
+ const shownOn = (b: SavedBoard, all: BoardFrame[]): BoardFrame[] => b.include
1694
+ ? all.filter((f) => b.include!.includes(frameKey(f)) || sectionKeys(f).some((k) => b.include!.includes(k)))
1695
+ : 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 };
1683
1697
  function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1684
- const { world, vendor, url, active, open, changed } = data;
1698
+ const { world, frame, url, active, boards, addTo, remove } = data;
1699
+ const key = frameKey(frame);
1700
+ const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
1685
1701
  return (
1686
1702
  <>
1687
1703
  <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}
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}
1690
1708
  </NodeToolbar>
1691
1709
  <div className={active ? 'frame on' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
1692
1710
  <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>
1711
+ <Mark vendor={frame.vendor} /><strong>{frame.title}</strong>
1712
+ <span className="frame-url">{frame.url.replace(/^https?:\/\//, '')}</span>
1713
+ {url ? <a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${frame.title} in a new tab`}>↗</a> : null}
1697
1714
  </div>
1698
1715
  <div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
1699
- <iframe title={`${vendor} screens`} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
1716
+ {url
1717
+ ? <iframe title={frame.title} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
1718
+ : <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
1719
  </div>
1701
1720
  </div>
1702
1721
  </>
1703
1722
  );
1704
1723
  }
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 {
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>;
1727
+ }
1728
+ const FRAME_TYPES = { screen: ScreenFrame, section: SectionBox };
1729
+ 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
1730
  return <ReactFlowProvider><CanvasSurface {...props} /></ReactFlowProvider>;
1708
1731
  }
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}`;
1732
+ /** 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
1733
+ * the top left (a canvas app's pages and layers), zoom at the bottom left, the minimap at the bottom right. */
1734
+ 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 {
1735
+ // 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.
1736
+ // Edits are saved one after another, each made from the version the last save left; one the World refuses (another
1737
+ // person saved first) is dropped and the World's boards are shown, said in a toast
1738
+ const held = useRef<{ boards: SavedBoard[]; version: string } | null>(null);
1739
+ const pending = useRef(0);
1740
+ const queue = useRef<Promise<void>>(Promise.resolve());
1741
+ // a save's generation: every save moves it when it starts and when it settles, and a read of the board takes its
1742
+ // boards only if none moved it while the read was out, so an older read never replaces a save's answer
1743
+ const generation = useRef(0);
1744
+ const [, rerender] = useState(0);
1745
+ const [loaded, setLoaded] = useState<Load<BoardDoor>>({ state: 'loading' });
1746
+ // the last read that did not answer, said beside the board it leaves on screen
1747
+ const [stale, setStale] = useState<string | null>(null);
1748
+ // reads in the order they went out: an earlier one answering after a later one is not taken
1749
+ const sent = useRef(0); const taken = useRef(0);
1750
+ /** The World's board read again (its frames always, its boards when no save moved since the read went out). */
1751
+ const readBoard = useCallback(async (): Promise<void> => {
1752
+ const at = generation.current; const mine = ++sent.current;
1753
+ const r = await door(token, `/-/${name}/board`, { signal: AbortSignal.timeout(15000) }).catch(() => null);
1754
+ const data = r?.ok ? ((await r.json().catch(() => null)) as BoardDoor | null) : null;
1755
+ if (mine < taken.current) return;
1756
+ taken.current = mine;
1757
+ if (!data?.frames || !data.boards || !data.version) {
1758
+ const error = r ? `the World answered ${r.status}` : 'the World did not answer';
1759
+ setLoaded((l) => (l.state === 'ok' ? l : { state: 'error', status: r?.status ?? 0, error }));
1760
+ setStale(error);
1761
+ return;
1762
+ }
1763
+ setStale(null);
1764
+ if (at === generation.current && pending.current === 0) held.current = { boards: data.boards, version: data.version };
1765
+ setLoaded({ state: 'ok', data });
1766
+ }, [token, name]);
1767
+ // read now and every ten seconds while the page is shown: another person's arrangement arrives as the World's changes do
1768
+ useEffect(() => { void readBoard(); const id = setInterval(() => { if (!document.hidden) void readBoard(); }, 10000); return () => clearInterval(id); }, [readBoard]);
1769
+ const all = loaded.state === 'ok' ? loaded.data.frames : [];
1770
+ const boards = held.current?.boards ?? [{ id: 'everything', name: 'Everything' }];
1771
+ const [boardId, setBoardId] = useState('everything');
1772
+ const board = boards.find((b) => b.id === boardId) ?? boards[0]!;
1773
+ const editable = canWrite && !snapshot;
1774
+ /** An edit of the boards: shown at once, saved after the edits before it, made from the boards as they then stand. */
1775
+ const save = useCallback((edit: (all: SavedBoard[]) => SavedBoard[]): Promise<void> => {
1776
+ if (!held.current) { toast('The board is being read again: make that change once it shows', 'error'); return Promise.resolve(); }
1777
+ held.current = { ...held.current, boards: edit(held.current.boards) };
1778
+ pending.current += 1; generation.current += 1; rerender((n) => n + 1);
1779
+ // each save settles whatever happens, so the next always runs: one the World refuses, or that never arrives, drops
1780
+ // this page's unsaved edits for the World's boards, read again
1781
+ const run = queue.current.then(async () => {
1782
+ const at = held.current;
1783
+ if (!at) return;
1784
+ try {
1785
+ // a save that never answers is a failed one, so the saves after it still run
1786
+ const r = await door(token, `/-/${name}/board`, { method: 'PUT', body: JSON.stringify({ boards: at.boards, version: at.version }), signal: AbortSignal.timeout(15000) });
1787
+ const answer = (await r.json().catch(() => null)) as (Partial<BoardDoor> & { error?: string }) | null;
1788
+ if (r.ok && answer?.boards && answer.version) held.current = { boards: pending.current > 1 && held.current ? held.current.boards : answer.boards, version: answer.version };
1789
+ 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'); }
1790
+ else throw new Error(answer?.error ?? `the World answered ${r.status}`);
1791
+ } catch (e) {
1792
+ toast(`The board was not kept: ${e instanceof Error ? e.message : String(e)}`, 'error');
1793
+ // the World's boards as they stand now, read for this, never an older read the page still holds
1794
+ held.current = null;
1795
+ const r = await door(token, `/-/${name}/board`, { signal: AbortSignal.timeout(15000) }).catch(() => null);
1796
+ const now = r?.ok ? ((await r.json().catch(() => null)) as BoardDoor | null) : null;
1797
+ if (now?.boards && now.version) held.current = { boards: now.boards, version: now.version };
1798
+ }
1799
+ }).finally(() => {
1800
+ pending.current -= 1; generation.current += 1; rerender((n) => n + 1);
1801
+ // a page left without the boards (the recovery read failed too) reads them once the saves are done
1802
+ if (pending.current === 0 && !held.current) void readBoard();
1803
+ });
1804
+ queue.current = run;
1805
+ return run;
1806
+ }, [token, name, readBoard]);
1807
+ const change = (id: string, edit: (b: SavedBoard) => SavedBoard): void => { void save((list) => list.map((b) => (b.id === id ? edit(b) : b))); };
1808
+ const shown = shownOn(board, all);
1809
+ const elsewhere = all.filter((f) => f.changes && !shown.includes(f)).reduce((n, f) => n + f.changes, 0);
1723
1810
  const [active, setActive] = useState<string | null>(null);
1724
- const [layers, setLayers] = useState(true);
1725
1811
  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)));
1812
+ /** Each frame's place: where it was put on this board, else after its section's last frame, a new section under all. */
1728
1813
  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]);
1814
+ const at = new Map<string, { x: number; y: number }>();
1815
+ for (const f of shown) { const p = board.placed?.[frameKey(f)]; if (p) at.set(frameKey(f), p); }
1816
+ 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) {
1819
+ const members = shown.filter((f) => sectionKey(f) === s);
1820
+ const placedOnes = members.filter((f) => at.has(frameKey(f))).map((f) => at.get(frameKey(f))!);
1821
+ let x = placedOnes.length ? Math.max(...placedOnes.map((p) => p.x)) + FRAME_W + FRAME_GAP : 0;
1822
+ const y = placedOnes.length ? Math.min(...placedOnes.map((p) => p.y)) : bottom + FRAME_GAP + SECTION_PAD * 2 + SECTION_HEAD;
1823
+ for (const f of members) if (!at.has(frameKey(f))) { at.set(frameKey(f), { x, y }); x += FRAME_W + FRAME_GAP; }
1824
+ bottom = Math.max(bottom, y + FRAME_H);
1825
+ }
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 } }));
1830
+ // 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]);
1734
1832
  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]);
1833
+ // a board opened is fitted to its own frames, once they are set: each board keeps its own layout
1834
+ const fitted = useRef('');
1835
+ 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]);
1737
1841
  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 */ } };
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 } };
1852
+ });
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) }])) } }));
1739
1856
  /** 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
1857
+ const focus = (key: string): void => {
1858
+ setActive(key);
1859
+ const n = flow.getNode(key); if (!n) return;
1744
1860
  const zoom = Math.min((window.innerWidth - 300) / FRAME_W, (window.innerHeight - 120) / FRAME_H, 1);
1745
1861
  void flow.setCenter(n.position.x + FRAME_W / 2 - 130 / zoom, n.position.y + FRAME_H / 2, { zoom, duration: 400 });
1746
1862
  };
1863
+ const addBoard = (): void => {
1864
+ const id = `board-${Date.now().toString(36)}`;
1865
+ setBoardId(id);
1866
+ void save((list) => [...list, { id, name: `Board ${list.length}`, include: [] }]);
1867
+ };
1868
+ 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
+ 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 }))] : [];
1872
+ const hidden = board.include ? [] : all.filter((f) => !shown.includes(f));
1747
1873
  const dark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
1748
- const ready = session.state === 'ok' && twins.state === 'ok';
1874
+ const ready = session.state === 'ok' && loaded.state === 'ok';
1749
1875
  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}
1876
+ <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)}
1879
+ minZoom={0.03} maxZoom={1.5} nodesConnectable={false} elementsSelectable={false}
1754
1880
  panOnScroll={false} zoomOnDoubleClick={false} colorMode={dark ? 'dark' : 'light'} proOptions={{ hideAttribution: true }}>
1755
1881
  <Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
1756
1882
  <Panel position="top-left" className="float layers">
1757
1883
  <div className="float-head">
1758
1884
  <button type="button" className="back" onClick={back} aria-label={`Back to ${name}`}>←</button>
1759
1885
  <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
1886
  </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" />
1887
+ <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>)}
1889
+ {editable ? <button type="button" className="ghost" onClick={addBoard} aria-label="A new board">+</button> : null}
1890
+ </div>
1891
+ {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);
1895
+ 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>
1901
+ </li>
1902
+ );
1903
+ })}
1904
+ </ul>
1905
+ {board.include && !shown.length ? <p className="mode pad-s">An empty board: add frames from Everything, or below.</p> : null}
1906
+ {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}
1907
+ {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}
1908
+ {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}
1909
+ <Pending load={loaded} what="the board" />
1910
+ {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
1911
  {session.state === 'pending' ? <p className="mode" role="status">Opening…</p> : null}
1769
1912
  {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
1913
  </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>
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>
1772
1915
  <Controls showInteractive={false} position="bottom-left" />
1773
1916
  <MiniMap pannable zoomable nodeBorderRadius={8} position="bottom-right" />
1774
1917
  </ReactFlow>