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
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Layer 4a — what one rung of the zoom ladder buys, in terminal cells.
3
+ *
4
+ * Terminals cannot scale glyphs, so zooming out COMPRESSES geometry instead:
5
+ * gaps, breathing rows and padding shrink, labels truncate toward a
6
+ * scale-proportional budget, and only when a further step would leave labels
7
+ * too short to mean anything does the picture switch mode. WHICH steps switch
8
+ * mode is semantics (zoomMode over in ../semantics); this module owns only
9
+ * the whitespace and label budgets each step spends.
10
+ */
11
+ import { zoomMode } from '../semantics/semantics.js';
12
+ /** Height of a box with nothing unfolded inside it: border, content, border. */
13
+ export const BOX_H = 3;
14
+ /** Columns between two boxes of one band at the roomy default. */
15
+ export const BOX_GAP = 2;
16
+ /** Columns before the leftmost box; the picture never starts at the edge. */
17
+ export const LEFT_MARGIN = 2;
18
+ /** Bar cells kept left of the narrowest band label, so a band still reads as a band. */
19
+ export const BAR_MIN_RUN = 7;
20
+ /** +1: a readable unfold. +2: the box grows into a reading card. */
21
+ const DETAIL_BUDGET = { innerMin: 22, innerMax: 32, noteRows: 3 };
22
+ const DETAIL_PLUS_BUDGET = { innerMin: 30, innerMax: 48, noteRows: 12 };
23
+ export function zoomGeometry(zoom) {
24
+ const m = zoomMode(zoom);
25
+ const mode = m === 'overview' ? 'constellation' : m;
26
+ switch (zoom) {
27
+ case 2:
28
+ return { mode, scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false, detail: DETAIL_PLUS_BUDGET };
29
+ case 1:
30
+ return { mode, scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false, detail: DETAIL_BUDGET };
31
+ case 0:
32
+ return { mode, scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false };
33
+ case -1:
34
+ return { mode, scale: 0.85, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false };
35
+ case -2:
36
+ return { mode, scale: 0.7, pad: 0, boxGap: BOX_GAP, breathe: 0, titleGap: 0, barGap: 1, bandCounts: false };
37
+ case -3:
38
+ return { mode, scale: 0.55, pad: 0, boxGap: 1, breathe: 0, titleGap: 0, barGap: 1, bandCounts: true };
39
+ case -4:
40
+ return { mode, scale: 0, pad: 0, boxGap: BOX_GAP, breathe: 0, titleGap: 0, barGap: 1, bandCounts: true };
41
+ }
42
+ }
43
+ /**
44
+ * Geometry for the aggregated far zoom: tight chrome, but FULL labels — the
45
+ * point of collapsing a map into its groups is reading their names.
46
+ */
47
+ export const AGGREGATE_GEO = {
48
+ mode: 'boxes',
49
+ scale: 1,
50
+ pad: 0,
51
+ boxGap: 1,
52
+ breathe: 0,
53
+ titleGap: 0,
54
+ barGap: 1,
55
+ bandCounts: false,
56
+ };
@@ -4,11 +4,16 @@
4
4
  * Every renderer (the terminal pane, a web panel, a future editor view) must
5
5
  * agree on what a zoom step MEANS, when a map aggregates into its groups,
6
6
  * how sequence time is oriented, and which map kinds render neutrally.
7
- * Those rules live here, pure of any medium: no cells, no glyphs, no DOM,
7
+ * Those rules live here, pure of any medium: no cells, no colors, no DOM,
8
8
  * no I/O. Geometry — how a mode maps onto character cells or pixels — stays
9
9
  * private to each renderer.
10
+ *
11
+ * The shared ALPHABET (status glyphs, spinner frames, node-kind glyphs) is
12
+ * medium-neutral for the same reason and lives beside this file in
13
+ * ./vocabulary.js, re-exported here so consumers keep one import site.
10
14
  */
11
15
  import type { MapGroup, MapNode, MellosMap, NodeStatus } from '../domain/types.js';
16
+ export { NODE_KIND_GLYPHS, SPINNER_FRAMES, STATUS_GLYPHS, UNVERIFIED_DONE_GLYPHS, kindGlyph, spinnerGlyph, statusGlyph, unverifiedDoneGlyph, } from './vocabulary.js';
12
17
  /** One wheel tick on the zoom ladder. */
13
18
  export type ZoomStep = -4 | -3 | -2 | -1 | 0 | 1 | 2;
14
19
  export declare const ZOOM_MIN: ZoomStep;
@@ -73,19 +78,53 @@ export interface GroupFocus {
73
78
  * when the far zoom's aggregated boxes are what the pointer is over. Pure
74
79
  * data: every renderer picks its own glyphs, colors, and words (a sequence
75
80
  * page reads uses/usedBy as after/before; that is the caller's vocabulary).
81
+ * Groups are resolved first, which is unambiguous: one id names one box on
82
+ * the map (I10), so no node can be shadowed by a group of the same name.
76
83
  * @param map - the map the focus lives in.
77
84
  * @param focusId - node or group id.
78
85
  * @returns the focus view, or undefined when the id names neither.
86
+ *
87
+ * VIOLATION: no-primitive-obsession - `focusId` is a raw string, not a brand.
88
+ * It has to be: the caller is a hit test over the picture, and at the far
89
+ * zoom that picture is the AGGREGATED map, whose boxes are groups. So the id
90
+ * arriving here is a NodeId or a GroupId and the caller cannot know which —
91
+ * deciding that is this function's whole job. `NodeId | GroupId` would type
92
+ * it, but every hit-test path (BoxHit.id, RenderOptions.focus, the pane's
93
+ * hover/selection state) would then have to carry that union and cast into it
94
+ * at the same boundary, moving the cast rather than removing it. The value is
95
+ * validated the only way it can be: an id that names neither answers
96
+ * undefined.
79
97
  */
80
98
  export declare function focusInfo(map: MellosMap, focusId: string): NodeFocus | GroupFocus | undefined;
81
99
  /**
82
- * Page slugs some node of any map dives into. A page referenced as a submap
83
- * is interior detail — it is reached by diving through its linking node,
84
- * never by sitting beside its parent as a sibling tab.
100
+ * Page slugs some node of any map dives into — the raw fact only, WITHOUT
101
+ * the rule a sibling strip needs on top of it (that is interiorPages).
85
102
  * @param maps - every known page's map (undefined entries are skipped).
86
103
  * @returns the referenced submap slugs.
87
104
  */
88
105
  export declare function submapRefs(maps: Iterable<MellosMap | undefined>): Set<string>;
106
+ /**
107
+ * Which pages are INTERIOR: reached by diving through a node, never by
108
+ * sitting beside their parent as a sibling tab.
109
+ *
110
+ * "Referenced by anyone" is NOT the rule, because a strip computed that way
111
+ * can erase itself. Two refinements make it total:
112
+ * - a page never hides itself. A node whose submap names its own page is a
113
+ * loop with no bottom, not a parent link — read as one, it deleted the
114
+ * tab of the very page it was drawn on.
115
+ * - a page referenced only from pages it can itself reach keeps its tab. A
116
+ * link cycle has no outside, so hiding every page in it leaves a strip
117
+ * with nothing in it and a client with nowhere left to go.
118
+ * Everything else is interior: some page OUTSIDE its own loop dives into it,
119
+ * and that page's node is the way in.
120
+ *
121
+ * @param pages - every known page as (slug, map) pairs; the default page has
122
+ * no slug a node could name, so it is passed as undefined and is never
123
+ * interior. Entries with no map (unreadable, not yet loaded) contribute no
124
+ * links.
125
+ * @returns the slugs to keep out of a sibling strip.
126
+ */
127
+ export declare function interiorPages(pages: Iterable<readonly [string | undefined, MellosMap | undefined]>): Set<string>;
89
128
  /**
90
129
  * Where a sub-map page was dived into from: the entry whose map links the
91
130
  * page, plus the linking node's label. Derived by scan, so a breadcrumb
@@ -106,6 +145,16 @@ export declare function diveParent<K>(entries: Iterable<readonly [K, MellosMap |
106
145
  * @param keys - candidate page keys in the caller's fallback order.
107
146
  * @param mtimeOf - last-written timestamp of a key, undefined when unknown.
108
147
  * @returns the winning key, or undefined for an empty candidate set.
148
+ *
149
+ * VIOLATION: prefer-objects - `mtimeOf` is a function parameter, which this
150
+ * layer otherwise avoids. It is what keeps the rule medium-neutral: the two
151
+ * callers ask different worlds for the same fact — the pane stats a file, the
152
+ * browser panel reads an mtime the host already sent over the wire — and
153
+ * neither timestamp source can be named here without dragging a filesystem or
154
+ * a transport into a module that must stay free of both. The alternative,
155
+ * taking `readonly [K, number | undefined][]`, only moves the same lookup to
156
+ * the caller and makes it allocate a pair per page per tick. The parameter is
157
+ * a pure query, called once per key, never stored.
109
158
  */
110
159
  export declare function mostRecentKey<K>(keys: readonly K[], mtimeOf: (key: K) => number | undefined): K | undefined;
111
160
  /**
@@ -4,11 +4,16 @@
4
4
  * Every renderer (the terminal pane, a web panel, a future editor view) must
5
5
  * agree on what a zoom step MEANS, when a map aggregates into its groups,
6
6
  * how sequence time is oriented, and which map kinds render neutrally.
7
- * Those rules live here, pure of any medium: no cells, no glyphs, no DOM,
7
+ * Those rules live here, pure of any medium: no cells, no colors, no DOM,
8
8
  * no I/O. Geometry — how a mode maps onto character cells or pixels — stays
9
9
  * private to each renderer.
10
+ *
11
+ * The shared ALPHABET (status glyphs, spinner frames, node-kind glyphs) is
12
+ * medium-neutral for the same reason and lives beside this file in
13
+ * ./vocabulary.js, re-exported here so consumers keep one import site.
10
14
  */
11
15
  import { groupStatus } from '../domain/ops.js';
16
+ export { NODE_KIND_GLYPHS, SPINNER_FRAMES, STATUS_GLYPHS, UNVERIFIED_DONE_GLYPHS, kindGlyph, spinnerGlyph, statusGlyph, unverifiedDoneGlyph, } from './vocabulary.js';
12
17
  export const ZOOM_MIN = -4;
13
18
  export const ZOOM_MAX = 2;
14
19
  export const ZOOM_DEFAULT = 0;
@@ -71,8 +76,17 @@ export function isNeutralKind(map) {
71
76
  export function aggregateMap(map) {
72
77
  if (map.groups.length === 0)
73
78
  return undefined;
74
- // Group ids join the node-id slug space inside this derived value; the
75
- // brands only guard PERSISTED maps, and this one never leaves rendering.
79
+ // VIOLATION: no-primitive-obsession - a GroupId is written into a NodeId
80
+ // slot below (`g.id as unknown as NodeId`), and the representative table is
81
+ // keyed by raw strings because it holds both kinds at once. The honest type
82
+ // would be a `BoxId = NodeId | GroupId` running through MellosMap, every op
83
+ // and the file format — a domain-wide change for a value that exists only
84
+ // between this function and a renderer. It is safe for one reason the
85
+ // domain guarantees rather than this cast: nodes and groups share ONE id
86
+ // namespace (I10), so no group id can collide with a node id here. The
87
+ // brands guard PERSISTED maps; this map is never persisted (see the doc
88
+ // comment above). Follow-up: introduce BoxId with the next format version,
89
+ // when the file format has to move anyway.
76
90
  const representative = new Map();
77
91
  for (const n of map.nodes)
78
92
  representative.set(n.id, (n.group ?? n.id));
@@ -115,9 +129,22 @@ export function aggregateMap(map) {
115
129
  * when the far zoom's aggregated boxes are what the pointer is over. Pure
116
130
  * data: every renderer picks its own glyphs, colors, and words (a sequence
117
131
  * page reads uses/usedBy as after/before; that is the caller's vocabulary).
132
+ * Groups are resolved first, which is unambiguous: one id names one box on
133
+ * the map (I10), so no node can be shadowed by a group of the same name.
118
134
  * @param map - the map the focus lives in.
119
135
  * @param focusId - node or group id.
120
136
  * @returns the focus view, or undefined when the id names neither.
137
+ *
138
+ * VIOLATION: no-primitive-obsession - `focusId` is a raw string, not a brand.
139
+ * It has to be: the caller is a hit test over the picture, and at the far
140
+ * zoom that picture is the AGGREGATED map, whose boxes are groups. So the id
141
+ * arriving here is a NodeId or a GroupId and the caller cannot know which —
142
+ * deciding that is this function's whole job. `NodeId | GroupId` would type
143
+ * it, but every hit-test path (BoxHit.id, RenderOptions.focus, the pane's
144
+ * hover/selection state) would then have to carry that union and cast into it
145
+ * at the same boundary, moving the cast rather than removing it. The value is
146
+ * validated the only way it can be: an id that names neither answers
147
+ * undefined.
121
148
  */
122
149
  export function focusInfo(map, focusId) {
123
150
  const layerNameOf = (layerId) => map.layers.find((l) => l.id === layerId)?.name ?? layerId;
@@ -183,10 +210,21 @@ export function focusInfo(map, focusId) {
183
210
  // ---------------------------------------------------------------------------
184
211
  // page-set semantics — which pages are siblings, and where a dive came from
185
212
  // ---------------------------------------------------------------------------
213
+ //
214
+ // VIOLATION: no-primitive-obsession - page slugs cross this section as raw
215
+ // strings (submapRefs, interiorPages, diveParent), never as a brand. This is
216
+ // exactly where the system's TWO brands over one grammar have to meet: a
217
+ // node's `submap` is a SubmapRef (Layer 0, a reference a map declares) and a
218
+ // page identity is a PageId (Layer 1a, what the store calls a file). Typing
219
+ // these parameters as either brand would force every caller on the other side
220
+ // to cast into it — moving the cast, not removing it — and unifying the two
221
+ // would make Layer 0 name a persistence concept. What these functions do with
222
+ // a slug is compare it; none parses or resolves one, and both brands are
223
+ // validated against the same rule where they enter the system (the tool
224
+ // schema and the file format).
186
225
  /**
187
- * Page slugs some node of any map dives into. A page referenced as a submap
188
- * is interior detail — it is reached by diving through its linking node,
189
- * never by sitting beside its parent as a sibling tab.
226
+ * Page slugs some node of any map dives into — the raw fact only, WITHOUT
227
+ * the rule a sibling strip needs on top of it (that is interiorPages).
190
228
  * @param maps - every known page's map (undefined entries are skipped).
191
229
  * @returns the referenced submap slugs.
192
230
  */
@@ -199,6 +237,73 @@ export function submapRefs(maps) {
199
237
  }
200
238
  return refs;
201
239
  }
240
+ /**
241
+ * Which pages are INTERIOR: reached by diving through a node, never by
242
+ * sitting beside their parent as a sibling tab.
243
+ *
244
+ * "Referenced by anyone" is NOT the rule, because a strip computed that way
245
+ * can erase itself. Two refinements make it total:
246
+ * - a page never hides itself. A node whose submap names its own page is a
247
+ * loop with no bottom, not a parent link — read as one, it deleted the
248
+ * tab of the very page it was drawn on.
249
+ * - a page referenced only from pages it can itself reach keeps its tab. A
250
+ * link cycle has no outside, so hiding every page in it leaves a strip
251
+ * with nothing in it and a client with nowhere left to go.
252
+ * Everything else is interior: some page OUTSIDE its own loop dives into it,
253
+ * and that page's node is the way in.
254
+ *
255
+ * @param pages - every known page as (slug, map) pairs; the default page has
256
+ * no slug a node could name, so it is passed as undefined and is never
257
+ * interior. Entries with no map (unreadable, not yet loaded) contribute no
258
+ * links.
259
+ * @returns the slugs to keep out of a sibling strip.
260
+ */
261
+ export function interiorPages(pages) {
262
+ /** slug -> the pages it dives into. */
263
+ const dives = new Map();
264
+ /** slug -> the pages that dive into it (undefined = the default page). */
265
+ const divedIntoBy = new Map();
266
+ for (const [slug, map] of pages) {
267
+ const targets = new Set();
268
+ for (const n of map?.nodes ?? []) {
269
+ const target = n.submap;
270
+ if (target === undefined || target === slug)
271
+ continue;
272
+ targets.add(target);
273
+ const sources = divedIntoBy.get(target) ?? new Set();
274
+ sources.add(slug);
275
+ divedIntoBy.set(target, sources);
276
+ }
277
+ if (slug !== undefined)
278
+ dives.set(slug, targets);
279
+ }
280
+ /** Every page reachable from `start` by following submap links onward. */
281
+ const reachableFrom = (start) => {
282
+ const seen = new Set();
283
+ const pending = [start];
284
+ while (pending.length > 0) {
285
+ for (const target of dives.get(pending.pop()) ?? []) {
286
+ if (seen.has(target))
287
+ continue;
288
+ seen.add(target);
289
+ pending.push(target);
290
+ }
291
+ }
292
+ return seen;
293
+ };
294
+ const interior = new Set();
295
+ for (const [target, sources] of divedIntoBy) {
296
+ const outward = reachableFrom(target);
297
+ for (const source of sources) {
298
+ // the default page (undefined) is never reachable: it has no slug
299
+ if (source === undefined || !outward.has(source)) {
300
+ interior.add(target);
301
+ break;
302
+ }
303
+ }
304
+ }
305
+ return interior;
306
+ }
202
307
  /**
203
308
  * Where a sub-map page was dived into from: the entry whose map links the
204
309
  * page, plus the linking node's label. Derived by scan, so a breadcrumb
@@ -223,6 +328,16 @@ export function diveParent(entries, pageId) {
223
328
  * @param keys - candidate page keys in the caller's fallback order.
224
329
  * @param mtimeOf - last-written timestamp of a key, undefined when unknown.
225
330
  * @returns the winning key, or undefined for an empty candidate set.
331
+ *
332
+ * VIOLATION: prefer-objects - `mtimeOf` is a function parameter, which this
333
+ * layer otherwise avoids. It is what keeps the rule medium-neutral: the two
334
+ * callers ask different worlds for the same fact — the pane stats a file, the
335
+ * browser panel reads an mtime the host already sent over the wire — and
336
+ * neither timestamp source can be named here without dragging a filesystem or
337
+ * a transport into a module that must stay free of both. The alternative,
338
+ * taking `readonly [K, number | undefined][]`, only moves the same lookup to
339
+ * the caller and makes it allocate a pair per page per tick. The parameter is
340
+ * a pure query, called once per key, never stored.
226
341
  */
227
342
  export function mostRecentKey(keys, mtimeOf) {
228
343
  let best;
@@ -250,6 +365,15 @@ export function flipForSequence(map) {
250
365
  return map;
251
366
  return {
252
367
  ...map,
368
+ // VIOLATION: state-explicit-in-types - `-l.rank as Rank` produces a value
369
+ // the Rank brand promises cannot exist: mirroring 0..99 gives -99..0, and
370
+ // makeRank would refuse every one of them. The alternative is a second
371
+ // ordered-position type (an unbranded `order` field) threaded through the
372
+ // renderer's whole layout stage purely so this one derived map can be
373
+ // typed — a large change to express "these ranks are an order, not a
374
+ // stored value". What makes it safe is the same thing that makes it
375
+ // wrong: this map only ever reaches a renderer, which compares ranks and
376
+ // never writes them (same contract as aggregateMap).
253
377
  layers: map.layers.map((l) => ({ ...l, rank: -l.rank })),
254
378
  edges: map.edges.map((e) => ({ from: e.to, to: e.from, ...(e.label !== undefined ? { label: e.label } : {}) })),
255
379
  };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Layer 1c — the medium-neutral VISUAL VOCABULARY of a map.
3
+ *
4
+ * A status and a node kind each have ONE agreed glyph. Not because glyphs are
5
+ * semantics — they are not — but because they are the map's shared alphabet:
6
+ * the terminal picture, the pane's tab strip and detail panel, and a browser
7
+ * view all show the same map to the same person, and a status that reads ⠿ in
8
+ * one place and ◐ in another is a lie about the map being one thing. The
9
+ * table lived in three files and had already drifted; it lives here now, and
10
+ * every medium reads it.
11
+ *
12
+ * What stays with each renderer is what its medium OWNS: SGR parameters and
13
+ * cell geometry in the terminal, CSS and SVG in a browser. This module has no
14
+ * colors, no cells, no DOM, no I/O — and no node:* imports, so it loads in a
15
+ * browser as-is (see ../browser-safe.test.ts).
16
+ *
17
+ * Exported through ./semantics.js so consumers keep one import site.
18
+ */
19
+ import type { NodeStatus } from '../domain/types.js';
20
+ /**
21
+ * Status -> [unicode, ascii] glyph, both display width 1.
22
+ *
23
+ * The in-progress glyph is the braille spinner AT REST: a surface that cannot
24
+ * animate (a tab, a panel line, a static page) shows the settled form of the
25
+ * same shape the animated boxes cycle through, so one status still reads as
26
+ * one thing across media.
27
+ */
28
+ export declare const STATUS_GLYPHS: Readonly<Record<NodeStatus, readonly [unicode: string, ascii: string]>>;
29
+ /**
30
+ * The glyph a status shows where nothing animates.
31
+ * @param unicode - false selects the ASCII fallback for terminals that cannot
32
+ * draw the box-drawing/braille repertoire.
33
+ */
34
+ export declare function statusGlyph(status: NodeStatus, unicode: boolean): string;
35
+ /**
36
+ * The glyph a DONE node wears when nothing was recorded to back the claim.
37
+ *
38
+ * "No evidence, no done" is the fourth rule of the ledger, and until now the
39
+ * picture could not say when it was broken: a done node with a test run
40
+ * behind it and a done node with nothing behind it were the same solid
41
+ * square. The hollow square is deliberately the SAME SHAPE — this is still
42
+ * done, just not filled in — so it reads as a degree of done, not as a fifth
43
+ * status. There is no fifth status: evidence is a property of a node, and
44
+ * whether it exists is presentation, not structure.
45
+ */
46
+ export declare const UNVERIFIED_DONE_GLYPHS: readonly [unicode: string, ascii: string];
47
+ /** The glyph for a done node with no evidence; see {@link UNVERIFIED_DONE_GLYPHS}. */
48
+ export declare function unverifiedDoneGlyph(unicode: boolean): string;
49
+ /**
50
+ * Animation frames for in-progress work, per repertoire. The caller owns the
51
+ * clock: it advances a frame index and this module maps it to a glyph, so
52
+ * every animated surface spins at whatever rate its medium can paint while
53
+ * showing the same shapes.
54
+ */
55
+ export declare const SPINNER_FRAMES: Readonly<Record<'unicode' | 'ascii', readonly string[]>>;
56
+ /**
57
+ * One spinner frame.
58
+ * @param frame - any integer; it wraps, negatives included, so a caller's
59
+ * clock never has to be normalized before it is used.
60
+ */
61
+ export declare function spinnerGlyph(frame: number, unicode: boolean): string;
62
+ /**
63
+ * Known node kinds -> [unicode, ascii] glyphs (all display width 1).
64
+ * Behavior trees, dataflow and architecture vocabularies; an unknown kind
65
+ * renders without a glyph and stays readable in a detail panel.
66
+ */
67
+ export declare const NODE_KIND_GLYPHS: Readonly<Record<string, readonly [string, string]>>;
68
+ /**
69
+ * Glyph for a node kind, or undefined for the open vocabulary's unknown kinds.
70
+ *
71
+ * VIOLATION: no-primitive-obsession - `kind` is a raw string where NodeKind
72
+ * exists. NodeKind is an OPEN vocabulary by design (the domain accepts any
73
+ * slug; this table knows thirteen of them), so the brand carries no
74
+ * information this function could use — it answers undefined for anything it
75
+ * does not know, branded or not. Requiring NodeKind would only force every
76
+ * caller reading `node.kind` to unwrap and re-wrap it, and would not let a
77
+ * single wrong call through any less.
78
+ */
79
+ export declare function kindGlyph(kind: string, unicode: boolean): string | undefined;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Layer 1c — the medium-neutral VISUAL VOCABULARY of a map.
3
+ *
4
+ * A status and a node kind each have ONE agreed glyph. Not because glyphs are
5
+ * semantics — they are not — but because they are the map's shared alphabet:
6
+ * the terminal picture, the pane's tab strip and detail panel, and a browser
7
+ * view all show the same map to the same person, and a status that reads ⠿ in
8
+ * one place and ◐ in another is a lie about the map being one thing. The
9
+ * table lived in three files and had already drifted; it lives here now, and
10
+ * every medium reads it.
11
+ *
12
+ * What stays with each renderer is what its medium OWNS: SGR parameters and
13
+ * cell geometry in the terminal, CSS and SVG in a browser. This module has no
14
+ * colors, no cells, no DOM, no I/O — and no node:* imports, so it loads in a
15
+ * browser as-is (see ../browser-safe.test.ts).
16
+ *
17
+ * Exported through ./semantics.js so consumers keep one import site.
18
+ */
19
+ /**
20
+ * Status -> [unicode, ascii] glyph, both display width 1.
21
+ *
22
+ * The in-progress glyph is the braille spinner AT REST: a surface that cannot
23
+ * animate (a tab, a panel line, a static page) shows the settled form of the
24
+ * same shape the animated boxes cycle through, so one status still reads as
25
+ * one thing across media.
26
+ */
27
+ export const STATUS_GLYPHS = {
28
+ planned: ['·', '.'],
29
+ 'in-progress': ['⠿', '*'],
30
+ done: ['■', '#'],
31
+ regressed: ['✗', 'X'],
32
+ };
33
+ /**
34
+ * The glyph a status shows where nothing animates.
35
+ * @param unicode - false selects the ASCII fallback for terminals that cannot
36
+ * draw the box-drawing/braille repertoire.
37
+ */
38
+ export function statusGlyph(status, unicode) {
39
+ const [uni, ascii] = STATUS_GLYPHS[status];
40
+ return unicode ? uni : ascii;
41
+ }
42
+ /**
43
+ * The glyph a DONE node wears when nothing was recorded to back the claim.
44
+ *
45
+ * "No evidence, no done" is the fourth rule of the ledger, and until now the
46
+ * picture could not say when it was broken: a done node with a test run
47
+ * behind it and a done node with nothing behind it were the same solid
48
+ * square. The hollow square is deliberately the SAME SHAPE — this is still
49
+ * done, just not filled in — so it reads as a degree of done, not as a fifth
50
+ * status. There is no fifth status: evidence is a property of a node, and
51
+ * whether it exists is presentation, not structure.
52
+ */
53
+ export const UNVERIFIED_DONE_GLYPHS = ['□', 'o'];
54
+ /** The glyph for a done node with no evidence; see {@link UNVERIFIED_DONE_GLYPHS}. */
55
+ export function unverifiedDoneGlyph(unicode) {
56
+ const [uni, ascii] = UNVERIFIED_DONE_GLYPHS;
57
+ return unicode ? uni : ascii;
58
+ }
59
+ /**
60
+ * Animation frames for in-progress work, per repertoire. The caller owns the
61
+ * clock: it advances a frame index and this module maps it to a glyph, so
62
+ * every animated surface spins at whatever rate its medium can paint while
63
+ * showing the same shapes.
64
+ */
65
+ export const SPINNER_FRAMES = {
66
+ unicode: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
67
+ ascii: ['|', '/', '-', '\\'],
68
+ };
69
+ /**
70
+ * One spinner frame.
71
+ * @param frame - any integer; it wraps, negatives included, so a caller's
72
+ * clock never has to be normalized before it is used.
73
+ */
74
+ export function spinnerGlyph(frame, unicode) {
75
+ const frames = SPINNER_FRAMES[unicode ? 'unicode' : 'ascii'];
76
+ return frames[((frame % frames.length) + frames.length) % frames.length];
77
+ }
78
+ /**
79
+ * Known node kinds -> [unicode, ascii] glyphs (all display width 1).
80
+ * Behavior trees, dataflow and architecture vocabularies; an unknown kind
81
+ * renders without a glyph and stays readable in a detail panel.
82
+ */
83
+ export const NODE_KIND_GLYPHS = {
84
+ selector: ['?', '?'],
85
+ sequence: ['»', '>'],
86
+ parallel: ['‖', '='],
87
+ decorator: ['◌', 'o'],
88
+ condition: ['◇', 'c'],
89
+ action: ['·', '.'],
90
+ source: ['○', 'o'],
91
+ transform: ['◐', '%'],
92
+ sink: ['●', '*'],
93
+ service: ['◆', 'S'],
94
+ db: ['▤', 'D'],
95
+ queue: ['≣', 'Q'],
96
+ ui: ['▣', 'U'],
97
+ };
98
+ /**
99
+ * Glyph for a node kind, or undefined for the open vocabulary's unknown kinds.
100
+ *
101
+ * VIOLATION: no-primitive-obsession - `kind` is a raw string where NodeKind
102
+ * exists. NodeKind is an OPEN vocabulary by design (the domain accepts any
103
+ * slug; this table knows thirteen of them), so the brand carries no
104
+ * information this function could use — it answers undefined for anything it
105
+ * does not know, branded or not. Requiring NodeKind would only force every
106
+ * caller reading `node.kind` to unwrap and re-wrap it, and would not let a
107
+ * single wrong call through any less.
108
+ */
109
+ export function kindGlyph(kind, unicode) {
110
+ const pair = NODE_KIND_GLYPHS[kind];
111
+ return pair === undefined ? undefined : unicode ? pair[0] : pair[1];
112
+ }
@@ -9,6 +9,11 @@
9
9
  * operations, so a hand-edited or corrupted file can never smuggle an
10
10
  * invariant violation into the process (validate at the boundary,
11
11
  * trust internal code afterwards).
12
+ * The shape is read STRICTLY: a wrong type is refused, never coerced
13
+ * and never quietly dropped. The reason is that the store round-trips —
14
+ * a lenient read of `{"nodes": {...}}` as "no nodes" would be written
15
+ * back by the next save, so leniency here does not tolerate a damaged
16
+ * file, it destroys one.
12
17
  * F1. serializeMap is the inverse of parseMap for valid maps.
13
18
  *
14
19
  * No node:* imports — this module must load in a browser as-is. Filesystem
@@ -37,6 +42,18 @@ export type StoreError = {
37
42
  readonly kind: 'invariant-violation';
38
43
  readonly path: string;
39
44
  readonly violation: MapError;
45
+ }
46
+ /** A write that did not land. The file still holds its previous content. */
47
+ | {
48
+ readonly kind: 'save-failed';
49
+ readonly path: string;
50
+ readonly detail: string;
51
+ }
52
+ /** A deletion the filesystem refused. The file is still there. */
53
+ | {
54
+ readonly kind: 'delete-failed';
55
+ readonly path: string;
56
+ readonly detail: string;
40
57
  };
41
58
  export declare function describeStoreError(e: StoreError): string;
42
59
  /**