visualproof 0.2.0 → 0.3.0

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.
Files changed (59) hide show
  1. package/README.md +40 -26
  2. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  3. package/dist/assets/webfonts/metrics.json +1 -0
  4. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  5. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  6. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  7. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  8. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  9. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  10. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  11. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  12. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  13. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  14. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  15. package/dist/server/agent.js +84 -86
  16. package/dist/server/agentdocs.js +1 -1
  17. package/dist/server/artcmd.js +335 -0
  18. package/dist/server/live.js +6 -0
  19. package/dist/server/main.js +136 -8
  20. package/dist/server/mcptools.js +4 -1
  21. package/dist/server/pipeline.js +15 -9
  22. package/dist/server/readonlygate.js +1 -1
  23. package/dist/server/review.js +4 -1
  24. package/dist/server/reviewapi.js +35 -1
  25. package/dist/server/snapshot.js +10 -1
  26. package/dist/server/view.js +0 -7
  27. package/dist/server/views.js +24 -0
  28. package/dist/shared/artfonts.js +81 -0
  29. package/dist/shared/artrecipe.js +316 -0
  30. package/dist/shared/collab.js +7 -2
  31. package/dist/shared/declfind.js +95 -0
  32. package/dist/shared/lostfound.js +1 -0
  33. package/dist/shared/pastgraph.js +6 -6
  34. package/dist/shared/review.js +62 -5
  35. package/dist/web/bundle/app.js +713 -164
  36. package/dist/web/bundle/edges.js +1 -1
  37. package/dist/web/src/agentmark.js +14 -0
  38. package/dist/web/src/artwork.js +1000 -0
  39. package/dist/web/src/bundles.js +151 -0
  40. package/dist/web/src/canvas.js +3694 -0
  41. package/dist/web/src/drag.js +61 -0
  42. package/dist/web/src/edgequeue.js +204 -0
  43. package/dist/web/src/gl/gl.js +123 -0
  44. package/dist/web/src/gl/renderer.js +451 -0
  45. package/dist/web/src/halo.js +95 -0
  46. package/dist/web/src/highlight.js +108 -0
  47. package/dist/web/src/overview.js +393 -0
  48. package/dist/web/src/reviewqueue.js +250 -0
  49. package/dist/web/src/selection.js +272 -0
  50. package/dist/web/src/status.js +80 -0
  51. package/dist/web/src/visitors.js +318 -0
  52. package/dist/web/src/visits.js +174 -0
  53. package/dist/web/style.css +7 -0
  54. package/docs/agent-api/README.md +1 -1
  55. package/docs/agent-api/export.md +41 -2
  56. package/docs/agent-api/history.md +2 -1
  57. package/docs/agent-api/mcp.md +1 -1
  58. package/docs/agent-api/reading.md +2 -1
  59. package/package.json +3 -1
@@ -71,8 +71,9 @@ function cleanFootSize(v2) {
71
71
  function artNameId(kind, s2) {
72
72
  return `${kind === "cluster" ? "c" : "d"}:${s2}`;
73
73
  }
74
- var ART_NAMES_BYTES = 4500;
74
+ var ART_NAMES_BYTES = 4e3;
75
75
  var ART_TARGETS_BYTES = 2500;
76
+ var ART_RULED_BYTES = 1e3;
76
77
  var jsonBytes = (x2) => new TextEncoder().encode(JSON.stringify(x2)).length + 1;
77
78
  function cleanNames(v2, budget, ok = () => true) {
78
79
  const out = [];
@@ -103,10 +104,11 @@ function cleanArtwork(v2) {
103
104
  if (typeof o2.notes !== "boolean" || typeof o2.selection !== "boolean") return null;
104
105
  const namesFrom = o2.namesFrom === "none" ? "none" : "board";
105
106
  const hiddenNames = cleanNames(o2.hiddenNames, ART_NAMES_BYTES, isArtNameId), shownNames = cleanNames(o2.shownNames, ART_NAMES_BYTES, isArtNameId);
107
+ const ruledNames = cleanNames(o2.ruledNames, ART_RULED_BYTES, isArtNameId);
106
108
  const shownTargets = cleanNames(o2.shownTargets, ART_TARGETS_BYTES);
107
109
  const keys = typeof o2.keys === "boolean" ? o2.keys : targets !== "plain";
108
110
  const style = one2(ART_STYLES, o2.style) ?? "board";
109
- return { w: w2, h: h2, scheme, theme, background, labels, namesFrom, hiddenNames, shownNames, edges, notes: o2.notes, targets, shownTargets, keys, selection: o2.selection, source: o2.source === true, style, title: cleanTitle(o2.title), subtitle: cleanTitle(o2.subtitle), titleSize: cleanTitleSize(o2.titleSize), footSize: cleanFootSize(o2.footSize), border: o2.border === true, region: [...r2] };
111
+ return { w: w2, h: h2, scheme, theme, background, labels, namesFrom, hiddenNames, shownNames, ruledNames, edges, notes: o2.notes, targets, shownTargets, keys, selection: o2.selection, source: o2.source === true, style, title: cleanTitle(o2.title), subtitle: cleanTitle(o2.subtitle), titleSize: cleanTitleSize(o2.titleSize), footSize: cleanFootSize(o2.footSize), border: o2.border === true, region: [...r2] };
110
112
  }
111
113
  var SCHEMES = ["kind", "status"];
112
114
  var ACTIVITY_KINDS = ["all", "review", "build", "change"];
@@ -1843,8 +1845,8 @@ function applyEvent(d2, e2) {
1843
1845
  case "name":
1844
1846
  return;
1845
1847
  case "opinion": {
1846
- const { op: _op, name, ...o2 } = e2;
1847
- (Object.hasOwn(d2.opinions, name) ? d2.opinions[name] : d2.opinions[name] = []).push(o2);
1848
+ const { op: _op, name, for: forName, forPerson, ...o2 } = e2;
1849
+ (Object.hasOwn(d2.opinions, name) ? d2.opinions[name] : d2.opinions[name] = []).push(forName === void 0 ? o2 : recordedFor(d2, o2, forName, forPerson));
1848
1850
  return;
1849
1851
  }
1850
1852
  case "target":
@@ -1878,12 +1880,23 @@ function named(d2, id, name) {
1878
1880
  const p2 = Object.hasOwn(d2.people, id) ? d2.people[id] : null;
1879
1881
  if (p2?.name === name) return;
1880
1882
  if (!p2) {
1881
- d2.people[id] = { name, was: [] };
1883
+ const was = /* @__PURE__ */ new Set();
1884
+ for (const ops of Object.values(d2.opinions))
1885
+ for (const o2 of ops)
1886
+ if (o2.person === id && o2.by !== name) {
1887
+ was.add(o2.by);
1888
+ o2.by = name;
1889
+ }
1890
+ d2.people[id] = { name, was: [...was] };
1882
1891
  return;
1883
1892
  }
1884
1893
  p2.was = [...p2.was.filter((n3) => n3 !== p2.name && n3 !== name), p2.name];
1885
1894
  p2.name = name;
1886
- for (const ops of Object.values(d2.opinions)) for (const o2 of ops) if (o2.person === id) o2.by = name;
1895
+ for (const ops of Object.values(d2.opinions))
1896
+ for (const o2 of ops) {
1897
+ if (o2.person === id) o2.by = name;
1898
+ if (o2.recordedBy?.person === id) o2.recordedBy.by = name;
1899
+ }
1887
1900
  for (const t2 of Object.values(d2.targets)) if (t2.person === id) t2.by = name;
1888
1901
  for (const marks of Object.values(d2.keys)) for (const m3 of Object.values(marks)) if (m3.person === id) m3.by = name;
1889
1902
  renamePerson(d2, id, name);
@@ -1918,6 +1931,14 @@ function readEvent(raw, agentLog, personLog = null) {
1918
1931
  e2[k2] = o2[k2];
1919
1932
  }
1920
1933
  if (e2.text !== void 0 && e2.statement !== void 0) return "a text and a statement";
1934
+ if (o2.for !== void 0 || o2.forPerson !== void 0) {
1935
+ if (agentLog) return "an agent's opinion recorded for someone else";
1936
+ if (typeof o2.for !== "string" || !validName(o2.for)) return "for";
1937
+ if (o2.forPerson !== void 0 && !validPersonId(o2.forPerson)) return "forPerson";
1938
+ if (o2.for === by || o2.forPerson !== void 0 && o2.forPerson === personLog) return "an opinion recorded for its own writer";
1939
+ e2.for = o2.for;
1940
+ if (o2.forPerson !== void 0) e2.forPerson = o2.forPerson;
1941
+ }
1921
1942
  if (agentLog) e2.agent = true;
1922
1943
  return e2;
1923
1944
  }
@@ -2001,6 +2022,11 @@ function problemsText(ps) {
2001
2022
  function author(o2) {
2002
2023
  return o2.person ? `link ${o2.person}` : (o2.agent ? "agent " : "person ") + o2.by;
2003
2024
  }
2025
+ function recordedFor(d2, o2, forName, forPerson) {
2026
+ const { by, person, ...rest } = o2;
2027
+ const name = forPerson && Object.hasOwn(d2.people, forPerson) ? d2.people[forPerson].name : forName;
2028
+ return { ...rest, by: name, ...forPerson ? { person: forPerson } : {}, recordedBy: { by, ...person ? { person } : {} } };
2029
+ }
2004
2030
  function latest(d2, name, agents) {
2005
2031
  const out = /* @__PURE__ */ new Map();
2006
2032
  for (const o2 of Object.hasOwn(d2.opinions, name) ? d2.opinions[name] : []) if (!!o2.agent === agents) out.set(author(o2), o2);
@@ -5580,17 +5606,50 @@ function rgba(css, alpha = 1) {
5580
5606
  const key = css;
5581
5607
  let c3 = colorCache.get(key);
5582
5608
  if (!c3) {
5583
- probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
5584
- probe.clearRect(0, 0, 1, 1);
5585
- probe.fillStyle = "#000";
5586
- probe.fillStyle = css || "transparent";
5587
- probe.fillRect(0, 0, 1, 1);
5588
- const d2 = probe.getImageData(0, 0, 1, 1).data;
5589
- c3 = [d2[0] / 255, d2[1] / 255, d2[2] / 255, d2[3] / 255];
5609
+ if (typeof document === "undefined") c3 = parseColour(css);
5610
+ else {
5611
+ probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
5612
+ probe.clearRect(0, 0, 1, 1);
5613
+ probe.fillStyle = "#000";
5614
+ probe.fillStyle = css || "transparent";
5615
+ probe.fillRect(0, 0, 1, 1);
5616
+ const d2 = probe.getImageData(0, 0, 1, 1).data;
5617
+ c3 = [d2[0] / 255, d2[1] / 255, d2[2] / 255, d2[3] / 255];
5618
+ }
5590
5619
  colorCache.set(key, c3);
5591
5620
  }
5592
5621
  return [c3[0], c3[1], c3[2], c3[3] * alpha];
5593
5622
  }
5623
+ var NAMED = { transparent: "#0000", black: "#000", white: "#fff", red: "#f00", green: "#008000", blue: "#00f", gray: "#808080", grey: "#808080", orange: "#ffa500", gold: "#ffd700" };
5624
+ function parseColour(css) {
5625
+ let s2 = (css || "").trim().toLowerCase();
5626
+ s2 = NAMED[s2] ?? s2;
5627
+ const byte = (v2) => Math.round(Math.min(255, Math.max(0, v2))) / 255;
5628
+ const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s2);
5629
+ if (hex) {
5630
+ let h3 = hex[1];
5631
+ if (h3.length <= 4) h3 = [...h3].map((x2) => x2 + x2).join("");
5632
+ const n3 = (i2) => parseInt(h3.slice(i2, i2 + 2), 16);
5633
+ return [n3(0) / 255, n3(2) / 255, n3(4) / 255, h3.length === 8 ? n3(6) / 255 : 1];
5634
+ }
5635
+ const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(s2);
5636
+ if (!fn) return [0, 0, 0, 0];
5637
+ const parts = fn[2].replace(/\//g, " / ").split(/[\s,]+/).filter((x2) => x2 && x2 !== "/");
5638
+ const num = (x2, scale) => x2 === void 0 ? NaN : x2.endsWith("%") ? parseFloat(x2) / 100 * scale : parseFloat(x2);
5639
+ const a3 = parts.length > 3 ? Math.min(1, Math.max(0, num(parts[3], 1))) : 1;
5640
+ if (fn[1].startsWith("rgb")) {
5641
+ const [r2, g2, b2] = [0, 1, 2].map((i2) => num(parts[i2], 255));
5642
+ return [r2, g2, b2].some(Number.isNaN) ? [0, 0, 0, 0] : [byte(r2), byte(g2), byte(b2), Number.isNaN(a3) ? 1 : Math.round(a3 * 255) / 255];
5643
+ }
5644
+ const h2 = (parseFloat(parts[0]) % 360 + 360) % 360 / 360, sat = num(parts[1], 1), l2 = num(parts[2], 1);
5645
+ if ([h2, sat, l2].some(Number.isNaN)) return [0, 0, 0, 0];
5646
+ const q2 = l2 < 0.5 ? l2 * (1 + sat) : l2 + sat - l2 * sat, p2 = 2 * l2 - q2;
5647
+ const t2 = (x2) => {
5648
+ x2 = (x2 + 1) % 1;
5649
+ return x2 < 1 / 6 ? p2 + (q2 - p2) * 6 * x2 : x2 < 1 / 2 ? q2 : x2 < 2 / 3 ? p2 + (q2 - p2) * (2 / 3 - x2) * 6 : p2;
5650
+ };
5651
+ return [byte(t2(h2 + 1 / 3) * 255), byte(t2(h2) * 255), byte(t2(h2 - 1 / 3) * 255), Number.isNaN(a3) ? 1 : Math.round(a3 * 255) / 255];
5652
+ }
5594
5653
  function program(gl, vs, fs) {
5595
5654
  const make = (type, src) => {
5596
5655
  const s2 = gl.createShader(type);
@@ -5618,7 +5677,7 @@ var Instances = class {
5618
5677
  this.gl = gl;
5619
5678
  this.stride = stride;
5620
5679
  this.data = new Float32Array(stride * capacity);
5621
- this.buffer = gl.createBuffer();
5680
+ this.buffer = gl ? gl.createBuffer() : null;
5622
5681
  }
5623
5682
  clear() {
5624
5683
  this.count = 0;
@@ -5640,6 +5699,7 @@ var Instances = class {
5640
5699
  upload() {
5641
5700
  if (this.uploaded === this.version) return;
5642
5701
  const gl = this.gl;
5702
+ if (!gl) return;
5643
5703
  gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
5644
5704
  gl.bufferData(gl.ARRAY_BUFFER, this.data.subarray(0, this.count * this.stride), gl.DYNAMIC_DRAW);
5645
5705
  this.uploaded = this.version;
@@ -5867,6 +5927,7 @@ function bundlePath(L2, paths, off, len, beta, widthPx, c0, c1) {
5867
5927
  d2[o2 + 18] = c1[2];
5868
5928
  d2[o2 + 19] = c1[3];
5869
5929
  }
5930
+ var NO_CARD_SIDE = 16384;
5870
5931
  var Renderer = class {
5871
5932
  gl;
5872
5933
  circleProg;
@@ -5881,6 +5942,10 @@ var Renderer = class {
5881
5942
  circleCorners;
5882
5943
  lineCorners;
5883
5944
  constructor(canvas) {
5945
+ if (!canvas) {
5946
+ this.gl = null;
5947
+ return;
5948
+ }
5884
5949
  const gl = canvas.getContext("webgl2", { antialias: false, premultipliedAlpha: true, alpha: true });
5885
5950
  if (!gl) throw new Error("WebGL2 is not available in this browser");
5886
5951
  this.gl = gl;
@@ -5903,6 +5968,11 @@ var Renderer = class {
5903
5968
  const gl = this.gl;
5904
5969
  const stride = kind === "lines" ? LINE_STRIDE : kind === "bundles" ? BUNDLE_STRIDE : CIRCLE_STRIDE;
5905
5970
  const inst = new Instances(gl, stride);
5971
+ if (!gl) {
5972
+ this.layers.set(name, { kind, inst, vao: null, visible: true });
5973
+ this.order.push(name);
5974
+ return inst;
5975
+ }
5906
5976
  const vao = gl.createVertexArray();
5907
5977
  gl.bindVertexArray(vao);
5908
5978
  if (kind === "circles" || kind === "lines") {
@@ -5925,6 +5995,7 @@ var Renderer = class {
5925
5995
  /** World positions the bundled edges read: (x, y) pairs, 1024 per texture row. */
5926
5996
  setPositions(xy) {
5927
5997
  const gl = this.gl;
5998
+ if (!gl) return;
5928
5999
  const count2 = xy.length / 2;
5929
6000
  const rows = Math.max(1, Math.ceil(count2 / 1024));
5930
6001
  gl.bindTexture(gl.TEXTURE_2D, this.posTex);
@@ -5963,6 +6034,7 @@ var Renderer = class {
5963
6034
  /** The most pixels a side of what this context draws at once can have. */
5964
6035
  maxSide() {
5965
6036
  const gl = this.gl;
6037
+ if (!gl) return NO_CARD_SIDE;
5966
6038
  const dims = gl.getParameter(gl.MAX_VIEWPORT_DIMS);
5967
6039
  return Math.min(dims[0], dims[1], gl.getParameter(gl.MAX_RENDERBUFFER_SIZE));
5968
6040
  }
@@ -5971,6 +6043,7 @@ var Renderer = class {
5971
6043
  * screen's (an artwork's PNG) keeps it as wide in pixels as the screen does. */
5972
6044
  render(cam, w2, h2, dpr, dotPx, aa = 0.35) {
5973
6045
  const gl = this.gl;
6046
+ if (!gl) return;
5974
6047
  gl.viewport(0, 0, Math.round(w2 * dpr), Math.round(h2 * dpr));
5975
6048
  gl.clearColor(0, 0, 0, 0);
5976
6049
  gl.clear(gl.COLOR_BUFFER_BIT);
@@ -6360,7 +6433,7 @@ var OverviewModel = class {
6360
6433
  return looks;
6361
6434
  }
6362
6435
  };
6363
- var DIM = 0.12;
6436
+ var DIM = 0.3;
6364
6437
  function blendPoints(sets, put) {
6365
6438
  const give = (L2, j2, beta, width, alpha) => {
6366
6439
  if (alpha > 2e-3) put(L2, j2, beta, width, alpha);
@@ -7857,9 +7930,9 @@ function statusCodes(d2, g2, files) {
7857
7930
  var PAST_GHOST = 1;
7858
7931
  var PAST_CHANGED = 4;
7859
7932
  var PAST_RENAMED = 8;
7860
- function gitThen(now, commit) {
7933
+ function gitThen(commit) {
7861
7934
  if (!commit || commit.guessed) return { git: void 0 };
7862
- return { git: { source: now?.source ?? null, commit: commit.hash, branch: commit.branch, ...commit.dirty ? { dirty: true } : {} } };
7935
+ return { git: { source: null, commit: commit.hash, branch: commit.branch, ...commit.dirty ? { dirty: true } : {} } };
7863
7936
  }
7864
7937
  function pastGraph(snap, edges, now, diff) {
7865
7938
  const P2 = snap.projectCount;
@@ -7871,8 +7944,8 @@ function pastGraph(snap, edges, now, diff) {
7871
7944
  const meta = {
7872
7945
  schema: 1,
7873
7946
  layoutVersion: now.meta.layoutVersion,
7874
- // the commit of that build, where it is known; the source is the project's now
7875
- project: { ...now.meta.project, extractedAt: snap.snapshot.at, ...gitThen(now.meta.project.git, snap.snapshot.commit) },
7947
+ // the commit of that build, where it is known
7948
+ project: { ...now.meta.project, extractedAt: snap.snapshot.at, ...gitThen(snap.snapshot.commit) },
7876
7949
  modules: snap.modules.map((m3) => ({ name: m3.name, file: null, imports: m3.imports })),
7877
7950
  nodes,
7878
7951
  projectCount: P2,
@@ -8314,7 +8387,7 @@ function openFor(plan, parent, chosen) {
8314
8387
  function placeVisits(S2, L2, plan, vs, ok = () => true) {
8315
8388
  const names2 = vs.map((v2) => v2.name);
8316
8389
  const pack2 = packPlan(plan, names2);
8317
- const keyOf2 = (c3) => c3 < 0 ? "" : L2.clusters[c3].key;
8390
+ const keyOf3 = (c3) => c3 < 0 ? "" : L2.clusters[c3].key;
8318
8391
  const items = [];
8319
8392
  const ofTemp = /* @__PURE__ */ new Map();
8320
8393
  plan.temps.forEach((t2, k2) => {
@@ -8337,7 +8410,7 @@ function placeVisits(S2, L2, plan, vs, ok = () => true) {
8337
8410
  const placed = /* @__PURE__ */ new Map();
8338
8411
  for (const j2 of order) {
8339
8412
  const it2 = items[j2];
8340
- const kept = members[j2].map((k2) => vs[k2].place).find((p2) => p2 && p2.in === keyOf2(it2.in));
8413
+ const kept = members[j2].map((k2) => vs[k2].place).find((p2) => p2 && p2.in === keyOf3(it2.in));
8341
8414
  if (kept) it2.at = [kept.at[0], kept.at[1]];
8342
8415
  else {
8343
8416
  const room2 = S2.roomOf(it2.in);
@@ -8362,7 +8435,7 @@ function placeVisits(S2, L2, plan, vs, ok = () => true) {
8362
8435
  }
8363
8436
  const held = /* @__PURE__ */ new Map();
8364
8437
  for (const [c3, list2] of placed) held.set(c3, list2.map((o2) => ({ x: o2.at[0], y: o2.at[1], r: o2.r })));
8365
- const places = item.map((j2) => ({ in: keyOf2(items[j2].in), at: [items[j2].at[0], items[j2].at[1]] }));
8438
+ const places = item.map((j2) => ({ in: keyOf3(items[j2].in), at: [items[j2].at[0], items[j2].at[1]] }));
8366
8439
  return { plan, pack: pack2, items, item, places, held };
8367
8440
  }
8368
8441
  function visitWorld(V2, S2) {
@@ -8704,7 +8777,9 @@ var DOUBLE_MS = 450;
8704
8777
  var HOLD_MS = 500;
8705
8778
  function readTheme() {
8706
8779
  const cs = getComputedStyle(document.body);
8707
- const v2 = (name) => cs.getPropertyValue(name).trim();
8780
+ return themeOf((name) => cs.getPropertyValue(name).trim());
8781
+ }
8782
+ function themeOf(v2) {
8708
8783
  const kind = {};
8709
8784
  for (const k2 of KINDS2) kind[k2] = rgba(v2(`--k-${k2}`));
8710
8785
  return {
@@ -8870,6 +8945,9 @@ var GraphCanvas = class {
8870
8945
  /** of its names shown by selection, the clusters' keys and the declarations' full names */
8871
8946
  artClusters = /* @__PURE__ */ new Set();
8872
8947
  artDecls = [];
8948
+ /** the names a recipe's rule chose (ExportSpec decision 20): clusters by key, declarations */
8949
+ artRuled = /* @__PURE__ */ new Set();
8950
+ artRuledDecls = [];
8873
8951
  /** what the names the last frame wrote name (artNameId), on an artwork: what its list of names
8874
8952
  * shows ticked */
8875
8953
  namesWritten = /* @__PURE__ */ new Set();
@@ -8925,11 +9003,17 @@ var GraphCanvas = class {
8925
9003
  * scene's and these */
8926
9004
  openAround = /* @__PURE__ */ new Set();
8927
9005
  tempOpen = /* @__PURE__ */ new Set();
8928
- constructor(canvas, labels, cardsEl, g2, ev) {
9006
+ /** drawn without a browser: no graphics card, no input, no frames but those asked for (`settle`) */
9007
+ headless;
9008
+ /** A board on the page's canvas; or, with `host` (and nulls for the elements), the same board drawn
9009
+ * without a browser — its renderer kept to its data, which an artwork's SVG reads (ExportSpec
9010
+ * decision 21). */
9011
+ constructor(canvas, labels, cardsEl, g2, ev, host) {
9012
+ this.headless = !!host;
8929
9013
  this.canvas = canvas;
8930
- this.gl = new Renderer(canvas);
9014
+ this.gl = new Renderer(host ? null : canvas);
8931
9015
  this.labels = labels;
8932
- this.ctx = labels.getContext("2d");
9016
+ this.ctx = host ? host.ctx : labels.getContext("2d");
8933
9017
  for (const [name, kind] of [
8934
9018
  ["discs", "circles"],
8935
9019
  ["figures", "lines"],
@@ -8948,15 +9032,20 @@ var GraphCanvas = class {
8948
9032
  this.g = g2;
8949
9033
  this.ev = ev;
8950
9034
  this.edgeQ = new EdgeQueue(
8951
- () => typeof Worker === "undefined" ? null : new Worker("/edges.js", { type: "module" }),
9035
+ () => host || typeof Worker === "undefined" ? null : new Worker("/edges.js", { type: "module" }),
8952
9036
  () => this.request()
8953
9037
  );
9038
+ if (host) {
9039
+ this.theme = host.theme;
9040
+ [this.w, this.h, this.dpr] = [host.w, host.h, 1];
9041
+ return;
9042
+ }
8954
9043
  this.theme = readTheme();
8955
9044
  new MutationObserver(() => {
8956
9045
  this.theme = readTheme();
8957
9046
  this.invalidate();
8958
9047
  }).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
8959
- new ResizeObserver(() => this.resize()).observe(canvas);
9048
+ new ResizeObserver(() => this.resize()).observe(this.canvas);
8960
9049
  this.resize();
8961
9050
  this.bindInput();
8962
9051
  let last2 = performance.now();
@@ -8982,6 +9071,21 @@ var GraphCanvas = class {
8982
9071
  request() {
8983
9072
  this.dirty = true;
8984
9073
  }
9074
+ /** Without a browser (ExportSpec decision 21): draw until nothing more comes — the scene at rest,
9075
+ * the overview's points chosen and blended, nothing asking for another frame — at most `most`
9076
+ * frames; whether it came to rest. */
9077
+ settle(most = 60) {
9078
+ for (let n3 = 0; n3 < most; n3++) {
9079
+ if (this.scene?.moving) {
9080
+ this.scene.step(64);
9081
+ this.afterMotion();
9082
+ }
9083
+ this.dirty = false;
9084
+ this.draw();
9085
+ if (!this.dirty && !this.scene?.moving) return true;
9086
+ }
9087
+ return false;
9088
+ }
8985
9089
  /** Called after every frame drawn: what lies over the canvas and follows it (the notes,
8986
9090
  * web/src/sticky.ts) is placed again. */
8987
9091
  onFrame = null;
@@ -9550,10 +9654,11 @@ var GraphCanvas = class {
9550
9654
  this.focus = focus;
9551
9655
  this.picked = clusters;
9552
9656
  this.active = closure;
9553
- requestAnimationFrame(() => {
9554
- this.theme = readTheme();
9555
- this.invalidate();
9556
- });
9657
+ if (!this.headless)
9658
+ requestAnimationFrame(() => {
9659
+ this.theme = readTheme();
9660
+ this.invalidate();
9661
+ });
9557
9662
  this.activeList = [];
9558
9663
  if (closure) {
9559
9664
  for (let i2 = 0; i2 < closure.length; i2++) if (closure[i2]) this.activeList.push(i2);
@@ -9691,7 +9796,7 @@ var GraphCanvas = class {
9691
9796
  for (let c3 = 0; c3 < S2.nc; c3++) {
9692
9797
  const spot = this.chosenSpot(c3);
9693
9798
  const cl = this.layout.clusters[c3];
9694
- if (!spot || !inside2(spot.x, spot.y) || !spot.board && !this.artClusters.has(cl.key)) continue;
9799
+ if (!spot || !inside2(spot.x, spot.y) || !spot.board && !this.artClusters.has(cl.key) && !this.artRuled.has(cl.key)) continue;
9695
9800
  out.push({ id: artNameId("cluster", cl.key), label: cl.label, full: cl.key.replace("\u27E8external\u27E9", "External"), cluster: true });
9696
9801
  }
9697
9802
  const mx = rect.x + rect.w / 2, my = rect.y + rect.h / 2;
@@ -9749,10 +9854,16 @@ var GraphCanvas = class {
9749
9854
  this.art = look;
9750
9855
  this.artClusters = /* @__PURE__ */ new Set();
9751
9856
  this.artDecls = [];
9857
+ this.artRuled = /* @__PURE__ */ new Set();
9858
+ this.artRuledDecls = [];
9752
9859
  for (const id of look?.shown ?? []) {
9753
9860
  if (id.startsWith("c:")) this.artClusters.add(id.slice(2));
9754
9861
  else if (id.startsWith("d:")) this.artDecls.push(id.slice(2));
9755
9862
  }
9863
+ for (const id of look?.ruled ?? []) {
9864
+ if (id.startsWith("c:")) this.artRuled.add(id.slice(2));
9865
+ else if (id.startsWith("d:")) this.artRuledDecls.push(id.slice(2));
9866
+ }
9756
9867
  this.overviewBase = "";
9757
9868
  this.setHover(null, 0, 0);
9758
9869
  this.invalidate();
@@ -10867,7 +10978,8 @@ var GraphCanvas = class {
10867
10978
  };
10868
10979
  const targets = art ? this.markedTargets() : [];
10869
10980
  const look = art ? artText(art.style, th) : null;
10870
- const artCluster = (c3, force) => {
10981
+ const artCluster = (c3, how) => {
10982
+ const force = how === true, anywhere = how !== false;
10871
10983
  const L2 = look;
10872
10984
  const cl = this.layout.clusters[c3];
10873
10985
  const id = artNameId("cluster", cl.key);
@@ -10876,12 +10988,12 @@ var GraphCanvas = class {
10876
10988
  ctx.textAlign = "center";
10877
10989
  if (S2.rep[c3] === c3) {
10878
10990
  const r2 = Math.max(6, S2.drawnR[c3] * k2);
10879
- if (!force && (r2 <= 10 || !onScreen(sx, sy, r2) || act && this.clusterHasActive(c3))) return false;
10991
+ if (!anywhere && (r2 <= 10 || !onScreen(sx, sy, r2) || act && this.clusterHasActive(c3))) return false;
10880
10992
  ctx.font = L2.sub(11);
10881
10993
  return place2(cl.label, sx, sy + r2 + 13, 11, id, force);
10882
10994
  }
10883
10995
  const sr = S2.drawnR[c3] * k2;
10884
- if (!force && (sr <= 38 || !onScreen(sx, sy, sr))) return false;
10996
+ if (!anywhere && (sr <= 38 || !onScreen(sx, sy, sr))) return false;
10885
10997
  const top = cl.depth === 0;
10886
10998
  const size = Math.max(10, Math.min(top ? 22 : 16, 9 + sr / 40));
10887
10999
  const text2 = top ? cl.label.toUpperCase() : cl.label;
@@ -10932,9 +11044,27 @@ var GraphCanvas = class {
10932
11044
  ctx.textAlign = "center";
10933
11045
  ctx.fillStyle = th.label;
10934
11046
  }
11047
+ if (chosen) {
11048
+ for (let c3 = 0; c3 < S2.nc; c3++) {
11049
+ const cl = this.layout.clusters[c3];
11050
+ if (!this.artRuled.has(cl.key) || this.artClusters.has(cl.key) || S2.rep[c3] >= 0 && S2.rep[c3] !== c3 || !this.clusterVisible(c3)) continue;
11051
+ const [sx, sy] = this.toScreen(S2.cwx[c3], S2.cwy[c3]);
11052
+ if (onScreen(sx, sy, 400)) artCluster(c3, "fit");
11053
+ }
11054
+ ctx.textAlign = "left";
11055
+ for (const name of this.artRuledDecls) {
11056
+ const i2 = this.g.byName.get(name);
11057
+ if (i2 === void 0 || !shown(i2)) continue;
11058
+ ctx.font = look.decl;
11059
+ ctx.fillStyle = look.declFill;
11060
+ nameAt(i2, this.g.short(i2), ctx.measureText(this.g.short(i2)).width);
11061
+ }
11062
+ ctx.textAlign = "center";
11063
+ ctx.fillStyle = th.label;
11064
+ }
10935
11065
  for (let c3 = 0; c3 < S2.nc; c3++) {
10936
11066
  if (art?.labels === "none" || chosen && !asShown) break;
10937
- if (chosen && this.artClusters.has(this.layout.clusters[c3].key)) continue;
11067
+ if (chosen && (this.artClusters.has(this.layout.clusters[c3].key) || this.artRuled.has(this.layout.clusters[c3].key))) continue;
10938
11068
  const rep = S2.rep[c3];
10939
11069
  if (!this.clusterVisible(c3)) continue;
10940
11070
  if (art) {
@@ -11574,6 +11704,7 @@ var GraphCanvas = class {
11574
11704
  }
11575
11705
  /** Place the cards for this frame; returns the nodes that got one. */
11576
11706
  drawCards(activeDots) {
11707
+ if (this.headless) return /* @__PURE__ */ new Set();
11577
11708
  const k2 = this.cam.k;
11578
11709
  const S2 = this.scene;
11579
11710
  const chosen = new Set(this.focus);
@@ -11765,7 +11896,8 @@ function menuItems(t2, ctx, title = "View") {
11765
11896
  const out = [];
11766
11897
  const note = ctx.arrange ? [{ act: "note", label: "Add a note here", sep: true }, { act: "note-view", label: "Add a note for this view" }] : [];
11767
11898
  if (t2.kind === "note") {
11768
- if (!ctx.arrange) return [];
11899
+ const toggle = { act: "note-toggle", label: t2.open ? "Collapse" : "Expand", key: "click" };
11900
+ if (!ctx.arrange) return t2.own ? [toggle] : [];
11769
11901
  const carries = t2.carries ?? [];
11770
11902
  const attach = t2.attach ? [{ act: "note-attach", label: "Attach\u2026", sep: true }] : [];
11771
11903
  if (carries.length) attach.push({ act: "note-detach", label: "Detach", ...attach.length ? {} : { sep: true }, sub: carries.map((label, k2) => ({ act: `detach:${k2}`, label })) });
@@ -11774,7 +11906,7 @@ function menuItems(t2, ctx, title = "View") {
11774
11906
  if (t2.items?.remove) items.push({ act: "item-remove", label: "Remove from this view", ...items.length ? {} : { sep: true } });
11775
11907
  return [
11776
11908
  { act: "note-edit", label: "Edit", key: "double-click" },
11777
- { act: "note-toggle", label: t2.open ? "Collapse" : "Expand", key: "click" },
11909
+ toggle,
11778
11910
  ...attach,
11779
11911
  // the other kind, keeping its text, its author, what it carries and where it is on this board
11780
11912
  { act: "note-kind", label: t2.view ? "In every view" : "Only in this view", sep: true },
@@ -12188,7 +12320,8 @@ var Panel = class {
12188
12320
  if (i2 === null) return this.hide();
12189
12321
  const same2 = i2 === this.current && this.el.classList.contains("open");
12190
12322
  const keepScroll = this.el.scrollTop;
12191
- const hadFocus = document.activeElement?.matches?.("#panel [data-rv-comment]") ?? false;
12323
+ const typing2 = document.activeElement?.matches?.("#panel [data-rv-comment], #panel [data-rv-for]") ? document.activeElement : null;
12324
+ const hadFocus = typing2 ? { sel: typing2.matches("[data-rv-for]") ? "[data-rv-for]" : "[data-rv-comment]", from: typing2.selectionStart, to: typing2.selectionEnd } : null;
12192
12325
  this.current = i2;
12193
12326
  const g2 = this.g;
12194
12327
  const v2 = g2.nodes[i2];
@@ -12277,7 +12410,11 @@ var Panel = class {
12277
12410
  this.el.classList.add("open");
12278
12411
  if (!same2) this.el.scrollTop = 0;
12279
12412
  else this.el.scrollTop = keepScroll;
12280
- if (same2 && hadFocus) this.el.querySelector("[data-rv-comment]")?.focus();
12413
+ if (same2 && hadFocus) {
12414
+ const box = this.el.querySelector(hadFocus.sel);
12415
+ box?.focus();
12416
+ if (hadFocus.from !== null && hadFocus.to !== null) box?.setSelectionRange(hadFocus.from, hadFocus.to);
12417
+ }
12281
12418
  this.box.reveal(this.el);
12282
12419
  }
12283
12420
  };
@@ -12954,6 +13091,7 @@ function snapshotViews(json) {
12954
13091
  }
12955
13092
  function snapshotSource(info2) {
12956
13093
  let sources = null;
13094
+ let git = null;
12957
13095
  const json = async (id) => JSON.parse(new TextDecoder().decode(await inflate(id)));
12958
13096
  return {
12959
13097
  views: snapshotViews(json),
@@ -12963,6 +13101,8 @@ function snapshotSource(info2) {
12963
13101
  forget: () => {
12964
13102
  },
12965
13103
  meta: () => json("vp-meta"),
13104
+ // the commit as the snapshot was taken, in its graph: read once
13105
+ git: () => git ??= json("vp-meta").then((m3) => m3.project.git ?? null),
12966
13106
  edges: () => inflate("vp-edges"),
12967
13107
  // a snapshot shows the review as it was when taken
12968
13108
  review: () => json("vp-review").then((data) => ({ epoch: "snapshot", rev: 0, data: reviewFromJson(data), problems: [], error: null })),
@@ -13034,6 +13174,7 @@ function serverSource() {
13034
13174
  status: () => fetch("/api/status").then((r2) => r2.json()),
13035
13175
  forget: () => cache.clear(),
13036
13176
  meta: (v2) => fetch(`/api/meta${v2 ? `?v=${v2}` : ""}`).then((r2) => r2.ok ? r2.json() : Promise.reject(new Error(`meta ${r2.status}`))),
13177
+ git: () => fetch("/api/git").then((r2) => r2.ok ? r2.json() : null).then((b2) => b2?.git ?? null).catch(() => null),
13037
13178
  // packed for the way (shared/edgecodec.ts): a quarter of the bytes
13038
13179
  edges: (v2) => fetch(`/api/edges?pack=2${v2 ? `&v=${v2}` : ""}`).then((r2) => r2.ok ? r2.arrayBuffer() : Promise.reject(new Error(`edges ${r2.status}`))).then((b2) => isPackedEdges(new Uint8Array(b2)) ? unpackEdges(new Uint8Array(b2)).buffer : b2),
13039
13180
  review: () => fetch("/api/review").then((r2) => r2.ok ? r2.json() : Promise.reject(new Error(`review ${r2.status}`))).then(reviewState),
@@ -13986,6 +14127,9 @@ function setPeople(list2) {
13986
14127
  known.clear();
13987
14128
  for (const p2 of Array.isArray(list2) ? list2 : []) if (p2 && typeof p2.id === "string" && typeof p2.name === "string") known.set(p2.id, p2);
13988
14129
  }
14130
+ function invitedNames() {
14131
+ return [...known.values()].filter((p2) => !p2.revoked).map((p2) => p2.name);
14132
+ }
13989
14133
  function nameNow(by, person) {
13990
14134
  return person && known.get(person)?.name || by;
13991
14135
  }
@@ -15440,12 +15584,22 @@ var ReviewBox = class {
15440
15584
  * review is drawn, since when it flashes (0: not yet), and the panel and windows that scrolled to
15441
15585
  * it already */
15442
15586
  flashing = null;
15587
+ /** whom the opinions given on this page are for, where not the reader (ReviewSpec decision 8): it
15588
+ * stays, on every review box, until changed back; kept for the tab */
15589
+ speakingFor = readFor();
15443
15590
  constructor(g2, hooks) {
15444
15591
  this.g = g2;
15445
15592
  this.hooks = hooks;
15446
15593
  }
15447
- /** Keep what is typed into a comment box: the other boxes of the declaration show it too. */
15594
+ /** Whom an opinion given now is for: another person's name, or null for the reader's own. */
15595
+ forName() {
15596
+ const f2 = this.speakingFor?.trim() || null;
15597
+ return f2 && f2 !== this.hooks.review().reviewer?.trim() ? f2 : null;
15598
+ }
15599
+ /** Keep what is typed into a comment box: the other boxes of the declaration show it too. Whom
15600
+ * one speaks for, typed into any box, is every box's. */
15448
15601
  input(t2, i2) {
15602
+ if (t2.matches("[data-rv-for]")) return this.speakFor(t2.value, t2);
15449
15603
  if (!t2.matches("[data-rv-comment]")) return;
15450
15604
  const name = this.g.nodes[i2].name;
15451
15605
  const value = t2.value;
@@ -15453,6 +15607,42 @@ var ReviewBox = class {
15453
15607
  this.mirror(name, value, t2);
15454
15608
  fitBox(t2);
15455
15609
  }
15610
+ /** The opinions given from now on are `name`'s (none: the reader's own): every review box on the
15611
+ * page says so at once, without being drawn again (ReviewSpec decision 8). */
15612
+ speakFor(name, except) {
15613
+ this.speakingFor = name.slice(0, MAX_NAME) || null;
15614
+ try {
15615
+ if (this.speakingFor) sessionStorage.setItem(FOR_KEY, this.speakingFor);
15616
+ else sessionStorage.removeItem(FOR_KEY);
15617
+ } catch {
15618
+ }
15619
+ if (typeof document === "undefined") return;
15620
+ const f2 = this.forName();
15621
+ for (const b2 of document.querySelectorAll("input[data-rv-for]")) if (b2 !== except && b2.value !== name) b2.value = name;
15622
+ for (const on of document.querySelectorAll("[data-rv-for-on]")) {
15623
+ on.hidden = !f2;
15624
+ on.innerHTML = f2 ? `Recording for <b>${escapeHtml(f2)}</b> \u2014 their opinion, recorded by you` : "";
15625
+ }
15626
+ for (const w2 of document.querySelectorAll("[data-rv-for-who]")) w2.textContent = f2 ? ` for ${f2}` : "";
15627
+ }
15628
+ /** The names the "Speaking for" box offers: the people invited by a personal link and those the
15629
+ * logs name, agents left out, the reader not; kept in the page's one list of them. */
15630
+ people(d2) {
15631
+ if (typeof document === "undefined") return;
15632
+ const me = this.hooks.review().reviewer?.trim();
15633
+ const names2 = new Set(invitedNames());
15634
+ for (const ops of Object.values(d2.opinions)) for (const o2 of ops) if (!o2.agent) names2.add(o2.by);
15635
+ for (const t2 of Object.values(d2.targets)) if (!t2.agent) names2.add(t2.by);
15636
+ if (me) names2.delete(me);
15637
+ let list2 = document.getElementById("rv-people");
15638
+ if (!list2) {
15639
+ list2 = document.createElement("datalist");
15640
+ list2.id = "rv-people";
15641
+ document.body.append(list2);
15642
+ }
15643
+ const html = [...names2].sort((a3, b2) => a3.localeCompare(b2)).map((n3) => `<option value="${escapeHtml(n3)}"></option>`).join("");
15644
+ if (list2.innerHTML !== html) list2.innerHTML = html;
15645
+ }
15456
15646
  /** Every comment box of declaration `name` on the page but `except` shows `value`: the panel and
15457
15647
  * a source window on the same declaration write one comment, so that what one of them holds is
15458
15648
  * never lost to what the other held. */
@@ -15605,7 +15795,8 @@ var ReviewBox = class {
15605
15795
  root.querySelector("[data-rv-comment]")?.focus();
15606
15796
  return;
15607
15797
  }
15608
- this.send(root, v2.name, async () => await this.hooks.edited(i2) ? EDITED : this.hooks.submit({ op: "opinion", name: v2.name, verdict, comment: comment || void 0 }));
15798
+ const f2 = this.forName();
15799
+ this.send(root, v2.name, async () => await this.hooks.edited(i2) ? EDITED : this.hooks.submit({ op: "opinion", name: v2.name, verdict, comment: comment || void 0, ...f2 ? { for: f2 } : {} }));
15609
15800
  }
15610
15801
  /** Approve or comment on an axiom by its name, from the panel of target `i` (the board has no
15611
15802
  * declaration for it): a comment is the one written there. */
@@ -15619,7 +15810,8 @@ var ReviewBox = class {
15619
15810
  root.querySelector("[data-rv-comment]")?.focus();
15620
15811
  return;
15621
15812
  }
15622
- this.send(root, target, () => this.hooks.submit({ op: "opinion", name, verdict, comment: comment || void 0 }));
15813
+ const f2 = this.forName();
15814
+ this.send(root, target, () => this.hooks.submit({ op: "opinion", name, verdict, comment: comment || void 0, ...f2 ? { for: f2 } : {} }));
15623
15815
  }
15624
15816
  /** Send an opinion with the comment written under `key` (the declaration's name; for an axiom,
15625
15817
  * its target's): the comment is spent as it goes, so that the boxes drawn again with the change
@@ -15786,8 +15978,12 @@ var ReviewBox = class {
15786
15978
  const quote = (o2) => o2.comment && writable ? ` <button type="button" class="rv-quote" data-rv-quote="${key(o2)}" title="Quote this comment in yours, linking back to it">Quote</button>` : "";
15787
15979
  const opinion = (o2, compare = false) => {
15788
15980
  const now2 = fresh(o2, v2.hash);
15789
- return `<div class="rv-op rv-${o2.verdict}${now2 ? "" : " old"}" data-rv-op="${key(o2)}"><span class="rv-dot"></span><b>${esc2(o2.by)}</b>${o2.person ? VERIFIED : ""} ${VERB[o2.verdict]} <span class="muted">\xB7 ${ago(o2.at)}${now2 ? "" : " \xB7 on an earlier version"}</span>${quote(o2)}` + (o2.comment ? `<div class="rv-c md">${this.comment(o2.comment, v2.name)}</div>` : "") + `</div>` + // beside the opinion, not in it: a stale opinion is faded, what changed must read clearly
15790
- (compare && !now2 ? this.changes(i2, o2) : "");
15981
+ return (
15982
+ // recorded for them by someone else: theirs, without the mark they did not give it under
15983
+ // (ReviewSpec decision 8)
15984
+ `<div class="rv-op rv-${o2.verdict}${now2 ? "" : " old"}" data-rv-op="${key(o2)}"><span class="rv-dot"></span><b>${esc2(o2.by)}</b>${o2.person && !o2.recordedBy ? VERIFIED : ""} ${VERB[o2.verdict]} <span class="muted">\xB7 ${ago(o2.at)}${o2.recordedBy ? ` \xB7 recorded by ${esc2(o2.recordedBy.by)}` : ""}${now2 ? "" : " \xB7 on an earlier version"}</span>${quote(o2)}` + (o2.comment ? `<div class="rv-c md">${this.comment(o2.comment, v2.name)}</div>` : "") + `</div>` + // beside the opinion, not in it: a stale opinion is faded, what changed must read clearly
15985
+ (compare && !now2 ? this.changes(i2, o2) : "")
15986
+ );
15791
15987
  };
15792
15988
  const now = current(d2, v2.name);
15793
15989
  const all = (Object.hasOwn(d2.opinions, v2.name) ? d2.opinions[v2.name] : []).filter((o2) => !o2.agent);
@@ -15803,8 +15999,11 @@ var ReviewBox = class {
15803
15999
  else if (R2.blocked) controls = `<div class="banner bad">${esc2(R2.blocked)}</div>`;
15804
16000
  else if (this.hooks.editedNow(i2) === true)
15805
16001
  controls = `<div class="banner warn">${EDITED}</div>`;
15806
- else
15807
- controls = `<textarea class="rv-text" data-rv-comment="${esc2(v2.name)}" rows="${rowsFor(this.drafts.get(v2.name) ?? "")}" placeholder="Comment (optional with an approval) \xB7 Markdown" title="Markdown: &gt; quote, \`code\`, \`\`\`lean blocks, **bold**, *italics*; a declaration's full name in backticks links to it. Quote an opinion with its Quote, lines of code by selecting them in the source window">${esc2(this.drafts.get(v2.name) ?? "")}</textarea><div class="rv-actions"><button class="rv-btn approve" data-rv="approve" title="Approve (a), with the comment if there is one">Approve</button><button class="rv-btn comment" data-rv="comment" title="Comment (m): send what you wrote in the box">Comment</button></div><div class="rv-err" hidden></div>`;
16002
+ else {
16003
+ this.people(d2);
16004
+ const f2 = this.forName();
16005
+ controls = `<div class="rv-for-on" data-rv-for-on${f2 ? "" : " hidden"}>${f2 ? `Recording for <b>${esc2(f2)}</b> \u2014 their opinion, recorded by you` : ""}</div><textarea class="rv-text" data-rv-comment="${esc2(v2.name)}" rows="${rowsFor(this.drafts.get(v2.name) ?? "")}" placeholder="Comment (optional with an approval) \xB7 Markdown" title="Markdown: &gt; quote, \`code\`, \`\`\`lean blocks, **bold**, *italics*; a declaration's full name in backticks links to it. Quote an opinion with its Quote, lines of code by selecting them in the source window">${esc2(this.drafts.get(v2.name) ?? "")}</textarea><div class="rv-actions"><button class="rv-btn approve" data-rv="approve" title="Approve (a), with the comment if there is one">Approve<span data-rv-for-who>${f2 ? ` for ${esc2(f2)}` : ""}</span></button><button class="rv-btn comment" data-rv="comment" title="Comment (m): send what you wrote in the box">Comment<span data-rv-for-who>${f2 ? ` for ${esc2(f2)}` : ""}</span></button><label class="rv-for" title="Record the opinion as another person's, at the same screen: it counts as theirs, recorded by you. Empty: your own">Speaking for <input class="rv-for-in" data-rv-for list="rv-people" value="${esc2(this.speakingFor ?? "")}" placeholder="${esc2(R2.reviewer ?? "yourself")}" maxlength="${MAX_NAME}" spellcheck="false" autocomplete="off"></label></div><div class="rv-err" hidden></div>`;
16006
+ }
15808
16007
  if (R2.error && !R2.readOnly && !R2.blocked) controls += `<div class="banner warn">${esc2(R2.error)}</div>`;
15809
16008
  const by = trustedBy(d2, v2.name, v2.module, ext);
15810
16009
  const why = STANDARD_AXIOMS.includes(v2.name) ? ` <span class="muted">\u2014 a standard axiom, trusted without review</span>` : nonStandardAxiom(g2, i2) ? ` <span class="muted">\u2014 a non-standard axiom: it needs review whatever is trusted</span>` : by.length ? ` <span class="muted rv-trusted-by">\u2014 trusted without review, under ${by.map((p2) => `<code>${esc2(p2)}</code>`).join(" and ")}</span>` : "";
@@ -15877,6 +16076,14 @@ function quotedCode(link) {
15877
16076
  const pre = link.matches(".md-cq-h") ? link.closest(".md-cq")?.querySelector("pre") : null;
15878
16077
  return pre?.textContent ? pre.textContent.split("\n") : null;
15879
16078
  }
16079
+ var FOR_KEY = "visualproof.speakingFor";
16080
+ function readFor() {
16081
+ try {
16082
+ return typeof sessionStorage === "undefined" ? null : sessionStorage.getItem(FOR_KEY);
16083
+ } catch {
16084
+ return null;
16085
+ }
16086
+ }
15880
16087
 
15881
16088
  // src/web/src/talk.ts
15882
16089
  var Talk = class {
@@ -17941,13 +18148,14 @@ var Activity = class {
17941
18148
  for (const [name, ops] of Object.entries(r2.opinions)) {
17942
18149
  for (const o2 of ops) {
17943
18150
  const verb = o2.verdict === "approve" ? o2.agent ? `suggests <span class="ok">approving</span>` : `<span class="ok">approved</span>` : `<span class="commented">${o2.agent ? "comments" : "commented"}</span> on`;
18151
+ const rec = o2.recordedBy;
17944
18152
  out.push({
17945
18153
  key: `o:${o2.agent ? "a" : "p"}:${name}:${o2.by}:${o2.at}`,
17946
18154
  at: o2.at,
17947
- by: now(o2.by, o2.person),
18155
+ by: rec ? now(rec.by, rec.person) : now(o2.by, o2.person),
17948
18156
  agent: !!o2.agent,
17949
18157
  cat: "review",
17950
- html: `${who(o2.by, o2.agent, o2.person)} ${verb} ${decl(name)}`,
18158
+ html: rec ? `${who(rec.by, false, rec.person)} recorded <b>${escapeHtml(o2.by)}</b>'s ${o2.verdict === "approve" ? `<span class="ok">approval</span> of` : `<span class="commented">comment</span> on`} ${decl(name)}` : `${who(o2.by, o2.agent, o2.person)} ${verb} ${decl(name)}`,
17951
18159
  ...o2.comment ? { comment: { text: o2.comment, on: name } } : {},
17952
18160
  names: [name],
17953
18161
  targets: [],
@@ -18444,7 +18652,9 @@ var LostAndFound = class {
18444
18652
  switch (e2.kind) {
18445
18653
  case "opinion": {
18446
18654
  const verb = e2.verdict === "approve" ? e2.agent ? `suggested <span class="ok">approving</span>` : `<span class="ok">approved</span>` : `<span class="commented">${e2.agent ? "commented (advice)" : "commented"}</span>`;
18447
- return `${by} ${verb} ${when}` + (e2.comment ? `<div class="act-quote md">${this.hooks.comment(e2.comment, e2.name)}</div>` : "");
18655
+ const rec = e2.recordedBy;
18656
+ const whose = rec ? `<b>${escapeHtml(e2.by ?? "")}</b>` : by;
18657
+ return `${whose} ${verb}${rec ? ` <span class="muted">\xB7 recorded by ${this.who(rec.by, void 0, rec.person)}</span>` : ""} ${when}` + (e2.comment ? `<div class="act-quote md">${this.hooks.comment(e2.comment, e2.name)}</div>` : "");
18448
18658
  }
18449
18659
  case "target":
18450
18660
  return `<span class="star">\u2605</span> a target, marked by ${by} ${when}`;
@@ -18784,6 +18994,7 @@ var Phone = class {
18784
18994
 
18785
18995
  // src/web/src/guide.ts
18786
18996
  var $2 = (sel) => document.querySelector(sel);
18997
+ var REPO = "https://github.com/LucianoXu/VisualProof";
18787
18998
  function around(...sels) {
18788
18999
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
18789
19000
  for (const s2 of sels) {
@@ -19119,7 +19330,7 @@ var Guide = class {
19119
19330
  ([group, rows]) => `<div class="help-group"><div class="help-h">${group}</div>${rows.map(
19120
19331
  (r2) => `<div class="help-row"><span class="help-k">${(r2.k ?? []).map((x2) => `<kbd>${escapeHtml(x2)}</kbd>`).join(" ")}${r2.k && r2.g ? " " : ""}${r2.g ? `<span class="help-g">${escapeHtml(r2.g)}</span>` : ""}</span><span>${escapeHtml(r2.what)}</span></div>`
19121
19332
  ).join("")}</div>`
19122
- ).join("")}</div>` + (phone ? "" : `<div class="help-editor"><div class="help-h">Open in editor</div><div class="help-note">A link from the panel and the source window to the declaration in your editor. <code>{path}</code> is the file on the server, <code>{rel}</code> the file in the project, <code>{line}</code> and <code>{col}</code> where it starts.</div><input class="help-input" data-help-editor value="${escapeHtml(mine ?? "")}" placeholder="${escapeHtml(server ?? "vscode://file/{path}:{line}:{col}")}" spellcheck="false" autocomplete="off" aria-label="Open in editor: URL template"><div class="modal-err" data-help-editor-err hidden></div><div class="help-note" data-help-editor-now>${this.editorNow()}</div></div>`) + `<div class="help-about"><div class="help-h">About ${escapeHtml(this.hooks.project())}</div><div class="help-note">${escapeHtml(this.hooks.about())}</div></div><div class="modal-actions"><button class="rv-btn" data-help-tour>Take the tour again</button><button class="rv-btn primary" data-help-close>Close</button></div></div>`;
19333
+ ).join("")}</div>` + (phone ? "" : `<div class="help-editor"><div class="help-h">Open in editor</div><div class="help-note">A link from the panel and the source window to the declaration in your editor. <code>{path}</code> is the file on the server, <code>{rel}</code> the file in the project, <code>{line}</code> and <code>{col}</code> where it starts.</div><input class="help-input" data-help-editor value="${escapeHtml(mine ?? "")}" placeholder="${escapeHtml(server ?? "vscode://file/{path}:{line}:{col}")}" spellcheck="false" autocomplete="off" aria-label="Open in editor: URL template"><div class="modal-err" data-help-editor-err hidden></div><div class="help-note" data-help-editor-now>${this.editorNow()}</div></div>`) + `<div class="help-about"><div class="help-h">About ${escapeHtml(this.hooks.project())}</div><div class="help-note">${escapeHtml(this.hooks.about())}</div></div><div class="help-about"><div class="help-h">About VisualProof</div><div class="help-note">Open source: its documentation, its code and its issues are at <a class="help-repo" href="${REPO}" target="_blank" rel="noopener noreferrer">${REPO.replace("https://", "")}</a>.</div></div><div class="modal-actions"><button class="rv-btn" data-help-tour>Take the tour again</button><button class="rv-btn primary" data-help-close>Close</button></div></div>`;
19123
19334
  this.help.hidden = false;
19124
19335
  this.help.querySelector(".help-box")?.focus();
19125
19336
  }
@@ -21166,7 +21377,7 @@ var SourceWindow = class {
21166
21377
  const scroll = this.el.querySelector(".sw-scroll");
21167
21378
  const keep = !fresh3 && scroll ? scroll.scrollTop : null;
21168
21379
  const typed = document.activeElement;
21169
- const hadFocus = typed instanceof HTMLTextAreaElement && this.el.contains(typed) && typed.matches("[data-rv-comment]") ? { from: typed.selectionStart, to: typed.selectionEnd } : null;
21380
+ const hadFocus = (typed instanceof HTMLTextAreaElement || typed instanceof HTMLInputElement) && this.el.contains(typed) && typed.matches("[data-rv-comment], [data-rv-for]") ? { sel: typed.matches("[data-rv-for]") ? "[data-rv-for]" : "[data-rv-comment]", from: typed.selectionStart, to: typed.selectionEnd } : null;
21170
21381
  const short6 = escapeHtml(v2.display ?? parts[parts.length - 1]);
21171
21382
  const pinned = this.pinned;
21172
21383
  const steps = () => this.hooks.steps();
@@ -21189,9 +21400,9 @@ var SourceWindow = class {
21189
21400
  const scrolled = this.el.querySelector(".sw-scroll");
21190
21401
  if (scrolled && keep !== null) scrolled.scrollTop = keep;
21191
21402
  if (hadFocus) {
21192
- const box = this.el.querySelector("[data-rv-comment]");
21403
+ const box = this.el.querySelector(hadFocus.sel);
21193
21404
  box?.focus();
21194
- box?.setSelectionRange(hadFocus.from, hadFocus.to);
21405
+ if (hadFocus.from !== null && hadFocus.to !== null) box?.setSelectionRange(hadFocus.from, hadFocus.to);
21195
21406
  }
21196
21407
  this.box.reveal(this.el);
21197
21408
  this.flashLines();
@@ -44403,6 +44614,231 @@ var Papers = class {
44403
44614
  }
44404
44615
  };
44405
44616
 
44617
+ // src/shared/artrecipe.ts
44618
+ var ART_PRESETS = [
44619
+ { key: "hd", name: "HD", w: 1920, h: 1080 },
44620
+ { key: "4k", name: "4K", w: 3840, h: 2160 },
44621
+ { key: "square", name: "Square", w: 4096, h: 4096 },
44622
+ { key: "a3", name: "A3, 300 dpi", w: 4961, h: 3508 },
44623
+ { key: "a2", name: "A2, 300 dpi", w: 7016, h: 4961 }
44624
+ ];
44625
+ var RECIPE_MODES = ["plain", "deps", "tcb", "keys", "refs", "apps"];
44626
+ var RECIPE_BYTES = 6e3;
44627
+ var FIELDS = ["size", "orientation", "style", "theme", "transparent", "colouring", "border", "title", "subtitle", "titleSize", "footSize", "source", "names", "targets", "keys", "notes", "edges", "selection", "lit", "view"];
44628
+ var NAME_FIELDS = ["from", "clusters", "minShare", "targets", "decls", "omit"];
44629
+ function readRecipe(v2) {
44630
+ const bad = (error, hint) => ({ error, hint });
44631
+ if (!v2 || typeof v2 !== "object" || Array.isArray(v2)) return bad("recipe is an object.", 'E.g. {"size": "a2", "orientation": "portrait", "names": {"clusters": 1}}.');
44632
+ if (new TextEncoder().encode(JSON.stringify(v2)).length > RECIPE_BYTES) return bad(`A recipe is at most ${RECIPE_BYTES} bytes.`, "Name clusters by a depth rather than one by one.");
44633
+ const o2 = v2;
44634
+ const unknown = Object.keys(o2).filter((k2) => !FIELDS.includes(k2));
44635
+ if (unknown.length) return bad(`recipe has no field ${unknown.join(", ")}.`, `Its fields: ${FIELDS.join(", ")}.`);
44636
+ const r2 = {};
44637
+ const bool = (k2) => {
44638
+ if (o2[k2] === void 0) return null;
44639
+ if (typeof o2[k2] !== "boolean") return `${k2} is true or false.`;
44640
+ r2[k2] = o2[k2];
44641
+ return null;
44642
+ };
44643
+ const oneOf = (k2, of) => {
44644
+ if (o2[k2] === void 0) return null;
44645
+ if (!of.includes(o2[k2])) return `${k2} is ${of.join(", ")}.`;
44646
+ r2[k2] = o2[k2];
44647
+ return null;
44648
+ };
44649
+ const num = (k2, least2, most) => {
44650
+ if (o2[k2] === void 0) return null;
44651
+ if (typeof o2[k2] !== "number" || !Number.isFinite(o2[k2]) || o2[k2] < least2 || o2[k2] > most) return `${k2} is a number from ${least2} to ${most} (hundredths of the picture's height).`;
44652
+ r2[k2] = o2[k2];
44653
+ return null;
44654
+ };
44655
+ const text2 = (k2) => {
44656
+ if (o2[k2] === void 0) return null;
44657
+ if (o2[k2] !== null && (typeof o2[k2] !== "string" || o2[k2].length > TITLE_CHARS)) return `${k2} is text of at most ${TITLE_CHARS} characters, "" for none, or null for the project's own.`;
44658
+ r2[k2] = o2[k2];
44659
+ return null;
44660
+ };
44661
+ const names2 = (x2) => Array.isArray(x2) && x2.length <= 2e3 && x2.every((n3) => typeof n3 === "string" && n3.length > 0 && n3.length <= 1e3);
44662
+ const fault = bool("transparent") ?? bool("border") ?? bool("source") ?? bool("keys") ?? bool("notes") ?? bool("lit") ?? oneOf("orientation", ["landscape", "portrait"]) ?? oneOf("style", ART_STYLES) ?? oneOf("theme", ["light", "dark"]) ?? oneOf("colouring", SCHEMES) ?? oneOf("edges", ["all", "selection", "none"]) ?? num("titleSize", TITLE_SIZE.least, TITLE_SIZE.most) ?? num("footSize", FOOT_SIZE.least, FOOT_SIZE.most) ?? text2("title") ?? text2("subtitle");
44663
+ if (fault) return bad(fault, 'See GET /api/agent/docs/export, "Artwork".');
44664
+ if (o2.size !== void 0) {
44665
+ const s2 = o2.size;
44666
+ if (typeof s2 === "string") {
44667
+ if (!ART_PRESETS.some((p2) => p2.key === s2)) return bad(`size ${s2} is no size.`, `A size by name (${ART_PRESETS.map((p2) => p2.key).join(", ")}) or {w, h} in pixels.`);
44668
+ r2.size = s2;
44669
+ } else {
44670
+ const sw = s2?.w, sh = s2?.h;
44671
+ const side2 = (x2) => Number.isInteger(x2) && x2 >= 16 && x2 <= ART_MAX_SIDE;
44672
+ if (!side2(sw) || !side2(sh)) return bad(`size is a name or {w, h}: whole pixels from 16 to ${ART_MAX_SIDE}.`, `Names: ${ART_PRESETS.map((p2) => p2.key).join(", ")}.`);
44673
+ r2.size = { w: sw, h: sh };
44674
+ }
44675
+ }
44676
+ if (o2.names !== void 0) {
44677
+ const n3 = o2.names;
44678
+ if (n3 === "board" || n3 === "clusters" || n3 === "none") r2.names = n3;
44679
+ else if (n3 && typeof n3 === "object" && !Array.isArray(n3)) {
44680
+ const m3 = n3;
44681
+ const extra = Object.keys(m3).filter((k2) => !NAME_FIELDS.includes(k2));
44682
+ if (extra.length) return bad(`names has no field ${extra.join(", ")}.`, `Its fields: ${NAME_FIELDS.join(", ")}.`);
44683
+ const rule = {};
44684
+ if (m3.from !== void 0) {
44685
+ if (m3.from !== "none" && m3.from !== "board") return bad("names.from is none or board.", "From none: only what the rule names; from board: what the board writes, the rule's besides.");
44686
+ rule.from = m3.from;
44687
+ }
44688
+ if (m3.clusters !== void 0) {
44689
+ if (Number.isInteger(m3.clusters) && m3.clusters >= 0 && m3.clusters <= 20) rule.clusters = m3.clusters;
44690
+ else if (names2(m3.clusters)) rule.clusters = m3.clusters;
44691
+ else return bad("names.clusters is a depth (0: the top level) or a list of clusters' keys.", "E.g. 1: the top level and the clusters right under it.");
44692
+ }
44693
+ if (m3.minShare !== void 0) {
44694
+ if (typeof m3.minShare !== "number" || !(m3.minShare >= 0 && m3.minShare <= 1)) return bad("names.minShare is a share from 0 to 1.", "0.01: a cluster below the top named where it holds at least 1% of the project's declarations.");
44695
+ rule.minShare = m3.minShare;
44696
+ }
44697
+ if (m3.targets !== void 0) {
44698
+ if (typeof m3.targets !== "boolean") return bad("names.targets is true or false.", "The shown targets' names.");
44699
+ rule.targets = m3.targets;
44700
+ }
44701
+ for (const k2 of ["decls", "omit"]) {
44702
+ if (m3[k2] === void 0) continue;
44703
+ if (!names2(m3[k2])) return bad(`names.${k2} is a list of names.`, k2 === "decls" ? "Declarations by full name." : "Clusters by key, declarations by full name.");
44704
+ rule[k2] = m3[k2];
44705
+ }
44706
+ r2.names = rule;
44707
+ } else return bad('names is "board", "clusters", "none" or a rule {from, clusters, minShare, targets, decls, omit}.', 'E.g. {"clusters": 1}: the top level and the clusters right under it, and the targets.');
44708
+ }
44709
+ if (o2.targets !== void 0) {
44710
+ const t2 = o2.targets;
44711
+ if (t2 === "all" || t2 === "none" || t2 === "faded") r2.targets = t2;
44712
+ else if (names2(t2)) r2.targets = t2;
44713
+ else return bad(`targets is "all", "none", "faded" or a list of targets' full names.`, "Those named are marked, the others drawn as any declaration.");
44714
+ }
44715
+ if (o2.selection !== void 0 && o2.selection !== null) {
44716
+ const s2 = o2.selection;
44717
+ if (!s2 || typeof s2 !== "object" || typeof s2.name !== "string" || !s2.name || s2.mode !== void 0 && !RECIPE_MODES.includes(s2.mode) || Object.keys(s2).some((k2) => k2 !== "name" && k2 !== "mode"))
44718
+ return bad("selection is {name, mode?}: a declaration's full name, and how it is selected.", `mode: ${RECIPE_MODES.join(", ")} (default tcb).`);
44719
+ r2.selection = { name: s2.name, ...s2.mode !== void 0 ? { mode: s2.mode } : {} };
44720
+ } else if (o2.selection === null) r2.selection = null;
44721
+ if (o2.view !== void 0 && o2.view !== null) {
44722
+ if (typeof o2.view !== "string" || !o2.view || o2.view.length > 200) return bad("view is a view's id.", "GET /api/agent/views lists them; left out, the Project.");
44723
+ r2.view = o2.view;
44724
+ }
44725
+ return { recipe: r2 };
44726
+ }
44727
+ var EXTERNAL = "\u27E8external\u27E9";
44728
+ var keyOf = (k2) => k2 === "External" ? EXTERNAL : k2.startsWith("External.") ? EXTERNAL + k2.slice(8) : k2;
44729
+ function resolveRecipe(r2, b2) {
44730
+ const problems = [];
44731
+ const preset = typeof r2.size === "string" ? ART_PRESETS.find((p2) => p2.key === r2.size) : ART_PRESETS[1];
44732
+ const given = typeof r2.size === "object" ? r2.size : { w: preset.w, h: preset.h };
44733
+ const portrait = r2.orientation ? r2.orientation === "portrait" : given.h > given.w;
44734
+ const [long, short6] = given.w >= given.h ? [given.w, given.h] : [given.h, given.w];
44735
+ const { w: w2, h: h2 } = portrait ? { w: short6, h: long } : { w: long, h: short6 };
44736
+ let left = 0;
44737
+ const targetSet = new Set(b2.targets);
44738
+ const held = (n4) => b2.clusterOf(n4) !== void 0;
44739
+ let targets = "marked";
44740
+ let shownTargets = [];
44741
+ let marked2 = b2.targets.filter(held);
44742
+ if (r2.targets === "none") [targets, marked2] = ["plain", []];
44743
+ else if (r2.targets === "faded") targets = "highlight";
44744
+ else if (Array.isArray(r2.targets)) {
44745
+ const unknown = r2.targets.filter((t2) => !targetSet.has(t2));
44746
+ if (unknown.length) problems.push(`not targets of the review: ${unknown.join(", ")}`);
44747
+ const off = r2.targets.filter((t2) => targetSet.has(t2) && !held(t2));
44748
+ if (off.length) problems.push(`targets not on the board: ${off.join(", ")}`);
44749
+ const on = r2.targets.filter((t2) => targetSet.has(t2) && held(t2));
44750
+ shownTargets = cleanNames(on, ART_TARGETS_BYTES);
44751
+ left += new Set(on).size - shownTargets.length;
44752
+ targets = "chosen";
44753
+ marked2 = shownTargets;
44754
+ }
44755
+ let labels = "board";
44756
+ let namesFrom = "board";
44757
+ let shown = [];
44758
+ let hidden = [];
44759
+ let ruledIds = [];
44760
+ const named2 = { clusters: [], ruled: [], decls: [] };
44761
+ const n3 = r2.names;
44762
+ if (n3 === "board" || n3 === "clusters" || n3 === "none") labels = n3;
44763
+ else if (n3) {
44764
+ labels = "chosen";
44765
+ namesFrom = n3.from ?? "none";
44766
+ const omit = new Set((n3.omit ?? []).map(keyOf));
44767
+ const byKey = new Map(b2.clusters.map((c3, i2) => [c3.key, i2]));
44768
+ if (typeof n3.clusters === "number") {
44769
+ const least2 = Math.ceil((n3.minShare ?? 0.01) * b2.projectCount);
44770
+ b2.clusters.forEach((c3) => {
44771
+ if ((c3.ext ? c3.depth === 0 : c3.depth <= n3.clusters && c3.count >= least2) && !omit.has(c3.key)) named2.ruled.push(c3.key);
44772
+ });
44773
+ } else if (Array.isArray(n3.clusters)) {
44774
+ for (const k2 of n3.clusters) {
44775
+ const key = keyOf(k2);
44776
+ if (!byKey.has(key)) problems.push(`no cluster ${k2} on the board`);
44777
+ else if (!omit.has(key) && !named2.clusters.includes(key)) named2.clusters.push(key);
44778
+ }
44779
+ }
44780
+ const decls = n3.targets === false ? [] : [...marked2];
44781
+ for (const d2 of n3.decls ?? []) {
44782
+ if (!held(d2)) problems.push(`no declaration ${d2} on the board`);
44783
+ else decls.push(d2);
44784
+ }
44785
+ for (const d2 of decls) if (!omit.has(d2) && !named2.decls.includes(d2)) named2.decls.push(d2);
44786
+ const ids = [...named2.clusters.map((k2) => artNameId("cluster", k2)), ...named2.decls.map((d2) => artNameId("decl", d2))];
44787
+ shown = cleanNames(ids, ART_NAMES_BYTES);
44788
+ const ruled = named2.ruled.map((k2) => artNameId("cluster", k2));
44789
+ ruledIds = cleanNames(ruled, ART_RULED_BYTES);
44790
+ left += ids.length + ruled.length - shown.length - ruledIds.length;
44791
+ const kept = /* @__PURE__ */ new Set([...shown, ...ruledIds]);
44792
+ named2.clusters = named2.clusters.filter((k2) => kept.has(artNameId("cluster", k2)));
44793
+ named2.ruled = named2.ruled.filter((k2) => kept.has(artNameId("cluster", k2)));
44794
+ named2.decls = named2.decls.filter((d2) => kept.has(artNameId("decl", d2)));
44795
+ if (namesFrom === "board") hidden = cleanNames([...omit].map((k2) => byKey.has(k2) ? artNameId("cluster", k2) : artNameId("decl", k2)), ART_NAMES_BYTES);
44796
+ }
44797
+ const look = {
44798
+ w: w2,
44799
+ h: h2,
44800
+ scheme: r2.colouring ?? "kind",
44801
+ theme: r2.theme ?? "light",
44802
+ background: r2.transparent ? "transparent" : "theme",
44803
+ labels,
44804
+ namesFrom,
44805
+ hiddenNames: hidden,
44806
+ shownNames: shown,
44807
+ ruledNames: ruledIds,
44808
+ edges: r2.edges ?? "all",
44809
+ notes: r2.notes ?? true,
44810
+ targets,
44811
+ shownTargets,
44812
+ keys: r2.keys ?? true,
44813
+ selection: r2.lit ?? true,
44814
+ source: r2.source ?? false,
44815
+ style: r2.style ?? "board",
44816
+ title: r2.title ?? null,
44817
+ subtitle: r2.subtitle ?? null,
44818
+ titleSize: r2.titleSize ?? TITLE_SIZE.first,
44819
+ footSize: r2.footSize ?? FOOT_SIZE.first,
44820
+ border: r2.border ?? false
44821
+ };
44822
+ return { look, named: named2, left, problems };
44823
+ }
44824
+ function decodeRecipe(text2) {
44825
+ try {
44826
+ const s2 = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
44827
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(s2, (c3) => c3.charCodeAt(0))));
44828
+ } catch {
44829
+ return null;
44830
+ }
44831
+ }
44832
+ var RECIPE_PARAM = "art";
44833
+ var SAVE_PARAM = "save";
44834
+ function recipeIn(hash) {
44835
+ const p2 = new URLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash);
44836
+ const text2 = p2.get(RECIPE_PARAM);
44837
+ if (!text2) return null;
44838
+ const save = (p2.get(SAVE_PARAM) ?? "").split(",").filter((k2) => k2 === "png" || k2 === "svg");
44839
+ return { text: text2, save: [...new Set(save)] };
44840
+ }
44841
+
44406
44842
  // src/web/src/tabs.ts
44407
44843
  function starred(targets, short6) {
44408
44844
  const s2 = targets.map((t2) => `\u2605${short6(t2)}`);
@@ -45444,7 +45880,7 @@ var EDGE_SPEED = 0.9;
45444
45880
  var ARCS_MOST = 6e3;
45445
45881
  var FOLD_MORE = 10;
45446
45882
  var START = "\0start";
45447
- var keyOf = (r2) => `${r2.kind}:${r2.name}`;
45883
+ var keyOf2 = (r2) => `${r2.kind}:${r2.name}`;
45448
45884
  var ease2 = (t2) => t2 < 0.5 ? 2 * t2 * t2 : 1 - (-2 * t2 + 2) ** 2 / 2;
45449
45885
  var lerp = (a3, b2, t2) => a3 + (b2 - a3) * t2;
45450
45886
  var LinearForm = class {
@@ -45805,8 +46241,8 @@ var LinearForm = class {
45805
46241
  const p2 = ease2(this.t);
45806
46242
  const sy = (y2) => v2.top + (y2 - this.cam.y) * k2;
45807
46243
  const onScreen = (y0, y1) => sy(y1) > -40 && sy(y0) < h2 + 40;
45808
- const oy = (r2) => this.off.get(keyOf(r2))?.dy ?? 0;
45809
- const ox = (r2) => this.off.get(keyOf(r2))?.dx ?? 0;
46244
+ const oy = (r2) => this.off.get(keyOf2(r2))?.dy ?? 0;
46245
+ const ox = (r2) => this.off.get(keyOf2(r2))?.dx ?? 0;
45810
46246
  const shown = (r2, y0, y1) => onScreen(y0 + oy(r2), y1 + oy(r2));
45811
46247
  const at = (r2, y2, rad) => {
45812
46248
  const f2 = r2.from;
@@ -45923,7 +46359,7 @@ var LinearForm = class {
45923
46359
  const still = held.size ? this.rows.filter((r2) => !held.has(r2)) : this.rows;
45924
46360
  circles(still);
45925
46361
  if (this.hooks.edges() && p2 > 0.6) this.arcs(c3, (r2) => [v2.x + ox(r2), sy(r2.cy + oy(r2))], (p2 - 0.6) / 0.4, css);
45926
- const on = this.hover && !this.drag ? this.rows.find((r2) => keyOf(r2) === this.hover) : void 0;
46362
+ const on = this.hover && !this.drag ? this.rows.find((r2) => keyOf2(r2) === this.hover) : void 0;
45927
46363
  if (on && p2 >= 1) {
45928
46364
  const top = sy(on.y0 + oy(on)), bottom = sy((on.kind === "section" ? on.y0 + ROW : on.y1) + oy(on));
45929
46365
  const x0 = v2.x - 12 + ox(on), x1 = this.nameEnd(on) + 8 + ox(on);
@@ -46046,7 +46482,7 @@ var LinearForm = class {
46046
46482
  }
46047
46483
  /** The row the pointer is on, lit; drawn again where it changed. */
46048
46484
  setHover(r2) {
46049
- const key = r2 ? keyOf(r2) : null;
46485
+ const key = r2 ? keyOf2(r2) : null;
46050
46486
  if (key === this.hover) return;
46051
46487
  this.hover = key;
46052
46488
  this.request();
@@ -46241,7 +46677,7 @@ var LinearForm = class {
46241
46677
  }
46242
46678
  }
46243
46679
  d2.at = at;
46244
- for (const r2 of d2.block) this.off.set(keyOf(r2), { dy, dx, ty: dy, tx: dx });
46680
+ for (const r2 of d2.block) this.off.set(keyOf2(r2), { dy, dx, ty: dy, tx: dx });
46245
46681
  d2.sibs.forEach((_2, i2) => {
46246
46682
  if (i2 === d2.from) return;
46247
46683
  const shift = d2.from < i2 && i2 < at ? -d2.h : at <= i2 && i2 < d2.from ? d2.h : 0;
@@ -46250,22 +46686,22 @@ var LinearForm = class {
46250
46686
  }
46251
46687
  /** Ease a row to `ty` off its place on the line. */
46252
46688
  slide(r2, ty) {
46253
- const o2 = this.off.get(keyOf(r2));
46689
+ const o2 = this.off.get(keyOf2(r2));
46254
46690
  if (o2) {
46255
46691
  o2.ty = ty;
46256
46692
  o2.tx = 0;
46257
- } else if (ty) this.off.set(keyOf(r2), { dy: 0, dx: 0, ty, tx: 0 });
46693
+ } else if (ty) this.off.set(keyOf2(r2), { dy: 0, dx: 0, ty, tx: 0 });
46258
46694
  }
46259
46695
  /** Where each row is drawn now, in the outline's units, and how far off the line. */
46260
46696
  drawnAt() {
46261
- return new Map(this.rows.map((r2) => [keyOf(r2), { y: r2.y0 + (this.off.get(keyOf(r2))?.dy ?? 0), dx: this.off.get(keyOf(r2))?.dx ?? 0 }]));
46697
+ return new Map(this.rows.map((r2) => [keyOf2(r2), { y: r2.y0 + (this.off.get(keyOf2(r2))?.dy ?? 0), dx: this.off.get(keyOf2(r2))?.dx ?? 0 }]));
46262
46698
  }
46263
46699
  /** The rows came in another order: each eases from where it was drawn to its new place. */
46264
46700
  settleFrom(before) {
46265
46701
  for (const r2 of this.rows) {
46266
- const b2 = before.get(keyOf(r2));
46267
- if (b2 && (Math.abs(b2.y - r2.y0) > 0.5 || b2.dx)) this.off.set(keyOf(r2), { dy: b2.y - r2.y0, dx: b2.dx, ty: 0, tx: 0 });
46268
- else this.off.delete(keyOf(r2));
46702
+ const b2 = before.get(keyOf2(r2));
46703
+ if (b2 && (Math.abs(b2.y - r2.y0) > 0.5 || b2.dx)) this.off.set(keyOf2(r2), { dy: b2.y - r2.y0, dx: b2.dx, ty: 0, tx: 0 });
46704
+ else this.off.delete(keyOf2(r2));
46269
46705
  }
46270
46706
  }
46271
46707
  /** Everything off its place on the line eases back to it. */
@@ -46421,13 +46857,7 @@ function triangle(c3, x2, y2, open) {
46421
46857
  var FONT2 = `-apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", sans-serif`;
46422
46858
 
46423
46859
  // src/web/src/artwork.ts
46424
- var PRESETS = [
46425
- { name: "HD", w: 1920, h: 1080 },
46426
- { name: "4K", w: 3840, h: 2160 },
46427
- { name: "Square", w: 4096, h: 4096 },
46428
- { name: "A3, 300 dpi", w: 4961, h: 3508 },
46429
- { name: "A2, 300 dpi", w: 7016, h: 4961 }
46430
- ];
46860
+ var PRESETS = ART_PRESETS;
46431
46861
  function presetOf(w2, h2) {
46432
46862
  return PRESETS.findIndex((p2) => p2.w === w2 && p2.h === h2 || p2.w === h2 && p2.h === w2);
46433
46863
  }
@@ -46446,6 +46876,7 @@ var DEFAULTS = {
46446
46876
  namesFrom: "board",
46447
46877
  hiddenNames: [],
46448
46878
  shownNames: [],
46879
+ ruledNames: [],
46449
46880
  edges: "all",
46450
46881
  notes: true,
46451
46882
  targets: "marked",
@@ -46475,6 +46906,7 @@ function cleanSettings(s2, now) {
46475
46906
  namesFrom: o2.namesFrom === "none" ? "none" : "board",
46476
46907
  hiddenNames: cleanNames(o2.hiddenNames, ART_NAMES_BYTES, isArtNameId),
46477
46908
  shownNames: cleanNames(o2.shownNames, ART_NAMES_BYTES, isArtNameId),
46909
+ ruledNames: cleanNames(o2.ruledNames, ART_RULED_BYTES, isArtNameId),
46478
46910
  edges: one(o2.edges, ["all", "selection", "none"], DEFAULTS.edges),
46479
46911
  notes: typeof o2.notes === "boolean" ? o2.notes : DEFAULTS.notes,
46480
46912
  targets,
@@ -46508,8 +46940,28 @@ function withChosen(list2, add, budget) {
46508
46940
  const had = new Set(list2), kept = new Set(out);
46509
46941
  return { list: out, left: new Set(add.filter((x2) => !had.has(x2) && !kept.has(x2))).size };
46510
46942
  }
46511
- function themeBackground() {
46512
- return getComputedStyle(document.body).getPropertyValue("--canvas").trim();
46943
+ function themeBackground(v2 = browserVar) {
46944
+ return v2("--canvas");
46945
+ }
46946
+ var browserVar = (name) => getComputedStyle(document.body).getPropertyValue(name).trim();
46947
+ var FRAME_ROOM = { panel: 300, margin: 32, gap: 12, head: 60, tail: 34, bar: 60 };
46948
+ function frameRect(screen, size) {
46949
+ const R2 = FRAME_ROOM;
46950
+ const roomW = Math.max(40, screen.w - R2.panel - 3 * R2.margin), roomH = Math.max(40, screen.h - R2.bar - R2.head - R2.tail - 2 * R2.gap);
46951
+ const scale = Math.min(roomW / size.w, roomH / size.h);
46952
+ const w2 = size.w * scale, h2 = size.h * scale;
46953
+ return { x: R2.margin + (roomW - w2) / 2, y: R2.bar + R2.gap + R2.head + (roomH - h2) / 2, w: w2, h: h2 };
46954
+ }
46955
+ function titleRoom(h2, s2, own2) {
46956
+ const titled = !!(s2.title ?? own2.title) || !!(s2.subtitle ?? own2.subtitle);
46957
+ return titled ? Math.min(h2 * 0.4, s2.titleSize * 1.7 / 100 * h2 + Math.max(14, h2 * 0.035)) : 0;
46958
+ }
46959
+ function writeOver(ctx, s2, rect, own2, source, v2 = browserVar) {
46960
+ const halo = s2.style === "cells" ? "#0f1520" : s2.style === "stars" ? "#090d1a" : v2("--canvas");
46961
+ if (s2.border) drawBorder(ctx, rect, s2.style === "cells" ? "rgba(200, 212, 236, 0.55)" : s2.style === "stars" ? "rgba(241, 230, 200, 0.55)" : v2("--text-2"));
46962
+ drawTitle(ctx, rect, s2.title ?? own2.title, s2.subtitle ?? own2.subtitle, s2.titleSize, s2.style, { ink: v2("--text"), quiet: v2("--text-2"), halo });
46963
+ const quiet = s2.style === "board" ? v2("--text-2") : "rgba(200, 208, 224, 0.7)";
46964
+ drawFoot(ctx, rect, s2.source ? source : null, { text: quiet, halo }, ART_SANS, s2.footSize);
46513
46965
  }
46514
46966
  function roundRect(ctx, x2, y2, w2, h2, r2) {
46515
46967
  r2 = Math.min(r2, w2 / 2, h2 / 2);
@@ -46813,6 +47265,7 @@ function svgColour(css, alpha = 1) {
46813
47265
  function glColour(c3) {
46814
47266
  return { rgb: `rgb(${Math.round(c3[0] * 255)},${Math.round(c3[1] * 255)},${Math.round(c3[2] * 255)})`, a: +c3[3].toFixed(3) };
46815
47267
  }
47268
+ var BUNDLE_PIECES = 12;
46816
47269
  var paint = (attr, c3) => ` ${attr}="${c3.rgb}"${c3.a < 1 ? ` ${attr}-opacity="${c3.a}"` : ""}`;
46817
47270
  var SvgGradient = class {
46818
47271
  stops = [];
@@ -47032,18 +47485,20 @@ var SvgContext = class {
47032
47485
  } else if (s2.kind === "line") {
47033
47486
  this.body.push(`<line x1="${n2(s2.x0)}" y1="${n2(s2.y0)}" x2="${n2(s2.x1)}" y2="${n2(s2.y1)}"${paint("stroke", glColour(s2.colour))} stroke-width="${n2(s2.width)}"/>`);
47034
47487
  } else {
47035
- const pts = bundlePoints(s2.ctrl, s2.beta, 48);
47036
- const d2 = `M${n2(pts[0])},${n2(pts[1])}${Array.from({ length: pts.length / 2 - 1 }, (_2, k2) => `L${n2(pts[2 * k2 + 2])},${n2(pts[2 * k2 + 3])}`).join("")}`;
47488
+ const pts = bundlePoints(s2.ctrl, s2.beta, BUNDLE_PIECES * 4);
47489
+ const n3 = pts.length / 2 - 1;
47490
+ const path = (i0, i1, c3) => `<path d="M${n2(pts[2 * i0])},${n2(pts[2 * i0 + 1])}${Array.from({ length: i1 - i0 }, (_2, k2) => `L${n2(pts[2 * (i0 + k2 + 1)])},${n2(pts[2 * (i0 + k2 + 1) + 1])}`).join("")}" fill="none"${paint("stroke", c3)} stroke-width="${n2(s2.width)}"/>`;
47037
47491
  const a3 = glColour(s2.c0), b2 = glColour(s2.c1);
47038
- let stroke;
47039
- if (a3.rgb === b2.rgb && a3.a === b2.a) stroke = paint("stroke", a3);
47492
+ if (a3.rgb === b2.rgb && a3.a === b2.a) this.body.push(path(0, n3, a3));
47040
47493
  else {
47041
- const id = `e${this.ids++}`;
47042
- const n3 = pts.length;
47043
- this.defs.push(`<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${n2(pts[0])}" y1="${n2(pts[1])}" x2="${n2(pts[n3 - 2])}" y2="${n2(pts[n3 - 1])}"><stop offset="0" stop-color="${a3.rgb}" stop-opacity="${a3.a}"/><stop offset="1" stop-color="${b2.rgb}" stop-opacity="${b2.a}"/></linearGradient>`);
47044
- stroke = ` stroke="url(#${id})"`;
47494
+ const apart = Math.max(...s2.c0.map((v2, k2) => Math.abs(v2 - s2.c1[k2])));
47495
+ const pieces = Math.min(BUNDLE_PIECES, Math.max(2, Math.ceil(apart * BUNDLE_PIECES)));
47496
+ for (let p2 = 0; p2 < pieces; p2++) {
47497
+ const i0 = Math.floor(p2 * n3 / pieces), i1 = Math.floor((p2 + 1) * n3 / pieces);
47498
+ const t2 = (i0 + i1) / (2 * n3);
47499
+ this.body.push(path(i0, i1, glColour(s2.c0.map((v2, k2) => v2 + (s2.c1[k2] - v2) * t2))));
47500
+ }
47045
47501
  }
47046
- this.body.push(`<path d="${d2}" fill="none"${stroke} stroke-width="${n2(s2.width)}"/>`);
47047
47502
  }
47048
47503
  }
47049
47504
  /** A circle of an artwork's style, as the renderer draws it: a glow fading from its centre; a
@@ -47200,13 +47655,37 @@ async function encodePng(w2, h2, bands) {
47200
47655
  const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
47201
47656
  return new Blob([sig, ...chunk("IHDR", ihdr), ...chunk("IDAT", idat), ...chunk("IEND", new Uint8Array(0))], { type: "image/png" });
47202
47657
  }
47658
+ function chooseIn(s2, written2, ids, on) {
47659
+ const hidden = new Set(s2.hiddenNames), shown = new Set(s2.shownNames), ruled = new Set(s2.ruledNames);
47660
+ const add = [], hide = [];
47661
+ for (const id of ids) {
47662
+ if (on) {
47663
+ hidden.delete(id);
47664
+ if (!written2.has(id) && !shown.has(id)) {
47665
+ add.push(id);
47666
+ ruled.delete(id);
47667
+ }
47668
+ } else if (shown.has(id)) shown.delete(id);
47669
+ else if (ruled.has(id)) {
47670
+ ruled.delete(id);
47671
+ if (s2.namesFrom === "board") hide.push(id);
47672
+ } else if (s2.namesFrom === "board" && written2.has(id)) hide.push(id);
47673
+ }
47674
+ return { hidden: [...hidden], shown: [...shown], ruled: [...ruled], add, hide };
47675
+ }
47676
+ async function untilStill(o2, since, quiet = 800, most = 2e4) {
47677
+ const t0 = o2.now();
47678
+ for (; ; ) {
47679
+ const t2 = o2.now();
47680
+ if (o2.drawnAt() >= since && t2 - o2.drawnAt() >= quiet) return true;
47681
+ if (t2 - t0 >= most) return false;
47682
+ await o2.sleep(100);
47683
+ }
47684
+ }
47203
47685
 
47204
47686
  // src/web/src/artframe.ts
47205
- var PANEL_W = 300;
47206
- var MARGIN = 32;
47207
- var GAP5 = 12;
47208
- var HEAD_ROOM = 60;
47209
- var TAIL_ROOM = 34;
47687
+ var PANEL_W = FRAME_ROOM.panel;
47688
+ var MARGIN = FRAME_ROOM.margin;
47210
47689
  var EASE_MS2 = 480;
47211
47690
  var DIP_MS = 180;
47212
47691
  var BAND_PIXELS = 4096 * 4096;
@@ -47332,23 +47811,27 @@ var ArtFrame = class {
47332
47811
  refresh() {
47333
47812
  if (this.isOpen) this.renderPanel();
47334
47813
  }
47335
- /** The mode, from the Export window: the reader's last settings, the whole board in the frame. */
47336
- open() {
47337
- if (this.isOpen && !this.leaving) return;
47338
- const s2 = cleanSettings(this.hooks.saved(), this.hooks.now());
47339
- this.start(s2, (r2) => {
47340
- const own2 = this.hooks.titles();
47341
- const titled = !!(s2.title ?? own2.title) || !!(s2.subtitle ?? own2.subtitle);
47342
- const below = titled ? Math.min(r2.h * 0.4, s2.titleSize * 1.7 / 100 * r2.h + Math.max(14, r2.h * 0.035)) : 0;
47814
+ /** The mode, from the Export window: the reader's last settings, the whole board in the frame; or,
47815
+ * from a link's recipe (ExportSpec decision 20, shared/artrecipe.ts), the settings it gives — not
47816
+ * kept as the reader's until they change one. Resolves once the mode has come: the board drawn
47817
+ * with its look, the camera where it goes. */
47818
+ open(given) {
47819
+ if (this.isOpen && !this.leaving) return Promise.resolve();
47820
+ const s2 = cleanSettings(given ?? this.hooks.saved(), this.hooks.now());
47821
+ const came = this.start(s2, (r2) => {
47822
+ const below = titleRoom(r2.h, s2, this.hooks.titles());
47343
47823
  return this.canvas.fitCam({ x: r2.x, y: r2.y + below, w: r2.w, h: r2.h - below });
47344
47824
  });
47345
47825
  this.hooks.changed();
47826
+ return came;
47346
47827
  }
47347
- /** The reader leaves: the board drawn as before, the camera where it was. */
47828
+ /** The reader leaves: the board drawn as before, the camera where it was. Resolves once the mode
47829
+ * has gone. */
47348
47830
  close() {
47349
- if (!this.isOpen || this.leaving) return;
47350
- this.end(true);
47831
+ if (!this.isOpen || this.leaving) return Promise.resolve();
47832
+ const gone = this.end(true);
47351
47833
  this.hooks.changed();
47834
+ return gone;
47352
47835
  }
47353
47836
  /** The mode as presence carries it: its settings, and the rectangle of the board's world the frame
47354
47837
  * holds — its middle, its width and height. */
@@ -47361,9 +47844,15 @@ var ArtFrame = class {
47361
47844
  /** A followed reader's artwork, or a link's: on with their settings — not kept as this reader's —
47362
47845
  * the frame holding what theirs holds; off where theirs is. */
47363
47846
  follow(v2) {
47364
- if (!v2) return this.end(false);
47847
+ if (!v2) {
47848
+ void this.end(false);
47849
+ return;
47850
+ }
47365
47851
  const { region, ...s2 } = v2;
47366
- if (!this.isOpen || this.leaving) return this.start(s2, (r2) => this.camHolding(region, r2));
47852
+ if (!this.isOpen || this.leaving) {
47853
+ void this.start(s2, (r2) => this.camHolding(region, r2));
47854
+ return;
47855
+ }
47367
47856
  if (!sameLook(this.s, s2)) {
47368
47857
  this.s = s2;
47369
47858
  this.hooks.look(s2);
@@ -47376,7 +47865,8 @@ var ArtFrame = class {
47376
47865
  /** The mode comes (ExportSpec decision 19, the owner's decision of 2026-10-15: as smoothly as the
47377
47866
  * linear form, web/src/linear.ts): the frame closes in from the whole screen as its shade comes,
47378
47867
  * the camera glides to where `aim` puts it in the frame, the panel slides in; the board dips to
47379
- * its ground a moment, takes the artwork's look and comes back; the title and the foot come last. */
47868
+ * its ground a moment, takes the artwork's look and comes back; the title and the foot come last.
47869
+ * Resolves when it has come (or a newer step took its place). */
47380
47870
  start(s2, aim2) {
47381
47871
  const turn = ++this.turn;
47382
47872
  const body = document.body;
@@ -47411,13 +47901,19 @@ var ArtFrame = class {
47411
47901
  this.renderSky();
47412
47902
  this.renderInk();
47413
47903
  }, DIP_MS);
47414
- setTimeout(() => turn === this.turn && body.classList.remove("art-moving"), EASE_MS2);
47904
+ return new Promise(
47905
+ (done) => setTimeout(() => {
47906
+ if (turn === this.turn) body.classList.remove("art-moving");
47907
+ done();
47908
+ }, EASE_MS2)
47909
+ );
47415
47910
  }
47416
47911
  /** The mode goes, as it came: the frame opens out to the whole screen as its shade goes, the
47417
47912
  * panel slides away, the camera glides back where it was (`back`); the board dips, takes its own
47418
- * look again, and comes back. A follower's goes with the other's, its camera theirs. */
47913
+ * look again, and comes back. A follower's goes with the other's, its camera theirs. Resolves when
47914
+ * it has gone (or a newer step took its place). */
47419
47915
  end(back) {
47420
- if (!this.isOpen || this.leaving) return;
47916
+ if (!this.isOpen || this.leaving) return Promise.resolve();
47421
47917
  const turn = ++this.turn;
47422
47918
  const body = document.body;
47423
47919
  this.leaving = true;
@@ -47436,17 +47932,20 @@ var ArtFrame = class {
47436
47932
  this.renderSky();
47437
47933
  body.classList.remove("art-dip");
47438
47934
  }, DIP_MS);
47439
- setTimeout(() => {
47440
- if (turn !== this.turn) return;
47441
- this.leaving = false;
47442
- this.rect = { x: 0, y: 0, w: 0, h: 0 };
47443
- this.root.hidden = true;
47444
- this.panel.hidden = true;
47445
- this.sky.hidden = true;
47446
- this.root.classList.remove("art-away");
47447
- this.panel.classList.remove("art-away");
47448
- body.classList.remove("art-moving");
47449
- }, EASE_MS2);
47935
+ return new Promise(
47936
+ (done) => setTimeout(() => {
47937
+ done();
47938
+ if (turn !== this.turn) return;
47939
+ this.leaving = false;
47940
+ this.rect = { x: 0, y: 0, w: 0, h: 0 };
47941
+ this.root.hidden = true;
47942
+ this.panel.hidden = true;
47943
+ this.sky.hidden = true;
47944
+ this.root.classList.remove("art-away");
47945
+ this.panel.classList.remove("art-away");
47946
+ body.classList.remove("art-moving");
47947
+ }, EASE_MS2)
47948
+ );
47450
47949
  }
47451
47950
  /** The camera that holds `region` of the board's world in the frame `r`: its middle at the frame's,
47452
47951
  * its width the frame's. */
@@ -47459,10 +47958,7 @@ var ArtFrame = class {
47459
47958
  * there keeps what it showed — the same middle, as much of the board. */
47460
47959
  place(keep) {
47461
47960
  const s2 = this.s;
47462
- const roomW = innerWidth - PANEL_W - 3 * MARGIN, roomH = innerHeight - 60 - HEAD_ROOM - TAIL_ROOM - 2 * GAP5;
47463
- const scale = Math.min(roomW / s2.w, roomH / s2.h);
47464
- const w2 = Math.max(40, s2.w * scale), h2 = Math.max(40, s2.h * scale);
47465
- const x2 = MARGIN + (roomW - w2) / 2, y2 = 60 + GAP5 + HEAD_ROOM + (roomH - h2) / 2;
47961
+ const { x: x2, y: y2, w: w2, h: h2 } = frameRect({ w: innerWidth, h: innerHeight }, s2);
47466
47962
  const was = this.rect;
47467
47963
  if (keep && was.w > 0) {
47468
47964
  const C2 = this.canvas;
@@ -47536,14 +48032,7 @@ var ArtFrame = class {
47536
48032
  }
47537
48033
  /** What an artwork writes over the board within `rect`: its border, its title and its foot. */
47538
48034
  writeOver(ctx, s2, rect) {
47539
- const own2 = this.hooks.titles();
47540
- const cs = getComputedStyle(document.body);
47541
- const v2 = (name) => cs.getPropertyValue(name).trim();
47542
- const halo = s2.style === "cells" ? "#0f1520" : s2.style === "stars" ? "#090d1a" : v2("--canvas");
47543
- if (s2.border) drawBorder(ctx, rect, s2.style === "cells" ? "rgba(200, 212, 236, 0.55)" : s2.style === "stars" ? "rgba(241, 230, 200, 0.55)" : v2("--text-2"));
47544
- drawTitle(ctx, rect, s2.title ?? own2.title, s2.subtitle ?? own2.subtitle, s2.titleSize, s2.style, { ink: v2("--text"), quiet: v2("--text-2"), halo });
47545
- const quiet = s2.style === "board" ? v2("--text-2") : "rgba(200, 208, 224, 0.7)";
47546
- drawFoot(ctx, rect, s2.source ? this.hooks.source() : null, { text: quiet, halo }, ART_SANS, s2.footSize);
48035
+ writeOver(ctx, s2, rect, this.hooks.titles(), this.hooks.source());
47547
48036
  }
47548
48037
  /** The title, its line or its size changed beside the frame, or the foot's size: kept, drawn over
47549
48038
  * the board at once, and said to those following once it has been still a moment. */
@@ -47600,7 +48089,7 @@ var ArtFrame = class {
47600
48089
  else {
47601
48090
  s2[k2] = t2.value;
47602
48091
  if (k2 === "labels" && t2.value === "chosen") {
47603
- [s2.namesFrom, s2.hiddenNames, s2.shownNames] = ["board", [], []];
48092
+ [s2.namesFrom, s2.hiddenNames, s2.shownNames, s2.ruledNames] = ["board", [], [], []];
47604
48093
  this.filter = "";
47605
48094
  } else if (k2 === "targets" && t2.value === "chosen") {
47606
48095
  const r2 = withChosen([], this.hooks.targets().map((x2) => x2.name), ART_TARGETS_BYTES);
@@ -47632,21 +48121,17 @@ var ArtFrame = class {
47632
48121
  /** Names shown, or hidden (By selection): from their list, its All and None, or the board's menu.
47633
48122
  * A name ticked is no longer hidden, and is shown besides where the board does not write it now; a
47634
48123
  * name unticked is no longer shown besides, and is hidden where the board would write it all the
47635
- * same. What does not fit in what the artwork carries is left out, and said. */
48124
+ * same. What does not fit in what the artwork carries is left out, and said. A name a recipe's
48125
+ * rule chose (ExportSpec decision 20) is the reader's once ticked — written wherever it falls, as
48126
+ * one ticked by hand — and no longer the rule's once unticked, hidden where the board would write
48127
+ * it (artwork chooseIn). */
47636
48128
  chooseNames(ids, on) {
47637
48129
  const s2 = this.s;
47638
48130
  if (!s2 || this.busy) return this.renderPanel();
47639
48131
  const now = this.canvas.writtenNames();
47640
- const hidden = new Set(s2.hiddenNames), shown = new Set(s2.shownNames);
47641
- const add = [], hide = [];
47642
- for (const id of ids) {
47643
- if (on) {
47644
- hidden.delete(id);
47645
- if (!now.has(id) && !shown.has(id)) add.push(id);
47646
- } else if (shown.has(id)) shown.delete(id);
47647
- else if (s2.namesFrom === "board" && now.has(id)) hide.push(id);
47648
- }
47649
- const a3 = withChosen([...shown], add, ART_NAMES_BYTES), h2 = withChosen([...hidden], hide, ART_NAMES_BYTES);
48132
+ const { hidden, shown, ruled, add, hide } = chooseIn(s2, now, ids, on);
48133
+ s2.ruledNames = ruled;
48134
+ const a3 = withChosen(shown, add, ART_NAMES_BYTES), h2 = withChosen(hidden, hide, ART_NAMES_BYTES);
47650
48135
  [s2.shownNames, s2.hiddenNames] = [a3.list, h2.list];
47651
48136
  if (a3.left + h2.left) this.hooks.say(NO_ROOM(a3.left + h2.left, "name"));
47652
48137
  const wrote = new Set(now);
@@ -47678,7 +48163,7 @@ var ArtFrame = class {
47678
48163
  }
47679
48164
  if (!this.filter.trim()) {
47680
48165
  if (this.busy) return this.renderPanel();
47681
- [s2.namesFrom, s2.hiddenNames, s2.shownNames] = [all === "all" ? "board" : "none", [], []];
48166
+ [s2.namesFrom, s2.hiddenNames, s2.shownNames, s2.ruledNames] = [all === "all" ? "board" : "none", [], [], []];
47682
48167
  return this.settled();
47683
48168
  }
47684
48169
  this.chooseNames(nameRows(this.rows, this.filter, Infinity).rows.map((r2) => r2.id), all === "all");
@@ -47715,7 +48200,7 @@ var ArtFrame = class {
47715
48200
  const s2 = this.s;
47716
48201
  const here = this.rows.filter((r2) => this.wrote.has(r2.id)).length;
47717
48202
  const n3 = (k2, what) => k2 ? ` \xB7 ${k2.toLocaleString("en")} ${what}` : "";
47718
- return `${here.toLocaleString("en")} of ${this.rows.length.toLocaleString("en")} in the frame written${s2?.namesFrom === "none" ? " \xB7 from none" : ""}${n3(s2?.hiddenNames.length ?? 0, "hidden")}${n3(s2?.shownNames.length ?? 0, "shown besides")}`;
48203
+ return `${here.toLocaleString("en")} of ${this.rows.length.toLocaleString("en")} in the frame written${s2?.namesFrom === "none" ? " \xB7 from none" : ""}${n3(s2?.hiddenNames.length ?? 0, "hidden")}${n3(s2?.shownNames.length ?? 0, "shown besides")}${n3(s2?.ruledNames.length ?? 0, "by a rule")}`;
47719
48204
  }
47720
48205
  renderPanel(held = false) {
47721
48206
  const s2 = this.s;
@@ -47791,10 +48276,18 @@ var ArtFrame = class {
47791
48276
  const n3 = all.filter((t2) => on.has(t2.name)).length;
47792
48277
  return `<div class="art-pick"><div class="art-pick-head"><span class="muted">Their star and name, each ticked</span><button class="rv-btn" data-art-pick="targets-all">All</button><button class="rv-btn" data-art-pick="targets-none">None</button></div><div class="art-rows" data-art-rows="targets">${rows || `<div class="art-rows-h">The review has no target</div>`}</div><div class="art-note muted">${n3} of ${all.length} marked</div></div>`;
47793
48278
  }
48279
+ /** The files a link's recipe asks for (ExportSpec decision 20), one after the other, as Save SVG
48280
+ * and Save PNG make them; those made. */
48281
+ async save(kinds) {
48282
+ const made2 = [];
48283
+ if (!this.isOpen || !this.s) return made2;
48284
+ for (const k2 of kinds) if (await this.saveFile(k2)) made2.push(k2);
48285
+ return made2;
48286
+ }
47794
48287
  /** Make the file, at the full size, from the board as it is drawn now, and hand it to the browser
47795
- * to save. */
48288
+ * to save; whether it was made. */
47796
48289
  async saveFile(kind) {
47797
- if (this.busy || !this.s) return;
48290
+ if (this.busy || !this.s) return false;
47798
48291
  const s2 = { ...this.s };
47799
48292
  const C2 = this.canvas;
47800
48293
  const rect = { ...this.rect }, cam = { ...C2.cam };
@@ -47819,7 +48312,8 @@ var ArtFrame = class {
47819
48312
  if (s2.border) pen.raw(`</g>`);
47820
48313
  this.writeOver(ctx, s2, rect);
47821
48314
  }, await faces());
47822
- return download(new Blob([svg], { type: "image/svg+xml" }), name);
48315
+ download(new Blob([svg], { type: "image/svg+xml" }), name);
48316
+ return true;
47823
48317
  }
47824
48318
  this.busy = true;
47825
48319
  this.renderPanel();
@@ -47881,8 +48375,10 @@ var ArtFrame = class {
47881
48375
  }
47882
48376
  }
47883
48377
  download(await encodePng(s2.w, s2.h, bands()), name);
48378
+ return true;
47884
48379
  } catch {
47885
48380
  this.hooks.say(`This browser could not draw a picture of ${s2.w} \xD7 ${s2.h} pixels: choose a smaller size, or save the SVG.`);
48381
+ return false;
47886
48382
  } finally {
47887
48383
  this.busy = false;
47888
48384
  this.renderPanel();
@@ -50278,8 +50774,10 @@ async function boot() {
50278
50774
  menu: (t2, x2, y2, world) => openMenu(t2, x2, y2, world),
50279
50775
  frozenDrag: () => say(frozenWhy())
50280
50776
  });
50777
+ const ownOpen = /* @__PURE__ */ new Map();
50778
+ const asOpenHere = (ns) => ownOpen.size ? ns.map((n3) => ownOpen.has(n3.id) && ownOpen.get(n3.id) !== n3.open ? { ...n3, open: ownOpen.get(n3.id) } : n3) : ns;
50281
50779
  const stickies = new Stickies($4("#stickies"), canvas, {
50282
- notes: () => ({ project: projectNotes.list, view: nav.board?.rec.notes ?? [] }),
50780
+ notes: () => ({ project: asOpenHere(projectNotes.list), view: asOpenHere(nav.board?.rec.notes ?? []) }),
50283
50781
  // the Project holds every note of the project; another board what its scope gives and its
50284
50782
  // view's lists (the owner's decision of 2026-10-11), the links read as the board reads the review
50285
50783
  items: () => ({ all: !nav.board || fixedScope(nav.board.rec), items: nav.board?.rec.items, links: boardReview().links ?? [] }),
@@ -50294,7 +50792,7 @@ async function boot() {
50294
50792
  if (i2 !== void 0) go(i2);
50295
50793
  },
50296
50794
  moves: (h2) => !frozen() && dragMoves(locked, h2),
50297
- setOpen: (id, kind, open) => void (kind === "project" ? projectOps([{ op: "pnote-open", id, open }]) : noteOps([{ op: "note-open", id, open }])),
50795
+ setOpen: (id, _kind, open) => setNoteOpen(id, open),
50298
50796
  moved: (id, to) => void moveNote(id, to),
50299
50797
  // where a card hangs from its icon is shared as its state is, and is no edit
50300
50798
  card: (id, kind, card) => void (kind === "project" ? projectOps([{ op: "pnote-card", id, card }]) : noteOps([{ op: "note-card", id, card }])),
@@ -50326,13 +50824,8 @@ async function boot() {
50326
50824
  edges: () => S2.showEdges,
50327
50825
  moveNote: (id, key, world, detach2) => stickies.moveInto(id, key, world, detach2),
50328
50826
  noteText: (id) => noteOf(id)?.note.text ?? null,
50329
- noteOpen: (id) => !!noteOf(id)?.note.open,
50330
- toggleNote: (id) => {
50331
- const n3 = noteOf(id);
50332
- if (frozen()) return say(frozenWhy());
50333
- if (n3?.kind === "project") void projectOps([{ op: "pnote-open", id: n3.note.id, open: !n3.note.open }]);
50334
- else if (n3) noteOps([{ op: "note-open", id: n3.note.id, open: !n3.note.open }]);
50335
- },
50827
+ noteOpen: (id) => noteOpenHere(id),
50828
+ toggleNote: (id) => setNoteOpen(id, !noteOpenHere(id)),
50336
50829
  // the board's menu on what a row is; Fold and Fit are the form's (menuAct)
50337
50830
  menu: (on, x2, y2, spot) => {
50338
50831
  if (on?.kind === "note") openNoteMenu(on.id, x2, y2);
@@ -50352,6 +50845,10 @@ async function boot() {
50352
50845
  const ART_INERT = "#bar .brand, #bar .library-wrap, #export-btn, #activity-btn, #bar .peers-wrap, #bar .who-wrap, #help-btn, #bar .more-wrap";
50353
50846
  const ART_KEYS = /* @__PURE__ */ new Set(["/", "1", "2", "3", "f", "+", "=", "-", "_", "e", "[", "]", "l"]);
50354
50847
  let artLook = null;
50848
+ let gitNow = null;
50849
+ let gitAsked = data.git().then((git) => {
50850
+ gitNow ??= git;
50851
+ });
50355
50852
  const art = new ArtFrame($4("#art"), $4("#art-panel"), $4("#art-sky"), canvas, {
50356
50853
  title: () => views.title(nav.board.id),
50357
50854
  saved: () => S2.artwork,
@@ -50361,13 +50858,21 @@ async function boot() {
50361
50858
  },
50362
50859
  now: () => ({ scheme: canvas.scheme, theme: document.documentElement.dataset.theme === "dark" ? "dark" : "light" }),
50363
50860
  look: (s2) => {
50861
+ const coming = !artLook && !!s2;
50364
50862
  artLook = s2 && { ...s2 };
50365
50863
  drawLook();
50864
+ if (coming)
50865
+ gitAsked = data.git().then((git) => {
50866
+ gitNow = git;
50867
+ art.refresh();
50868
+ });
50366
50869
  },
50367
50870
  changed: () => people.report(),
50368
50871
  notes: () => stickies.asDrawn(),
50872
+ // the commit checked out now; on a past build's board, that build's, with the project's source
50369
50873
  source: () => {
50370
- const git = vg().meta.project.git;
50874
+ const then = vg().meta.project.git;
50875
+ const git = pastShown() ? then && { ...then, source: then.source ?? gitNow?.source ?? null } : gitNow;
50371
50876
  return git ? sourceLine(git) : null;
50372
50877
  },
50373
50878
  // the targets a picture can mark: every one of the review's, by the name the board writes
@@ -50394,12 +50899,14 @@ async function boot() {
50394
50899
  linear.hide("reader");
50395
50900
  document.documentElement.dataset.theme = a3.style === "board" ? a3.theme : "dark";
50396
50901
  } else applyTheme();
50397
- canvas.setArt(a3 && { labels: a3.labels, namesFrom: a3.namesFrom, hidden: new Set(a3.hiddenNames), shown: new Set(a3.shownNames), edges: a3.edges, targets: a3.targets, shownTargets: new Set(a3.shownTargets), keys: a3.keys, style: a3.style });
50902
+ canvas.setArt(a3 && { labels: a3.labels, namesFrom: a3.namesFrom, hidden: new Set(a3.hiddenNames), shown: new Set(a3.shownNames), ruled: new Set(a3.ruledNames), edges: a3.edges, targets: a3.targets, shownTargets: new Set(a3.shownTargets), keys: a3.keys, style: a3.style });
50398
50903
  applyScheme();
50399
50904
  syncEdges();
50400
50905
  apply();
50401
50906
  };
50907
+ let drawnAt = 0;
50402
50908
  canvas.onFrame = () => {
50909
+ drawnAt = performance.now();
50403
50910
  stickies.frame();
50404
50911
  art.frame();
50405
50912
  };
@@ -50813,6 +51320,33 @@ async function boot() {
50813
51320
  const line = missingLine(gone);
50814
51321
  if (line) say(line);
50815
51322
  };
51323
+ const openRecipe = async (link) => {
51324
+ const read = readRecipe(decodeRecipe(link.text));
51325
+ if ("error" in read) return say(`This link's artwork could not be read: ${read.error}`);
51326
+ const L2 = canvas.layout;
51327
+ const { look, problems, left } = resolveRecipe(read.recipe, {
51328
+ clusters: L2.clusters,
51329
+ clusterOf: (name) => {
51330
+ const i2 = g2.byName.get(name);
51331
+ const j2 = i2 === void 0 ? -1 : toView(i2);
51332
+ return j2 < 0 ? void 0 : L2.cluster[j2];
51333
+ },
51334
+ targets: Object.keys(review.data.targets),
51335
+ projectCount: g2.projectCount
51336
+ });
51337
+ if (problems.length || left) say(`This link's artwork: ${[...problems, ...left ? [`${left} names left out, more than an artwork carries`] : []].join("; ")}.`);
51338
+ if (art.isOpen) await art.close();
51339
+ await art.open(look);
51340
+ if (!link.save.length) return;
51341
+ const since = performance.now();
51342
+ await gitAsked;
51343
+ await document.fonts?.ready;
51344
+ canvas.request();
51345
+ await untilStill({ now: () => performance.now(), drawnAt: () => drawnAt, sleep: (ms) => new Promise((ok) => setTimeout(ok, ms)) }, since);
51346
+ if (link.save.length > 1) say(`Saving the picture as ${link.save.map((k2) => k2.toUpperCase()).join(" and ")}: your browser may ask to allow this site to download several files.`);
51347
+ const made3 = await art.save(link.save);
51348
+ document.documentElement.dataset.artworkSaved = made3.length ? made3.join(",") : "none";
51349
+ };
50816
51350
  handsOver = () => !reader && (!!data.snapshot || !!people.name || !!data.privateSession);
50817
51351
  const syncPeers = () => {
50818
51352
  const marks = [];
@@ -51621,7 +52155,12 @@ async function boot() {
51621
52155
  const cam = e2.state?.cam ?? null;
51622
52156
  const tmAt = e2.state?.tm;
51623
52157
  const snap2 = snapshotIn(location.hash);
52158
+ const recipe2 = recipeIn(location.hash);
51624
52159
  void (st2 ? restore2(st2, cam, typeof tmAt === "number" ? tmAt : void 0) : Promise.resolve()).then(() => snap2 ? openSnapshot(snap2).then(() => writeAddress(false)) : void 0).then(() => {
52160
+ if (!recipe2) return;
52161
+ void openRecipe(recipe2);
52162
+ writeAddress(false);
52163
+ }).then(() => {
51625
52164
  renderNav();
51626
52165
  people.report();
51627
52166
  });
@@ -52125,6 +52664,16 @@ async function boot() {
52125
52664
  const p2 = projectNotes.get(id);
52126
52665
  return p2 ? { kind: "project", note: p2 } : null;
52127
52666
  };
52667
+ const noteOpenHere = (id) => ownOpen.get(id) ?? !!noteOf(id)?.note.open;
52668
+ const setNoteOpen = (id, open) => {
52669
+ const n3 = noteOf(id);
52670
+ if (!n3) return;
52671
+ if (reader) {
52672
+ ownOpen.set(id, open);
52673
+ stickies.render();
52674
+ } else if (n3.kind === "project") void projectOps([{ op: "pnote-open", id, open }]);
52675
+ else noteOps([{ op: "note-open", id, open }]);
52676
+ };
52128
52677
  const moveNote = (id, d2) => {
52129
52678
  const had = noteOf(id)?.note.decl;
52130
52679
  const attach = d2.decl || had ? d2.decl : void 0;
@@ -52238,8 +52787,9 @@ async function boot() {
52238
52787
  if (!n3) return;
52239
52788
  const t2 = {
52240
52789
  kind: "note",
52241
- open: n3.note.open,
52790
+ open: noteOpenHere(id),
52242
52791
  view: n3.kind === "view",
52792
+ ...reader ? { own: true } : {},
52243
52793
  carries: (n3.note.carries ?? []).map(carryLabel),
52244
52794
  attach: !data.snapshot && !reader,
52245
52795
  ...n3.kind === "project" ? { items: noteViews(n3.note) } : {}
@@ -52462,12 +53012,9 @@ async function boot() {
52462
53012
  case "note-edit":
52463
53013
  if (on.note) await editNote(on.note, on.at);
52464
53014
  return;
52465
- case "note-toggle": {
52466
- const n3 = on.note ? noteOf(on.note) : null;
52467
- if (n3?.kind === "project") void projectOps([{ op: "pnote-open", id: n3.note.id, open: !n3.note.open }]);
52468
- else if (n3) noteOps([{ op: "note-open", id: n3.note.id, open: !n3.note.open }]);
53015
+ case "note-toggle":
53016
+ if (on.note) setNoteOpen(on.note, !noteOpenHere(on.note));
52469
53017
  return;
52470
- }
52471
53018
  case "note-kind":
52472
53019
  if (on.note) await switchNote(on.note);
52473
53020
  return;
@@ -52606,6 +53153,8 @@ ${n3.note.text.slice(0, 200)}`)) return;
52606
53153
  listen();
52607
53154
  const snap = snapshotIn(location.hash);
52608
53155
  if (snap) await openSnapshot(snap);
53156
+ const recipe = recipeIn(location.hash);
53157
+ if (recipe) void openRecipe(recipe);
52609
53158
  if (!data.snapshot) void tm.load().then(() => renderTabs());
52610
53159
  try {
52611
53160
  history.replaceState({ n: 0 }, "", "#" + encodeState(current2()));