@volter/world-console 3.0.21 → 3.0.23

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.
@@ -662,6 +662,8 @@ main.app.home { max-width: none; }
662
662
  .compare-head { display: flex; align-items: center; gap: 8px; }
663
663
  .compare-base { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
664
664
  .compare-base select { font: inherit; max-width: 22ch; }
665
+ .board-viewing { margin-left: 0; }
666
+ .board-viewing select { min-width: 0; flex: 1; }
665
667
  .compare-acts-head { margin: 12px 0 0; font-size: 12px; }
666
668
  .compare-head .mode { flex: 1; }
667
669
  .compare-sides { display: flex; gap: 16px; flex-wrap: wrap; }
@@ -1691,10 +1691,12 @@ const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160; const SECTION_
1691
1691
  const FRAME_NAME_ROOM = 72;
1692
1692
  /** A change behind a frame's count: the writes one request made, as words read them, and when. */
1693
1693
  type BoardAct = { at: string; writes: number; entries: Array<{ op: string; operation?: string; subject: { type: string; id: string }; fields: Record<string, string> }> };
1694
- type BoardFrame = { vendor: string; id: string; kind: string; title: string; section: { id: string; title: string; aka?: string[] }; url: string; /** the page it sits under at the vendor (its id there) */ parent?: string; /** the World's own */ ours?: boolean; 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 };
1694
+ type BoardFrame = { vendor: string; id: string; kind: string; title: string; section: { id: string; title: string; aka?: string[] }; url: string; /** the page it sits under at the vendor (its id there) */ parent?: string; /** the World's own */ ours?: boolean; at: { site: string } | { screen: string } | null; unseen?: 'not-drawn' | 'sites-not-routed' | 'branch-sites'; changes: number; /** when its first change not yet cut was made */ since?: string | null };
1695
1695
  /** A section of a board: its region and name, or a pack's group removed from the board (`gone`). */
1696
1696
  type SavedSection = { x: number; y: number; w: number; h: number; name?: string; gone?: true };
1697
1697
  type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }>; sections?: Record<string, SavedSection> };
1698
+ type BoardBranch = { name: string; origin?: string; sites?: string; at: { instant?: string; label?: string; live?: boolean } | null; expiresAt: string | null };
1699
+ const BRANCH_SITES_UNSEEN = "This World shows its branches' sites only on this machine.";
1698
1700
  type BoardDoor = { frames: BoardFrame[]; boards: SavedBoard[]; version: string };
1699
1701
  const frameKey = (f: BoardFrame): string => `${f.vendor}:${f.id}`;
1700
1702
  const sectionKey = (f: BoardFrame): string => `${f.vendor}:section:${f.section.id}`;
@@ -1702,13 +1704,17 @@ const sectionKey = (f: BoardFrame): string => `${f.vendor}:section:${f.section.i
1702
1704
  const sectionKeys = (f: BoardFrame): string[] => [f.section.id, ...(f.section.aka ?? [])].map((id) => `${f.vendor}:section:${id}`);
1703
1705
  /** Where a frame opens in this World: a site at its own origin, a vendor page at the twin's place, or nowhere yet. */
1704
1706
  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);
1707
+ /** A vendor page on a branch always opens through its parent's read-pass door. */
1708
+ const branchFrameUrl = (name: string, branch: string, f: BoardFrame): string | null => f.at && 'screen' in f.at
1709
+ ? `${WORLDS}/-/${name}/branches/${branch}/pages?${new URLSearchParams({ land: `/${branch}/${f.vendor}${f.at.screen}` }).toString()}`
1710
+ : frameUrl(branch, f);
1705
1711
  /** The frames a board shows: everything but what it hides, or what it includes (a frame, or a section's every frame). */
1706
1712
  const shownOn = (b: SavedBoard, all: BoardFrame[]): BoardFrame[] => b.include
1707
1713
  ? all.filter((f) => b.include!.includes(frameKey(f)) || sectionKeys(f).some((k) => b.include!.includes(k)))
1708
1714
  : all.filter((f) => !(b.hidden ?? []).includes(frameKey(f)));
1709
- 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; /** its name moves it, as a canvas app's frame name does */ grab: (e: React.PointerEvent) => void };
1715
+ type Screen = { world: string; branch: boolean; 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; /** its name moves it, as a canvas app's frame name does */ grab: (e: React.PointerEvent) => void };
1710
1716
  function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1711
- const { frame, url, active, picked, boards, addTo, remove, compare, grab } = data;
1717
+ const { frame, url, active, picked, boards, addTo, remove, compare, grab, branch } = data;
1712
1718
  const key = frameKey(frame);
1713
1719
  const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
1714
1720
  // its name is drawn the same size at every zoom while the head above it shrinks: zoomed out past the point the name fits
@@ -1719,7 +1725,7 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1719
1725
  {/* its name, as a canvas app labels its frames; what can be done to it once it is selected */}
1720
1726
  <NodeToolbar isVisible={roomy || picked || active} position={Position.Top} align="start" className="frame-label">
1721
1727
  <span className="frame-name" onPointerDown={grab}><Mark vendor={frame.vendor} /><span>{frame.title}</span></span>
1722
- {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>
1728
+ {frame.changes ? <button type="button" className="frame-changed nodrag" onClick={() => compare(key)} title={`${counted(frame.changes, 'change')} ${branch ? 'on this branch' : 'here, not in a changeset'}: compare`}><Icon name="square-dot" size={14} />{frame.changes}</button>
1723
1729
  : picked ? <button type="button" className="ghost nodrag" onClick={() => compare(key)} title="Compare with a branch of this World" aria-label={`Compare ${frame.title} with a branch`}><Icon name="file-diff" size={14} /></button> : null}
1724
1730
  {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}
1725
1731
  {picked && remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
@@ -1733,7 +1739,7 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1733
1739
  <div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
1734
1740
  {url
1735
1741
  ? <iframe title={frame.title} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
1736
- : <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>}
1742
+ : <div className="frame-none"><p><strong>{frame.unseen === 'branch-sites' ? BRANCH_SITES_UNSEEN : 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>}
1737
1743
  </div>
1738
1744
  </div>
1739
1745
  </>
@@ -1774,19 +1780,21 @@ function Thumb({ url, title }: { url: string; title: string }): React.JSX.Elemen
1774
1780
  /** THE COMPARISON, as a canvas app's "compare changes": a frame's changes not yet cut, each in words, and the frame as it
1775
1781
  * 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
1776
1782
  * branch the World makes for an hour, removed when this closes), its site read at the copy's own address. */
1777
- 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 {
1783
+ function Compare({ name, token, frame, onClose, reads, viewing = '' }: { viewing?: string; 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 {
1778
1784
  // what "before" is: the World as it was the moment before this frame's first change not yet cut (a copy made for the
1779
1785
  // comparison), or any branch of the World chosen here (a pull request's preview, a named branch), as a compare view
1780
1786
  // sets one branch beside another
1781
- const [base, setBase] = useState<string>(frame.since ? 'before' : '');
1787
+ const [base, setBase] = useState<string>(viewing ? 'world' : frame.since ? 'before' : '');
1782
1788
  const [before, setBefore] = useState<{ state: 'opening' } | { state: 'ok'; url: string; at?: string } | { state: 'none'; why: string }>({ state: 'opening' });
1783
1789
  const [made, setMade] = useState<string | null>(null);
1784
1790
  // the copies this panel made, kept out of the choices after they are gone (the list is read again only every so often)
1785
1791
  const mine = useRef(new Set<string>());
1786
- const branches = useDoor<{ branches: Array<{ name: string; origin?: string; sites?: string; at: { instant?: string; label?: string; live?: boolean } | null; expiresAt: string | null }> }>(token, `/-/${name}/branches`, reads);
1787
- const choices = branches.state === 'ok' ? branches.data.branches.filter((b) => b.name !== made && !mine.current.has(b.name)) : [];
1792
+ const branches = useDoor<{ branches: BoardBranch[] }>(token, `/-/${name}/branches`, reads);
1793
+ const choices = branches.state === 'ok' ? branches.data.branches.filter((b) => b.name !== viewing && b.name !== made && !mine.current.has(b.name)) : [];
1794
+ const parent = useDoor<BoardDoor>(token, viewing ? `/-/${name}/board` : null, reads);
1795
+ const parentFrame = parent.state === 'ok' ? parent.data.frames.find((f) => frameKey(f) === frameKey(frame)) : undefined;
1788
1796
  // its changes, read for this comparison alone
1789
- const listed = useDoor<{ total: number; acts: BoardAct[] }>(token, `/-/${name}/board/changes?frame=${encodeURIComponent(frameKey(frame))}`, reads);
1797
+ const listed = useDoor<{ total: number; acts: BoardAct[] }>(token, `/-/${name}/${viewing ? `branches/${viewing}/` : ''}board/changes?frame=${encodeURIComponent(frameKey(frame))}`, reads);
1790
1798
  // a branch's page beside this World's: a site at the branch's own site address, a vendor's page on the branch's own
1791
1799
  // content plane through this World's branch door (a one-time read pass), never running on this World's origin
1792
1800
  const pageOf = async (branch: string, sites: string | null, isClosed: () => boolean): Promise<{ url: string } | { why: string }> => {
@@ -1796,7 +1804,7 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
1796
1804
  // private to its people and open through the World's own address
1797
1805
  const u = new URL(frame.url);
1798
1806
  const nested = sites && /^https?:\/\/\{host\}\./.test(sites) ? sites.replace('{host}', u.hostname.toLowerCase()) : null;
1799
- return nested ? { url: `${nested}${u.pathname}${u.search}` } : { why: "This World shows its branches' sites only on this machine." };
1807
+ return nested ? { url: `${nested}${u.pathname}${u.search}` } : { why: BRANCH_SITES_UNSEEN };
1800
1808
  }
1801
1809
  const beside = `${WORLDS}/-/${name}/branches/${branch}/pages?${new URLSearchParams({ land: `/${branch}/${frame.vendor}${shown.screen}` }).toString()}`;
1802
1810
  // the door answers a navigation with the branch's address; a host that shows no content plane says so instead
@@ -1805,7 +1813,12 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
1805
1813
  return asked?.type === 'opaqueredirect' ? { url: beside } : { why: `This World does not show its branches' pages${asked ? `: ${await failure(asked)}` : ''}.` };
1806
1814
  };
1807
1815
  useEffect(() => {
1808
- 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; }
1816
+ if (base === 'world') {
1817
+ if (parent.state === 'loading') { setBefore({ state: 'opening' }); return; }
1818
+ const url = parentFrame ? frameUrl(name, parentFrame) : null;
1819
+ setBefore(url ? { state: 'ok', url } : { state: 'none', why: parent.state === 'error' ? parent.error : 'The World has no page here to show.' }); return;
1820
+ }
1821
+ if (!frame.at) { setBefore({ state: 'none', why: frame.unseen === 'branch-sites' ? BRANCH_SITES_UNSEEN : `${vendorName(frame.vendor)}'s twin draws no page for this ${frame.kind} yet; its changes are listed below.` }); return; }
1809
1822
  if (base === '') { setBefore({ state: 'none', why: 'Choose a branch of this World to set beside it.' }); return; }
1810
1823
  let closed = false;
1811
1824
  if (base !== 'before') {
@@ -1835,20 +1848,21 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
1835
1848
  const left = (e: PageTransitionEvent): void => { if (!e.persisted) drop(); };
1836
1849
  window.addEventListener('pagehide', left);
1837
1850
  return () => { closed = true; clearTimeout(wait); window.removeEventListener('pagehide', left); drop(); setMade(null); };
1838
- }, [name, token, frame.vendor, frame.id, frame.since, base]);
1839
- const now = frameUrl(name, frame);
1851
+ }, [name, token, frame.vendor, frame.id, frame.since, base, parent.state, JSON.stringify(parentFrame?.at)]);
1852
+ const now = viewing ? branchFrameUrl(name, viewing, frame) : frameUrl(name, frame);
1840
1853
  // nothing to choose: said, rather than an empty list
1841
1854
  const noBranches = !frame.at || base !== '' ? null : branches.state === 'ok' && choices.length === 0 ? 'This World has no branches to set beside it: make one under Branches.' : branches.state === 'error' ? `The World's branches could not be read: ${branches.error}` : null;
1842
1855
  return (
1843
1856
  <div className="compare" role="dialog" aria-label={`Changes to ${frame.title}`}>
1844
1857
  <div className="compare-head">
1845
1858
  <Mark vendor={frame.vendor} /><strong>{frame.title}</strong>
1846
- <span className="mode">{frame.changes ? `${counted(frame.changes, 'change')} not yet cut${frame.since ? `, since ${when(frame.since)}` : ''}` : 'No changes here not yet cut'}</span>
1859
+ <span className="mode">{frame.changes ? `${counted(frame.changes, 'change')} ${viewing ? 'on this branch' : 'not yet cut'}${frame.since ? `, since ${when(frame.since)}` : ''}` : viewing ? 'No changes here on this branch' : 'No changes here not yet cut'}</span>
1847
1860
  {frame.at ? <label className="compare-base">Compare with{' '}
1848
1861
  <select value={base} onChange={(e) => setBase(e.target.value)}>
1849
1862
  {base === '' ? <option value="">Choose a branch…</option> : null}
1850
- {frame.since || base === 'before' ? <option value="before">Before these changes</option> : null}
1851
- {base && base !== 'before' && !choices.some((b) => b.name === base) ? <option value={base}>{base.split('/')[1]}</option> : null}
1863
+ {viewing ? <option value="world">The World</option> : null}
1864
+ {!viewing && (frame.since || base === 'before') ? <option value="before">Before these changes</option> : null}
1865
+ {base && base !== 'before' && base !== 'world' && !choices.some((b) => b.name === base) ? <option value={base}>{base.split('/')[1]}</option> : null}
1852
1866
  {choices.map((b) => <option key={b.name} value={b.name}>{b.name.split('/')[1]}{b.at?.label ? ` (${b.at.label})` : b.at?.instant ? ` (as of ${when(b.at.instant)})` : b.at?.live ? ' (live)' : ''}</option>)}
1853
1867
  </select>
1854
1868
  </label> : null}
@@ -1856,16 +1870,16 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
1856
1870
  </div>
1857
1871
  <div className="compare-sides">
1858
1872
  <figure>
1859
- <figcaption>{base === 'before' ? `Before${before.state === 'ok' && before.at ? ` · as of ${when(before.at)}` : ''}` : base ? base.split('/')[1] : 'A branch'}</figcaption>
1873
+ <figcaption>{base === 'before' ? `Before${before.state === 'ok' && before.at ? ` · as of ${when(before.at)}` : ''}` : base === 'world' ? 'The World' : base ? base.split('/')[1] : 'A branch'}</figcaption>
1860
1874
  {noBranches ? <p className="mode compare-note">{noBranches}</p> : 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>}
1861
1875
  </figure>
1862
1876
  <figure>
1863
- <figcaption>Now</figcaption>
1864
- {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>}
1877
+ <figcaption>{viewing ? `${viewing.split('/')[1]} · branch` : 'Now'}</figcaption>
1878
+ {now ? <Thumb url={now} title={`${frame.title} now`} /> : <p className="mode compare-note">{frame.unseen === 'branch-sites' ? BRANCH_SITES_UNSEEN : `${vendorName(frame.vendor)}'s twin draws no page for this ${frame.kind} yet.`}</p>}
1865
1879
  </figure>
1866
1880
  </div>
1867
1881
  {listed.state === 'error' ? <p role="alert" className="mode">Its changes could not be read: {listed.error}</p> : null}
1868
- {base !== 'before' && base && listed.state === 'ok' && listed.data.acts.length ? <p className="mode compare-acts-head">This World's own changes here, not yet cut:</p> : null}
1882
+ {base !== 'before' && base && listed.state === 'ok' && listed.data.acts.length ? <p className="mode compare-acts-head">{viewing ? 'This branch’s changes here:' : "This World's own changes here, not yet cut:"}</p> : null}
1869
1883
  <ol className="compare-acts">
1870
1884
  {(listed.state === 'ok' ? listed.data.acts : []).map((a) => {
1871
1885
  const first = a.entries[0]!; const words = said({ ...first, service: frame.vendor, id: '', occurredAt: a.at } as unknown as Entry);
@@ -1880,17 +1894,28 @@ function Compare({ name, token, frame, onClose, reads }: { name: string; token:
1880
1894
  })}
1881
1895
  </ol>
1882
1896
  {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}
1883
- <a className="compare-all" href={`${BASE}/${name}/~/changes`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changes']); }}>Every change in the World →</a>
1897
+ {!viewing ? <a className="compare-all" href={`${BASE}/${name}/~/changes`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changes']); }}>Every change in the World →</a> : null}
1884
1898
  </div>
1885
1899
  );
1886
1900
  }
1887
1901
  const FRAME_TYPES = { screen: ScreenFrame, section: SectionBox };
1888
1902
  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 {
1889
- return <ReactFlowProvider><CanvasSurface {...props} /></ReactFlowProvider>;
1903
+ const [viewing, setViewing] = useState(() => new URLSearchParams(window.location.search).get('viewing') ?? '');
1904
+ useEffect(() => {
1905
+ const on = (): void => setViewing(new URLSearchParams(window.location.search).get('viewing') ?? '');
1906
+ window.addEventListener('popstate', on); return () => window.removeEventListener('popstate', on);
1907
+ }, []);
1908
+ const view = (branch: string): void => {
1909
+ const url = new URL(window.location.href);
1910
+ if (branch) url.searchParams.set('viewing', branch); else url.searchParams.delete('viewing');
1911
+ window.history.pushState(null, '', url);
1912
+ window.dispatchEvent(new PopStateEvent('popstate'));
1913
+ };
1914
+ return <ReactFlowProvider key={`${props.name}:${viewing}`}><CanvasSurface {...props} viewing={viewing} view={view} /></ReactFlowProvider>;
1890
1915
  }
1891
1916
  /** 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
1892
1917
  * the top left (a canvas app's pages and layers), zoom at the bottom left, the minimap at the bottom right. */
1893
- 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 {
1918
+ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite, viewing, view }: { viewing: string; view: (branch: string) => void; name: string; token: string; twins: Load<{ twins: Twin[] }>; session: ViewSession; retry: () => void; snapshot: Snapshot | null; back: () => void; canWrite: boolean }): React.JSX.Element {
1894
1919
  // 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.
1895
1920
  // Edits are saved one after another, each made from the version the last save left; one the World refuses (another
1896
1921
  // person saved first) is dropped and the World's boards are shown, said in a toast
@@ -1905,28 +1930,40 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
1905
1930
  // the last read that did not answer, said beside the board it leaves on screen
1906
1931
  const [stale, setStale] = useState<string | null>(null);
1907
1932
  const [reads, setReads] = useState(0);
1933
+ const branches = useDoor<{ branches: BoardBranch[] }>(token, `/-/${name}/branches`, reads);
1934
+ const branch = branches.state === 'ok' ? branches.data.branches.find((b) => b.name === viewing) : undefined;
1935
+ // comparison copies are unlabelled, expiring views at an instant; the door carries no purpose, so other copies
1936
+ // with those same facts are left out too. A URL may still name one, and its full row still supplies its sites.
1937
+ const choices = branches.state === 'ok' ? branches.data.branches.filter((b) => !(b.at?.instant && b.expiresAt && !b.at.label)) : [];
1908
1938
  // reads in the order they went out: an earlier one answering after a later one is not taken
1909
1939
  const sent = useRef(0); const taken = useRef(0);
1910
1940
  /** The World's board read again (its frames always, its boards when no save moved since the read went out). */
1911
1941
  const readBoard = useCallback(async (): Promise<void> => {
1912
1942
  const at = generation.current; const mine = ++sent.current;
1913
- const r = await door(token, `/-/${name}/board`, { signal: AbortSignal.timeout(15000) }).catch(() => null);
1943
+ const path = viewing ? `branches/${viewing.split('/').map(encodeURIComponent).join('/')}/board` : 'board';
1944
+ const [r, parent] = await Promise.all([
1945
+ door(token, `/-/${name}/${path}`, { signal: AbortSignal.timeout(15000) }).catch(() => null),
1946
+ viewing ? door(token, `/-/${name}/board`, { signal: AbortSignal.timeout(15000) }).catch(() => null) : Promise.resolve(null),
1947
+ ]);
1914
1948
  const data = r?.ok ? ((await r.json().catch(() => null)) as BoardDoor | null) : null;
1949
+ const arrangement = viewing ? parent?.ok ? ((await parent.json().catch(() => null)) as BoardDoor | null) : null : data;
1950
+ if (data && arrangement) { data.boards = arrangement.boards; data.version = arrangement.version; }
1915
1951
  if (mine < taken.current) return;
1916
1952
  taken.current = mine;
1917
- if (!data?.frames || !data.boards || !data.version) {
1918
- const error = r ? `the World answered ${r.status}` : 'the World did not answer';
1919
- setLoaded((l) => (l.state === 'ok' ? l : { state: 'error', status: r?.status ?? 0, error }));
1953
+ if (!data?.frames || !arrangement?.boards || !arrangement.version) {
1954
+ const failed = !r?.ok ? r : parent;
1955
+ const error = failed ? `the World answered ${failed.status}` : 'the World did not answer';
1956
+ setLoaded((l) => (l.state === 'ok' ? l : { state: 'error', status: failed?.status ?? 0, error }));
1920
1957
  setStale(error);
1921
1958
  return;
1922
1959
  }
1923
1960
  setStale(null); setReads((n) => n + 1);
1924
1961
  if (at === generation.current && pending.current === 0) held.current = { boards: data.boards, version: data.version };
1925
1962
  setLoaded({ state: 'ok', data });
1926
- }, [token, name]);
1963
+ }, [token, name, viewing]);
1927
1964
  // read now and every ten seconds while the page is shown: another person's arrangement arrives as the World's changes do
1928
1965
  useEffect(() => { void readBoard(); const id = setInterval(() => { if (!document.hidden) void readBoard(); }, 10000); return () => clearInterval(id); }, [readBoard]);
1929
- const all = loaded.state === 'ok' ? loaded.data.frames : [];
1966
+ const all = loaded.state === 'ok' ? loaded.data.frames.map((f): BoardFrame => viewing && f.at && 'site' in f.at && !/^https?:\/\/\{host\}\./.test(branch?.sites ?? '') ? { ...f, at: null, unseen: 'branch-sites' } : f) : [];
1930
1967
  const boards = held.current?.boards ?? [{ id: 'everything', name: 'Everything' }];
1931
1968
  // what is shown (architecture, "The board"): the pages whose records changed, everything, or a board people keep.
1932
1969
  // Changes is no board of its own: it is the first board's places and sections, holding only the pages that changed
@@ -1937,9 +1974,10 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
1937
1974
  const board = (lens === 'changes' ? boards[0] : boards.find((b) => b.id === lens)) ?? boards[0]!;
1938
1975
  // the pages opened from the ones they sit under (Everything shows a vendor's top pages; a page's own open from it)
1939
1976
  const [opened, setOpened] = useState<Set<string>>(new Set());
1940
- const editable = canWrite && !snapshot;
1977
+ const editable = canWrite && !snapshot && !viewing;
1941
1978
  /** An edit of the boards: shown at once, saved after the edits before it, made from the boards as they then stand. */
1942
1979
  const save = useCallback((edit: (all: SavedBoard[]) => SavedBoard[]): Promise<void> => {
1980
+ if (!editable) return Promise.resolve();
1943
1981
  if (!held.current) { toast('The board is being read again: make that change once it shows', 'error'); return Promise.resolve(); }
1944
1982
  held.current = { ...held.current, boards: edit(held.current.boards) };
1945
1983
  pending.current += 1; generation.current += 1; rerender((n) => n + 1);
@@ -1970,7 +2008,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
1970
2008
  });
1971
2009
  queue.current = run;
1972
2010
  return run;
1973
- }, [token, name, readBoard]);
2011
+ }, [token, name, readBoard, editable]);
1974
2012
  const change = (id: string, edit: (b: SavedBoard) => SavedBoard): void => { void save((list) => list.map((b) => (b.id === id ? edit(b) : b))); };
1975
2013
  const parentKey = (f: BoardFrame): string | null => (f.parent ? `${f.vendor}:${f.parent}` : null);
1976
2014
  const keys = new Set(all.map(frameKey));
@@ -2037,10 +2075,10 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
2037
2075
  // frames above the sections they sit in; a section's name tag above the board, where it can be clicked
2038
2076
  // a frame's size given, never left to be measured again: a board laid out anew keeps showing what did not change size.
2039
2077
  // A frame moves from anywhere on it, as a canvas app's does, until it is in use (its page then takes the pointer)
2040
- const frames: Node[] = shown.map((f) => ({ id: frameKey(f), type: 'screen', zIndex: 1, width: FRAME_W, height: FRAME_H, draggable: editable, position: at.get(frameKey(f))!, data: { world: name, frame: f, url: frameUrl(name, f) } }));
2078
+ const frames: Node[] = shown.map((f) => ({ id: frameKey(f), type: 'screen', zIndex: 1, width: FRAME_W, height: FRAME_H, draggable: editable, position: at.get(frameKey(f))!, data: { world: name, branch: Boolean(viewing), frame: f, url: viewing ? branchFrameUrl(name, viewing, f) : frameUrl(name, f) } }));
2041
2079
  return [...sections, ...frames];
2042
2080
  // biome-ignore lint/correctness/useExhaustiveDependencies: the frames, the board and its places are what change it
2043
- }, [JSON.stringify(shown), JSON.stringify(board), name, editable]);
2081
+ }, [JSON.stringify(shown), JSON.stringify(board), name, editable, viewing]);
2044
2082
  const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
2045
2083
  // the canvas's zoom, for what is drawn the same size on screen at every zoom (a section's edges, where it is resized)
2046
2084
  const zoom = useStore((st) => st.transform[2]);
@@ -2192,7 +2230,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
2192
2230
  const inSection = new Map(frameNodes.map((n) => [n.id, sectionOf(n, nodes)?.id ?? null]));
2193
2231
  // how many pages sit under each page, opened from it in Everything
2194
2232
  const under = new Map<string, number>(); for (const f of all) { const p = parentKey(f); if (p && keys.has(p)) under.set(p, (under.get(p) ?? 0) + 1); }
2195
- 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>{under.get(n.id) && lens !== 'changes' && !board.include ? <button type="button" className="ghost layer-open" aria-expanded={opened.has(n.id)} onClick={() => setOpened((o) => { const x = new Set(o); if (x.has(n.id)) x.delete(n.id); else x.add(n.id); return x; })} title={opened.has(n.id) ? 'Close the pages under it' : 'Open the pages under it'}>{opened.has(n.id) ? '−' : '+'}{under.get(n.id)}</button> : null}</li>; };
2233
+ 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')} ${viewing ? 'on this branch' : 'not in a changeset'}: its mark on the board opens a comparison`}><Icon name="square-dot" size={14} />{f.changes}</span> : null}</button>{under.get(n.id) && lens !== 'changes' && !board.include ? <button type="button" className="ghost layer-open" aria-expanded={opened.has(n.id)} onClick={() => setOpened((o) => { const x = new Set(o); if (x.has(n.id)) x.delete(n.id); else x.add(n.id); return x; })} title={opened.has(n.id) ? 'Close the pages under it' : 'Open the pages under it'}>{opened.has(n.id) ? '−' : '+'}{under.get(n.id)}</button> : null}</li>; };
2196
2234
  const dark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
2197
2235
  const ready = session.state === 'ok' && loaded.state === 'ok';
2198
2236
  return (
@@ -2209,10 +2247,18 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
2209
2247
  <Panel position="top-left" className="float layers">
2210
2248
  <div className="float-head">
2211
2249
  <button type="button" className="back" onClick={back} aria-label={`Back to ${name}`}>←</button>
2212
- <span className="float-title"><strong>{name.split('/')[1]}</strong><span className="mode">{name.split('/')[0]}{snapshot ? ' · snapshot' : ''}</span></span>
2250
+ <span className="float-title"><strong>{(viewing || name).split('/')[1]}</strong><span className="mode">{name.split('/')[0]}{viewing ? ' · branch' : snapshot ? ' · snapshot' : ''}</span></span>
2213
2251
  </div>
2252
+ <label className="compare-base board-viewing">Viewing
2253
+ <select value={viewing} disabled={pending.current > 0} onChange={(e) => view(e.target.value)}>
2254
+ <option value="">The World</option>
2255
+ {viewing && !choices.some((b) => b.name === viewing) ? <option value={viewing}>{viewing} · branch</option> : null}
2256
+ {choices.map((b) => <option key={b.name} value={b.name}>{b.name.split('/')[1]} · branch</option>)}
2257
+ </select>
2258
+ </label>
2259
+ {branches.state === 'error' && branches.status !== 404 ? <p className="mode" role="alert">Branches could not be read: {branches.error}</p> : null}
2214
2260
  <div className="board-tabs" role="tablist" aria-label="Boards">
2215
- <button type="button" role="tab" aria-selected={lens === 'changes'} className={lens === 'changes' ? 'on' : undefined} onClick={() => { setPicked(null); setActive(null); setBoardId('changes'); }} title="The pages whose records changed and are not yet in a changeset">Changes{anyChanged ? ` (${all.filter((f) => f.changes).length})` : ''}</button>
2261
+ <button type="button" role="tab" aria-selected={lens === 'changes'} className={lens === 'changes' ? 'on' : undefined} onClick={() => { setPicked(null); setActive(null); setBoardId('changes'); }} title={viewing ? "The pages whose records changed on this branch" : "The pages whose records changed and are not yet in a changeset"}>Changes{anyChanged ? ` (${all.filter((f) => f.changes).length})` : ''}</button>
2216
2262
  {boards.map((b) => (renaming === `board:${b.id}` ? <NameField key={b.id} name={b.name} label="Name this board" done={(next) => { setRenaming(null); if (next) change(b.id, (x) => ({ ...x, name: next })); }} /> : <button key={b.id} type="button" role="tab" aria-selected={lens !== 'changes' && b.id === board.id} className={lens !== 'changes' && 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>))}
2217
2263
  {editable ? <button type="button" className="ghost" onClick={addBoard} aria-label="A new board">+</button> : null}
2218
2264
  </div>
@@ -2243,7 +2289,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite }
2243
2289
  : <Panel position="top-center" className="float hint mode">Scroll to pan · pinch or ⌘ scroll to zoom · double-click a frame to use it</Panel>}
2244
2290
  {/* the board's own tools, along the bottom as a canvas app keeps them */}
2245
2291
  {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}
2246
- {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}
2292
+ {comparing && shown.some((f) => frameKey(f) === comparing) ? <Panel position="top-right" className="float compare-panel"><Compare key={comparing} viewing={viewing} name={name} token={token} frame={shown.find((f) => frameKey(f) === comparing)!} onClose={() => setComparing(null)} reads={reads} /></Panel> : null}
2247
2293
  <Controls showInteractive={false} position="bottom-left" />
2248
2294
  <MiniMap pannable zoomable nodeBorderRadius={8} position="bottom-right" />
2249
2295
  </ReactFlow>