@volter/world-console 3.0.25 → 3.0.27

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.
@@ -5,6 +5,7 @@
5
5
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { Background, BackgroundVariant, Controls, MiniMap, NodeResizer, NodeToolbar, Panel, Position, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, useStore, 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
+ import { boardFrameKey } from '../../../packages/world-core/src/board-keys.ts';
8
9
  import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
9
10
  import { ICONS } from './brand/icons.ts';
10
11
 
@@ -1691,17 +1692,31 @@ const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160; const SECTION_
1691
1692
  const FRAME_NAME_ROOM = 72;
1692
1693
  /** A change behind a frame's count: the writes one request made, as words read them, and when. */
1693
1694
  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' | 'branch-sites'; changes: number; /** when its first change not yet cut was made */ since?: string | null };
1695
+ type BoardFrame = { browser?: string; 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
1696
  /** A section of a board: its region and name, or a pack's group removed from the board (`gone`). */
1696
1697
  type SavedSection = { x: number; y: number; w: number; h: number; name?: string; gone?: true };
1697
1698
  type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }>; sections?: Record<string, SavedSection> };
1698
1699
  type BoardBranch = { name: string; origin?: string; sites?: string; at: { instant?: string; label?: string; live?: boolean } | null; expiresAt: string | null };
1699
1700
  const BRANCH_SITES_UNSEEN = "This World shows its branches' sites only on this machine.";
1700
1701
  type BoardDoor = { frames: BoardFrame[]; boards: SavedBoard[]; version: string };
1701
- const frameKey = (f: BoardFrame): string => `${f.vendor}:${f.id}`;
1702
- const sectionKey = (f: BoardFrame): string => `${f.vendor}:section:${f.section.id}`;
1703
- /** Every name a frame's section answers to on a board: its id, and those it was known by (an account a deploy adopted). */
1704
- const sectionKeys = (f: BoardFrame): string[] => [f.section.id, ...(f.section.aka ?? [])].map((id) => `${f.vendor}:section:${id}`);
1702
+ const frameKey = boardFrameKey;
1703
+ const sectionKey = (f: BoardFrame): string => frameKey({ ...f, id: `section:${f.section.id}` });
1704
+ type BoardAliases = { first?: string; frames: Set<string>; sections: Set<string> };
1705
+ const boardAliases = (all: BoardFrame[]): BoardAliases => ({
1706
+ first: all.flatMap(f => f.browser ? [f.browser] : []).sort()[0],
1707
+ frames: new Set(all.filter(f => !f.browser).map(frameKey)),
1708
+ sections: new Set(all.filter(f => !f.browser).flatMap(f => [f.section.id, ...(f.section.aka ?? [])].map(id => `${f.vendor}:section:${id}`))),
1709
+ });
1710
+ const frameKeys = (f: BoardFrame, aliases: BoardAliases): string[] => {
1711
+ const plain = `${f.vendor}:${f.id}`;
1712
+ return f.browser === aliases.first && !aliases.frames.has(plain) ? [frameKey(f), plain] : [frameKey(f)];
1713
+ };
1714
+ /** A section's current and adopted ids; saved plain keys apply only to the first browser copy. */
1715
+ const sectionKeys = (f: BoardFrame, aliases: BoardAliases): string[] => [...new Set([f.section.id, ...(f.section.aka ?? [])].flatMap(id => {
1716
+ const key = frameKey({ ...f, id: `section:${id}` });
1717
+ const plain = `${f.vendor}:section:${id}`;
1718
+ return f.browser === aliases.first && !aliases.sections.has(plain) ? [key, plain] : [key];
1719
+ }))];
1705
1720
  /** Where a frame opens in this World: a site at its own origin, a vendor page at the twin's place, or nowhere yet. */
1706
1721
  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
1722
  /** A vendor page on a branch always opens through its parent's read-pass door. */
@@ -1709,44 +1724,15 @@ const branchFrameUrl = (name: string, branch: string, f: BoardFrame): string | n
1709
1724
  ? `${WORLDS}/-/${name}/branches/${branch}/pages?${new URLSearchParams({ land: `/${branch}/${f.vendor}${f.at.screen}` }).toString()}`
1710
1725
  : frameUrl(branch, f);
1711
1726
  /** The frames a board shows: everything but what it hides, or what it includes (a frame, or a section's every frame). */
1712
- const shownOn = (b: SavedBoard, all: BoardFrame[]): BoardFrame[] => b.include
1713
- ? all.filter((f) => b.include!.includes(frameKey(f)) || sectionKeys(f).some((k) => b.include!.includes(k)))
1714
- : all.filter((f) => !(b.hidden ?? []).includes(frameKey(f)));
1715
- type ViewedPerson = { vendor: string; person: string | null; label: string };
1716
- type ViewAsMenuData = { world: string; token: string; vendor: string; label?: string; busy: boolean; choose: (person: string | null) => void };
1717
- type ViewAsNavigation = { frame: string; url: string; pass: string; done: (viewed?: ViewedPerson, error?: string) => void };
1718
- /** The choice lives in the vendor's cookie; a menu reads people, never password material. */
1719
- function ViewAsMenu({ data }: { data: ViewAsMenuData }): React.JSX.Element {
1720
- const [reads, setReads] = useState(0);
1721
- const people = useDoor<{ viewAs: boolean; people: Array<{ id: string; label: string }> }>(data.token, `/-/${data.world}/twins/${data.vendor}/people`, reads);
1722
- const offered = useRef(false);
1723
- if (people.state === 'ok') offered.current = people.data.viewAs;
1724
- if (!offered.current) return <></>;
1725
- return <select className="nodrag nopan frame-add" value="" aria-label={`View ${vendorName(data.vendor)} as`} disabled={data.busy}
1726
- onFocus={() => setReads((n) => n + 1)} onChange={(e) => { if (e.target.value) data.choose(e.target.value === 'signed-out' ? null : e.target.value); }}>
1727
- <option value="">{data.busy ? 'Signing in…' : data.label ? `View as: ${data.label}` : 'View as…'}</option>
1728
- <option value="signed-out">Signed out</option>
1729
- {people.state === 'ok' ? people.data.people.map((p) => <option key={p.id} value={p.id}>{p.label}</option>) : <option disabled>{people.state === 'error' ? people.error : 'Loading people…'}</option>}
1730
- </select>;
1731
- }
1732
- type Screen = { viewAs?: ViewAsMenuData; navigation?: ViewAsNavigation; revision?: number; 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 };
1727
+ const shownOn = (b: SavedBoard, all: BoardFrame[], aliases: BoardAliases): BoardFrame[] => b.include
1728
+ ? all.filter((f) => frameKeys(f, aliases).some(k => b.include!.includes(k)) || sectionKeys(f, aliases).some((k) => b.include!.includes(k)))
1729
+ : all.filter((f) => !frameKeys(f, aliases).some(k => (b.hidden ?? []).includes(k)));
1730
+ type Screen = { aliases: BoardAliases; 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 };
1733
1731
  function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1734
- const { frame, url, active, picked, boards, addTo, remove, compare, grab, branch } = data;
1732
+ const { aliases, frame, url, active, picked, boards, addTo, remove, compare, grab, branch } = data;
1735
1733
  const key = frameKey(frame);
1736
1734
  const iframe = useRef<HTMLIFrameElement>(null);
1737
- const navigation = data.navigation?.frame === key ? data.navigation : undefined;
1738
- useEffect(() => {
1739
- if (!navigation) return;
1740
- const origin = new URL(navigation.url).origin;
1741
- const on = (event: MessageEvent): void => {
1742
- if (event.origin !== origin || event.source !== iframe.current?.contentWindow || event.data?.type !== 'volter-view-as' || event.data.pass !== navigation.pass) return;
1743
- if (event.data.viewed?.vendor !== frame.vendor && !event.data.error) return;
1744
- navigation.done(event.data.viewed, event.data.error);
1745
- };
1746
- window.addEventListener('message', on);
1747
- return () => window.removeEventListener('message', on);
1748
- }, [navigation, frame.vendor]);
1749
- const others = boards.filter((b) => b.include && !b.include.includes(key) && !sectionKeys(frame).some((k) => b.include!.includes(k)));
1735
+ const others = boards.filter((b) => b.include && !frameKeys(frame, aliases).some(k => b.include!.includes(k)) && !sectionKeys(frame, aliases).some((k) => b.include!.includes(k)));
1750
1736
  // its name is drawn the same size at every zoom while the head above it shrinks: zoomed out past the point the name fits
1751
1737
  // there, it is left out, as a canvas app leaves out a frame name that does not fit; selected or in use, it is always shown
1752
1738
  const roomy = useStore((st) => st.transform[2] * (SECTION_PAD + SECTION_HEAD) >= FRAME_NAME_ROOM);
@@ -1757,19 +1743,18 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
1757
1743
  <span className="frame-name" onPointerDown={grab}><Mark vendor={frame.vendor} /><span>{frame.title}</span></span>
1758
1744
  {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>
1759
1745
  : 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}
1760
- {picked && data.viewAs ? <ViewAsMenu data={data.viewAs} /> : null}
1761
1746
  {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}
1762
1747
  {picked && remove ? <button type="button" className="ghost nodrag" onClick={() => remove.act(key)} title={remove.label} aria-label={remove.label}>✕</button> : null}
1763
1748
  </NodeToolbar>
1764
1749
  <div className={active ? 'frame on' : picked ? 'frame picked' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
1765
1750
  <div className="frame-bar">
1766
- <Mark vendor={frame.vendor} /><strong>{vendorName(frame.vendor)} · {frame.kind}</strong>
1751
+ <Mark vendor={frame.vendor} /><strong>{frame.browser ? `${frame.browser} · ` : ''}{vendorName(frame.vendor)} · {frame.kind}</strong>
1767
1752
  <span className="frame-url">{frame.url.replace(/^https?:\/\//, '')}</span>
1768
1753
  {url ? <a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${frame.title} in a new tab`}>↗</a> : null}
1769
1754
  </div>
1770
1755
  <div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
1771
1756
  {url
1772
- ? <iframe ref={iframe} key={data.revision ?? 0} title={frame.title} src={navigation?.url ?? url} loading={navigation ? "eager" : "lazy"} style={{ pointerEvents: active ? 'auto' : 'none' }} />
1757
+ ? <iframe ref={iframe} title={frame.title} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
1773
1758
  : <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>}
1774
1759
  </div>
1775
1760
  </div>
@@ -1786,7 +1771,7 @@ function NameField({ name, label, done }: { name: string; label: string; done: (
1786
1771
  }
1787
1772
  /** 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
1788
1773
  * same size at every zoom; selected, it can be resized from its edges, renamed (double-click the name) or removed. */
1789
- type SectionData = { viewAs?: ViewAsMenuData; key: string; name: string; vendor: string | null; /** renamed by someone, so its name is kept */ named: boolean; picked: boolean; editable: boolean; renaming: boolean; pick: (key: string) => void; rename: (key: string) => void; renamed: (key: string, next: string | null) => void; remove: (key: string) => void; resized: () => void; /** its name moves it, with what it holds */ grab: (e: React.PointerEvent) => void };
1774
+ type SectionData = { key: string; name: string; vendor: string | null; /** renamed by someone, so its name is kept */ named: boolean; picked: boolean; editable: boolean; renaming: boolean; pick: (key: string) => void; rename: (key: string) => void; renamed: (key: string, next: string | null) => void; remove: (key: string) => void; resized: () => void; /** its name moves it, with what it holds */ grab: (e: React.PointerEvent) => void };
1790
1775
  function SectionBox({ data }: NodeProps<Node<SectionData>>): React.JSX.Element {
1791
1776
  return (
1792
1777
  <>
@@ -1797,7 +1782,6 @@ function SectionBox({ data }: NodeProps<Node<SectionData>>): React.JSX.Element {
1797
1782
  : <button type="button" className={data.picked ? 'nodrag on' : 'nodrag'} onPointerDown={data.grab} onClick={() => data.pick(data.key)} onDoubleClick={() => { if (data.editable) data.rename(data.key); }} title={data.editable ? 'Double-click to rename' : undefined}>
1798
1783
  {data.vendor ? <Mark vendor={data.vendor} /> : null}<span>{data.name}</span>
1799
1784
  </button>}
1800
- {data.viewAs ? <ViewAsMenu data={data.viewAs} /> : null}
1801
1785
  {data.picked && data.editable && !data.renaming ? <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}
1802
1786
  </NodeToolbar>
1803
1787
  <div className={data.picked ? 'section-box on' : 'section-box'} />
@@ -1995,7 +1979,8 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
1995
1979
  }, [token, name, viewing]);
1996
1980
  // read now and every ten seconds while the page is shown: another person's arrangement arrives as the World's changes do
1997
1981
  useEffect(() => { void readBoard(); const id = setInterval(() => { if (!document.hidden) void readBoard(); }, 10000); return () => clearInterval(id); }, [readBoard]);
1998
- 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) : [];
1982
+ const all = loaded.state === 'ok' ? loaded.data.frames.map((f): BoardFrame => viewing && (f.browser || (f.at && 'site' in f.at && !/^https?:\/\/\{host\}\./.test(branch?.sites ?? ''))) ? { ...f, at: null, unseen: 'branch-sites' } : f) : [];
1983
+ const aliases = boardAliases(all);
1999
1984
  const boards = held.current?.boards ?? [{ id: 'everything', name: 'Everything' }];
2000
1985
  // what is shown (architecture, "The board"): the pages whose records changed, everything, or a board people keep.
2001
1986
  // Changes is no board of its own: it is the first board's places and sections, holding only the pages that changed
@@ -2007,13 +1992,6 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2007
1992
  // the pages opened from the ones they sit under (Everything shows a vendor's top pages; a page's own open from it)
2008
1993
  const [opened, setOpened] = useState<Set<string>>(new Set());
2009
1994
  const editable = canWrite && !snapshot && !viewing;
2010
- const [viewedAs, setViewedAs] = useState<Record<string, { label: string; revision: number }>>({});
2011
- const [signingAs, setSigningAs] = useState<Record<string, ViewAsNavigation>>({});
2012
- const busyAs = useRef(new Set<string>());
2013
- const [askingAs, setAskingAs] = useState<string[]>([]);
2014
- const viewAsTimers = useRef(new Set<ReturnType<typeof setTimeout>>());
2015
- const viewAsMounted = useRef(true);
2016
- useEffect(() => { viewAsMounted.current = true; return () => { viewAsMounted.current = false; for (const timer of viewAsTimers.current) clearTimeout(timer); }; }, []);
2017
1995
  /** An edit of the boards: shown at once, saved after the edits before it, made from the boards as they then stand. */
2018
1996
  const save = useCallback((edit: (all: SavedBoard[]) => SavedBoard[]): Promise<void> => {
2019
1997
  if (!editable) return Promise.resolve();
@@ -2049,13 +2027,13 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2049
2027
  return run;
2050
2028
  }, [token, name, readBoard, editable]);
2051
2029
  const change = (id: string, edit: (b: SavedBoard) => SavedBoard): void => { void save((list) => list.map((b) => (b.id === id ? edit(b) : b))); };
2052
- const parentKey = (f: BoardFrame): string | null => (f.parent ? `${f.vendor}:${f.parent}` : null);
2030
+ const parentKey = (f: BoardFrame): string | null => f.parent ? frameKey({ ...f, id: f.parent }) : null;
2053
2031
  const keys = new Set(all.map(frameKey));
2054
2032
  // a page under one that is not on the list stands at the top
2055
2033
  const top = (f: BoardFrame): boolean => { const p = parentKey(f); return !p || !keys.has(p); };
2056
2034
  const shown = lens === 'changes' ? all.filter((f) => f.changes)
2057
- : board.include ? shownOn(board, all)
2058
- : shownOn(board, all).filter((f) => top(f) || opened.has(parentKey(f)!));
2035
+ : board.include ? shownOn(board, all, aliases)
2036
+ : shownOn(board, all, aliases).filter((f) => top(f) || opened.has(parentKey(f)!));
2059
2037
  const elsewhere = all.filter((f) => f.changes && !shown.includes(f)).reduce((n, f) => n + f.changes, 0);
2060
2038
  // 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
2061
2039
  const [active, setActive] = useState<string | null>(null);
@@ -2065,7 +2043,15 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2065
2043
  const flow = useReactFlow();
2066
2044
  const others = boards.filter((b) => b.include);
2067
2045
  const addTo = (id: string, key: string): void => change(id, (b) => ({ ...b, include: [...(b.include ?? []), key] }));
2068
- 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] })); } };
2046
+ const savedFrameKey = (key: string, held: string[]): string => {
2047
+ const f = all.find(frame => frameKey(frame) === key);
2048
+ return f ? frameKeys(f, aliases).find(candidate => held.includes(candidate)) ?? key : key;
2049
+ };
2050
+ const removeFrame = { label: board.include ? 'Take it off this board' : 'Hide it', act: (key: string): void => {
2051
+ setPicked(null);
2052
+ change(board.id, b => b.include ? { ...b, include: b.include.filter(k => k !== savedFrameKey(key, b.include!)) }
2053
+ : { ...b, hidden: [...(b.hidden ?? []), key] });
2054
+ } };
2069
2055
  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 });
2070
2056
  /** The section a frame sits in, as on a canvas: the smallest whose region holds the frame's middle, the newest of equals
2071
2057
  * (a section drawn around a frame takes it). */
@@ -2079,7 +2065,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2079
2065
  * sections people drew where they drew them. */
2080
2066
  const layout = useCallback((): Node[] => {
2081
2067
  const at = new Map<string, { x: number; y: number }>();
2082
- for (const f of shown) { const p = board.placed?.[frameKey(f)]; if (p) at.set(frameKey(f), p); }
2068
+ for (const f of shown) { const p = frameKeys(f, aliases).map(k => board.placed?.[k]).find(Boolean); if (p) at.set(frameKey(f), p); }
2083
2069
  // a new group goes under the frames placed so far; a section someone draws never moves what is already laid out
2084
2070
  let bottom = Math.max(-FRAME_GAP, ...[...at.values()].map((p) => p.y + FRAME_H));
2085
2071
  const groups = [...new Set(shown.map(sectionKey))];
@@ -2097,7 +2083,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2097
2083
  for (const s of groups) {
2098
2084
  const f = shown.find((x) => sectionKey(x) === s)!;
2099
2085
  // a pack's group as people left it, under whichever id it was known by when they did (an account adopted since)
2100
- const keyed = sectionKeys(f).find((k) => kept[k]) ?? s;
2086
+ const keyed = sectionKeys(f, aliases).find((k) => kept[k]) ?? s;
2101
2087
  const own = kept[keyed];
2102
2088
  if (own?.gone) continue;
2103
2089
  const ps = shown.filter((x) => sectionKey(x) === s).map((x) => at.get(frameKey(x))!);
@@ -2105,16 +2091,16 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2105
2091
  const around = { x: Math.min(...ps.map((p) => p.x)) - SECTION_PAD, y: Math.min(...ps.map((p) => p.y)) - SECTION_PAD - SECTION_HEAD, w: 0, h: 0 };
2106
2092
  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;
2107
2093
  // a group people arranged still takes its new frames: its region reaches over the ones placed after it
2108
- const unplacedOf = shown.filter((x) => sectionKey(x) === s && !board.placed?.[frameKey(x)]).map((x) => at.get(frameKey(x))!);
2094
+ const unplacedOf = shown.filter((x) => sectionKey(x) === s && !frameKeys(x, aliases).some(key => board.placed?.[key])).map((x) => at.get(frameKey(x))!);
2109
2095
  const r = own ? { ...own } : around;
2110
2096
  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); }
2111
- sections.push(section(keyed, r, own?.name ?? `${vendorName(f.vendor)} · ${f.section.title}`, f.vendor, Boolean(own?.name)));
2097
+ sections.push(section(s, r, own?.name ?? `${f.browser ? `${f.browser} · ` : ''}${vendorName(f.vendor)} · ${f.section.title}`, f.vendor, Boolean(own?.name)));
2112
2098
  }
2113
2099
  for (const [k, r] of Object.entries(kept)) if (k.startsWith('user:') && !r.gone) sections.push(section(k, r, r.name ?? 'Section', null, true));
2114
2100
  // frames above the sections they sit in; a section's name tag above the board, where it can be clicked
2115
2101
  // a frame's size given, never left to be measured again: a board laid out anew keeps showing what did not change size.
2116
2102
  // A frame moves from anywhere on it, as a canvas app's does, until it is in use (its page then takes the pointer)
2117
- 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) } }));
2103
+ 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: { aliases, world: name, branch: Boolean(viewing), frame: f, url: viewing ? branchFrameUrl(name, viewing, f) : frameUrl(name, f) } }));
2118
2104
  return [...sections, ...frames];
2119
2105
  // biome-ignore lint/correctness/useExhaustiveDependencies: the frames, the board and its places are what change it
2120
2106
  }, [JSON.stringify(shown), JSON.stringify(board), name, editable, viewing]);
@@ -2169,9 +2155,9 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2169
2155
  const frames = new Map(moved.filter((m) => !m.id.startsWith('sec:')).map((m) => [m.id, { x: m.x, y: m.y }]));
2170
2156
  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))]);
2171
2157
  // a group by every id its section was known by (its kept settings may be under an adopted account's earlier one)
2172
- const groups = new Set(ns.filter((n) => frames.has(n.id)).flatMap((n) => sectionKeys((n.data as Screen).frame)));
2158
+ const groups = new Set(ns.filter((n) => frames.has(n.id)).map((n) => sectionKey((n.data as Screen).frame)));
2173
2159
  for (const n of ns) {
2174
- 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 });
2160
+ if (n.type === 'screen' && sectionKeys((n.data as Screen).frame, aliases).some((k) => groups.has(k)) && !frames.has(n.id)) frames.set(n.id, { x: n.position.x, y: n.position.y });
2175
2161
  if (n.type === 'section' && groups.has((n.data as SectionData).key)) sections.add((n.data as SectionData).key);
2176
2162
  }
2177
2163
  keep(Object.fromEntries(frames), [...sections]);
@@ -2179,7 +2165,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2179
2165
  };
2180
2166
  /** A pack's group's frames where they now stand: kept with its section when the section is arranged, so they stay in it. */
2181
2167
  const groupFrames = (key: string): Record<string, { x: number; y: number }> =>
2182
- 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 }]));
2168
+ Object.fromEntries(flow.getNodes().filter((n) => n.type === 'screen' && sectionKeys((n.data as Screen).frame, aliases).includes(key)).map((n) => [n.id, { x: n.position.x, y: n.position.y }]));
2183
2169
  // the name being edited where it is shown: a section's (its key) or this board's (`board:<id>`)
2184
2170
  const [renaming, setRenaming] = useState<string | null>(null);
2185
2171
  const renameSection = (key: string): void => { setPicked(`sec:${key}`); setRenaming(key); };
@@ -2231,52 +2217,17 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2231
2217
  };
2232
2218
  el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);
2233
2219
  };
2234
- const choosePerson = async (vendor: string, person: string | null): Promise<void> => {
2235
- if (!editable || busyAs.current.has(vendor)) return;
2236
- const target = nodes.find((n) => n.type === 'screen' && (n.data as Screen).frame.vendor === vendor && (n.data as Screen).frame.at && 'screen' in (n.data as Screen).frame.at!);
2237
- if (!target) { toast('Open a page of this vendor on the board first', 'error'); return; }
2238
- const frame = (target.data as Screen).frame;
2239
- busyAs.current.add(vendor); setAskingAs([...busyAs.current]);
2240
- let timer: ReturnType<typeof setTimeout> | undefined;
2241
- let settled = false;
2242
- const done = (viewed?: ViewedPerson, error?: string): void => {
2243
- if (settled || !viewAsMounted.current) return;
2244
- settled = true;
2245
- if (timer) { clearTimeout(timer); viewAsTimers.current.delete(timer); }
2246
- busyAs.current.delete(vendor);
2247
- setAskingAs([...busyAs.current]);
2248
- setSigningAs((all) => { const next = { ...all }; delete next[vendor]; return next; });
2249
- if (error) { toast(error, 'error'); return; }
2250
- // Reload every frame, including one that had navigated to the sign-in page. No board arrangement is saved.
2251
- setViewedAs((all) => ({ ...all, [vendor]: { label: viewed?.label ?? all[vendor]?.label ?? '', revision: (all[vendor]?.revision ?? 0) + 1 } }));
2252
- };
2253
- try {
2254
- const land = `/${name}/${vendor}${(frame.at as { screen: string }).screen}`;
2255
- const answer = await door(SESSION, `/-/${name}/twins/${vendor}/session`, { method: 'POST', body: JSON.stringify({ person, land }) });
2256
- if (!answer.ok) { done(undefined, await failure(answer)); return; }
2257
- const opened = await answer.json() as { url: string };
2258
- if (!viewAsMounted.current) return;
2259
- const pass = new URLSearchParams(new URL(opened.url).hash.slice(1)).get('pass');
2260
- if (!pass) throw new Error('The World did not return a sign-in pass.');
2261
- setSigningAs((all) => ({ ...all, [vendor]: { frame: target.id, url: opened.url, pass, done } }));
2262
- // The pass lasts a minute; also recover if its frame was removed before it answered.
2263
- timer = setTimeout(() => done(undefined, 'The sign-in did not finish. Choose View as again.'), 60_000);
2264
- viewAsTimers.current.add(timer);
2265
- } catch (error) { done(undefined, error instanceof Error ? error.message : 'The World did not answer.'); }
2266
- };
2267
- const viewAsMenu = (vendor: string | null): ViewAsMenuData | undefined => editable && token === SESSION && vendor && nodes.some((n) => n.type === 'screen' && (n.data as Screen).frame.vendor === vendor && (n.data as Screen).frame.at && 'screen' in (n.data as Screen).frame.at!)
2268
- ? { world: name, token, vendor, label: viewedAs[vendor]?.label, busy: askingAs.includes(vendor), choose: (person) => { void choosePerson(vendor, person); } } : undefined;
2269
2220
  // what each node shows: in use, selected, and what can be done to it
2270
2221
  const shownNodes = nodes.map((n) => (n.type === 'section'
2271
- ? { ...n, data: { ...n.data, viewAs: viewAsMenu((n.data as SectionData).vendor), grab: grab(n.id), picked: picked === n.id, editable, renaming: editable && renaming === (n.data as { key: string }).key, pick: (k: string) => setPicked(`sec:${k}`), rename: renameSection, renamed: renamedSection, remove: removeSection, resized: () => requestAnimationFrame(() => { const k = (n.data as { key: string }).key; keep(groupFrames(k), [k]); }) } }
2272
- : { ...n, data: { ...n.data, viewAs: (n.data as Screen).frame.at && 'screen' in (n.data as Screen).frame.at! ? viewAsMenu((n.data as Screen).frame.vendor) : undefined, navigation: editable ? signingAs[(n.data as Screen).frame.vendor] : undefined, revision: viewedAs[(n.data as Screen).frame.vendor]?.revision, grab: grab(n.id), 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); } } }));
2222
+ ? { ...n, data: { ...n.data, grab: grab(n.id), picked: picked === n.id, editable, renaming: editable && renaming === (n.data as { key: string }).key, pick: (k: string) => setPicked(`sec:${k}`), rename: renameSection, renamed: renamedSection, remove: removeSection, resized: () => requestAnimationFrame(() => { const k = (n.data as { key: string }).key; keep(groupFrames(k), [k]); }) } }
2223
+ : { ...n, data: { ...n.data, grab: grab(n.id), active: active === n.id, picked: picked === n.id, boards: editable ? others : [], addTo, remove: !editable || (board.include && !frameKeys((n.data as Screen).frame, aliases).some(k => board.include!.includes(k))) ? null : removeFrame, compare: (k: string) => { setPicked(k); setComparing(k); } } }));
2273
2224
  // the keys a canvas app answers: Esc leaves a frame, then the selection; Delete removes what is selected; ⇧S draws a section
2274
2225
  useEffect(() => {
2275
2226
  const on = (e: KeyboardEvent): void => {
2276
2227
  if ((e.target as HTMLElement | null)?.closest('input, textarea, select, [contenteditable]')) return;
2277
2228
  if (e.key === 'Escape') { if (active) setActive(null); else setPicked(null); return; }
2278
2229
  if (!editable || active) return;
2279
- 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; }
2230
+ if ((e.key === 'Delete' || e.key === 'Backspace') && picked) { e.preventDefault(); if (picked.startsWith('sec:')) removeSection(picked.slice(4)); else if (!board.include || all.some(f => frameKey(f) === picked && frameKeys(f, aliases).some(k => board.include!.includes(k)))) 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; }
2280
2231
  if (e.key === 'S' && e.shiftKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); addSection(); }
2281
2232
  };
2282
2233
  document.addEventListener('keydown', on); return () => document.removeEventListener('keydown', on);
@@ -2296,8 +2247,8 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2296
2247
  const rename = (): void => setRenaming(`board:${board.id}`);
2297
2248
  const drop = (): void => { const gone = board.id; setBoardId(null); void save((list) => list.filter((b) => b.id !== gone)); };
2298
2249
  const groups = [...new Map(all.map((f) => [sectionKey(f), f])).values()];
2299
- 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 }))] : [];
2300
- const hidden = board.include ? [] : all.filter((f) => (board.hidden ?? []).includes(frameKey(f)));
2250
+ const missing = board.include ? [...groups.filter((f) => !sectionKeys(f, aliases).some((k) => board.include!.includes(k))).map((f) => ({ key: sectionKey(f), label: `${f.browser ? `${f.browser} · ` : ''}${vendorName(f.vendor)} · ${f.section.title} (all)` })), ...all.filter((f) => !shown.includes(f)).map((f) => ({ key: frameKey(f), label: `${f.browser ? `${f.browser} · ` : ''}${f.title}` }))] : [];
2251
+ const hidden = board.include ? [] : all.filter((f) => frameKeys(f, aliases).some(k => (board.hidden ?? []).includes(k)));
2301
2252
  // the layers as the canvas has them: each section with the frames in it, then the frames in none
2302
2253
  const sectionNodes = nodes.filter((n) => n.type === 'section');
2303
2254
  const frameNodes = nodes.filter((n) => n.type === 'screen');
@@ -2351,7 +2302,7 @@ function CanvasSurface({ name, token, session, retry, snapshot, back, canWrite,
2351
2302
  </ul>
2352
2303
  {board.include && !shown.length ? <p className="mode pad-s">An empty board: add frames from Everything, or below.</p> : null}
2353
2304
  {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}
2354
- {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}
2305
+ {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 !== savedFrameKey(frameKey(f), b.hidden ?? [])) }))}><span>{f.browser ? `${f.browser} · ` : ''}{f.title}</span><span className="mode">Show</span></button> : <span className="pad-s">{f.browser ? `${f.browser} · ` : ''}{f.title}</span>}</li>)}</ul></details> : null}
2355
2306
  {elsewhere ? <button type="button" className="board-elsewhere" onClick={() => setBoardId('changes')}><Icon name="square-dot" size={14} />{counted(elsewhere, 'change')} not on this board</button> : null}
2356
2307
  <Pending load={loaded} what="the board" />
2357
2308
  {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}