mellos-mapping 0.20.0 → 0.20.2

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 (43) hide show
  1. package/README.md +360 -63
  2. package/README.zh-CN.md +314 -54
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1614 -809
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1391 -760
  8. package/lib/domain/ops.d.ts +71 -12
  9. package/lib/domain/ops.js +145 -14
  10. package/lib/domain/types.d.ts +47 -6
  11. package/lib/domain/types.js +34 -3
  12. package/lib/render/canvas.d.ts +50 -0
  13. package/lib/render/canvas.js +210 -0
  14. package/lib/render/draw.d.ts +37 -0
  15. package/lib/render/draw.js +111 -0
  16. package/lib/render/layout.d.ts +89 -0
  17. package/lib/render/layout.js +200 -0
  18. package/lib/render/options.d.ts +39 -0
  19. package/lib/render/options.js +10 -0
  20. package/lib/render/render.d.ts +32 -46
  21. package/lib/render/render.js +58 -789
  22. package/lib/render/routing.d.ts +56 -0
  23. package/lib/render/routing.js +244 -0
  24. package/lib/render/skins.d.ts +54 -0
  25. package/lib/render/skins.js +99 -0
  26. package/lib/render/width.d.ts +24 -0
  27. package/lib/render/width.js +139 -0
  28. package/lib/render/zoom-geometry.d.ts +52 -0
  29. package/lib/render/zoom-geometry.js +56 -0
  30. package/lib/semantics/semantics.d.ts +53 -4
  31. package/lib/semantics/semantics.js +130 -6
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +17 -0
  35. package/lib/store/format.js +185 -66
  36. package/lib/store/store.d.ts +220 -20
  37. package/lib/store/store.js +491 -38
  38. package/package.json +12 -4
  39. package/scripts/codex-register.mjs +89 -20
  40. package/scripts/install-mmap-command.mjs +293 -0
  41. package/scripts/mmap.mjs +213 -0
  42. package/scripts/open-pane.mjs +115 -254
  43. package/scripts/pane-core.mjs +418 -0
package/dist/watch.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);
3
3
 
4
4
  // src/watch/watch.ts
5
- import { realpathSync, statSync } from "node:fs";
5
+ import { realpathSync, statSync as statSync2 } from "node:fs";
6
6
  import { dirname as dirname2, join as join2 } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
 
@@ -29,6 +29,12 @@ function makeNodeKind(raw) {
29
29
  function makeSubmapRef(raw) {
30
30
  return ID_RULE.test(raw) ? ok(raw) : err({ kind: "invalid-id", raw, rule: ID_RULE_TEXT });
31
31
  }
32
+ var RANK_MIN = 0;
33
+ var RANK_MAX = 99;
34
+ var RANK_RULE_TEXT = `an integer in ${RANK_MIN}..${RANK_MAX}, 0 = bottom / most primitive`;
35
+ function makeRank(raw) {
36
+ return Number.isInteger(raw) && raw >= RANK_MIN && raw <= RANK_MAX ? ok(raw) : err({ kind: "invalid-rank", raw, rule: RANK_RULE_TEXT });
37
+ }
32
38
  var MAP_KINDS = ["dev", "architecture", "dataflow", "behavior-tree", "sequence"];
33
39
  function makeMapKind(raw) {
34
40
  return MAP_KINDS.includes(raw) ? ok(raw) : err({ kind: "invalid-map-kind", raw });
@@ -42,6 +48,8 @@ function describeMapError(e) {
42
48
  switch (e.kind) {
43
49
  case "invalid-id":
44
50
  return `invalid id "${e.raw}" (rule: ${e.rule})`;
51
+ case "invalid-rank":
52
+ return `invalid rank ${e.raw} (rule: ${e.rule})`;
45
53
  case "invalid-status":
46
54
  return `invalid status "${e.raw}" (expected: ${NODE_STATUSES.join(" | ")})`;
47
55
  case "duplicate-layer":
@@ -62,6 +70,8 @@ function describeMapError(e) {
62
70
  return `node "${e.id}" cannot depend on itself`;
63
71
  case "duplicate-group":
64
72
  return `group "${e.id}" already exists`;
73
+ case "id-collision":
74
+ return `id "${e.id}" already names a ${e.taken} on this map; nodes and groups share one id namespace (both render as boxes, so one id must mean one box) \u2014 rename "${e.id}"`;
65
75
  case "unknown-group":
66
76
  return `group "${e.id}" does not exist`;
67
77
  case "invalid-map-kind":
@@ -73,9 +83,9 @@ function describeMapError(e) {
73
83
  case "group-layer-mismatch":
74
84
  return `node "${e.node}" (layer ${e.nodeLayer}) cannot join group "${e.group}" (layer ${e.groupLayer}); groups cluster nodes within one band`;
75
85
  case "layer-not-empty":
76
- return `layer "${e.id}" still holds node "${e.occupant}"; move or remove its nodes first`;
86
+ return `layer "${e.id}" still holds node "${e.occupant}"; move its nodes to another band (moveNode) or remove them (removeNode) first`;
77
87
  case "layer-holds-group":
78
- return `layer "${e.id}" still holds group "${e.occupant}"; remove its groups first`;
88
+ return `layer "${e.id}" still holds group "${e.occupant}"; remove its groups (removeGroup) first`;
79
89
  case "edge-not-downward":
80
90
  return `edge ${e.from} (rank ${e.fromRank}) -> ${e.to} (rank ${e.toRank}) is not strictly downward; dependencies may only point to a lower layer`;
81
91
  }
@@ -101,7 +111,15 @@ function checkMembership(map, node, nodeLayer, group) {
101
111
  function hasEdge(map, from, to) {
102
112
  return map.edges.some((e) => e.from === from && e.to === to);
103
113
  }
114
+ function checkIdSpace(map, id, declaring) {
115
+ const taken = declaring === "node" ? map.groups.some((g) => g.id === id) : map.nodes.some((n) => n.id === id);
116
+ return taken ? { kind: "id-collision", id, taken: declaring === "node" ? "group" : "node" } : void 0;
117
+ }
104
118
  function setTitle(map, title) {
119
+ if (title === null || title === void 0) {
120
+ const { title: _dropped, ...rest } = map;
121
+ return rest;
122
+ }
105
123
  return { ...map, title };
106
124
  }
107
125
  function setKind(map, kind) {
@@ -122,6 +140,8 @@ function declareLayer(map, input) {
122
140
  }
123
141
  function declareGroup(map, input) {
124
142
  if (findGroup(map, input.id)) return err({ kind: "duplicate-group", id: input.id });
143
+ const collision = checkIdSpace(map, input.id, "group");
144
+ if (collision) return err(collision);
125
145
  if (!findLayer(map, input.layer)) return err({ kind: "unknown-layer", id: input.layer });
126
146
  return ok({ ...map, groups: [...map.groups, { id: input.id, label: input.label, layer: input.layer }] });
127
147
  }
@@ -139,6 +159,8 @@ function mapStatus(map) {
139
159
  }
140
160
  function declareNode(map, input) {
141
161
  if (findNode(map, input.id)) return err({ kind: "duplicate-node", id: input.id });
162
+ const collision = checkIdSpace(map, input.id, "node");
163
+ if (collision) return err(collision);
142
164
  if (!findLayer(map, input.layer)) return err({ kind: "unknown-layer", id: input.layer });
143
165
  if (input.group !== void 0) {
144
166
  const bad = checkMembership(map, input.id, input.layer, input.group);
@@ -150,6 +172,7 @@ function declareNode(map, input) {
150
172
  label: input.label,
151
173
  layer: input.layer,
152
174
  status: input.status ?? "planned",
175
+ ...input.evidence !== void 0 ? { evidence: input.evidence } : {},
153
176
  ...input.detail !== void 0 ? { detail: input.detail } : {},
154
177
  ...input.group !== void 0 ? { group: input.group } : {},
155
178
  ...input.kind !== void 0 ? { kind: input.kind } : {},
@@ -170,6 +193,9 @@ function linkNodes(map, from, to, label) {
170
193
  if (fromRank <= toRank) return err({ kind: "edge-not-downward", from, fromRank, to, toRank });
171
194
  return ok({ ...map, edges: [...map.edges, { from, to, ...label !== void 0 ? { label } : {} }] });
172
195
  }
196
+ function resolveOptional(input, current) {
197
+ return input === void 0 ? current : input === null ? void 0 : input;
198
+ }
173
199
  function updateNode(map, input) {
174
200
  const node = findNode(map, input.id);
175
201
  if (!node) return err({ kind: "unknown-node", id: input.id });
@@ -180,25 +206,79 @@ function updateNode(map, input) {
180
206
  if (input.lane !== void 0 && input.lane !== null && !findLane(map, input.lane)) {
181
207
  return err({ kind: "unknown-lane", id: input.lane });
182
208
  }
183
- const { group: currentGroup, kind: currentKind, lane: currentLane, submap: currentSubmap, ...bare } = node;
184
- const nextGroup = input.group === void 0 ? currentGroup : input.group === null ? void 0 : input.group;
185
- const nextKind = input.kind === void 0 ? currentKind : input.kind === null ? void 0 : input.kind;
186
- const nextLane = input.lane === void 0 ? currentLane : input.lane === null ? void 0 : input.lane;
187
- const nextSubmap = input.submap === void 0 ? currentSubmap : input.submap === null ? void 0 : input.submap;
209
+ const {
210
+ group: currentGroup,
211
+ kind: currentKind,
212
+ lane: currentLane,
213
+ submap: currentSubmap,
214
+ evidence: currentEvidence,
215
+ detail: currentDetail,
216
+ ...bare
217
+ } = node;
218
+ const nextGroup = resolveOptional(input.group, currentGroup);
219
+ const nextKind = resolveOptional(input.kind, currentKind);
220
+ const nextLane = resolveOptional(input.lane, currentLane);
221
+ const nextSubmap = resolveOptional(input.submap, currentSubmap);
222
+ const nextEvidence = resolveOptional(input.evidence, currentEvidence);
223
+ const nextDetail = resolveOptional(input.detail, currentDetail);
188
224
  const updated = {
189
225
  ...bare,
226
+ ...nextEvidence !== void 0 ? { evidence: nextEvidence } : {},
227
+ ...nextDetail !== void 0 ? { detail: nextDetail } : {},
190
228
  ...nextGroup !== void 0 ? { group: nextGroup } : {},
191
229
  ...nextKind !== void 0 ? { kind: nextKind } : {},
192
230
  ...nextLane !== void 0 ? { lane: nextLane } : {},
193
231
  ...nextSubmap !== void 0 ? { submap: nextSubmap } : {},
194
232
  ...input.status !== void 0 ? { status: input.status } : {},
195
- ...input.label !== void 0 ? { label: input.label } : {},
196
- ...input.evidence !== void 0 ? { evidence: input.evidence } : {},
197
- ...input.detail !== void 0 ? { detail: input.detail } : {}
233
+ ...input.label !== void 0 ? { label: input.label } : {}
198
234
  };
199
235
  return ok({ ...map, nodes: map.nodes.map((n) => n.id === input.id ? updated : n) });
200
236
  }
201
237
 
238
+ // src/semantics/vocabulary.ts
239
+ var STATUS_GLYPHS = {
240
+ planned: ["\xB7", "."],
241
+ "in-progress": ["\u283F", "*"],
242
+ done: ["\u25A0", "#"],
243
+ regressed: ["\u2717", "X"]
244
+ };
245
+ function statusGlyph(status, unicode) {
246
+ const [uni, ascii] = STATUS_GLYPHS[status];
247
+ return unicode ? uni : ascii;
248
+ }
249
+ var UNVERIFIED_DONE_GLYPHS = ["\u25A1", "o"];
250
+ function unverifiedDoneGlyph(unicode) {
251
+ const [uni, ascii] = UNVERIFIED_DONE_GLYPHS;
252
+ return unicode ? uni : ascii;
253
+ }
254
+ var SPINNER_FRAMES = {
255
+ unicode: ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"],
256
+ ascii: ["|", "/", "-", "\\"]
257
+ };
258
+ function spinnerGlyph(frame, unicode) {
259
+ const frames = SPINNER_FRAMES[unicode ? "unicode" : "ascii"];
260
+ return frames[(frame % frames.length + frames.length) % frames.length];
261
+ }
262
+ var NODE_KIND_GLYPHS = {
263
+ selector: ["?", "?"],
264
+ sequence: ["\xBB", ">"],
265
+ parallel: ["\u2016", "="],
266
+ decorator: ["\u25CC", "o"],
267
+ condition: ["\u25C7", "c"],
268
+ action: ["\xB7", "."],
269
+ source: ["\u25CB", "o"],
270
+ transform: ["\u25D0", "%"],
271
+ sink: ["\u25CF", "*"],
272
+ service: ["\u25C6", "S"],
273
+ db: ["\u25A4", "D"],
274
+ queue: ["\u2263", "Q"],
275
+ ui: ["\u25A3", "U"]
276
+ };
277
+ function kindGlyph(kind, unicode) {
278
+ const pair = NODE_KIND_GLYPHS[kind];
279
+ return pair === void 0 ? void 0 : unicode ? pair[0] : pair[1];
280
+ }
281
+
202
282
  // src/semantics/semantics.ts
203
283
  var ZOOM_MIN = -4;
204
284
  var ZOOM_MAX = 2;
@@ -323,12 +403,44 @@ function focusInfo(map, focusId) {
323
403
  usedBy: map.edges.filter((e) => e.to === node.id).map((e) => ref(e.from, e.label))
324
404
  };
325
405
  }
326
- function submapRefs(maps) {
327
- const refs = /* @__PURE__ */ new Set();
328
- for (const m of maps) {
329
- for (const n of m?.nodes ?? []) if (n.submap !== void 0) refs.add(n.submap);
406
+ function interiorPages(pages) {
407
+ const dives = /* @__PURE__ */ new Map();
408
+ const divedIntoBy = /* @__PURE__ */ new Map();
409
+ for (const [slug, map] of pages) {
410
+ const targets = /* @__PURE__ */ new Set();
411
+ for (const n of map?.nodes ?? []) {
412
+ const target = n.submap;
413
+ if (target === void 0 || target === slug) continue;
414
+ targets.add(target);
415
+ const sources = divedIntoBy.get(target) ?? /* @__PURE__ */ new Set();
416
+ sources.add(slug);
417
+ divedIntoBy.set(target, sources);
418
+ }
419
+ if (slug !== void 0) dives.set(slug, targets);
420
+ }
421
+ const reachableFrom = (start) => {
422
+ const seen = /* @__PURE__ */ new Set();
423
+ const pending = [start];
424
+ while (pending.length > 0) {
425
+ for (const target of dives.get(pending.pop()) ?? []) {
426
+ if (seen.has(target)) continue;
427
+ seen.add(target);
428
+ pending.push(target);
429
+ }
430
+ }
431
+ return seen;
432
+ };
433
+ const interior = /* @__PURE__ */ new Set();
434
+ for (const [target, sources] of divedIntoBy) {
435
+ const outward = reachableFrom(target);
436
+ for (const source of sources) {
437
+ if (source === void 0 || !outward.has(source)) {
438
+ interior.add(target);
439
+ break;
440
+ }
441
+ }
330
442
  }
331
- return refs;
443
+ return interior;
332
444
  }
333
445
  function diveParent(entries, pageId) {
334
446
  for (const [key, m] of entries) {
@@ -337,11 +449,11 @@ function diveParent(entries, pageId) {
337
449
  }
338
450
  return void 0;
339
451
  }
340
- function mostRecentKey(keys, mtimeOf) {
452
+ function mostRecentKey(keys, mtimeOf2) {
341
453
  let best;
342
454
  let bestMtime = -Infinity;
343
455
  for (const key of keys) {
344
- const mtime = mtimeOf(key);
456
+ const mtime = mtimeOf2(key);
345
457
  if (mtime !== void 0 && mtime > bestMtime) {
346
458
  best = key;
347
459
  bestMtime = mtime;
@@ -353,15 +465,60 @@ function flipForSequence(map) {
353
465
  if (map.kind !== "sequence") return map;
354
466
  return {
355
467
  ...map,
468
+ // VIOLATION: state-explicit-in-types - `-l.rank as Rank` produces a value
469
+ // the Rank brand promises cannot exist: mirroring 0..99 gives -99..0, and
470
+ // makeRank would refuse every one of them. The alternative is a second
471
+ // ordered-position type (an unbranded `order` field) threaded through the
472
+ // renderer's whole layout stage purely so this one derived map can be
473
+ // typed — a large change to express "these ranks are an order, not a
474
+ // stored value". What makes it safe is the same thing that makes it
475
+ // wrong: this map only ever reaches a renderer, which compares ranks and
476
+ // never writes them (same contract as aggregateMap).
356
477
  layers: map.layers.map((l) => ({ ...l, rank: -l.rank })),
357
478
  edges: map.edges.map((e) => ({ from: e.to, to: e.from, ...e.label !== void 0 ? { label: e.label } : {} }))
358
479
  };
359
480
  }
360
481
 
361
- // src/render/render.ts
482
+ // src/render/width.ts
362
483
  var WIDE_RANGES = [
363
484
  [4352, 4447],
364
485
  // Hangul Jamo
486
+ // Wide symbols scattered through the BMP — mostly emoji that predate the
487
+ // emoji planes (⌚ ⏰ ⚡ ✅ ✨ ❌ ❓ ⭐ ⬛ …).
488
+ [8986, 8987],
489
+ [9001, 9002],
490
+ [9193, 9196],
491
+ [9200, 9200],
492
+ [9203, 9203],
493
+ [9725, 9726],
494
+ [9748, 9749],
495
+ [9800, 9811],
496
+ [9855, 9855],
497
+ [9875, 9875],
498
+ [9889, 9889],
499
+ [9898, 9899],
500
+ [9917, 9918],
501
+ [9924, 9925],
502
+ [9934, 9934],
503
+ [9940, 9940],
504
+ [9962, 9962],
505
+ [9970, 9971],
506
+ [9973, 9973],
507
+ [9978, 9978],
508
+ [9981, 9981],
509
+ [9989, 9989],
510
+ [9994, 9995],
511
+ [10024, 10024],
512
+ [10060, 10060],
513
+ [10062, 10062],
514
+ [10067, 10069],
515
+ [10071, 10071],
516
+ [10133, 10135],
517
+ [10160, 10160],
518
+ [10175, 10175],
519
+ [11035, 11036],
520
+ [11088, 11088],
521
+ [11093, 11093],
365
522
  [11904, 42191],
366
523
  // CJK radicals .. Yi (covers CJK Unified Ideographs)
367
524
  [43360, 43391],
@@ -374,14 +531,41 @@ var WIDE_RANGES = [
374
531
  [65280, 65376],
375
532
  // fullwidth forms
376
533
  [65504, 65510],
534
+ [127744, 128591],
535
+ // pictographs, transport, emoticons (🚀 🎯 😀 …)
536
+ [128640, 128767],
537
+ [129280, 129535],
538
+ // supplemental symbols (🤖 🧱 …)
539
+ [129648, 129791],
540
+ // symbols extended-A
377
541
  [131072, 262141]
378
542
  // CJK extension planes
379
543
  ];
380
- function charWidth(cp) {
381
- for (const [lo, hi] of WIDE_RANGES) {
382
- if (cp >= lo && cp <= hi) return 2;
544
+ var ZERO_WIDTH_RANGES = [
545
+ [768, 879],
546
+ // combining diacritical marks (decomposed 'e' + ´)
547
+ [6832, 6911],
548
+ [7616, 7679],
549
+ [8203, 8207],
550
+ // zero-width space .. RLM, zero-width joiner among them
551
+ [8400, 8432],
552
+ // combining marks for symbols
553
+ [65024, 65039],
554
+ // variation selectors, VS16 (emoji presentation) included
555
+ [65056, 65071],
556
+ // combining half marks
557
+ [127995, 127999]
558
+ // emoji skin tone modifiers — always applied to a base
559
+ ];
560
+ function inRanges(cp, ranges) {
561
+ for (const [lo, hi] of ranges) {
562
+ if (cp >= lo && cp <= hi) return true;
383
563
  }
384
- return 1;
564
+ return false;
565
+ }
566
+ function charWidth(cp) {
567
+ if (inRanges(cp, ZERO_WIDTH_RANGES)) return 0;
568
+ return inRanges(cp, WIDE_RANGES) ? 2 : 1;
385
569
  }
386
570
  function displayWidth(text) {
387
571
  let w = 0;
@@ -423,6 +607,19 @@ function wrapWidth(s, width) {
423
607
  if (line !== "") lines.push(line);
424
608
  return lines;
425
609
  }
610
+
611
+ // src/render/canvas.ts
612
+ var SGR = {
613
+ none: "",
614
+ dim: "2",
615
+ amber: "33",
616
+ green: "32",
617
+ greenDim: "32;2",
618
+ // done, but nothing behind the claim: green, not fully lit
619
+ red: "31",
620
+ faint: "90"
621
+ };
622
+ var ANSI_RESET = "\x1B[0m";
426
623
  var UP = 1;
427
624
  var DOWN = 2;
428
625
  var LEFT = 4;
@@ -457,15 +654,6 @@ function maskChar(mask, heavyHorizontal, unicode) {
457
654
  }
458
655
  return LIGHT_BY_MASK[mask] ?? "\u253C";
459
656
  }
460
- var SGR = {
461
- none: "",
462
- dim: "2",
463
- amber: "33",
464
- green: "32",
465
- red: "31",
466
- faint: "90"
467
- };
468
- var ANSI_RESET = "\x1B[0m";
469
657
  var BORDER_JUNCTION = {
470
658
  "\u2500": { down: "\u252C", up: "\u2534" },
471
659
  "\u254C": { down: "\u252C", up: "\u2534" },
@@ -491,11 +679,17 @@ var Canvas = class {
491
679
  text(x, y, s, style, bold = false) {
492
680
  let cx = x;
493
681
  for (const ch of s) {
682
+ const w = charWidth(ch.codePointAt(0));
683
+ if (w === 0) {
684
+ const base = this.cell(Math.max(0, cx - 1), y);
685
+ const target = base.literal === "" ? this.cell(Math.max(0, cx - 2), y) : base;
686
+ target.literal = (target.literal ?? "") + ch;
687
+ continue;
688
+ }
494
689
  const c = this.cell(cx, y);
495
690
  c.literal = ch;
496
691
  c.style = style;
497
692
  c.bold = bold;
498
- const w = charWidth(ch.codePointAt(0));
499
693
  if (w === 2) {
500
694
  const phantom = this.cell(cx + 1, y);
501
695
  phantom.literal = "";
@@ -574,61 +768,12 @@ function drawPath(canvas, points, bright = false) {
574
768
  }
575
769
  }
576
770
  }
577
- var SPINNER_UNICODE = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
578
- var SPINNER_ASCII = ["|", "/", "-", "\\"];
579
- function skinFor(status, unicode) {
580
- const style = status === "planned" ? "dim" : status === "in-progress" ? "amber" : status === "done" ? "green" : "red";
581
- if (!unicode) {
582
- return status === "planned" ? { h: ".", v: ":", corners: ["+", "+", "+", "+"], style } : { h: "-", v: "|", corners: ["+", "+", "+", "+"], style };
583
- }
584
- switch (status) {
585
- case "planned":
586
- return { h: "\u254C", v: "\u254E", corners: ["\u256D", "\u256E", "\u2570", "\u256F"], style };
587
- case "in-progress":
588
- return { h: "\u2500", v: "\u2502", corners: ["\u256D", "\u256E", "\u2570", "\u256F"], style };
589
- case "done":
590
- case "regressed":
591
- return { h: "\u2501", v: "\u2503", corners: ["\u250F", "\u2513", "\u2517", "\u251B"], style };
592
- }
593
- }
594
- function glyphFor(status, opts) {
595
- const spinner = opts.unicode ? SPINNER_UNICODE : SPINNER_ASCII;
596
- switch (status) {
597
- case "planned":
598
- return opts.unicode ? "\xB7" : ".";
599
- case "in-progress":
600
- return spinner[opts.spinnerFrame % spinner.length];
601
- case "done":
602
- return opts.unicode ? "\u25A0" : "#";
603
- case "regressed":
604
- return opts.unicode ? "\u2717" : "X";
605
- }
606
- }
607
- var NODE_KIND_GLYPHS = {
608
- selector: ["?", "?"],
609
- sequence: ["\xBB", ">"],
610
- parallel: ["\u2016", "="],
611
- decorator: ["\u25CC", "o"],
612
- condition: ["\u25C7", "c"],
613
- action: ["\xB7", "."],
614
- source: ["\u25CB", "o"],
615
- transform: ["\u25D0", "%"],
616
- sink: ["\u25CF", "*"],
617
- service: ["\u25C6", "S"],
618
- db: ["\u25A4", "D"],
619
- queue: ["\u2263", "Q"],
620
- ui: ["\u25A3", "U"]
621
- };
622
- function kindGlyph(kind, unicode) {
623
- const pair = NODE_KIND_GLYPHS[kind];
624
- return pair === void 0 ? void 0 : unicode ? pair[0] : pair[1];
625
- }
626
- function neutralSkin(unicode) {
627
- return unicode ? { h: "\u2500", v: "\u2502", corners: ["\u256D", "\u256E", "\u2570", "\u256F"], style: "none" } : { h: "-", v: "|", corners: ["+", "+", "+", "+"], style: "none" };
628
- }
771
+
772
+ // src/render/zoom-geometry.ts
629
773
  var BOX_H = 3;
630
774
  var BOX_GAP = 2;
631
775
  var LEFT_MARGIN = 2;
776
+ var BAR_MIN_RUN = 7;
632
777
  var DETAIL_BUDGET = { innerMin: 22, innerMax: 32, noteRows: 3 };
633
778
  var DETAIL_PLUS_BUDGET = { innerMin: 30, innerMax: 48, noteRows: 12 };
634
779
  function zoomGeometry(zoom) {
@@ -651,56 +796,6 @@ function zoomGeometry(zoom) {
651
796
  return { mode, scale: 0, pad: 0, boxGap: BOX_GAP, breathe: 0, titleGap: 0, barGap: 1, bandCounts: true };
652
797
  }
653
798
  }
654
- var LABEL_BUDGET_MIN = 4;
655
- function boxSpec(node, geo, unicode, neutral) {
656
- const glyph = node.kind !== void 0 ? kindGlyph(node.kind, unicode) : void 0;
657
- const badge = node.submap !== void 0 ? unicode ? " \u229E" : " +" : "";
658
- const badgeW = displayWidth(badge);
659
- const text = !neutral && glyph !== void 0 ? `${glyph} ${node.label}` : node.label;
660
- if (geo.mode === "constellation") {
661
- return { w: 3, h: 1, label: "", pad: 0, borderless: true, extra: [] };
662
- }
663
- if (geo.mode === "detail" && geo.detail !== void 0) {
664
- const budget2 = geo.detail;
665
- const innerW = Math.min(Math.max(displayWidth(text) + badgeW + 4, budget2.innerMin), budget2.innerMax);
666
- const extra = [];
667
- if (node.evidence !== void 0) extra.push({ text: fitWidth(` ${node.evidence}`, innerW), style: "faint" });
668
- if (node.detail !== void 0) {
669
- const wrapped = wrapWidth(node.detail, innerW - 2);
670
- for (let i = 0; i < Math.min(wrapped.length, budget2.noteRows); i++) {
671
- const cut = i === budget2.noteRows - 1 && wrapped.length > budget2.noteRows;
672
- extra.push({ text: ` ${cut ? fitWidth(wrapped[i] + "\u2026", innerW - 2) : wrapped[i]}`, style: "none" });
673
- }
674
- }
675
- return {
676
- w: innerW + 2,
677
- h: BOX_H + extra.length,
678
- label: fitWidth(text, innerW - 4 - badgeW) + badge,
679
- pad: 1,
680
- borderless: false,
681
- extra
682
- };
683
- }
684
- const budget = Math.max(LABEL_BUDGET_MIN, Math.ceil(displayWidth(text) * geo.scale));
685
- const label = fitWidth(text, budget) + badge;
686
- return {
687
- w: displayWidth(label) + 4 + 2 * geo.pad,
688
- h: BOX_H,
689
- label,
690
- pad: geo.pad,
691
- borderless: false,
692
- extra: []
693
- };
694
- }
695
- function renderMapWindow(map, opts, viewport) {
696
- const built = buildCanvas(map, opts);
697
- return {
698
- lines: built.canvas.emit(opts, viewport),
699
- contentWidth: built.canvas.width,
700
- contentHeight: built.canvas.height,
701
- hits: built.hits
702
- };
703
- }
704
799
  var AGGREGATE_GEO = {
705
800
  mode: "boxes",
706
801
  scale: 1,
@@ -711,99 +806,31 @@ var AGGREGATE_GEO = {
711
806
  barGap: 1,
712
807
  bandCounts: false
713
808
  };
714
- function buildCanvas(map, opts) {
715
- const oriented = flipForSequence(map);
716
- const plainGeo = zoomGeometry(opts.zoom ?? ZOOM_DEFAULT);
717
- const aggregated = plainGeo.mode === "constellation" ? aggregateMap(oriented) : void 0;
718
- return buildCanvasWith(aggregated ?? oriented, opts, aggregated !== void 0 ? AGGREGATE_GEO : plainGeo);
719
- }
720
- function buildCanvasWith(map, opts, geo) {
721
- const canvas = new Canvas();
722
- const neutral = isNeutralKind(map);
723
- const bands = [...map.layers].sort((a, b) => b.rank - a.rank);
724
- if (bands.length === 0) {
725
- canvas.text(0, 0, map.title ?? "mellos mapping", "none", true);
726
- canvas.text(0, 2, "(empty map \u2014 declare layers and nodes to begin)", "dim");
727
- return { canvas, hits: [] };
728
- }
729
- const bandIndexOf = new Map(bands.map((l, i) => [l.id, i]));
730
- const boxes = /* @__PURE__ */ new Map();
731
- const bandBoxes = bands.map(() => []);
732
- for (const node of map.nodes) {
733
- const band = bandIndexOf.get(node.layer);
734
- const box = { node, ...boxSpec(node, geo, opts.unicode, neutral), x: LEFT_MARGIN, y: 0 };
735
- bandBoxes[band].push(box);
736
- boxes.set(node.id, box);
737
- }
738
- const laneCount = map.lanes.length;
739
- const laneX = [];
740
- const laneW = [];
741
- if (laneCount === 0) {
742
- for (const row of bandBoxes) {
743
- let x = LEFT_MARGIN;
744
- for (const box of row) {
745
- box.x = x;
746
- x += box.w + geo.boxGap;
747
- }
748
- }
749
- } else {
750
- const laneGap = geo.boxGap + 2;
751
- const laneIndexOf = new Map(map.lanes.map((l, i) => [l.id, i]));
752
- const regions = laneCount + 1;
753
- const grouped = bandBoxes.map((row) => {
754
- const cells = Array.from({ length: regions }, () => []);
755
- for (const box of row) {
756
- const lane = box.node.lane;
757
- cells[lane !== void 0 ? laneIndexOf.get(lane) : regions - 1].push(box);
758
- }
759
- return cells;
760
- });
761
- const regionW = Array.from({ length: regions }, () => 0);
762
- for (const cells of grouped) {
763
- for (let i = 0; i < regions; i++) {
764
- const rowW = cells[i].reduce((sum, b, k) => sum + b.w + (k > 0 ? geo.boxGap : 0), 0);
765
- regionW[i] = Math.max(regionW[i], rowW);
766
- }
767
- }
768
- for (let i = 0; i < laneCount; i++) regionW[i] = Math.max(regionW[i], displayWidth(map.lanes[i].label) + 2);
769
- let x0 = LEFT_MARGIN;
770
- for (let i = 0; i < regions; i++) {
771
- laneX.push(x0);
772
- laneW.push(regionW[i]);
773
- x0 += regionW[i] + laneGap;
774
- }
775
- for (const cells of grouped) {
776
- for (let i = 0; i < regions; i++) {
777
- let x = laneX[i];
778
- for (const box of cells[i]) {
779
- box.x = x;
780
- x += box.w + geo.boxGap;
781
- }
782
- }
783
- }
784
- }
785
- const bandLabel = bands.map((l, i) => {
786
- const row = bandBoxes[i];
787
- const done = row.filter((b) => b.node.status === "done").length;
788
- return geo.bandCounts && row.length > 0 && !neutral ? ` ${l.name} ${done}/${row.length}` : ` ${l.name}`;
789
- });
790
- let contentWidth = LEFT_MARGIN;
791
- for (const row of bandBoxes) {
792
- const last = row[row.length - 1];
793
- if (last) contentWidth = Math.max(contentWidth, last.x + last.w);
794
- }
795
- for (let i = 0; i < laneCount; i++) contentWidth = Math.max(contentWidth, laneX[i] + laneW[i]);
796
- for (const label of bandLabel) contentWidth = Math.max(contentWidth, LEFT_MARGIN + displayWidth(label) + 7);
797
- const routes = map.edges.map((e) => {
798
- const fromBox = boxes.get(e.from);
799
- const toBox = boxes.get(e.to);
809
+
810
+ // src/render/routing.ts
811
+ function routeEdges(map, columns) {
812
+ const { bandIndexOf, bandBoxes, boxOf, contentWidth } = columns;
813
+ const pending = map.edges.map((e) => {
814
+ const from = boxOf.get(e.from);
815
+ const to = boxOf.get(e.to);
800
816
  return {
801
- fromBox,
802
- toBox,
803
- fromBand: bandIndexOf.get(fromBox.node.layer),
804
- toBand: bandIndexOf.get(toBox.node.layer)
817
+ from,
818
+ to,
819
+ fromBand: bandIndexOf.get(from.node.layer),
820
+ toBand: bandIndexOf.get(to.node.layer)
805
821
  };
806
822
  });
823
+ const gapVerticals = Array.from(
824
+ { length: Math.max(0, columns.bands.length - 1) },
825
+ () => /* @__PURE__ */ new Map()
826
+ );
827
+ const verticalFree = (gap, x, edge) => {
828
+ const owner = gapVerticals[gap]?.get(x);
829
+ return owner === void 0 || owner === edge;
830
+ };
831
+ const takeVertical = (gap, x, edge) => {
832
+ gapVerticals[gap]?.set(x, edge);
833
+ };
807
834
  const claimedColumns = /* @__PURE__ */ new Map();
808
835
  const isFree = (box, x) => !(claimedColumns.get(box)?.has(x) ?? false);
809
836
  const claim = (box, x) => {
@@ -812,60 +839,65 @@ function buildCanvasWith(map, opts, geo) {
812
839
  set.add(x);
813
840
  return x;
814
841
  };
815
- const straightX = /* @__PURE__ */ new Map();
816
- for (const r of routes) {
842
+ for (const r of pending) {
817
843
  if (r.toBand - r.fromBand !== 1) continue;
818
- const lo = Math.max(r.fromBox.x + 1, r.toBox.x + 1);
819
- const hi = Math.min(r.fromBox.x + r.fromBox.w - 2, r.toBox.x + r.toBox.w - 2);
844
+ const lo = Math.max(r.from.x + 1, r.to.x + 1);
845
+ const hi = Math.min(r.from.x + r.from.w - 2, r.to.x + r.to.w - 2);
820
846
  if (lo > hi) continue;
821
847
  const mid = Math.floor((lo + hi) / 2);
822
- for (let d = 0; d <= hi - lo && !straightX.has(r); d++) {
848
+ for (let d = 0; d <= hi - lo && r.straightX === void 0; d++) {
823
849
  for (const x of d === 0 ? [mid] : [mid - d, mid + d]) {
824
- if (x >= lo && x <= hi && isFree(r.fromBox, x) && isFree(r.toBox, x)) {
825
- straightX.set(r, claim(r.toBox, claim(r.fromBox, x)));
850
+ if (x >= lo && x <= hi && isFree(r.from, x) && isFree(r.to, x) && verticalFree(r.fromBand, x, r)) {
851
+ r.straightX = claim(r.to, claim(r.from, x));
852
+ takeVertical(r.fromBand, x, r);
826
853
  break;
827
854
  }
828
855
  }
829
856
  }
830
857
  }
831
- const bent = routes.filter((r) => !straightX.has(r));
858
+ const bent = pending.filter((r) => r.straightX === void 0);
832
859
  const outgoing = /* @__PURE__ */ new Map();
833
860
  const incoming = /* @__PURE__ */ new Map();
834
861
  for (const r of bent) {
835
- outgoing.set(r.fromBox, [...outgoing.get(r.fromBox) ?? [], r]);
836
- incoming.set(r.toBox, [...incoming.get(r.toBox) ?? [], r]);
862
+ outgoing.set(r.from, [...outgoing.get(r.from) ?? [], r]);
863
+ incoming.set(r.to, [...incoming.get(r.to) ?? [], r]);
837
864
  }
838
- const freeSlot = (box, k, n) => {
865
+ const freeSlot = (box, k, n, edge, gap) => {
839
866
  const lo = box.x + 1;
840
867
  const hi = box.x + box.w - 2;
841
868
  const ideal = box.x + Math.min(box.w - 2, Math.max(1, Math.round((k + 1) * (box.w - 1) / (n + 1))));
842
869
  for (let d = 0; d <= hi - lo; d++) {
843
870
  for (const x of d === 0 ? [ideal] : [ideal - d, ideal + d]) {
844
- if (x >= lo && x <= hi && isFree(box, x)) return claim(box, x);
871
+ if (x >= lo && x <= hi && isFree(box, x) && verticalFree(gap, x, edge)) {
872
+ takeVertical(gap, x, edge);
873
+ return claim(box, x);
874
+ }
845
875
  }
846
876
  }
847
877
  return ideal;
848
878
  };
849
- const attach = /* @__PURE__ */ new Map();
850
879
  for (const r of bent) {
851
- const outs = outgoing.get(r.fromBox);
852
- const ins = incoming.get(r.toBox);
853
- attach.set(r, {
854
- sx: freeSlot(r.fromBox, outs.indexOf(r), outs.length),
855
- ex: freeSlot(r.toBox, ins.indexOf(r), ins.length)
856
- });
880
+ const outs = outgoing.get(r.from);
881
+ const ins = incoming.get(r.to);
882
+ r.exitX = freeSlot(r.from, outs.indexOf(r), outs.length, r, r.fromBand);
883
+ r.entryX = freeSlot(r.to, ins.indexOf(r), ins.length, r, r.toBand - 1);
857
884
  }
858
- const skipRoutes = bent.filter((r) => r.toBand - r.fromBand > 1);
859
885
  const usedDescent = /* @__PURE__ */ new Set();
860
- const descentX = /* @__PURE__ */ new Map();
861
886
  let fallbackCount = 0;
862
887
  const blockedByBox = (band, x) => bandBoxes[band].some((b) => x >= b.x && x <= b.x + b.w - 1);
863
- for (const r of skipRoutes) {
864
- const { ex } = attach.get(r);
888
+ const descentGapsFree = (r, c) => {
889
+ for (let g = r.fromBand; g <= r.toBand - 1; g++) {
890
+ if (!verticalFree(g, c, r)) return false;
891
+ }
892
+ return true;
893
+ };
894
+ for (const r of bent.filter((e) => e.toBand - e.fromBand > 1)) {
895
+ const ex = r.entryX;
865
896
  let chosen;
866
897
  for (let d = 0; d <= contentWidth && chosen === void 0; d++) {
867
898
  for (const c of d === 0 ? [ex] : [ex - d, ex + d]) {
868
899
  if (c < LEFT_MARGIN || c > contentWidth + 1 || usedDescent.has(c)) continue;
900
+ if (!descentGapsFree(r, c)) continue;
869
901
  let blocked = false;
870
902
  for (let b = r.fromBand + 1; b < r.toBand && !blocked; b++) blocked = blockedByBox(b, c);
871
903
  if (!blocked) {
@@ -874,58 +906,368 @@ function buildCanvasWith(map, opts, geo) {
874
906
  }
875
907
  }
876
908
  }
877
- if (chosen === void 0) chosen = contentWidth + 2 + fallbackCount++ * 2;
878
- usedDescent.add(chosen);
879
- descentX.set(r, chosen);
880
- }
881
- const totalWidth = fallbackCount > 0 ? contentWidth + 2 + fallbackCount * 2 : contentWidth;
882
- const gapCount = bands.length - 1;
883
- const gapSegments = Array.from({ length: gapCount }, () => []);
884
- const segmentOf = /* @__PURE__ */ new Map();
885
- for (const r of bent) {
886
- const { sx, ex } = attach.get(r);
887
- if (r.toBand - r.fromBand === 1) {
888
- const landing = { route: r, kind: "landing", lo: Math.min(sx, ex), hi: Math.max(sx, ex) };
889
- gapSegments[r.toBand - 1].push(landing);
890
- segmentOf.set(r, { landing });
891
- } else {
892
- const c = descentX.get(r);
893
- const exit = { route: r, kind: "exit", lo: Math.min(sx, c), hi: Math.max(sx, c) };
894
- const landing = { route: r, kind: "landing", lo: Math.min(c, ex), hi: Math.max(c, ex) };
895
- gapSegments[r.fromBand].push(exit);
896
- gapSegments[r.toBand - 1].push(landing);
897
- segmentOf.set(r, { exit, landing });
909
+ if (chosen === void 0) chosen = contentWidth + 2 + fallbackCount++ * 2;
910
+ usedDescent.add(chosen);
911
+ for (let g = r.fromBand; g <= r.toBand - 1; g++) takeVertical(g, chosen, r);
912
+ r.descentX = chosen;
913
+ }
914
+ const gapCount = Math.max(0, columns.bands.length - 1);
915
+ const gapSegments = Array.from(
916
+ { length: gapCount },
917
+ () => []
918
+ );
919
+ for (const r of bent) {
920
+ const sx = r.exitX;
921
+ const ex = r.entryX;
922
+ if (r.descentX === void 0) {
923
+ gapSegments[r.toBand - 1].push({
924
+ edge: r,
925
+ kind: "landing",
926
+ segment: { lo: Math.min(sx, ex), hi: Math.max(sx, ex) }
927
+ });
928
+ } else {
929
+ const c = r.descentX;
930
+ gapSegments[r.fromBand].push({ edge: r, kind: "exit", segment: { lo: Math.min(sx, c), hi: Math.max(sx, c) } });
931
+ gapSegments[r.toBand - 1].push({
932
+ edge: r,
933
+ kind: "landing",
934
+ segment: { lo: Math.min(c, ex), hi: Math.max(c, ex) }
935
+ });
936
+ }
937
+ }
938
+ const exitRow = /* @__PURE__ */ new Map();
939
+ const landingRow = /* @__PURE__ */ new Map();
940
+ const gapRowCount = gapSegments.map((entries) => {
941
+ const rowEnds = [];
942
+ for (const e of [...entries].sort((a, b) => a.segment.lo - b.segment.lo)) {
943
+ let row = rowEnds.findIndex((end) => e.segment.lo > end + 1);
944
+ if (row === -1) {
945
+ rowEnds.push(e.segment.hi);
946
+ row = rowEnds.length - 1;
947
+ } else {
948
+ rowEnds[row] = Math.max(rowEnds[row], e.segment.hi);
949
+ }
950
+ (e.kind === "exit" ? exitRow : landingRow).set(e.edge, row);
951
+ }
952
+ return rowEnds.length;
953
+ });
954
+ const edges = pending.map((r) => {
955
+ const common = { from: r.from, to: r.to, fromBand: r.fromBand, toBand: r.toBand };
956
+ if (r.straightX !== void 0) return { ...common, kind: "straight", x: r.straightX };
957
+ if (r.descentX === void 0) {
958
+ return { ...common, kind: "dogleg", exitX: r.exitX, entryX: r.entryX, landingRow: landingRow.get(r) };
959
+ }
960
+ return {
961
+ ...common,
962
+ kind: "thread",
963
+ exitX: r.exitX,
964
+ entryX: r.entryX,
965
+ descentX: r.descentX,
966
+ exitRow: exitRow.get(r),
967
+ landingRow: landingRow.get(r)
968
+ };
969
+ });
970
+ return { edges, gapRowCount, fallbackCount };
971
+ }
972
+ function edgePolyline(edge, rows) {
973
+ const from = rows.boxOf.get(edge.from.node.id);
974
+ const to = rows.boxOf.get(edge.to.node.id);
975
+ const sy = from.y + from.h - 1;
976
+ const ey = to.y;
977
+ if (edge.kind === "straight") {
978
+ return [
979
+ [edge.x, sy],
980
+ [edge.x, ey]
981
+ ];
982
+ }
983
+ const landingY = rows.gapTrackStartY[edge.toBand - 1] + edge.landingRow;
984
+ if (edge.kind === "dogleg") {
985
+ return [
986
+ [edge.exitX, sy],
987
+ [edge.exitX, landingY],
988
+ [edge.entryX, landingY],
989
+ [edge.entryX, ey]
990
+ ];
991
+ }
992
+ const exitY = rows.gapTrackStartY[edge.fromBand] + edge.exitRow;
993
+ return [
994
+ [edge.exitX, sy],
995
+ [edge.exitX, exitY],
996
+ [edge.descentX, exitY],
997
+ [edge.descentX, landingY],
998
+ [edge.entryX, landingY],
999
+ [edge.entryX, ey]
1000
+ ];
1001
+ }
1002
+
1003
+ // src/render/skins.ts
1004
+ function styleFor(face) {
1005
+ switch (face) {
1006
+ case "planned":
1007
+ return "dim";
1008
+ case "in-progress":
1009
+ return "amber";
1010
+ case "done":
1011
+ return "green";
1012
+ case "done-unverified":
1013
+ return "greenDim";
1014
+ case "regressed":
1015
+ return "red";
1016
+ }
1017
+ }
1018
+ function statusSgr(status) {
1019
+ return SGR[styleFor(status)];
1020
+ }
1021
+ function skinFor(face, unicode) {
1022
+ const style = styleFor(face);
1023
+ if (!unicode) {
1024
+ return face === "planned" ? { h: ".", v: ":", corners: ["+", "+", "+", "+"], style } : { h: "-", v: "|", corners: ["+", "+", "+", "+"], style };
1025
+ }
1026
+ switch (face) {
1027
+ case "planned":
1028
+ return { h: "\u254C", v: "\u254E", corners: ["\u256D", "\u256E", "\u2570", "\u256F"], style };
1029
+ case "in-progress":
1030
+ return { h: "\u2500", v: "\u2502", corners: ["\u256D", "\u256E", "\u2570", "\u256F"], style };
1031
+ // an unverified done keeps the heavy border of done — it is the same
1032
+ // claim, told with a hollow glyph and a dimmer green
1033
+ case "done":
1034
+ case "done-unverified":
1035
+ case "regressed":
1036
+ return { h: "\u2501", v: "\u2503", corners: ["\u250F", "\u2513", "\u2517", "\u251B"], style };
1037
+ }
1038
+ }
1039
+ function glyphFor(face, opts) {
1040
+ if (face === "in-progress") return spinnerGlyph(opts.spinnerFrame, opts.unicode);
1041
+ return face === "done-unverified" ? unverifiedDoneGlyph(opts.unicode) : statusGlyph(face, opts.unicode);
1042
+ }
1043
+ function neutralSkin(unicode) {
1044
+ return unicode ? { h: "\u2500", v: "\u2502", corners: ["\u256D", "\u256E", "\u2570", "\u256F"], style: "none" } : { h: "-", v: "|", corners: ["+", "+", "+", "+"], style: "none" };
1045
+ }
1046
+ function neutralGlyph(node, unicode) {
1047
+ return (node.kind !== void 0 ? kindGlyph(node.kind, unicode) : void 0) ?? (unicode ? "\xB7" : ".");
1048
+ }
1049
+ function unverifiedDoneIds(declared, drawn) {
1050
+ const out = /* @__PURE__ */ new Set();
1051
+ const declaredById = new Map(declared.nodes.map((n) => [n.id, n]));
1052
+ for (const node of drawn.nodes) {
1053
+ if (node.status !== "done") continue;
1054
+ const own = declaredById.get(node.id);
1055
+ if (own !== void 0) {
1056
+ if (own.evidence === void 0) out.add(node.id);
1057
+ } else if (declared.nodes.some(
1058
+ (m) => m.group === node.id && m.status === "done" && m.evidence === void 0
1059
+ )) {
1060
+ out.add(node.id);
1061
+ }
1062
+ }
1063
+ return out;
1064
+ }
1065
+
1066
+ // src/render/draw.ts
1067
+ function drawTitle(canvas, title) {
1068
+ canvas.text(LEFT_MARGIN, 0, title, "none", true);
1069
+ }
1070
+ function drawLaneHeaders(canvas, map, columns, rows) {
1071
+ if (rows.laneHeaderY === void 0) return;
1072
+ for (let i = 0; i < map.lanes.length; i++) {
1073
+ const region = columns.lanes[i];
1074
+ const label = fitWidth(map.lanes[i].label, region.w);
1075
+ const cx = region.x + Math.max(0, Math.floor((region.w - displayWidth(label)) / 2));
1076
+ canvas.text(cx, rows.laneHeaderY, label, "faint", true);
1077
+ }
1078
+ }
1079
+ function drawBands(canvas, columns, rows, wiredWidth, totalWidth) {
1080
+ for (let b = 0; b < columns.bands.length; b++) {
1081
+ const label = columns.bandLabel[b];
1082
+ for (let x = 0; x < wiredWidth; x++) canvas.line(x, rows.barY[b], LEFT | RIGHT, true);
1083
+ canvas.text(totalWidth - displayWidth(label), rows.barY[b], label, "none", true);
1084
+ }
1085
+ }
1086
+ function drawBox(canvas, box, opts, neutral, face, focused = false) {
1087
+ const { node, x, y, w } = box;
1088
+ const skin = neutral ? neutralSkin(opts.unicode) : skinFor(face, opts.unicode);
1089
+ const slotGlyph = neutral ? neutralGlyph(node, opts.unicode) : glyphFor(face, opts);
1090
+ if (box.borderless) {
1091
+ canvas.text(x + 1, y, slotGlyph, skin.style, true);
1092
+ return;
1093
+ }
1094
+ const inner = w - 2;
1095
+ const pad = box.pad === 1 ? " " : "";
1096
+ canvas.text(x, y, skin.corners[0] + skin.h.repeat(inner) + skin.corners[1], skin.style, focused);
1097
+ canvas.text(x, y + 1, skin.v, skin.style, focused);
1098
+ canvas.text(x + 1, y + 1, `${pad}${slotGlyph} ${box.label}${pad}`, skin.style, true);
1099
+ canvas.text(x + w - 1, y + 1, skin.v, skin.style, focused);
1100
+ for (let i = 0; i < box.extra.length; i++) {
1101
+ const row = box.extra[i];
1102
+ const yy = y + 2 + i;
1103
+ canvas.text(x, yy, skin.v, skin.style, focused);
1104
+ canvas.text(x + 1, yy, row.text, row.style);
1105
+ canvas.text(x + w - 1, yy, skin.v, skin.style, focused);
1106
+ }
1107
+ canvas.text(x, y + box.h - 1, skin.corners[2] + skin.h.repeat(inner) + skin.corners[3], skin.style, focused);
1108
+ }
1109
+ function drawEdges(canvas, edges, rows, opts) {
1110
+ for (const edge of edges) {
1111
+ const bright = opts.focus !== void 0 && (edge.from.node.id === opts.focus || edge.to.node.id === opts.focus);
1112
+ drawPath(canvas, edgePolyline(edge, rows), bright);
1113
+ }
1114
+ }
1115
+ function drawLegend(canvas, map, opts, legendY, neutral, anyUnverified) {
1116
+ let lx = LEFT_MARGIN;
1117
+ if (neutral) {
1118
+ lx = canvas.text(lx, legendY, map.kind, "faint");
1119
+ const seen = /* @__PURE__ */ new Set();
1120
+ for (const n of map.nodes) {
1121
+ const k = n.kind;
1122
+ if (k === void 0 || seen.has(k) || kindGlyph(k, opts.unicode) === void 0) continue;
1123
+ seen.add(k);
1124
+ lx = canvas.text(lx, legendY, " ", "none");
1125
+ lx = canvas.text(lx, legendY, `${kindGlyph(k, opts.unicode)} ${k}`, "none");
1126
+ }
1127
+ return;
1128
+ }
1129
+ const legendOpts = { ...opts, spinnerFrame: 0 };
1130
+ const faces = ["planned", "in-progress", "done", "regressed"];
1131
+ if (anyUnverified) faces.push("done-unverified");
1132
+ for (const face of faces) {
1133
+ if (lx > LEFT_MARGIN) lx = canvas.text(lx, legendY, " ", "none");
1134
+ const word = face === "done-unverified" ? "done, no evidence" : face;
1135
+ lx = canvas.text(lx, legendY, `${glyphFor(face, legendOpts)} ${word}`, styleFor(face));
1136
+ }
1137
+ }
1138
+
1139
+ // src/render/layout.ts
1140
+ var LABEL_BUDGET_MIN = 4;
1141
+ function boxSpec(node, geo, unicode, neutral) {
1142
+ const glyph = node.kind !== void 0 ? kindGlyph(node.kind, unicode) : void 0;
1143
+ const badge = node.submap !== void 0 ? unicode ? " \u229E" : " +" : "";
1144
+ const badgeW = displayWidth(badge);
1145
+ const text = !neutral && glyph !== void 0 ? `${glyph} ${node.label}` : node.label;
1146
+ if (geo.mode === "constellation") {
1147
+ return { w: 3, h: 1, label: "", pad: 0, borderless: true, extra: [] };
1148
+ }
1149
+ if (geo.mode === "detail" && geo.detail !== void 0) {
1150
+ const budget2 = geo.detail;
1151
+ const innerW = Math.min(Math.max(displayWidth(text) + badgeW + 4, budget2.innerMin), budget2.innerMax);
1152
+ const extra = [];
1153
+ if (node.evidence !== void 0) extra.push({ text: fitWidth(` ${node.evidence}`, innerW), style: "faint" });
1154
+ if (node.detail !== void 0) {
1155
+ const wrapped = wrapWidth(node.detail, innerW - 2);
1156
+ for (let i = 0; i < Math.min(wrapped.length, budget2.noteRows); i++) {
1157
+ const cut = i === budget2.noteRows - 1 && wrapped.length > budget2.noteRows;
1158
+ extra.push({ text: ` ${cut ? fitWidth(wrapped[i] + "\u2026", innerW - 2) : wrapped[i]}`, style: "none" });
1159
+ }
1160
+ }
1161
+ return {
1162
+ w: innerW + 2,
1163
+ h: BOX_H + extra.length,
1164
+ label: fitWidth(text, innerW - 4 - badgeW) + badge,
1165
+ pad: 1,
1166
+ borderless: false,
1167
+ extra
1168
+ };
1169
+ }
1170
+ const budget = Math.max(LABEL_BUDGET_MIN, Math.ceil(displayWidth(text) * geo.scale));
1171
+ const label = fitWidth(text, budget) + badge;
1172
+ return {
1173
+ w: displayWidth(label) + 4 + 2 * geo.pad,
1174
+ h: BOX_H,
1175
+ label,
1176
+ pad: geo.pad,
1177
+ borderless: false,
1178
+ extra: []
1179
+ };
1180
+ }
1181
+ function layoutColumns(map, geo, unicode, neutral) {
1182
+ const bands = [...map.layers].sort((a, b) => b.rank - a.rank);
1183
+ const bandIndexOf = new Map(bands.map((l, i) => [l.id, i]));
1184
+ const sized = /* @__PURE__ */ new Map();
1185
+ const bandSized = bands.map(() => []);
1186
+ for (const node of map.nodes) {
1187
+ const spec = { node, ...boxSpec(node, geo, unicode, neutral) };
1188
+ bandSized[bandIndexOf.get(node.layer)].push(spec);
1189
+ sized.set(node.id, spec);
1190
+ }
1191
+ const columnOf = /* @__PURE__ */ new Map();
1192
+ const lanes = [];
1193
+ if (map.lanes.length === 0) {
1194
+ for (const row of bandSized) {
1195
+ let x = LEFT_MARGIN;
1196
+ for (const spec of row) {
1197
+ columnOf.set(spec, x);
1198
+ x += spec.w + geo.boxGap;
1199
+ }
1200
+ }
1201
+ } else {
1202
+ const laneCount = map.lanes.length;
1203
+ const laneGap = geo.boxGap + 2;
1204
+ const laneIndexOf = new Map(map.lanes.map((l, i) => [l.id, i]));
1205
+ const regions = laneCount + 1;
1206
+ const grouped = bandSized.map((row) => {
1207
+ const cells = Array.from({ length: regions }, () => []);
1208
+ for (const spec of row) {
1209
+ const lane = spec.node.lane;
1210
+ cells[lane !== void 0 ? laneIndexOf.get(lane) : regions - 1].push(spec);
1211
+ }
1212
+ return cells;
1213
+ });
1214
+ const regionW = Array.from({ length: regions }, () => 0);
1215
+ for (const cells of grouped) {
1216
+ for (let i = 0; i < regions; i++) {
1217
+ const rowW = cells[i].reduce((sum, b, k) => sum + b.w + (k > 0 ? geo.boxGap : 0), 0);
1218
+ regionW[i] = Math.max(regionW[i], rowW);
1219
+ }
1220
+ }
1221
+ for (let i = 0; i < laneCount; i++) regionW[i] = Math.max(regionW[i], displayWidth(map.lanes[i].label) + 2);
1222
+ let x0 = LEFT_MARGIN;
1223
+ for (let i = 0; i < regions; i++) {
1224
+ lanes.push({ x: x0, w: regionW[i] });
1225
+ x0 += regionW[i] + laneGap;
898
1226
  }
899
- }
900
- const segmentRow = /* @__PURE__ */ new Map();
901
- const gapRowCount = gapSegments.map((segments) => {
902
- const rowEnds = [];
903
- for (const s of [...segments].sort((a, b) => a.lo - b.lo)) {
904
- let row = rowEnds.findIndex((end) => s.lo > end + 1);
905
- if (row === -1) {
906
- rowEnds.push(s.hi);
907
- row = rowEnds.length - 1;
908
- } else {
909
- rowEnds[row] = Math.max(rowEnds[row], s.hi);
1227
+ for (const cells of grouped) {
1228
+ for (let i = 0; i < regions; i++) {
1229
+ let x = lanes[i].x;
1230
+ for (const spec of cells[i]) {
1231
+ columnOf.set(spec, x);
1232
+ x += spec.w + geo.boxGap;
1233
+ }
910
1234
  }
911
- segmentRow.set(s, row);
912
1235
  }
913
- return rowEnds.length;
1236
+ }
1237
+ const placed = /* @__PURE__ */ new Map();
1238
+ for (const [, spec] of sized) placed.set(spec, { ...spec, x: columnOf.get(spec) ?? LEFT_MARGIN });
1239
+ const bandBoxes = bandSized.map((row) => row.map((spec) => placed.get(spec)));
1240
+ const boxOf = /* @__PURE__ */ new Map();
1241
+ for (const node of map.nodes) boxOf.set(node.id, placed.get(sized.get(node.id)));
1242
+ const bandLabel = bands.map((l, i) => {
1243
+ const row = bandBoxes[i];
1244
+ const done = row.filter((b) => b.node.status === "done").length;
1245
+ return geo.bandCounts && row.length > 0 && !neutral ? ` ${l.name} ${done}/${row.length}` : ` ${l.name}`;
914
1246
  });
1247
+ let contentWidth = LEFT_MARGIN + BAR_MIN_RUN;
1248
+ for (const row of bandBoxes) for (const box of row) contentWidth = Math.max(contentWidth, box.x + box.w);
1249
+ for (const lane of lanes) contentWidth = Math.max(contentWidth, lane.x + lane.w);
1250
+ return { bands, bandIndexOf, bandBoxes, boxOf, lanes, bandLabel, contentWidth };
1251
+ }
1252
+ function layoutRows(columns, geo, gapRowCount, hasTitle, hasLanes) {
915
1253
  let y = 0;
916
- if (map.title !== void 0) y += 1 + geo.titleGap;
1254
+ if (hasTitle) y += 1 + geo.titleGap;
917
1255
  let laneHeaderY;
918
- if (laneCount > 0) {
1256
+ if (hasLanes) {
919
1257
  laneHeaderY = y;
920
1258
  y += 1 + geo.barGap;
921
1259
  }
922
1260
  const barY = [];
923
1261
  const gapTrackStartY = [];
924
- for (let b = 0; b < bands.length; b++) {
1262
+ const bandBoxes = [];
1263
+ const placed = /* @__PURE__ */ new Map();
1264
+ const gapCount = columns.bands.length - 1;
1265
+ for (let b = 0; b < columns.bands.length; b++) {
925
1266
  barY.push(y);
926
1267
  y += 1 + geo.barGap;
927
- const row = bandBoxes[b];
928
- for (const box of row) box.y = y;
1268
+ const row = columns.bandBoxes[b];
1269
+ for (const box of row) placed.set(box, { ...box, y });
1270
+ bandBoxes.push(row.map((box) => placed.get(box)));
929
1271
  y += row.reduce((max, box) => Math.max(max, box.h), geo.mode === "constellation" ? 1 : BOX_H);
930
1272
  if (b < gapCount) {
931
1273
  y += geo.breathe;
@@ -934,97 +1276,52 @@ function buildCanvasWith(map, opts, geo) {
934
1276
  y += geo.breathe;
935
1277
  }
936
1278
  }
937
- const legendY = y + 1;
938
- const rowYOf = (gap, s) => gapTrackStartY[gap] + segmentRow.get(s);
939
- if (map.title !== void 0) canvas.text(LEFT_MARGIN, 0, map.title, "none", true);
940
- if (laneHeaderY !== void 0) {
941
- for (let i = 0; i < laneCount; i++) {
942
- const label = fitWidth(map.lanes[i].label, laneW[i]);
943
- const cx = laneX[i] + Math.max(0, Math.floor((laneW[i] - displayWidth(label)) / 2));
944
- canvas.text(cx, laneHeaderY, label, "faint", true);
945
- }
946
- }
947
- for (let b = 0; b < bands.length; b++) {
948
- const label = bandLabel[b];
949
- for (let x = 0; x < totalWidth; x++) canvas.line(x, barY[b], LEFT | RIGHT, true);
950
- const labelStart = (fallbackCount > 0 ? contentWidth : totalWidth) - displayWidth(label);
951
- canvas.text(labelStart, barY[b], label, "none", true);
952
- }
953
- for (const box of boxes.values()) {
954
- drawBox(canvas, box, opts, neutral, opts.focus !== void 0 && box.node.id === opts.focus);
955
- }
956
- for (const r of routes) {
957
- const sy = r.fromBox.y + r.fromBox.h - 1;
958
- const ey = r.toBox.y;
959
- const bright = opts.focus !== void 0 && (r.fromBox.node.id === opts.focus || r.toBox.node.id === opts.focus);
960
- const direct = straightX.get(r);
961
- if (direct !== void 0) {
962
- drawPath(
963
- canvas,
964
- [
965
- [direct, sy],
966
- [direct, ey]
967
- ],
968
- bright
969
- );
970
- continue;
971
- }
972
- const { sx, ex } = attach.get(r);
973
- const segments = segmentOf.get(r);
974
- const landingY = rowYOf(r.toBand - 1, segments.landing);
975
- if (r.toBand - r.fromBand === 1) {
976
- drawPath(
977
- canvas,
978
- [
979
- [sx, sy],
980
- [sx, landingY],
981
- [ex, landingY],
982
- [ex, ey]
983
- ],
984
- bright
985
- );
986
- } else {
987
- const c = descentX.get(r);
988
- const exitY = rowYOf(r.fromBand, segments.exit);
989
- drawPath(
990
- canvas,
991
- [
992
- [sx, sy],
993
- [sx, exitY],
994
- [c, exitY],
995
- [c, landingY],
996
- [ex, landingY],
997
- [ex, ey]
998
- ],
999
- bright
1000
- );
1001
- }
1002
- }
1003
- let lx = LEFT_MARGIN;
1004
- if (neutral) {
1005
- lx = canvas.text(lx, legendY, map.kind, "faint");
1006
- const seen = /* @__PURE__ */ new Set();
1007
- for (const n of map.nodes) {
1008
- const k = n.kind;
1009
- if (k === void 0 || seen.has(k) || kindGlyph(k, opts.unicode) === void 0) continue;
1010
- seen.add(k);
1011
- lx = canvas.text(lx, legendY, " ", "none");
1012
- lx = canvas.text(lx, legendY, `${kindGlyph(k, opts.unicode)} ${k}`, "none");
1013
- }
1014
- } else {
1015
- const legendOpts = { ...opts, spinnerFrame: 0 };
1016
- const legendEntries = [
1017
- ["planned", "dim"],
1018
- ["in-progress", "amber"],
1019
- ["done", "green"],
1020
- ["regressed", "red"]
1021
- ];
1022
- for (const [status, style] of legendEntries) {
1023
- if (lx > LEFT_MARGIN) lx = canvas.text(lx, legendY, " ", "none");
1024
- lx = canvas.text(lx, legendY, `${glyphFor(status, legendOpts)} ${status}`, style);
1025
- }
1279
+ const boxOf = /* @__PURE__ */ new Map();
1280
+ for (const [id, box] of columns.boxOf) boxOf.set(id, placed.get(box));
1281
+ return { boxOf, bandBoxes, barY, gapTrackStartY, laneHeaderY, legendY: y + 1 };
1282
+ }
1283
+
1284
+ // src/render/render.ts
1285
+ function renderMapWindow(map, opts, viewport) {
1286
+ const built = buildCanvas(map, opts);
1287
+ return {
1288
+ lines: built.canvas.emit(opts, viewport),
1289
+ contentWidth: built.canvas.width,
1290
+ contentHeight: built.canvas.height,
1291
+ hits: built.hits
1292
+ };
1293
+ }
1294
+ function buildCanvas(map, opts) {
1295
+ const oriented = flipForSequence(map);
1296
+ const plainGeo = zoomGeometry(opts.zoom ?? ZOOM_DEFAULT);
1297
+ const aggregated = plainGeo.mode === "constellation" ? aggregateMap(oriented) : void 0;
1298
+ const drawn = aggregated ?? oriented;
1299
+ return paint(drawn, opts, aggregated !== void 0 ? AGGREGATE_GEO : plainGeo, unverifiedDoneIds(oriented, drawn));
1300
+ }
1301
+ function paint(map, opts, geo, unverified) {
1302
+ const canvas = new Canvas();
1303
+ if (map.layers.length === 0) {
1304
+ canvas.text(0, 0, map.title ?? "mellos mapping", "none", true);
1305
+ canvas.text(0, 2, "(empty map \u2014 declare layers and nodes to begin)", "dim");
1306
+ return { canvas, hits: [] };
1026
1307
  }
1027
- const hits = [...boxes.values()].map((b) => ({
1308
+ const neutral = isNeutralKind(map);
1309
+ const columns = layoutColumns(map, geo, opts.unicode, neutral);
1310
+ const routing = routeEdges(map, columns);
1311
+ const rows = layoutRows(columns, geo, routing.gapRowCount, map.title !== void 0, map.lanes.length > 0);
1312
+ const wiredWidth = routing.fallbackCount > 0 ? columns.contentWidth + 2 + routing.fallbackCount * 2 : columns.contentWidth;
1313
+ const totalWidth = wiredWidth + Math.max(...columns.bandLabel.map(displayWidth));
1314
+ if (map.title !== void 0) drawTitle(canvas, map.title);
1315
+ drawLaneHeaders(canvas, map, columns, rows);
1316
+ drawBands(canvas, columns, rows, wiredWidth, totalWidth);
1317
+ const faceOf = (id, status) => unverified.has(id) ? "done-unverified" : status;
1318
+ for (const box of rows.boxOf.values()) {
1319
+ const id = box.node.id;
1320
+ drawBox(canvas, box, opts, neutral, faceOf(id, box.node.status), opts.focus !== void 0 && id === opts.focus);
1321
+ }
1322
+ drawEdges(canvas, routing.edges, rows, opts);
1323
+ drawLegend(canvas, map, opts, rows.legendY, neutral, unverified.size > 0);
1324
+ const hits = [...rows.boxOf.values()].map((b) => ({
1028
1325
  id: b.node.id,
1029
1326
  x: b.x,
1030
1327
  y: b.y,
@@ -1033,32 +1330,9 @@ function buildCanvasWith(map, opts, geo) {
1033
1330
  }));
1034
1331
  return { canvas, hits };
1035
1332
  }
1036
- function drawBox(canvas, box, opts, neutral, focused = false) {
1037
- const { node, x, y, w } = box;
1038
- const skin = neutral ? neutralSkin(opts.unicode) : skinFor(node.status, opts.unicode);
1039
- const slotGlyph = neutral ? (node.kind !== void 0 ? kindGlyph(node.kind, opts.unicode) : void 0) ?? (opts.unicode ? "\xB7" : ".") : glyphFor(node.status, opts);
1040
- if (box.borderless) {
1041
- canvas.text(x + 1, y, slotGlyph, skin.style, true);
1042
- return;
1043
- }
1044
- const inner = w - 2;
1045
- const pad = box.pad === 1 ? " " : "";
1046
- canvas.text(x, y, skin.corners[0] + skin.h.repeat(inner) + skin.corners[1], skin.style, focused);
1047
- canvas.text(x, y + 1, skin.v, skin.style, focused);
1048
- canvas.text(x + 1, y + 1, `${pad}${slotGlyph} ${box.label}${pad}`, skin.style, true);
1049
- canvas.text(x + w - 1, y + 1, skin.v, skin.style, focused);
1050
- for (let i = 0; i < box.extra.length; i++) {
1051
- const row = box.extra[i];
1052
- const yy = y + 2 + i;
1053
- canvas.text(x, yy, skin.v, skin.style, focused);
1054
- canvas.text(x + 1, yy, row.text, row.style);
1055
- canvas.text(x + w - 1, yy, skin.v, skin.style, focused);
1056
- }
1057
- canvas.text(x, y + box.h - 1, skin.corners[2] + skin.h.repeat(inner) + skin.corners[3], skin.style, focused);
1058
- }
1059
1333
 
1060
1334
  // src/store/store.ts
1061
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1335
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1062
1336
  import { basename, dirname, join } from "node:path";
1063
1337
 
1064
1338
  // src/store/format.ts
@@ -1076,111 +1350,169 @@ function describeStoreError(e) {
1076
1350
  return `map file ${e.path} has an unexpected shape: ${e.detail}`;
1077
1351
  case "invariant-violation":
1078
1352
  return `map file ${e.path} violates a structural invariant: ${describeMapError(e.violation)}`;
1353
+ case "save-failed":
1354
+ return `could not write ${e.path}: ${e.detail}`;
1355
+ case "delete-failed":
1356
+ return `could not delete ${e.path}: ${e.detail}`;
1079
1357
  }
1080
1358
  }
1081
1359
  function isRecord(v) {
1082
1360
  return typeof v === "object" && v !== null && !Array.isArray(v);
1083
1361
  }
1084
- function asArray(v) {
1085
- return Array.isArray(v) ? v : [];
1362
+ function describeValue(v) {
1363
+ if (v === void 0) return "missing";
1364
+ if (v === null) return "null";
1365
+ if (Array.isArray(v)) return "an array";
1366
+ return `a ${typeof v}`;
1367
+ }
1368
+ function badShape(path, where, expected, got) {
1369
+ return err({ kind: "bad-shape", path, detail: `${where} is ${describeValue(got)}, expected ${expected}` });
1086
1370
  }
1087
- function optionalString(v) {
1088
- return typeof v === "string" ? v : void 0;
1371
+ function arrayField(raw, key, path, presence) {
1372
+ const v = raw[key];
1373
+ if (Array.isArray(v)) return ok(v);
1374
+ if (v === void 0 && presence === "optional") return ok([]);
1375
+ return badShape(path, `"${key}"`, "an array", v);
1376
+ }
1377
+ function requiredString(rec, key, where, path) {
1378
+ const v = rec[key];
1379
+ return typeof v === "string" ? ok(v) : badShape(path, `${where}.${key}`, "a string", v);
1380
+ }
1381
+ function optionalString(rec, key, where, path) {
1382
+ const v = rec[key];
1383
+ if (v === void 0) return ok(void 0);
1384
+ return typeof v === "string" ? ok(v) : badShape(path, `${where}.${key}`, "a string", v);
1089
1385
  }
1090
1386
  function parseMap(raw, path) {
1091
1387
  if (!isRecord(raw)) return err({ kind: "bad-shape", path, detail: "root is not an object" });
1092
1388
  if (raw["version"] !== STATE_FILE_VERSION) {
1093
1389
  return err({ kind: "bad-shape", path, detail: `version is ${String(raw["version"])}, expected ${STATE_FILE_VERSION}` });
1094
1390
  }
1391
+ const layers = arrayField(raw, "layers", path, "required");
1392
+ if (!layers.ok) return layers;
1393
+ const nodes = arrayField(raw, "nodes", path, "required");
1394
+ if (!nodes.ok) return nodes;
1395
+ const edges = arrayField(raw, "edges", path, "required");
1396
+ if (!edges.ok) return edges;
1397
+ const lanes = arrayField(raw, "lanes", path, "optional");
1398
+ if (!lanes.ok) return lanes;
1399
+ const groups = arrayField(raw, "groups", path, "optional");
1400
+ if (!groups.ok) return groups;
1095
1401
  let map = EMPTY_MAP;
1096
- const title = optionalString(raw["title"]);
1097
- if (title !== void 0) map = setTitle(map, title);
1098
- const rawKind = optionalString(raw["kind"]);
1099
- if (rawKind !== void 0) {
1100
- const kind = makeMapKind(rawKind);
1402
+ const title = optionalString(raw, "title", "map", path);
1403
+ if (!title.ok) return title;
1404
+ if (title.value !== void 0) map = setTitle(map, title.value);
1405
+ const rawKind = optionalString(raw, "kind", "map", path);
1406
+ if (!rawKind.ok) return rawKind;
1407
+ if (rawKind.value !== void 0) {
1408
+ const kind = makeMapKind(rawKind.value);
1101
1409
  if (!kind.ok) return err({ kind: "invariant-violation", path, violation: kind.error });
1102
1410
  map = setKind(map, kind.value);
1103
1411
  }
1104
- for (const [i, rawLayer] of asArray(raw["layers"]).entries()) {
1105
- if (!isRecord(rawLayer)) return err({ kind: "bad-shape", path, detail: `layers[${i}] is not an object` });
1106
- const id = makeLayerId(String(rawLayer["id"] ?? ""));
1412
+ for (const [i, rawLayer] of layers.value.entries()) {
1413
+ const where = `layers[${i}]`;
1414
+ if (!isRecord(rawLayer)) return badShape(path, where, "an object", rawLayer);
1415
+ const rawId = requiredString(rawLayer, "id", where, path);
1416
+ if (!rawId.ok) return rawId;
1417
+ const id = makeLayerId(rawId.value);
1107
1418
  if (!id.ok) return err({ kind: "invariant-violation", path, violation: id.error });
1108
- const name = optionalString(rawLayer["name"]);
1109
- const rank = rawLayer["rank"];
1110
- if (name === void 0 || typeof rank !== "number" || !Number.isInteger(rank)) {
1111
- return err({ kind: "bad-shape", path, detail: `layers[${i}] needs a string name and an integer rank` });
1112
- }
1113
- const next = declareLayer(map, { id: id.value, name, rank });
1419
+ const name = requiredString(rawLayer, "name", where, path);
1420
+ if (!name.ok) return name;
1421
+ const rawRank = rawLayer["rank"];
1422
+ if (typeof rawRank !== "number") return badShape(path, `${where}.rank`, "a number", rawRank);
1423
+ const rank = makeRank(rawRank);
1424
+ if (!rank.ok) return err({ kind: "invariant-violation", path, violation: rank.error });
1425
+ const next = declareLayer(map, { id: id.value, name: name.value, rank: rank.value });
1114
1426
  if (!next.ok) return err({ kind: "invariant-violation", path, violation: next.error });
1115
1427
  map = next.value;
1116
1428
  }
1117
- for (const [i, rawLane] of asArray(raw["lanes"]).entries()) {
1118
- if (!isRecord(rawLane)) return err({ kind: "bad-shape", path, detail: `lanes[${i}] is not an object` });
1119
- const id = makeLaneId(String(rawLane["id"] ?? ""));
1429
+ for (const [i, rawLane] of lanes.value.entries()) {
1430
+ const where = `lanes[${i}]`;
1431
+ if (!isRecord(rawLane)) return badShape(path, where, "an object", rawLane);
1432
+ const rawId = requiredString(rawLane, "id", where, path);
1433
+ if (!rawId.ok) return rawId;
1434
+ const id = makeLaneId(rawId.value);
1120
1435
  if (!id.ok) return err({ kind: "invariant-violation", path, violation: id.error });
1121
- const label = optionalString(rawLane["label"]);
1122
- if (label === void 0) return err({ kind: "bad-shape", path, detail: `lanes[${i}] needs a string label` });
1123
- const declared = declareLane(map, { id: id.value, label });
1436
+ const label = requiredString(rawLane, "label", where, path);
1437
+ if (!label.ok) return label;
1438
+ const declared = declareLane(map, { id: id.value, label: label.value });
1124
1439
  if (!declared.ok) return err({ kind: "invariant-violation", path, violation: declared.error });
1125
1440
  map = declared.value;
1126
1441
  }
1127
- for (const [i, rawGroup] of asArray(raw["groups"]).entries()) {
1128
- if (!isRecord(rawGroup)) return err({ kind: "bad-shape", path, detail: `groups[${i}] is not an object` });
1129
- const id = makeGroupId(String(rawGroup["id"] ?? ""));
1442
+ for (const [i, rawGroup] of groups.value.entries()) {
1443
+ const where = `groups[${i}]`;
1444
+ if (!isRecord(rawGroup)) return badShape(path, where, "an object", rawGroup);
1445
+ const rawId = requiredString(rawGroup, "id", where, path);
1446
+ if (!rawId.ok) return rawId;
1447
+ const id = makeGroupId(rawId.value);
1130
1448
  if (!id.ok) return err({ kind: "invariant-violation", path, violation: id.error });
1131
- const layer = makeLayerId(String(rawGroup["layer"] ?? ""));
1449
+ const rawLayer = requiredString(rawGroup, "layer", where, path);
1450
+ if (!rawLayer.ok) return rawLayer;
1451
+ const layer = makeLayerId(rawLayer.value);
1132
1452
  if (!layer.ok) return err({ kind: "invariant-violation", path, violation: layer.error });
1133
- const label = optionalString(rawGroup["label"]);
1134
- if (label === void 0) return err({ kind: "bad-shape", path, detail: `groups[${i}] needs a string label` });
1135
- const declared = declareGroup(map, { id: id.value, label, layer: layer.value });
1453
+ const label = requiredString(rawGroup, "label", where, path);
1454
+ if (!label.ok) return label;
1455
+ const declared = declareGroup(map, { id: id.value, label: label.value, layer: layer.value });
1136
1456
  if (!declared.ok) return err({ kind: "invariant-violation", path, violation: declared.error });
1137
1457
  map = declared.value;
1138
1458
  }
1139
- for (const [i, rawNode] of asArray(raw["nodes"]).entries()) {
1140
- if (!isRecord(rawNode)) return err({ kind: "bad-shape", path, detail: `nodes[${i}] is not an object` });
1141
- const id = makeNodeId(String(rawNode["id"] ?? ""));
1459
+ for (const [i, rawNode] of nodes.value.entries()) {
1460
+ const where = `nodes[${i}]`;
1461
+ if (!isRecord(rawNode)) return badShape(path, where, "an object", rawNode);
1462
+ const rawId = requiredString(rawNode, "id", where, path);
1463
+ if (!rawId.ok) return rawId;
1464
+ const id = makeNodeId(rawId.value);
1142
1465
  if (!id.ok) return err({ kind: "invariant-violation", path, violation: id.error });
1143
- const layer = makeLayerId(String(rawNode["layer"] ?? ""));
1466
+ const rawLayer = requiredString(rawNode, "layer", where, path);
1467
+ if (!rawLayer.ok) return rawLayer;
1468
+ const layer = makeLayerId(rawLayer.value);
1144
1469
  if (!layer.ok) return err({ kind: "invariant-violation", path, violation: layer.error });
1145
- const status = makeNodeStatus(String(rawNode["status"] ?? ""));
1470
+ const rawStatus = requiredString(rawNode, "status", where, path);
1471
+ if (!rawStatus.ok) return rawStatus;
1472
+ const status = makeNodeStatus(rawStatus.value);
1146
1473
  if (!status.ok) return err({ kind: "invariant-violation", path, violation: status.error });
1147
- const label = optionalString(rawNode["label"]);
1148
- if (label === void 0) return err({ kind: "bad-shape", path, detail: `nodes[${i}] needs a string label` });
1149
- const detail = optionalString(rawNode["detail"]);
1150
- const rawGroup = optionalString(rawNode["group"]);
1474
+ const label = requiredString(rawNode, "label", where, path);
1475
+ if (!label.ok) return label;
1476
+ const detail = optionalString(rawNode, "detail", where, path);
1477
+ if (!detail.ok) return detail;
1478
+ const rawGroup = optionalString(rawNode, "group", where, path);
1479
+ if (!rawGroup.ok) return rawGroup;
1151
1480
  let group;
1152
- if (rawGroup !== void 0) {
1153
- const made = makeGroupId(rawGroup);
1481
+ if (rawGroup.value !== void 0) {
1482
+ const made = makeGroupId(rawGroup.value);
1154
1483
  if (!made.ok) return err({ kind: "invariant-violation", path, violation: made.error });
1155
1484
  group = made.value;
1156
1485
  }
1157
- const rawNodeKind = optionalString(rawNode["kind"]);
1486
+ const rawNodeKind = optionalString(rawNode, "kind", where, path);
1487
+ if (!rawNodeKind.ok) return rawNodeKind;
1158
1488
  let nodeKind;
1159
- if (rawNodeKind !== void 0) {
1160
- const made = makeNodeKind(rawNodeKind);
1489
+ if (rawNodeKind.value !== void 0) {
1490
+ const made = makeNodeKind(rawNodeKind.value);
1161
1491
  if (!made.ok) return err({ kind: "invariant-violation", path, violation: made.error });
1162
1492
  nodeKind = made.value;
1163
1493
  }
1164
- const rawLane = optionalString(rawNode["lane"]);
1494
+ const rawLane = optionalString(rawNode, "lane", where, path);
1495
+ if (!rawLane.ok) return rawLane;
1165
1496
  let lane;
1166
- if (rawLane !== void 0) {
1167
- const made = makeLaneId(rawLane);
1497
+ if (rawLane.value !== void 0) {
1498
+ const made = makeLaneId(rawLane.value);
1168
1499
  if (!made.ok) return err({ kind: "invariant-violation", path, violation: made.error });
1169
1500
  lane = made.value;
1170
1501
  }
1171
- const rawSubmap = optionalString(rawNode["submap"]);
1502
+ const rawSubmap = optionalString(rawNode, "submap", where, path);
1503
+ if (!rawSubmap.ok) return rawSubmap;
1172
1504
  let submap;
1173
- if (rawSubmap !== void 0) {
1174
- const made = makeSubmapRef(rawSubmap);
1505
+ if (rawSubmap.value !== void 0) {
1506
+ const made = makeSubmapRef(rawSubmap.value);
1175
1507
  if (!made.ok) return err({ kind: "invariant-violation", path, violation: made.error });
1176
1508
  submap = made.value;
1177
1509
  }
1178
1510
  const declared = declareNode(map, {
1179
1511
  id: id.value,
1180
- label,
1512
+ label: label.value,
1181
1513
  layer: layer.value,
1182
1514
  status: status.value,
1183
- ...detail !== void 0 ? { detail } : {},
1515
+ ...detail.value !== void 0 ? { detail: detail.value } : {},
1184
1516
  ...group !== void 0 ? { group } : {},
1185
1517
  ...nodeKind !== void 0 ? { kind: nodeKind } : {},
1186
1518
  ...lane !== void 0 ? { lane } : {},
@@ -1188,20 +1520,28 @@ function parseMap(raw, path) {
1188
1520
  });
1189
1521
  if (!declared.ok) return err({ kind: "invariant-violation", path, violation: declared.error });
1190
1522
  map = declared.value;
1191
- const evidence = optionalString(rawNode["evidence"]);
1192
- if (evidence !== void 0) {
1193
- const updated = updateNode(map, { id: id.value, evidence });
1523
+ const evidence = optionalString(rawNode, "evidence", where, path);
1524
+ if (!evidence.ok) return evidence;
1525
+ if (evidence.value !== void 0) {
1526
+ const updated = updateNode(map, { id: id.value, evidence: evidence.value });
1194
1527
  if (!updated.ok) return err({ kind: "invariant-violation", path, violation: updated.error });
1195
1528
  map = updated.value;
1196
1529
  }
1197
1530
  }
1198
- for (const [i, rawEdge] of asArray(raw["edges"]).entries()) {
1199
- if (!isRecord(rawEdge)) return err({ kind: "bad-shape", path, detail: `edges[${i}] is not an object` });
1200
- const from = makeNodeId(String(rawEdge["from"] ?? ""));
1531
+ for (const [i, rawEdge] of edges.value.entries()) {
1532
+ const where = `edges[${i}]`;
1533
+ if (!isRecord(rawEdge)) return badShape(path, where, "an object", rawEdge);
1534
+ const rawFrom = requiredString(rawEdge, "from", where, path);
1535
+ if (!rawFrom.ok) return rawFrom;
1536
+ const from = makeNodeId(rawFrom.value);
1201
1537
  if (!from.ok) return err({ kind: "invariant-violation", path, violation: from.error });
1202
- const to = makeNodeId(String(rawEdge["to"] ?? ""));
1538
+ const rawTo = requiredString(rawEdge, "to", where, path);
1539
+ if (!rawTo.ok) return rawTo;
1540
+ const to = makeNodeId(rawTo.value);
1203
1541
  if (!to.ok) return err({ kind: "invariant-violation", path, violation: to.error });
1204
- const linked = linkNodes(map, from.value, to.value, optionalString(rawEdge["label"]));
1542
+ const label = optionalString(rawEdge, "label", where, path);
1543
+ if (!label.ok) return label;
1544
+ const linked = linkNodes(map, from.value, to.value, label.value);
1205
1545
  if (!linked.ok) return err({ kind: "invariant-violation", path, violation: linked.error });
1206
1546
  map = linked.value;
1207
1547
  }
@@ -1209,7 +1549,54 @@ function parseMap(raw, path) {
1209
1549
  }
1210
1550
 
1211
1551
  // src/store/store.ts
1212
- var STATE_FILE_RELATIVE_PATH = join(".mellos", "map.json");
1552
+ var RENAME_MAX_ATTEMPTS = 10;
1553
+ var RENAME_BACKOFF_STEP_MS = 10;
1554
+ var TRANSIENT_RENAME_CODES = /* @__PURE__ */ new Set(["EPERM", "EBUSY", "EACCES", "ENOENT"]);
1555
+ function sleepSync(ms) {
1556
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1557
+ }
1558
+ function discardTemp(tmp) {
1559
+ try {
1560
+ rmSync(tmp, { force: true });
1561
+ } catch {
1562
+ }
1563
+ }
1564
+ function errnoOf(e) {
1565
+ return e.code ?? e.message;
1566
+ }
1567
+ function writeFileAtomic(path, contents) {
1568
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
1569
+ try {
1570
+ mkdirSync(dirname(path), { recursive: true });
1571
+ writeFileSync(tmp, contents, "utf8");
1572
+ } catch (e) {
1573
+ discardTemp(tmp);
1574
+ return err({ kind: "save-failed", path, detail: `writing the temp file failed: ${errnoOf(e)}` });
1575
+ }
1576
+ let attempt = 1;
1577
+ for (; ; ) {
1578
+ try {
1579
+ renameSync(tmp, path);
1580
+ return ok(void 0);
1581
+ } catch (e) {
1582
+ const code = errnoOf(e);
1583
+ if (!TRANSIENT_RENAME_CODES.has(code) || attempt >= RENAME_MAX_ATTEMPTS) {
1584
+ discardTemp(tmp);
1585
+ return err({ kind: "save-failed", path, detail: `${code} after ${attempt} attempt(s)` });
1586
+ }
1587
+ sleepSync(attempt * RENAME_BACKOFF_STEP_MS);
1588
+ attempt += 1;
1589
+ }
1590
+ }
1591
+ }
1592
+ function isRecord2(v) {
1593
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1594
+ }
1595
+ function stripBom(text) {
1596
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
1597
+ }
1598
+ var STORE_DIR_NAME = ".mellos";
1599
+ var STATE_FILE_RELATIVE_PATH = join(STORE_DIR_NAME, "map.json");
1213
1600
  var PAGES_DIR_NAME = "pages";
1214
1601
  function pageFilePath(defaultFile, page) {
1215
1602
  return page === void 0 ? defaultFile : join(dirname(defaultFile), PAGES_DIR_NAME, `${page}.json`);
@@ -1232,6 +1619,14 @@ function listPageFiles(defaultFile) {
1232
1619
  }
1233
1620
  return out;
1234
1621
  }
1622
+ function deletePageFile(path) {
1623
+ try {
1624
+ rmSync(path, { force: true });
1625
+ return ok(void 0);
1626
+ } catch (e) {
1627
+ return err({ kind: "delete-failed", path, detail: errnoOf(e) });
1628
+ }
1629
+ }
1235
1630
  var FOCUS_FILE_NAME = "focus";
1236
1631
  function focusFilePath(defaultFile) {
1237
1632
  return join(dirname(defaultFile), FOCUS_FILE_NAME);
@@ -1261,6 +1656,53 @@ function takeFocusRequest(defaultFile) {
1261
1656
  const id = makePageId(page);
1262
1657
  return id.ok ? { page: id.value } : void 0;
1263
1658
  }
1659
+ var QUIT_FILE_NAME = "quit";
1660
+ function quitFilePath(defaultFile) {
1661
+ return join(dirname(defaultFile), QUIT_FILE_NAME);
1662
+ }
1663
+ function takeQuitRequest(defaultFile) {
1664
+ const path = quitFilePath(defaultFile);
1665
+ let raw;
1666
+ try {
1667
+ raw = readFileSync(path, "utf8");
1668
+ } catch {
1669
+ return false;
1670
+ }
1671
+ sweepQuitRequest(defaultFile);
1672
+ let parsed;
1673
+ try {
1674
+ parsed = JSON.parse(stripBom(raw));
1675
+ } catch {
1676
+ return false;
1677
+ }
1678
+ return isRecord2(parsed);
1679
+ }
1680
+ function sweepQuitRequest(defaultFile) {
1681
+ try {
1682
+ rmSync(quitFilePath(defaultFile), { force: true });
1683
+ } catch {
1684
+ }
1685
+ }
1686
+ var VIEWERS_DIR_NAME = "viewers";
1687
+ var VIEWER_FILE_VERSION = 1;
1688
+ var VIEWER_HEARTBEAT_MS = 1e3;
1689
+ function viewersDirPath(defaultFile) {
1690
+ return join(dirname(defaultFile), VIEWERS_DIR_NAME);
1691
+ }
1692
+ function viewerFilePath(defaultFile, pid) {
1693
+ return join(viewersDirPath(defaultFile), `${pid}.json`);
1694
+ }
1695
+ function publishViewer(defaultFile, pid, report) {
1696
+ const body = { version: VIEWER_FILE_VERSION, page: report.page ?? null, follow: report.follow };
1697
+ return writeFileAtomic(viewerFilePath(defaultFile, pid), `${JSON.stringify(body, null, 2)}
1698
+ `);
1699
+ }
1700
+ function retireViewer(defaultFile, pid) {
1701
+ try {
1702
+ rmSync(viewerFilePath(defaultFile, pid), { force: true });
1703
+ } catch {
1704
+ }
1705
+ }
1264
1706
  var CONFIG_FILE_NAME = "config.json";
1265
1707
  function configFilePath(defaultFile) {
1266
1708
  return join(dirname(defaultFile), CONFIG_FILE_NAME);
@@ -1293,7 +1735,7 @@ function loadMapFile(path) {
1293
1735
  }
1294
1736
  let raw;
1295
1737
  try {
1296
- raw = JSON.parse(text);
1738
+ raw = JSON.parse(stripBom(text));
1297
1739
  } catch (e) {
1298
1740
  return err({ kind: "malformed-json", path, detail: e.message });
1299
1741
  }
@@ -1304,14 +1746,18 @@ function loadMapFile(path) {
1304
1746
  var KEY_H_STEP = 4;
1305
1747
  var KEY_V_STEP = 2;
1306
1748
  var WHEEL_V_STEP = 3;
1749
+ var WHEEL_H_STEP = 4;
1307
1750
  var MOTION = 32;
1308
1751
  var WHEEL = 64;
1309
1752
  var SHIFT = 4;
1310
1753
  var BUTTON_BITS = 3;
1754
+ var WHEEL_BITS = 3;
1311
1755
  var SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
1312
1756
  var ARROW = /^\x1b\[([ABCD])/;
1313
1757
  var SHIFT_TAB = /^\x1b\[Z/;
1314
- var PARTIAL_ESCAPE = /(?:\x1b|\x1b\[|\x1b\[<[\d;]*)$/;
1758
+ var CSI_SEQUENCE = /^\x1b\[[0-9;:<=>?]*[ -/]*[@-~]/;
1759
+ var SS3_SEQUENCE = /^\x1bO[@-~]/;
1760
+ var PARTIAL_ESCAPE = /^(?:\x1b\[[0-9;:<=>?]*[ -/]*|\x1bO)$/;
1315
1761
  var ARROW_PAN = {
1316
1762
  A: { dx: 0, dy: -KEY_V_STEP },
1317
1763
  B: { dx: 0, dy: KEY_V_STEP },
@@ -1326,8 +1772,17 @@ var KEY_PAN = {
1326
1772
  };
1327
1773
  function mouseEvent(code, x, y, final) {
1328
1774
  if (code & WHEEL) {
1329
- const down = (code & 1) !== 0;
1330
- return code & SHIFT ? { kind: "pan", dx: 0, dy: (down ? 1 : -1) * WHEEL_V_STEP } : { kind: "zoom", delta: down ? -1 : 1, at: { x, y } };
1775
+ switch (code & WHEEL_BITS) {
1776
+ case 0:
1777
+ case 1: {
1778
+ const down = (code & 1) !== 0;
1779
+ return code & SHIFT ? { kind: "pan", dx: 0, dy: (down ? 1 : -1) * WHEEL_V_STEP } : { kind: "zoom", delta: down ? -1 : 1, at: { x, y } };
1780
+ }
1781
+ default: {
1782
+ const right = (code & 1) !== 0;
1783
+ return { kind: "pan", dx: (right ? 1 : -1) * WHEEL_H_STEP, dy: 0 };
1784
+ }
1785
+ }
1331
1786
  }
1332
1787
  const buttons = code & BUTTON_BITS;
1333
1788
  if (final === "m") return buttons === 0 ? { kind: "mouse-up", x, y } : void 0;
@@ -1339,7 +1794,6 @@ function mouseEvent(code, x, y, final) {
1339
1794
  return buttons === 0 ? { kind: "mouse-down", x, y } : void 0;
1340
1795
  }
1341
1796
  function parseInput(chunk) {
1342
- if (chunk === "\x1B") return { events: [{ kind: "clear" }], rest: "" };
1343
1797
  const events = [];
1344
1798
  let i = 0;
1345
1799
  while (i < chunk.length) {
@@ -1364,18 +1818,24 @@ function parseInput(chunk) {
1364
1818
  i += shiftTab[0].length;
1365
1819
  continue;
1366
1820
  }
1367
- const partial = PARTIAL_ESCAPE.exec(slice);
1368
- if (partial && partial.index === 0) {
1821
+ const sequence = CSI_SEQUENCE.exec(slice) ?? SS3_SEQUENCE.exec(slice);
1822
+ if (sequence) {
1823
+ i += sequence[0].length;
1824
+ continue;
1825
+ }
1826
+ if (PARTIAL_ESCAPE.test(slice)) {
1369
1827
  return { events, rest: slice };
1370
1828
  }
1371
1829
  const ch = chunk[i];
1372
- if (ch === "q" || ch === "Q" || ch === "" || ch === "") events.push({ kind: "quit" });
1830
+ if (ch === "\x1B") events.push({ kind: "clear" });
1831
+ else if (ch === "q" || ch === "Q" || ch === "" || ch === "") events.push({ kind: "quit" });
1373
1832
  else if (ch === "0") events.push({ kind: "reset" });
1374
1833
  else if (ch === "+" || ch === "=") events.push({ kind: "zoom", delta: 1 });
1375
1834
  else if (ch === "-") events.push({ kind: "zoom", delta: -1 });
1376
1835
  else if (ch === " ") events.push({ kind: "next-page" });
1377
1836
  else if (ch === "\x7F" || ch === "\b") events.push({ kind: "back" });
1378
1837
  else if (ch === "f" || ch === "F") events.push({ kind: "follow-toggle" });
1838
+ else if (ch === "x" || ch === "X") events.push({ kind: "delete-page" });
1379
1839
  else if (ch >= "1" && ch <= "9") events.push({ kind: "page", index: ch.charCodeAt(0) - "1".charCodeAt(0) });
1380
1840
  else if (KEY_PAN[ch]) events.push({ kind: "pan", ...KEY_PAN[ch] });
1381
1841
  i += 1;
@@ -1383,28 +1843,209 @@ function parseInput(chunk) {
1383
1843
  return { events, rest: "" };
1384
1844
  }
1385
1845
 
1846
+ // src/watch/pane-state.ts
1847
+ function describePageFault(fault) {
1848
+ return fault.kind === "unreadable" ? `cannot read ${fault.path}: ${fault.detail}` : describeStoreError(fault);
1849
+ }
1850
+ function isTransient(fault) {
1851
+ return fault.kind === "malformed-json";
1852
+ }
1853
+ function mapOf(entry) {
1854
+ if (entry === void 0 || entry.state.kind === "absent") return void 0;
1855
+ return entry.state.kind === "loaded" ? entry.state.map : entry.state.lastGood;
1856
+ }
1857
+ function mtimeOf(entry) {
1858
+ return entry === void 0 || entry.state.kind === "absent" ? void 0 : entry.state.mtimeMs;
1859
+ }
1860
+ function initialPaneState(follow, requestedFile) {
1861
+ return {
1862
+ pages: [],
1863
+ activeFile: void 0,
1864
+ pendingFocusFile: requestedFile,
1865
+ follow,
1866
+ diveStack: [],
1867
+ scanned: false,
1868
+ pendingDelete: void 0
1869
+ };
1870
+ }
1871
+ function entryOf(state, file) {
1872
+ return file === void 0 ? void 0 : state.pages.find((p) => p.file === file);
1873
+ }
1874
+ function mapsOf(state) {
1875
+ return new Map(state.pages.map((p) => [p.file, mapOf(p)]));
1876
+ }
1877
+ function filesOf(state) {
1878
+ return state.pages.map((p) => p.file);
1879
+ }
1880
+ function markViewed(state, file) {
1881
+ if (file === void 0 || !state.pages.some((p) => p.file === file && p.fresh)) return state;
1882
+ return { ...state, pages: state.pages.map((p) => p.file === file ? { ...p, fresh: false } : p) };
1883
+ }
1884
+ function userSwitch(state, file) {
1885
+ const followTurnedOff = state.follow;
1886
+ return {
1887
+ state: markViewed(
1888
+ // Looking elsewhere withdraws an armed deletion: it was aimed at the
1889
+ // page that was on screen, and the confirming press must never land on
1890
+ // whichever page took its place.
1891
+ { ...state, activeFile: file, pendingFocusFile: void 0, follow: false, pendingDelete: void 0 },
1892
+ file
1893
+ ),
1894
+ followTurnedOff
1895
+ };
1896
+ }
1897
+ function disarmDelete(state) {
1898
+ return state.pendingDelete === void 0 ? state : { ...state, pendingDelete: void 0 };
1899
+ }
1900
+ function requestDelete(state, now, windowMs) {
1901
+ const file = state.activeFile;
1902
+ if (file === void 0) return { state: disarmDelete(state), request: { kind: "none" } };
1903
+ if (entryOf(state, file)?.state.kind === "absent") {
1904
+ return { state: disarmDelete(state), request: { kind: "absent", file } };
1905
+ }
1906
+ const armed = state.pendingDelete;
1907
+ if (armed !== void 0 && armed.file === file && now <= armed.until) {
1908
+ return { state: { ...state, pendingDelete: void 0 }, request: { kind: "confirmed", file } };
1909
+ }
1910
+ const until = now + windowMs;
1911
+ return { state: { ...state, pendingDelete: { file, until } }, request: { kind: "armed", file, until } };
1912
+ }
1913
+ function toggleFollow(state) {
1914
+ return { ...state, follow: !state.follow };
1915
+ }
1916
+ function pushDive(state, file) {
1917
+ return { ...state, diveStack: [...state.diveStack, file] };
1918
+ }
1919
+ function popDive(state) {
1920
+ const files = new Set(filesOf(state));
1921
+ const stack = [...state.diveStack];
1922
+ while (stack.length > 0) {
1923
+ const parent = stack.pop();
1924
+ if (files.has(parent)) return { state: { ...state, diveStack: stack }, parent };
1925
+ }
1926
+ return { state: { ...state, diveStack: [] }, parent: void 0 };
1927
+ }
1928
+ function scan(state, input) {
1929
+ const first = !state.scanned;
1930
+ const previousActive = state.activeFile;
1931
+ const pages = [];
1932
+ const freshened = [];
1933
+ const changed = [];
1934
+ for (const file of input.files) {
1935
+ const held = entryOf(state, file);
1936
+ const mtimeMs = input.mtimeAt(file);
1937
+ if (mtimeMs === void 0) {
1938
+ pages.push(held ?? { file, state: { kind: "absent" }, fresh: false });
1939
+ continue;
1940
+ }
1941
+ const settled = held !== void 0 && mtimeOf(held) === mtimeMs && !(held.state.kind === "faulted" && held.state.transient);
1942
+ if (settled) {
1943
+ pages.push(held);
1944
+ continue;
1945
+ }
1946
+ const loaded = input.load(file);
1947
+ if (loaded.ok) {
1948
+ if (!first) changed.push(file);
1949
+ const fresh = !first && file !== previousActive;
1950
+ if (fresh) freshened.push(file);
1951
+ pages.push({ file, state: { kind: "loaded", map: loaded.value, mtimeMs }, fresh });
1952
+ continue;
1953
+ }
1954
+ pages.push({
1955
+ file,
1956
+ state: {
1957
+ kind: "faulted",
1958
+ fault: loaded.error,
1959
+ lastGood: mapOf(held),
1960
+ mtimeMs,
1961
+ transient: isTransient(loaded.error)
1962
+ },
1963
+ fresh: held?.fresh ?? false
1964
+ });
1965
+ }
1966
+ let pendingFocusFile = state.pendingFocusFile;
1967
+ if (input.focusRequest !== void 0 && !(first && pendingFocusFile !== void 0)) {
1968
+ pendingFocusFile = input.focusRequest;
1969
+ }
1970
+ let activeFile = previousActive;
1971
+ let requestApplied = false;
1972
+ if (pendingFocusFile !== void 0 && input.files.includes(pendingFocusFile)) {
1973
+ activeFile = pendingFocusFile;
1974
+ pendingFocusFile = void 0;
1975
+ requestApplied = true;
1976
+ }
1977
+ const mtimeIn = (file) => mtimeOf(pages.find((p) => p.file === file));
1978
+ if (state.follow && !requestApplied && changed.length > 0 && !input.engaged) {
1979
+ activeFile = mostRecentKey(changed, mtimeIn) ?? activeFile;
1980
+ }
1981
+ if (activeFile === void 0 || !input.files.includes(activeFile)) {
1982
+ activeFile = mostRecentKey(input.files, mtimeIn);
1983
+ }
1984
+ const files = new Set(input.files);
1985
+ const next = {
1986
+ pages,
1987
+ activeFile,
1988
+ pendingFocusFile,
1989
+ follow: state.follow,
1990
+ diveStack: state.diveStack.filter((f) => files.has(f)),
1991
+ scanned: true,
1992
+ // An armed deletion belongs to the page it was armed on. If the scan
1993
+ // moved the view (follow, a request, a page that vanished), the request
1994
+ // is stale — the confirming press must never hit a page that merely
1995
+ // arrived under the cursor.
1996
+ pendingDelete: state.pendingDelete?.file === activeFile ? state.pendingDelete : void 0
1997
+ };
1998
+ return { state: markViewed(next, activeFile), freshened };
1999
+ }
2000
+
1386
2001
  // src/watch/watch.ts
2002
+ function describeArgsError(e) {
2003
+ switch (e.kind) {
2004
+ case "unknown-flag":
2005
+ return `unknown flag "${e.flag}"`;
2006
+ case "missing-value":
2007
+ return `${e.flag} needs a value`;
2008
+ case "invalid-value":
2009
+ return `${e.flag} got "${e.raw}" (expected: ${e.rule})`;
2010
+ }
2011
+ }
2012
+ var USAGE = "usage: mellos-mapping-watch [--file <map.json>] [--page <slug>] [--interval <ms>] [--ascii] [--no-color] [--no-mouse] [--no-follow]";
1387
2013
  function parseArgs(argv, cwd) {
1388
2014
  let file = join2(cwd, STATE_FILE_RELATIVE_PATH);
1389
- let intervalMs = 250;
2015
+ let intervalMs = POLL_INTERVAL_DEFAULT_MS;
1390
2016
  let unicode = true;
1391
2017
  let color = true;
1392
2018
  let mouse = true;
1393
2019
  let page;
1394
2020
  let follow = true;
2021
+ const valueOf = (flag, raw) => raw === void 0 || raw.startsWith("--") ? err({ kind: "missing-value", flag }) : ok(raw);
1395
2022
  for (let i = 0; i < argv.length; i++) {
1396
- switch (argv[i]) {
1397
- case "--file":
1398
- file = argv[++i] ?? file;
2023
+ const flag = argv[i];
2024
+ switch (flag) {
2025
+ case "--file": {
2026
+ const value = valueOf(flag, argv[++i]);
2027
+ if (!value.ok) return value;
2028
+ file = value.value;
1399
2029
  break;
2030
+ }
1400
2031
  case "--page": {
1401
- const parsed = makePageId(argv[++i] ?? "");
1402
- if (parsed.ok) page = parsed.value;
2032
+ const value = valueOf(flag, argv[++i]);
2033
+ if (!value.ok) return value;
2034
+ const parsed = makePageId(value.value);
2035
+ if (!parsed.ok) return err({ kind: "invalid-value", flag, raw: value.value, rule: parsed.error.rule });
2036
+ page = parsed.value;
1403
2037
  break;
1404
2038
  }
1405
- case "--interval":
1406
- intervalMs = Math.max(50, Number(argv[++i]) || intervalMs);
2039
+ case "--interval": {
2040
+ const value = valueOf(flag, argv[++i]);
2041
+ if (!value.ok) return value;
2042
+ const ms = Number(value.value);
2043
+ if (!Number.isFinite(ms) || ms <= 0) {
2044
+ return err({ kind: "invalid-value", flag, raw: value.value, rule: "a positive number of milliseconds" });
2045
+ }
2046
+ intervalMs = Math.max(POLL_INTERVAL_MIN_MS, ms);
1407
2047
  break;
2048
+ }
1408
2049
  case "--ascii":
1409
2050
  unicode = false;
1410
2051
  break;
@@ -1418,10 +2059,24 @@ function parseArgs(argv, cwd) {
1418
2059
  follow = false;
1419
2060
  break;
1420
2061
  default:
1421
- break;
2062
+ return err({ kind: "unknown-flag", flag });
1422
2063
  }
1423
2064
  }
1424
- return { file, intervalMs, unicode, color, mouse, page, follow };
2065
+ return ok({ file, intervalMs, unicode, color, mouse, page, follow });
2066
+ }
2067
+ function readPage(file) {
2068
+ try {
2069
+ return loadMapFile(file);
2070
+ } catch (e) {
2071
+ return err({ kind: "unreadable", path: file, detail: e.code ?? e.message });
2072
+ }
2073
+ }
2074
+ function renderWindow(map, opts, viewport) {
2075
+ try {
2076
+ return ok(renderMapWindow(map, opts, viewport));
2077
+ } catch (e) {
2078
+ return err(`this map could not be drawn: ${e.message}`);
2079
+ }
1425
2080
  }
1426
2081
  function dividerRow(width, unicode, follow) {
1427
2082
  const grip = unicode ? " \u22EF " : " ~ ";
@@ -1435,9 +2090,6 @@ function dividerRow(width, unicode, follow) {
1435
2090
  }
1436
2091
  return bar;
1437
2092
  }
1438
- function mostRecentPageFile(files, mtimeOf) {
1439
- return mostRecentKey(files, mtimeOf);
1440
- }
1441
2093
  var HIDE_CURSOR = "\x1B[?25l";
1442
2094
  var SHOW_CURSOR = "\x1B[?25h";
1443
2095
  var CLEAR_ALL = "\x1B[H\x1B[2J";
@@ -1446,9 +2098,23 @@ var ERASE_LINE_END = "\x1B[K";
1446
2098
  var MOUSE_ON = "\x1B[?1003h\x1B[?1006h";
1447
2099
  var MOUSE_OFF = "\x1B[?1003l\x1B[?1006l";
1448
2100
  var RESET = "\x1B[0m";
2101
+ function terminalRestoreSequence(mouseActive) {
2102
+ return (mouseActive ? MOUSE_OFF : "") + SHOW_CURSOR + RESET + "\n";
2103
+ }
1449
2104
  var PANEL_CONTENT_ROWS = 6;
1450
2105
  var PANEL_ROWS_MIN = 2;
1451
2106
  var MAP_ROWS_MIN = 4;
2107
+ var POLL_INTERVAL_DEFAULT_MS = 250;
2108
+ var POLL_INTERVAL_MIN_MS = 50;
2109
+ var SPLASH_FRAME_MS = 80;
2110
+ var FLASH_ACK_MS = 2500;
2111
+ var FLASH_NOTICE_MS = 3e3;
2112
+ var FLASH_BACKGROUND_NEWS_MS = 4e3;
2113
+ var DOUBLE_CLICK_MS = 450;
2114
+ var CONFIRM_WINDOW_MS = 3e3;
2115
+ var FALLBACK_COLUMNS = 100;
2116
+ var FALLBACK_ROWS = 30;
2117
+ var DEFAULT_PAGE_TAB_LABEL = "main";
1452
2118
  function usableColumns(cols) {
1453
2119
  return Math.max(1, cols - 1);
1454
2120
  }
@@ -1459,18 +2125,6 @@ function clampPanelRows(wanted, totalRows, tabRows) {
1459
2125
  function panelRowsFromDividerY(termY, totalRows, tabRows) {
1460
2126
  return clampPanelRows(totalRows - termY - 1, totalRows, tabRows);
1461
2127
  }
1462
- var STATUS_GLYPH = {
1463
- planned: ["\xB7", "."],
1464
- "in-progress": ["\u283F", "*"],
1465
- done: ["\u25A0", "#"],
1466
- regressed: ["\u2717", "X"]
1467
- };
1468
- var STATUS_SGR = {
1469
- planned: "2",
1470
- "in-progress": "33",
1471
- done: "32",
1472
- regressed: "31"
1473
- };
1474
2128
  function anchorOffsets(anchor, offset, before, after) {
1475
2129
  if (anchor) {
1476
2130
  return {
@@ -1484,14 +2138,19 @@ function anchorOffsets(anchor, offset, before, after) {
1484
2138
  };
1485
2139
  }
1486
2140
  var TAB_INDICATOR_W = 3;
1487
- function pageTabRow(tabs, width, unicode, scroll = 0) {
2141
+ var CLOSE_TAB_TEXT = { unicode: "\xD7 ", ascii: "x " };
2142
+ var CLOSE_TAB_SGR = "90";
2143
+ function pageTabRow(tabs, width, unicode, scroll = 0, closable = false) {
1488
2144
  const texts = tabs.map((tab) => {
1489
2145
  const marker = tab.active ? unicode ? "\u25CF" : "*" : unicode ? "\u25CB" : "o";
1490
- const glyph = STATUS_GLYPH[tab.status][unicode ? 0 : 1];
2146
+ const glyph = statusGlyph(tab.status, unicode);
1491
2147
  return tab.neutral === true ? ` ${marker} ${tab.title} ` : ` ${marker} ${glyph} ${tab.title} `;
1492
2148
  });
1493
- const sgrOf = (tab) => tab.neutral === true ? tab.active ? "1" : tab.fresh ? "36" : "90" : tab.active ? `${STATUS_SGR[tab.status]};1` : tab.fresh ? STATUS_SGR[tab.status] : "90";
1494
- const widths = texts.map(displayWidth);
2149
+ const sgrOf = (tab) => tab.neutral === true ? tab.active ? "1" : tab.fresh ? "36" : "90" : tab.active ? `${statusSgr(tab.status)};1` : tab.fresh ? statusSgr(tab.status) : "90";
2150
+ const closeText = CLOSE_TAB_TEXT[unicode ? "unicode" : "ascii"];
2151
+ const closeW = displayWidth(closeText);
2152
+ const closeOf = (i) => closable && tabs[i].active ? closeW : 0;
2153
+ const widths = texts.map((t, i) => displayWidth(t) + closeOf(i));
1495
2154
  const count = tabs.length;
1496
2155
  let lo = 0;
1497
2156
  let hi = count - 1;
@@ -1511,29 +2170,36 @@ function pageTabRow(tabs, width, unicode, scroll = 0) {
1511
2170
  if (lo > 0) push(unicode ? " \u2039 " : " < ", "90", { kind: "scroll", delta: -1 });
1512
2171
  const tail = hi < count - 1 ? TAB_INDICATOR_W : 0;
1513
2172
  for (let i = lo; i <= hi; i++) {
1514
- push(fitWidth(texts[i], Math.max(1, width - (col - 1) - tail)), sgrOf(tabs[i]), { kind: "switch", index: i });
2173
+ const close = closeOf(i);
2174
+ push(fitWidth(texts[i], Math.max(1, width - (col - 1) - tail - close)), sgrOf(tabs[i]), {
2175
+ kind: "switch",
2176
+ index: i
2177
+ });
2178
+ if (close > 0 && col - 1 + close + tail <= width) push(closeText, CLOSE_TAB_SGR, { kind: "delete" });
1515
2179
  }
1516
2180
  if (hi < count - 1) push(unicode ? " \u203A " : " > ", "90", { kind: "scroll", delta: 1 });
1517
2181
  return segments;
1518
2182
  }
1519
- function tabScrollFor(tabs, width, unicode, scroll, index) {
2183
+ function tabScrollFor(tabs, width, unicode, scroll, index, closable = false) {
1520
2184
  if (index <= scroll) return Math.max(0, index);
1521
- const visibleAt = (s2) => pageTabRow(tabs, width, unicode, s2).some((seg) => seg.action.kind === "switch" && seg.action.index === index);
2185
+ const visibleAt = (s2) => pageTabRow(tabs, width, unicode, s2, closable).some(
2186
+ (seg) => seg.action.kind === "switch" && seg.action.index === index
2187
+ );
1522
2188
  let s = Math.max(0, Math.min(scroll, tabs.length - 1));
1523
2189
  while (s < index && !visibleAt(s)) s++;
1524
2190
  return s;
1525
2191
  }
1526
- function topLevelFiles(defaultFile, files, mapOf) {
1527
- const refs = submapRefs(mapOf.values());
2192
+ function topLevelFiles(defaultFile, files, mapOf2) {
2193
+ const interior = interiorPages(files.map((f) => [pageIdOfFile(defaultFile, f), mapOf2.get(f)]));
1528
2194
  return files.filter((f) => {
1529
2195
  const id = pageIdOfFile(defaultFile, f);
1530
- return id === void 0 || !refs.has(id);
2196
+ return id === void 0 || !interior.has(id);
1531
2197
  });
1532
2198
  }
1533
- function diveOrigin(defaultFile, file, files, mapOf) {
2199
+ function diveOrigin(defaultFile, file, files, mapOf2) {
1534
2200
  const id = pageIdOfFile(defaultFile, file);
1535
2201
  if (id === void 0) return void 0;
1536
- const entries = files.filter((f) => f !== file).map((f) => [f, mapOf.get(f)]);
2202
+ const entries = files.filter((f) => f !== file).map((f) => [f, mapOf2.get(f)]);
1537
2203
  return diveParent(entries, id);
1538
2204
  }
1539
2205
  function nearestHit(hits, cx, cy) {
@@ -1549,7 +2215,7 @@ function nearestHit(hits, cx, cy) {
1549
2215
  return best;
1550
2216
  }
1551
2217
  function nodePanel(map, focusId, unicode, width, pinned, rows = PANEL_CONTENT_ROWS) {
1552
- const g = (s) => STATUS_GLYPH[s][unicode ? 0 : 1];
2218
+ const g = (s) => statusGlyph(s, unicode);
1553
2219
  const pinMark = pinned ? unicode ? " \u2299 pinned" : " * pinned" : "";
1554
2220
  const focus = focusInfo(map, focusId);
1555
2221
  if (focus === void 0) return void 0;
@@ -1565,7 +2231,7 @@ function nodePanel(map, focusId, unicode, width, pinned, rows = PANEL_CONTENT_RO
1565
2231
  `${g(status)} ${group.label} [${group.id}] \xB7 ${layerName2} \xB7 ${status} \xB7 ${members.length} member(s)${pinMark}`,
1566
2232
  width
1567
2233
  ),
1568
- sgr: `${STATUS_SGR[status]};1`
2234
+ sgr: `${statusSgr(status)};1`
1569
2235
  },
1570
2236
  {
1571
2237
  text: fitWidth(`members: ${members.map((n) => `${g(n.status)} ${n.label}`).join(" ") || "\u2014"}`, width),
@@ -1596,7 +2262,7 @@ function nodePanel(map, focusId, unicode, width, pinned, rows = PANEL_CONTENT_RO
1596
2262
  const lines = [
1597
2263
  {
1598
2264
  text: fitWidth(`${headParts.join(" \xB7 ")}${pin}`, width),
1599
- sgr: neutral ? "1" : `${STATUS_SGR[node.status]};1`
2265
+ sgr: neutral ? "1" : `${statusSgr(node.status)};1`
1600
2266
  },
1601
2267
  { text: fitWidth(`evidence: ${node.evidence ?? "\u2014"}`, width), sgr: "90" },
1602
2268
  { text: fitWidth(`${usesWord} ${right} ${uses.join(" ") || "\u2014"}`, width), sgr: "" },
@@ -1613,17 +2279,6 @@ function nodePanel(map, focusId, unicode, width, pinned, rows = PANEL_CONTENT_RO
1613
2279
  }
1614
2280
  return lines.slice(0, rows);
1615
2281
  }
1616
- var WATER_ROWS = 7;
1617
- var WATER_COLS_MAX = 60;
1618
- var SPLASH_SHADES = {
1619
- unicode: ["\u2591", "\u2591", "\u2592", "\u2592", "\u2593", "\u2593", "\u2588", "\u2588"],
1620
- ascii: [".", ".", ":", ":", "=", "=", "#", "#"]
1621
- };
1622
- var WAVE_RAMP = [17, 18, 19, 61, 24, 25, 31, 37, 44, 45, 51, 87, 123, 159, 195];
1623
- var SPINNER_FRAMES = {
1624
- unicode: ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"],
1625
- ascii: ["|", "/", "-", "\\"]
1626
- };
1627
2282
  function elapsedLabel(ms) {
1628
2283
  const s = Math.max(0, Math.floor(ms / 1e3));
1629
2284
  const m = Math.floor(s / 60);
@@ -1643,95 +2298,19 @@ function waitingInfo(s, width) {
1643
2298
  lines.push("the map appears at the first mmap_declare");
1644
2299
  return lines.map((l) => fitWidth(l, w));
1645
2300
  }
1646
- var WAVE_INTERVAL = 18;
1647
- var WAVE_LIFETIME = 64;
1648
- var WAVE_SPEED = 0.9;
1649
- var WAVE_ENVELOPE = 10;
1650
- var WAVE_NUMBER = 0.42;
1651
- var WAVE_LEVELS = 7;
1652
- var WAVE_GAIN = 4.5;
1653
- function waveHash(n) {
1654
- let h = Math.imul(n + 1, 2654435761) >>> 0;
1655
- h ^= h >>> 15;
1656
- h = Math.imul(h, 2246822519) >>> 0;
1657
- h ^= h >>> 13;
1658
- return h >>> 0;
1659
- }
1660
- var WAVE_CORNERS = [
1661
- [0, 0],
1662
- [1, 0],
1663
- [0, 1],
1664
- [1, 1]
1665
- ];
1666
- function liveRipples(frame, width, height) {
1667
- const out = [];
1668
- const first = Math.floor((frame - WAVE_LIFETIME - WAVE_INTERVAL) / WAVE_INTERVAL);
1669
- const last = Math.floor(frame / WAVE_INTERVAL);
1670
- for (let n = Math.max(0, first); n <= last; n++) {
1671
- const h = waveHash(n);
1672
- const age = frame - (n * WAVE_INTERVAL + h % WAVE_INTERVAL);
1673
- if (age < 0 || age > WAVE_LIFETIME) continue;
1674
- const [fx, fy] = WAVE_CORNERS[h % WAVE_CORNERS.length];
1675
- out.push({
1676
- ox: fx * (width - 1),
1677
- oy: fy * (height - 1),
1678
- r: age * WAVE_SPEED,
1679
- fade: 1 - age / WAVE_LIFETIME
1680
- });
1681
- }
1682
- return out;
1683
- }
1684
- function waveAt(ripples, x, y) {
1685
- let value = 0;
1686
- for (const w of ripples) {
1687
- const front = Math.hypot(x - w.ox, (y - w.oy) * 2) - w.r;
1688
- value += Math.cos(front * WAVE_NUMBER) * Math.exp(-(front * front) / (2 * WAVE_ENVELOPE ** 2)) * w.fade;
1689
- }
1690
- return value;
1691
- }
1692
- function waveLevel(value) {
1693
- return Math.max(-WAVE_LEVELS, Math.min(WAVE_LEVELS, Math.round(value * WAVE_GAIN)));
1694
- }
1695
2301
  function splashFrame(notice, info, frame, width, height, unicode, color) {
1696
- const fieldW = Math.min(width - 4, WATER_COLS_MAX);
1697
- if (fieldW < 24 || height < WATER_ROWS + info.length + 3) return void 0;
1698
- const mode = unicode ? "unicode" : "ascii";
1699
- const shades = SPLASH_SHADES[mode];
1700
- const indent = " ".repeat(Math.max(0, Math.floor((width - fieldW) / 2)));
1701
- const ripples = liveRipples(frame, fieldW, WATER_ROWS);
1702
- const paintRow = (y) => {
1703
- const levels = Array.from({ length: fieldW }, (_, x) => waveLevel(waveAt(ripples, x, y)));
1704
- let out = "";
1705
- for (let i = 0; i < fieldW; ) {
1706
- const level = levels[i];
1707
- let j = i;
1708
- while (j < fieldW && levels[j] === level) j++;
1709
- if (level === 0) out += " ".repeat(j - i);
1710
- else {
1711
- const ink = shades[Math.abs(level)].repeat(j - i);
1712
- out += color ? `\x1B[38;5;${WAVE_RAMP[WAVE_LEVELS + level]}m${ink}${RESET}` : ink;
1713
- }
1714
- i = j;
1715
- }
1716
- return out;
1717
- };
2302
+ if (width < 24 || height < info.length + 3) return void 0;
1718
2303
  const dim = (s) => color ? `\x1B[90m${s}${RESET}` : s;
1719
- const spinner = SPINNER_FRAMES[mode];
2304
+ const spinner = SPINNER_FRAMES[unicode ? "unicode" : "ascii"];
1720
2305
  const status = fitWidth(`${spinner[frame % spinner.length]} ${notice}`, Math.max(1, width - 2));
1721
2306
  const statusIndent = " ".repeat(Math.max(0, Math.floor((width - displayWidth(status)) / 2)));
1722
2307
  const infoWidth = Math.max(0, ...info.map((l) => displayWidth(l)));
1723
2308
  const infoIndent = " ".repeat(Math.max(0, Math.floor((width - infoWidth) / 2)));
1724
- const block = [
1725
- ...Array.from({ length: WATER_ROWS }, (_, y) => indent + paintRow(y)),
1726
- "",
1727
- statusIndent + dim(status),
1728
- "",
1729
- ...info.map((l) => infoIndent + dim(l))
1730
- ];
2309
+ const block = [statusIndent + dim(status), "", ...info.map((l) => infoIndent + dim(l))];
1731
2310
  return [...Array.from({ length: Math.max(0, Math.floor((height - block.length) / 2)) }, () => ""), ...block];
1732
2311
  }
1733
2312
  function mapPanel(map, unicode, width, rows = PANEL_CONTENT_ROWS) {
1734
- const g = (s) => STATUS_GLYPH[s][unicode ? 0 : 1];
2313
+ const g = (s) => statusGlyph(s, unicode);
1735
2314
  const count = (s) => map.nodes.filter((n) => n.status === s).length;
1736
2315
  const statuses = ["done", "in-progress", "planned", "regressed"];
1737
2316
  const counts = statuses.filter((s) => count(s) > 0).map((s) => `${g(s)} ${count(s)} ${s}`).join(" ");
@@ -1748,9 +2327,20 @@ function mapPanel(map, unicode, width, rows = PANEL_CONTENT_ROWS) {
1748
2327
  while (lines.length < rows) lines.push({ text: "", sgr: "" });
1749
2328
  return lines.slice(0, rows);
1750
2329
  }
2330
+ function viewerReportOf(pane, defaultFile) {
2331
+ const shown = pane.activeFile ?? pane.pendingFocusFile ?? defaultFile;
2332
+ return { page: pageIdOfFile(defaultFile, shown), follow: pane.follow };
2333
+ }
1751
2334
  function main() {
1752
- const cfg = parseArgs(process.argv.slice(2), process.cwd());
1753
- migrateLegacyStore(cfg.file);
2335
+ const parsed = parseArgs(process.argv.slice(2), process.cwd());
2336
+ if (!parsed.ok) {
2337
+ console.error(`mellos-mapping-watch: ${describeArgsError(parsed.error)}
2338
+ ${USAGE}`);
2339
+ process.exit(1);
2340
+ }
2341
+ const cfg = parsed.value;
2342
+ if (migrateLegacyStore(cfg.file)) console.error("mellos-mapping: moved the legacy .claude map store to .mellos/ \u2014 commit the move.");
2343
+ sweepQuitRequest(cfg.file);
1754
2344
  const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
1755
2345
  const mouseActive = interactive && cfg.mouse;
1756
2346
  let lastFrame = "";
@@ -1762,13 +2352,11 @@ function main() {
1762
2352
  let notice = standbyNotice;
1763
2353
  let lastCols = process.stdout.columns ?? 0;
1764
2354
  let lastRows = process.stdout.rows ?? 0;
1765
- let pageFiles = [cfg.file];
1766
- const pageData = /* @__PURE__ */ new Map();
2355
+ let pane = initialPaneState(
2356
+ cfg.follow,
2357
+ cfg.page === void 0 ? void 0 : pageFilePath(cfg.file, cfg.page)
2358
+ );
1767
2359
  const pageViews = /* @__PURE__ */ new Map();
1768
- let activeFile;
1769
- let firstScan = true;
1770
- let pendingFocusFile = cfg.page === void 0 ? void 0 : pageFilePath(cfg.file, cfg.page);
1771
- let follow = cfg.follow;
1772
2360
  let lastTabSegments = [];
1773
2361
  let tabScroll = 0;
1774
2362
  let offsetX = 0;
@@ -1784,62 +2372,72 @@ function main() {
1784
2372
  let panelContentRows = PANEL_CONTENT_ROWS;
1785
2373
  let dividerDrag = false;
1786
2374
  let lastClick;
1787
- const diveStack = [];
1788
2375
  let flash;
1789
2376
  let lastTabFiles = [];
1790
- const maps = () => new Map([...pageData].map(([f, e]) => [f, e.map]));
1791
- const topFiles = () => topLevelFiles(cfg.file, pageFiles, maps());
1792
- const inSubmap = () => activeFile !== void 0 && !topFiles().includes(activeFile);
2377
+ const topFiles = () => topLevelFiles(cfg.file, filesOf(pane), mapsOf(pane));
2378
+ const inSubmap = () => pane.activeFile !== void 0 && !topFiles().includes(pane.activeFile);
1793
2379
  const tabRows = () => topFiles().length > 1 || inSubmap() ? 1 : 0;
1794
2380
  const climbBack = () => {
1795
- let parent = diveStack.pop();
1796
- while (parent !== void 0 && !pageFiles.includes(parent)) parent = diveStack.pop();
1797
- if (parent === void 0 && activeFile !== void 0) {
1798
- parent = diveOrigin(cfg.file, activeFile, pageFiles, maps())?.parent;
1799
- }
1800
- if (parent !== void 0 && parent !== activeFile) {
2381
+ const climbed = popDive(pane);
2382
+ pane = climbed.state;
2383
+ const parent = climbed.parent ?? (pane.activeFile !== void 0 ? diveOrigin(cfg.file, pane.activeFile, filesOf(pane), mapsOf(pane))?.parent : void 0);
2384
+ if (parent !== void 0 && parent !== pane.activeFile) {
1801
2385
  handSwitch(parent);
1802
2386
  return true;
1803
2387
  }
1804
2388
  return false;
1805
2389
  };
1806
- const viewWidth = () => usableColumns(process.stdout.columns ?? 100);
1807
- const viewHeight = () => Math.max(1, (process.stdout.rows ?? 30) - (1 + panelContentRows) - 1 - tabRows());
2390
+ const viewWidth = () => usableColumns(process.stdout.columns ?? FALLBACK_COLUMNS);
2391
+ const viewHeight = () => Math.max(1, (process.stdout.rows ?? FALLBACK_ROWS) - (1 + panelContentRows) - 1 - tabRows());
1808
2392
  const dividerY = () => tabRows() + viewHeight() + 1;
1809
2393
  const pageTabsOf = (files) => files.map((f) => {
1810
- const m = pageData.get(f)?.map;
2394
+ const entry = entryOf(pane, f);
2395
+ const m = mapOf(entry);
1811
2396
  return {
1812
- title: m?.title ?? (pageIdOfFile(cfg.file, f) ?? "main"),
2397
+ title: m?.title ?? (pageIdOfFile(cfg.file, f) ?? DEFAULT_PAGE_TAB_LABEL),
1813
2398
  status: m !== void 0 ? mapStatus(m) : "planned",
1814
- active: f === activeFile,
1815
- fresh: pageData.get(f)?.fresh ?? false,
2399
+ active: f === pane.activeFile,
2400
+ fresh: entry?.fresh ?? false,
1816
2401
  neutral: m !== void 0 && isNeutralKind(m)
1817
2402
  };
1818
2403
  });
1819
- const switchPage = (file) => {
1820
- if (activeFile !== void 0) pageViews.set(activeFile, { offsetX, offsetY, zoom, selectedId });
1821
- activeFile = file;
2404
+ const noticeFor = (file) => {
2405
+ const entry = entryOf(pane, file);
2406
+ if (entry === void 0 || entry.state.kind === "absent") {
2407
+ return file === void 0 || file === cfg.file ? standbyNotice : `waiting for ${file} ...`;
2408
+ }
2409
+ if (entry.state.kind === "loaded") return "";
2410
+ return entry.state.transient && entry.state.lastGood !== void 0 ? "" : describePageFault(entry.state.fault);
2411
+ };
2412
+ const adoptPage = () => {
2413
+ map = mapOf(entryOf(pane, pane.activeFile));
2414
+ notice = noticeFor(pane.activeFile);
2415
+ };
2416
+ const adoptView = (previous) => {
2417
+ const file = pane.activeFile;
2418
+ if (file === void 0 || file === previous) return;
2419
+ if (previous !== void 0) pageViews.set(previous, { offsetX, offsetY, zoom, selectedId });
1822
2420
  const view = pageViews.get(file);
1823
2421
  offsetX = view?.offsetX ?? 0;
1824
2422
  offsetY = view?.offsetY ?? 0;
1825
2423
  zoom = view?.zoom ?? ZOOM_DEFAULT;
1826
2424
  selectedId = view?.selectedId;
1827
2425
  hoverId = void 0;
1828
- const entry = pageData.get(file);
1829
- if (entry !== void 0 && entry.fresh) pageData.set(file, { ...entry, fresh: false });
1830
- map = entry?.map;
1831
- notice = map !== void 0 ? "" : entry?.error ?? (file === cfg.file ? standbyNotice : `waiting for ${file} ...`);
1832
2426
  const top = topFiles();
1833
2427
  const tabIndex = top.indexOf(file);
1834
- if (tabIndex >= 0) tabScroll = tabScrollFor(pageTabsOf(top), viewWidth(), cfg.unicode, tabScroll, tabIndex);
2428
+ if (tabIndex >= 0) {
2429
+ tabScroll = tabScrollFor(pageTabsOf(top), viewWidth(), cfg.unicode, tabScroll, tabIndex, mouseActive);
2430
+ }
1835
2431
  };
1836
2432
  const handSwitch = (file) => {
1837
- pendingFocusFile = void 0;
1838
- if (follow) {
1839
- follow = false;
1840
- flash = { text: "auto-follow off \u2014 press f to re-enable", until: Date.now() + 3e3 };
2433
+ const previous = pane.activeFile;
2434
+ const switched = userSwitch(pane, file);
2435
+ pane = switched.state;
2436
+ if (switched.followTurnedOff) {
2437
+ flash = { text: "auto-follow off \u2014 press f to re-enable", until: Date.now() + FLASH_NOTICE_MS };
1841
2438
  }
1842
- switchPage(file);
2439
+ adoptView(previous);
2440
+ adoptPage();
1843
2441
  };
1844
2442
  const hitTest = (termX, termY) => {
1845
2443
  const sx = termX - 1;
@@ -1850,41 +2448,57 @@ function main() {
1850
2448
  const cy = sy + offsetY;
1851
2449
  return lastHits.find((h) => cx >= h.x && cx < h.x + h.w && cy >= h.y && cy < h.y + h.h)?.id;
1852
2450
  };
1853
- process.stdout.write(HIDE_CURSOR + CLEAR_ALL + (mouseActive ? MOUSE_ON : ""));
1854
- const restore = () => {
1855
- process.stdout.write((mouseActive ? MOUSE_OFF : "") + SHOW_CURSOR + "\n");
1856
- process.exit(0);
2451
+ const publishPresence = () => {
2452
+ publishViewer(cfg.file, process.pid, viewerReportOf(pane, cfg.file));
1857
2453
  };
1858
- process.on("SIGINT", restore);
1859
- process.on("SIGTERM", restore);
1860
- const paint = () => {
1861
- const cols = process.stdout.columns ?? 100;
2454
+ process.stdout.write(HIDE_CURSOR + CLEAR_ALL + (mouseActive ? MOUSE_ON : ""));
2455
+ process.on("exit", () => {
2456
+ process.stdout.write(terminalRestoreSequence(mouseActive));
2457
+ retireViewer(cfg.file, process.pid);
2458
+ });
2459
+ const quit = () => process.exit(0);
2460
+ process.on("SIGINT", quit);
2461
+ process.on("SIGTERM", quit);
2462
+ process.on("uncaughtException", (e) => {
2463
+ process.stderr.write(`
2464
+ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
2465
+ `);
2466
+ process.exit(1);
2467
+ });
2468
+ const paint2 = () => {
2469
+ const cols = process.stdout.columns ?? FALLBACK_COLUMNS;
1862
2470
  const viewW = viewWidth();
1863
- panelContentRows = clampPanelRows(panelContentRows, process.stdout.rows ?? 30, tabRows());
2471
+ panelContentRows = clampPanelRows(panelContentRows, process.stdout.rows ?? FALLBACK_ROWS, tabRows());
1864
2472
  const viewH = viewHeight();
1865
2473
  const focus = hoverId ?? selectedId;
1866
2474
  let body;
1867
2475
  let panned = "";
1868
2476
  let pannable = false;
1869
2477
  if (map !== void 0) {
1870
- const windowed = renderMapWindow(
2478
+ const rendered = renderWindow(
1871
2479
  map,
1872
2480
  { color: cfg.color, unicode: cfg.unicode, spinnerFrame, focus, zoom },
1873
2481
  { x: offsetX, y: offsetY, width: viewW, height: viewH }
1874
2482
  );
1875
- const maxX = Math.max(0, windowed.contentWidth - viewW);
1876
- const maxY = Math.max(0, windowed.contentHeight - viewH);
1877
- if (offsetX > maxX || offsetY > maxY || offsetX < 0 || offsetY < 0) {
1878
- offsetX = Math.min(Math.max(0, offsetX), maxX);
1879
- offsetY = Math.min(Math.max(0, offsetY), maxY);
1880
- paint();
1881
- return;
2483
+ if (!rendered.ok) {
2484
+ body = ["", fitWidth(` ! ${rendered.error}`, viewW), ""];
2485
+ lastHits = [];
2486
+ } else {
2487
+ const windowed = rendered.value;
2488
+ const maxX = Math.max(0, windowed.contentWidth - viewW);
2489
+ const maxY = Math.max(0, windowed.contentHeight - viewH);
2490
+ if (offsetX > maxX || offsetY > maxY || offsetX < 0 || offsetY < 0) {
2491
+ offsetX = Math.min(Math.max(0, offsetX), maxX);
2492
+ offsetY = Math.min(Math.max(0, offsetY), maxY);
2493
+ paint2();
2494
+ return;
2495
+ }
2496
+ pannable = maxX > 0 || maxY > 0;
2497
+ body = windowed.lines;
2498
+ lastHits = windowed.hits;
2499
+ lastContent = { w: windowed.contentWidth, h: windowed.contentHeight };
2500
+ if (offsetX !== 0 || offsetY !== 0) panned = ` (+${offsetX},+${offsetY})`;
1882
2501
  }
1883
- pannable = maxX > 0 || maxY > 0;
1884
- body = windowed.lines;
1885
- lastHits = windowed.hits;
1886
- lastContent = { w: windowed.contentWidth, h: windowed.contentHeight };
1887
- if (offsetX !== 0 || offsetY !== 0) panned = ` (+${offsetX},+${offsetY})`;
1888
2502
  } else {
1889
2503
  const info = waitingInfo(
1890
2504
  {
@@ -1892,7 +2506,9 @@ function main() {
1892
2506
  pagesDir: join2(dirname2(cfg.file), PAGES_DIR_NAME),
1893
2507
  intervalMs: cfg.intervalMs,
1894
2508
  elapsedMs: interactive ? Date.now() - startedAt : void 0,
1895
- broken: [...pageData.values()].flatMap((e) => e.map === void 0 && e.error !== void 0 ? [e.error] : [])
2509
+ broken: pane.pages.flatMap(
2510
+ (p) => p.state.kind === "faulted" && p.state.lastGood === void 0 ? [describePageFault(p.state.fault)] : []
2511
+ )
1896
2512
  },
1897
2513
  Math.max(1, viewW - 2)
1898
2514
  );
@@ -1910,7 +2526,7 @@ function main() {
1910
2526
  } else {
1911
2527
  panel = mapPanel(map, cfg.unicode, panelWidth, panelContentRows);
1912
2528
  }
1913
- const separator = dividerRow(viewW, cfg.unicode, follow);
2529
+ const separator = dividerRow(viewW, cfg.unicode, pane.follow);
1914
2530
  const panelRows = [
1915
2531
  cfg.color ? `\x1B[90m${separator}${RESET}` : separator,
1916
2532
  ...panel.map(
@@ -1919,12 +2535,12 @@ function main() {
1919
2535
  ];
1920
2536
  let tabLine;
1921
2537
  lastTabFiles = topFiles();
1922
- if (inSubmap() && activeFile !== void 0) {
1923
- const stackParent = [...diveStack].reverse().find((f) => pageFiles.includes(f));
1924
- const scanned = diveOrigin(cfg.file, activeFile, pageFiles, maps());
1925
- const parentFile = stackParent ?? scanned?.parent;
1926
- const parentTitle = parentFile !== void 0 ? pageData.get(parentFile)?.map?.title ?? (pageIdOfFile(cfg.file, parentFile) ?? "main") : "main";
1927
- const nodeLabel = scanned?.label ?? map?.title ?? "";
2538
+ if (inSubmap() && pane.activeFile !== void 0) {
2539
+ const stackParent = pane.diveStack[pane.diveStack.length - 1];
2540
+ const origin = diveOrigin(cfg.file, pane.activeFile, filesOf(pane), mapsOf(pane));
2541
+ const parentFile = stackParent ?? origin?.parent;
2542
+ const parentTitle = parentFile !== void 0 ? mapOf(entryOf(pane, parentFile))?.title ?? (pageIdOfFile(cfg.file, parentFile) ?? DEFAULT_PAGE_TAB_LABEL) : DEFAULT_PAGE_TAB_LABEL;
2543
+ const nodeLabel = origin?.label ?? map?.title ?? "";
1928
2544
  const crumbHead = ` ${cfg.unicode ? "\u232B" : "<"} ${parentTitle} ${cfg.unicode ? "\u25B8" : ">"} `;
1929
2545
  const head = { text: crumbHead, sgr: "90", lo: 1, hi: displayWidth(crumbHead), action: { kind: "back" } };
1930
2546
  const tailText = fitWidth(`${nodeLabel} `, Math.max(1, viewW - displayWidth(crumbHead)));
@@ -1938,14 +2554,14 @@ function main() {
1938
2554
  lastTabSegments = [head, tail];
1939
2555
  tabLine = lastTabSegments.map((s) => cfg.color && s.sgr !== "" ? `\x1B[${s.sgr}m${s.text}${RESET}` : s.text).join("");
1940
2556
  } else if (tabRows() > 0) {
1941
- const segments = pageTabRow(pageTabsOf(lastTabFiles), viewW, cfg.unicode, tabScroll);
2557
+ const segments = pageTabRow(pageTabsOf(lastTabFiles), viewW, cfg.unicode, tabScroll, mouseActive);
1942
2558
  lastTabSegments = segments;
1943
2559
  tabLine = segments.map((s) => cfg.color && s.sgr !== "" ? `\x1B[${s.sgr}m${s.text}${RESET}` : s.text).join("");
1944
2560
  } else {
1945
2561
  lastTabSegments = [];
1946
2562
  }
1947
2563
  const zoomTag = `${cfg.unicode ? "\u2295" : "zoom"} ${zoomLabel(zoom)}`;
1948
- const hint = !interactive ? cfg.file : (flash !== void 0 ? `${flash.text} \xB7 ` : "") + `${zoomTag} \xB7 wheel zoom \xB7 ` + (pannable ? "drag pan \xB7 " : "") + "hover/click \xB7 0 reset \xB7 q quit";
2564
+ const hint = !interactive ? cfg.file : (flash !== void 0 ? `${flash.text} \xB7 ` : "") + `${zoomTag} \xB7 wheel zoom \xB7 ` + (pannable ? "drag pan \xB7 " : "") + "hover/click \xB7 0 reset \xB7 x delete page \xB7 q quit";
1949
2565
  const footerText = fitWidth(` ${hint}${panned}`, viewW);
1950
2566
  const footer = cfg.color ? `\x1B[90m${footerText}${RESET}` : footerText;
1951
2567
  let frame = HOME;
@@ -1963,89 +2579,87 @@ function main() {
1963
2579
  lastRows = process.stdout.rows ?? lastRows;
1964
2580
  lastFrame = "";
1965
2581
  process.stdout.write(CLEAR_ALL);
1966
- paint();
2582
+ paint2();
1967
2583
  };
1968
2584
  const tick = () => {
2585
+ if (takeQuitRequest(cfg.file)) quit();
1969
2586
  if ((process.stdout.columns ?? lastCols) !== lastCols || (process.stdout.rows ?? lastRows) !== lastRows) {
1970
2587
  handleResize();
1971
2588
  }
1972
2589
  const discovered = listPageFiles(cfg.file);
1973
- pageFiles = discovered.length > 0 ? discovered : [cfg.file];
1974
- for (const known of [...pageData.keys()]) {
1975
- if (!pageFiles.includes(known)) {
1976
- pageData.delete(known);
1977
- pageViews.delete(known);
1978
- }
1979
- }
1980
- const changedFiles = [];
1981
- for (const file of pageFiles) {
1982
- let mtimeMs;
1983
- try {
1984
- mtimeMs = statSync(file).mtimeMs;
1985
- } catch {
1986
- continue;
1987
- }
1988
- const entry = pageData.get(file);
1989
- if (mtimeMs === entry?.mtimeMs) continue;
1990
- const loaded = loadMapFile(file);
1991
- if (loaded.ok) {
1992
- if (!firstScan) changedFiles.push(file);
1993
- const becameFresh = !firstScan && file !== activeFile;
1994
- pageData.set(file, { map: loaded.value, mtimeMs, fresh: becameFresh });
1995
- if (file === activeFile) {
1996
- map = loaded.value;
1997
- notice = "";
1998
- } else if (becameFresh && !topFiles().includes(file)) {
1999
- const title = loaded.value.title ?? (pageIdOfFile(cfg.file, file) ?? "?");
2000
- flash = { text: `${cfg.unicode ? "\u229E " : ""}${title} updated`, until: Date.now() + 4e3 };
2001
- }
2002
- } else if (loaded.error.kind === "malformed-json") {
2003
- pageData.set(file, {
2004
- map: entry?.map,
2005
- mtimeMs: entry?.mtimeMs ?? -1,
2006
- fresh: entry?.fresh ?? false,
2007
- error: describeStoreError(loaded.error)
2008
- });
2009
- if (file === activeFile && entry?.map === void 0) notice = describeStoreError(loaded.error);
2010
- } else {
2011
- pageData.set(file, { map: entry?.map, mtimeMs, fresh: entry?.fresh ?? false, error: describeStoreError(loaded.error) });
2012
- if (file === activeFile) notice = describeStoreError(loaded.error);
2013
- }
2014
- }
2590
+ const files = discovered.length > 0 ? discovered : [cfg.file];
2015
2591
  const request = takeFocusRequest(cfg.file);
2016
- if (request !== void 0 && !(firstScan && pendingFocusFile !== void 0)) {
2017
- pendingFocusFile = pageFilePath(cfg.file, request.page);
2592
+ const previous = pane.activeFile;
2593
+ const scanned = scan(pane, {
2594
+ files,
2595
+ mtimeAt: (file) => {
2596
+ try {
2597
+ return statSync2(file).mtimeMs;
2598
+ } catch {
2599
+ return void 0;
2600
+ }
2601
+ },
2602
+ load: readPage,
2603
+ focusRequest: request === void 0 ? void 0 : pageFilePath(cfg.file, request.page),
2604
+ // a drag in progress holds auto-follow off: the user is engaged with
2605
+ // THIS page, and a missed switch is re-triggered by the next save
2606
+ engaged: dragAnchor !== void 0
2607
+ });
2608
+ pane = scanned.state;
2609
+ for (const known of [...pageViews.keys()]) {
2610
+ if (!files.includes(known)) pageViews.delete(known);
2018
2611
  }
2019
- let requestApplied = false;
2020
- if (pendingFocusFile !== void 0 && pageFiles.includes(pendingFocusFile)) {
2021
- if (pendingFocusFile !== activeFile) switchPage(pendingFocusFile);
2022
- pendingFocusFile = void 0;
2023
- requestApplied = true;
2612
+ adoptView(previous);
2613
+ adoptPage();
2614
+ if (flash?.confirm === true && pane.pendingDelete === void 0) flash = void 0;
2615
+ const top = topFiles();
2616
+ for (const file of scanned.freshened) {
2617
+ if (top.includes(file)) continue;
2618
+ const title = mapOf(entryOf(pane, file))?.title ?? (pageIdOfFile(cfg.file, file) ?? "?");
2619
+ flash = { text: `${cfg.unicode ? "\u229E " : ""}${title} updated`, until: Date.now() + FLASH_BACKGROUND_NEWS_MS };
2024
2620
  }
2025
- if (follow && !requestApplied && changedFiles.length > 0 && dragAnchor === void 0) {
2026
- const target = mostRecentPageFile(changedFiles, (f) => pageData.get(f)?.mtimeMs);
2027
- if (target !== activeFile) switchPage(target);
2621
+ if (pane.pages.some((p) => mapOf(p)?.nodes.some((n) => n.status === "in-progress"))) spinnerFrame++;
2622
+ if (flash !== void 0 && Date.now() > flash.until) flash = void 0;
2623
+ paint2();
2624
+ };
2625
+ const pageName = (file) => pageIdOfFile(cfg.file, file) ?? DEFAULT_PAGE_TAB_LABEL;
2626
+ const askDelete = () => {
2627
+ const now = Date.now();
2628
+ const asked = requestDelete(pane, now, CONFIRM_WINDOW_MS);
2629
+ pane = asked.state;
2630
+ if (asked.request.kind === "none") return;
2631
+ if (asked.request.kind === "absent") {
2632
+ flash = {
2633
+ text: `nothing to delete \u2014 ${pageName(asked.request.file)} has no file`,
2634
+ until: now + FLASH_NOTICE_MS
2635
+ };
2636
+ return;
2028
2637
  }
2029
- firstScan = false;
2030
- if (activeFile === void 0 || !pageFiles.includes(activeFile)) {
2031
- switchPage(mostRecentPageFile(pageFiles, (f) => pageData.get(f)?.mtimeMs));
2638
+ if (asked.request.kind === "armed") {
2639
+ flash = {
2640
+ text: `press x again to delete ${pageName(asked.request.file)} \u2014 its file is removed`,
2641
+ until: asked.request.until,
2642
+ confirm: true
2643
+ };
2644
+ return;
2032
2645
  }
2033
- if ([...pageData.values()].some((p) => p.map?.nodes.some((n) => n.status === "in-progress"))) spinnerFrame++;
2034
- if (flash !== void 0 && Date.now() > flash.until) flash = void 0;
2035
- paint();
2646
+ const file = asked.request.file;
2647
+ const removed = deletePageFile(file);
2648
+ flash = removed.ok ? { text: `deleted ${pageName(file)}`, until: now + FLASH_ACK_MS } : { text: describeStoreError(removed.error), until: now + FLASH_NOTICE_MS };
2649
+ tick();
2036
2650
  };
2037
2651
  if (interactive) {
2038
2652
  process.stdin.setRawMode(true);
2039
2653
  process.stdin.resume();
2040
2654
  process.stdin.setEncoding("utf8");
2041
2655
  process.stdin.on("data", (chunk) => {
2042
- const parsed = parseInput(pendingInput + chunk);
2043
- pendingInput = parsed.rest;
2656
+ const parsed2 = parseInput(pendingInput + chunk);
2657
+ pendingInput = parsed2.rest;
2044
2658
  let dirty = false;
2045
- for (const event of parsed.events) {
2659
+ for (const event of parsed2.events) {
2046
2660
  switch (event.kind) {
2047
2661
  case "quit":
2048
- restore();
2662
+ quit();
2049
2663
  return;
2050
2664
  case "reset":
2051
2665
  offsetX = 0;
@@ -2054,7 +2668,10 @@ function main() {
2054
2668
  dirty = true;
2055
2669
  break;
2056
2670
  case "clear":
2057
- if (selectedId !== void 0) selectedId = void 0;
2671
+ if (pane.pendingDelete !== void 0) {
2672
+ pane = disarmDelete(pane);
2673
+ flash = void 0;
2674
+ } else if (selectedId !== void 0) selectedId = void 0;
2058
2675
  else climbBack();
2059
2676
  dirty = true;
2060
2677
  break;
@@ -2074,11 +2691,16 @@ function main() {
2074
2691
  const anchorId = hoverId ?? selectedId ?? nearestHit(lastHits, offsetX + viewWidth() / 2, offsetY + viewHeight() / 2)?.id;
2075
2692
  const before = lastHits.find((h) => h.id === anchorId);
2076
2693
  zoom = next;
2077
- const sized = renderMapWindow(
2694
+ const measured = renderWindow(
2078
2695
  map,
2079
2696
  { color: false, unicode: cfg.unicode, spinnerFrame: 0, zoom },
2080
2697
  { x: 0, y: 0, width: 0, height: 0 }
2081
2698
  );
2699
+ if (!measured.ok) {
2700
+ dirty = true;
2701
+ break;
2702
+ }
2703
+ const sized = measured.value;
2082
2704
  const after = before === void 0 ? void 0 : sized.hits.find((h) => h.id === before.id);
2083
2705
  const moved = anchorOffsets(
2084
2706
  before !== void 0 && after !== void 0 ? { before, after } : void 0,
@@ -2109,7 +2731,7 @@ function main() {
2109
2731
  break;
2110
2732
  case "mouse-drag":
2111
2733
  if (dividerDrag) {
2112
- const next = panelRowsFromDividerY(event.y, process.stdout.rows ?? 30, tabRows());
2734
+ const next = panelRowsFromDividerY(event.y, process.stdout.rows ?? FALLBACK_ROWS, tabRows());
2113
2735
  if (next !== panelContentRows) {
2114
2736
  panelContentRows = next;
2115
2737
  dirty = true;
@@ -2139,22 +2761,25 @@ function main() {
2139
2761
  climbBack();
2140
2762
  } else if (tabHit.action.kind === "scroll") {
2141
2763
  tabScroll = Math.max(0, Math.min(tabScroll + tabHit.action.delta, lastTabFiles.length - 1));
2764
+ } else if (tabHit.action.kind === "delete") {
2765
+ askDelete();
2142
2766
  } else {
2143
2767
  const target = lastTabFiles[tabHit.action.index];
2144
- if (target !== void 0 && target !== activeFile) handSwitch(target);
2768
+ if (target !== void 0 && target !== pane.activeFile) handSwitch(target);
2145
2769
  }
2146
2770
  } else {
2147
2771
  const id = hitTest(event.x, event.y);
2148
2772
  const now = Date.now();
2149
- if (id !== void 0 && lastClick?.id === id && now - lastClick.at <= 450) {
2773
+ if (id !== void 0 && lastClick?.id === id && now - lastClick.at <= DOUBLE_CLICK_MS) {
2150
2774
  const submap = map?.nodes.find((n) => n.id === id)?.submap;
2151
- if (submap !== void 0 && activeFile !== void 0) {
2775
+ if (submap !== void 0 && pane.activeFile !== void 0) {
2152
2776
  const target = pageFilePath(cfg.file, submap);
2153
- if (pageFiles.includes(target) && target !== activeFile) {
2154
- diveStack.push(activeFile);
2777
+ const files = filesOf(pane);
2778
+ if (files.includes(target) && target !== pane.activeFile) {
2779
+ pane = pushDive(pane, pane.activeFile);
2155
2780
  handSwitch(target);
2156
- } else if (!pageFiles.includes(target)) {
2157
- flash = { text: `submap "${submap}" has no page yet`, until: now + 2500 };
2781
+ } else if (!files.includes(target)) {
2782
+ flash = { text: `submap "${submap}" has no page yet`, until: now + FLASH_ACK_MS };
2158
2783
  }
2159
2784
  }
2160
2785
  lastClick = void 0;
@@ -2171,11 +2796,11 @@ function main() {
2171
2796
  case "next-page":
2172
2797
  case "prev-page": {
2173
2798
  const top = topFiles();
2174
- if (top.length > 0 && activeFile !== void 0) {
2175
- const current = top.indexOf(activeFile);
2799
+ if (top.length > 0 && pane.activeFile !== void 0) {
2800
+ const current = top.indexOf(pane.activeFile);
2176
2801
  const step = event.kind === "next-page" ? 1 : -1;
2177
2802
  const target = top[(current + step + top.length) % top.length];
2178
- if (target !== activeFile) {
2803
+ if (target !== pane.activeFile) {
2179
2804
  handSwitch(target);
2180
2805
  dirty = true;
2181
2806
  }
@@ -2184,7 +2809,7 @@ function main() {
2184
2809
  }
2185
2810
  case "page": {
2186
2811
  const target = topFiles()[event.index];
2187
- if (target !== void 0 && target !== activeFile) {
2812
+ if (target !== void 0 && target !== pane.activeFile) {
2188
2813
  handSwitch(target);
2189
2814
  dirty = true;
2190
2815
  }
@@ -2194,24 +2819,30 @@ function main() {
2194
2819
  if (climbBack()) dirty = true;
2195
2820
  break;
2196
2821
  case "follow-toggle":
2197
- follow = !follow;
2198
- flash = { text: follow ? "auto-follow on" : "auto-follow off", until: Date.now() + 2500 };
2822
+ pane = toggleFollow(pane);
2823
+ flash = { text: pane.follow ? "auto-follow on" : "auto-follow off", until: Date.now() + FLASH_ACK_MS };
2824
+ dirty = true;
2825
+ break;
2826
+ case "delete-page":
2827
+ askDelete();
2199
2828
  dirty = true;
2200
2829
  break;
2201
2830
  }
2202
2831
  }
2203
- if (dirty) paint();
2832
+ if (dirty) paint2();
2204
2833
  });
2205
2834
  process.stdout.on("resize", handleResize);
2206
2835
  }
2207
2836
  tick();
2208
2837
  setInterval(tick, cfg.intervalMs);
2838
+ publishPresence();
2839
+ setInterval(publishPresence, VIEWER_HEARTBEAT_MS);
2209
2840
  if (interactive) {
2210
2841
  setInterval(() => {
2211
2842
  if (map !== void 0) return;
2212
2843
  splashTick++;
2213
- paint();
2214
- }, 80);
2844
+ paint2();
2845
+ }, SPLASH_FRAME_MS);
2215
2846
  }
2216
2847
  }
2217
2848
  function launchedAsEntry(argv1, moduleUrl) {
@@ -2227,30 +2858,30 @@ if (launchedAsEntry(process.argv[1], import.meta.url)) {
2227
2858
  }
2228
2859
  export {
2229
2860
  PANEL_ROWS_MIN,
2230
- WAVE_LEVELS,
2231
- WAVE_NUMBER,
2861
+ USAGE,
2232
2862
  anchorOffsets,
2233
2863
  clampPanelRows,
2864
+ describeArgsError,
2865
+ describePageFault,
2234
2866
  diveOrigin,
2235
2867
  dividerRow,
2236
2868
  elapsedLabel,
2237
2869
  fitWidth,
2238
2870
  launchedAsEntry,
2239
- liveRipples,
2240
2871
  mapPanel,
2241
- mostRecentPageFile,
2242
2872
  nearestHit,
2243
2873
  nodePanel,
2244
2874
  pageTabRow,
2245
2875
  panelRowsFromDividerY,
2246
2876
  parseArgs,
2877
+ readPage,
2878
+ renderWindow,
2247
2879
  splashFrame,
2248
2880
  tabScrollFor,
2881
+ terminalRestoreSequence,
2249
2882
  topLevelFiles,
2250
2883
  usableColumns,
2884
+ viewerReportOf,
2251
2885
  waitingInfo,
2252
- waveAt,
2253
- waveHash,
2254
- waveLevel,
2255
2886
  wrapWidth
2256
2887
  };