mellos-mapping 0.19.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 +366 -56
  2. package/README.zh-CN.md +319 -47
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1737 -897
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1612 -902
  8. package/lib/domain/ops.d.ts +171 -0
  9. package/lib/domain/ops.js +384 -0
  10. package/lib/domain/types.d.ts +283 -0
  11. package/lib/domain/types.js +153 -0
  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 +88 -0
  21. package/lib/render/render.js +128 -0
  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 +169 -0
  31. package/lib/semantics/semantics.js +380 -0
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +67 -0
  35. package/lib/store/format.js +334 -0
  36. package/lib/store/store.d.ts +296 -0
  37. package/lib/store/store.js +734 -0
  38. package/package.json +41 -5
  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,380 @@
1
+ /**
2
+ * Layer 1c — medium-neutral VIEW SEMANTICS of a MellosMap.
3
+ *
4
+ * Every renderer (the terminal pane, a web panel, a future editor view) must
5
+ * agree on what a zoom step MEANS, when a map aggregates into its groups,
6
+ * how sequence time is oriented, and which map kinds render neutrally.
7
+ * Those rules live here, pure of any medium: no cells, no colors, no DOM,
8
+ * no I/O. Geometry — how a mode maps onto character cells or pixels — stays
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.
14
+ */
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';
17
+ export const ZOOM_MIN = -4;
18
+ export const ZOOM_MAX = 2;
19
+ export const ZOOM_DEFAULT = 0;
20
+ export function clampZoom(n) {
21
+ return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(n)));
22
+ }
23
+ /**
24
+ * What a zoom step MEANS, before any renderer decides what it looks like:
25
+ * scaling only compresses, the ends of the ladder switch mode. 'detail'
26
+ * unfolds evidence and design notes; 'overview' switches to the aggregated
27
+ * far view (groups become the nodes — see aggregateMap); 'boxes' is every
28
+ * step in between, where boxes stay boxes and only whitespace and label
29
+ * budgets change.
30
+ */
31
+ export function zoomMode(zoom) {
32
+ if (zoom >= 1)
33
+ return 'detail';
34
+ if (zoom <= -4)
35
+ return 'overview';
36
+ return 'boxes';
37
+ }
38
+ /** What a footer shows: a percentage while scaling, a mode name at the ends. */
39
+ export function zoomLabel(zoom) {
40
+ switch (zoom) {
41
+ case 2:
42
+ return 'detail+';
43
+ case 1:
44
+ return 'detail';
45
+ case 0:
46
+ return '100%';
47
+ case -1:
48
+ return '85%';
49
+ case -2:
50
+ return '70%';
51
+ case -3:
52
+ return '55%';
53
+ case -4:
54
+ return 'overview';
55
+ }
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // kind semantics
59
+ // ---------------------------------------------------------------------------
60
+ /** Documentation kinds render neutrally: no status skins, no progress counts. */
61
+ export function isNeutralKind(map) {
62
+ return map.kind !== undefined && map.kind !== 'dev';
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // derived views (never persisted)
66
+ // ---------------------------------------------------------------------------
67
+ /**
68
+ * The derived coarse picture the far zoom renders when the map declares
69
+ * groups: each group becomes ONE labeled node (status derived from members,
70
+ * label carrying done/total), ungrouped nodes stay themselves, and edges
71
+ * collapse onto representatives (intra-group wiring disappears into the
72
+ * box). Derived for rendering only — never persisted. A map without groups
73
+ * returns undefined and falls back to whatever anonymous overview the
74
+ * renderer draws.
75
+ */
76
+ export function aggregateMap(map) {
77
+ if (map.groups.length === 0)
78
+ return undefined;
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.
90
+ const representative = new Map();
91
+ for (const n of map.nodes)
92
+ representative.set(n.id, (n.group ?? n.id));
93
+ const nodes = map.groups.map((g) => {
94
+ const members = map.nodes.filter((n) => n.group === g.id);
95
+ const done = members.filter((n) => n.status === 'done').length;
96
+ return {
97
+ id: g.id,
98
+ // neutral kinds document structure, not progress — no member counts
99
+ label: isNeutralKind(map) ? g.label : `${g.label} ${done}/${members.length}`,
100
+ layer: g.layer,
101
+ status: groupStatus(map, g.id),
102
+ };
103
+ });
104
+ for (const n of map.nodes)
105
+ if (n.group === undefined)
106
+ nodes.push(n);
107
+ const seen = new Set();
108
+ const edges = [];
109
+ for (const e of map.edges) {
110
+ const from = representative.get(e.from);
111
+ const to = representative.get(e.to);
112
+ if (from === to || seen.has(`${from}->${to}`))
113
+ continue;
114
+ seen.add(`${from}->${to}`);
115
+ edges.push({ from: from, to: to });
116
+ }
117
+ return {
118
+ ...(map.title !== undefined ? { title: map.title } : {}),
119
+ ...(map.kind !== undefined ? { kind: map.kind } : {}),
120
+ layers: map.layers,
121
+ groups: [],
122
+ lanes: map.lanes,
123
+ nodes,
124
+ edges,
125
+ };
126
+ }
127
+ /**
128
+ * Derive what a detail panel says about one focused id — a node, or a group
129
+ * when the far zoom's aggregated boxes are what the pointer is over. Pure
130
+ * data: every renderer picks its own glyphs, colors, and words (a sequence
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.
134
+ * @param map - the map the focus lives in.
135
+ * @param focusId - node or group id.
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.
148
+ */
149
+ export function focusInfo(map, focusId) {
150
+ const layerNameOf = (layerId) => map.layers.find((l) => l.id === layerId)?.name ?? layerId;
151
+ const group = map.groups.find((g) => g.id === focusId);
152
+ if (group) {
153
+ const members = map.nodes.filter((n) => n.group === group.id);
154
+ const memberIds = new Set(members.map((n) => n.id));
155
+ // A neighbour is shown as its own group when it has one, else as itself.
156
+ const rep = (id) => {
157
+ const n = map.nodes.find((x) => x.id === id);
158
+ const owner = n.group !== undefined ? map.groups.find((g) => g.id === n.group) : undefined;
159
+ return owner !== undefined
160
+ ? { id: owner.id, label: owner.label, status: groupStatus(map, owner.id) }
161
+ : { id: n.id, label: n.label, status: n.status };
162
+ };
163
+ const dedupe = (refs) => {
164
+ const seen = new Set();
165
+ const out = [];
166
+ for (const r of refs) {
167
+ if (seen.has(r.id))
168
+ continue;
169
+ seen.add(r.id);
170
+ out.push(r);
171
+ }
172
+ return out;
173
+ };
174
+ return {
175
+ kind: 'group',
176
+ group,
177
+ status: groupStatus(map, group.id),
178
+ layerName: layerNameOf(group.layer),
179
+ members,
180
+ uses: dedupe(map.edges
181
+ .filter((e) => memberIds.has(e.from) && !memberIds.has(e.to))
182
+ .map((e) => rep(e.to))),
183
+ usedBy: dedupe(map.edges
184
+ .filter((e) => memberIds.has(e.to) && !memberIds.has(e.from))
185
+ .map((e) => rep(e.from))),
186
+ };
187
+ }
188
+ const node = map.nodes.find((n) => n.id === focusId);
189
+ if (!node)
190
+ return undefined;
191
+ const ref = (id, edgeLabel) => {
192
+ const n = map.nodes.find((x) => x.id === id);
193
+ return {
194
+ id,
195
+ label: n?.label ?? id,
196
+ status: n?.status ?? 'planned',
197
+ ...(edgeLabel !== undefined ? { edgeLabel } : {}),
198
+ };
199
+ };
200
+ const laneLabel = node.lane !== undefined ? map.lanes.find((l) => l.id === node.lane)?.label : undefined;
201
+ return {
202
+ kind: 'node',
203
+ node,
204
+ layerName: layerNameOf(node.layer),
205
+ ...(laneLabel !== undefined ? { laneLabel } : {}),
206
+ uses: map.edges.filter((e) => e.from === node.id).map((e) => ref(e.to, e.label)),
207
+ usedBy: map.edges.filter((e) => e.to === node.id).map((e) => ref(e.from, e.label)),
208
+ };
209
+ }
210
+ // ---------------------------------------------------------------------------
211
+ // page-set semantics — which pages are siblings, and where a dive came from
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).
225
+ /**
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).
228
+ * @param maps - every known page's map (undefined entries are skipped).
229
+ * @returns the referenced submap slugs.
230
+ */
231
+ export function submapRefs(maps) {
232
+ const refs = new Set();
233
+ for (const m of maps) {
234
+ for (const n of m?.nodes ?? [])
235
+ if (n.submap !== undefined)
236
+ refs.add(n.submap);
237
+ }
238
+ return refs;
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
+ }
307
+ /**
308
+ * Where a sub-map page was dived into from: the entry whose map links the
309
+ * page, plus the linking node's label. Derived by scan, so a breadcrumb
310
+ * survives any client restart with an empty dive stack.
311
+ * @param entries - known pages as (key, map) pairs; keys are caller-owned.
312
+ * @param pageId - the sub-map page's slug.
313
+ * @returns the linking entry's key and node label, or undefined for a top-level page.
314
+ */
315
+ export function diveParent(entries, pageId) {
316
+ for (const [key, m] of entries) {
317
+ const node = m?.nodes.find((n) => n.submap === pageId);
318
+ if (node !== undefined)
319
+ return { parent: key, label: node.label };
320
+ }
321
+ return undefined;
322
+ }
323
+ /**
324
+ * The page a client shows when nobody asked for one: the most recently
325
+ * WRITTEN page — the ledger last touched is almost always the effort under
326
+ * way. Keys without a readable timestamp lose; an empty set answers the
327
+ * first key (caller-ordered: default page first, then slug order).
328
+ * @param keys - candidate page keys in the caller's fallback order.
329
+ * @param mtimeOf - last-written timestamp of a key, undefined when unknown.
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.
341
+ */
342
+ export function mostRecentKey(keys, mtimeOf) {
343
+ let best;
344
+ let bestMtime = -Infinity;
345
+ for (const key of keys) {
346
+ const mtime = mtimeOf(key);
347
+ if (mtime !== undefined && mtime > bestMtime) {
348
+ best = key;
349
+ bestMtime = mtime;
350
+ }
351
+ }
352
+ return best ?? keys[0];
353
+ }
354
+ /**
355
+ * Sequence pages read like the classic diagram: time flows DOWNWARD, the
356
+ * earliest step right under the participant headers. The stored map keeps
357
+ * rank 0 = earliest with edges pointing later -> earlier ("later stands on
358
+ * earlier"); this derived value inverts the ranks and reverses the edges so
359
+ * unchanged top-down machinery draws top-down time — each wire now runs
360
+ * from the sender's moment down into the receiver's. Derived for rendering
361
+ * only, never persisted (same contract as aggregateMap).
362
+ */
363
+ export function flipForSequence(map) {
364
+ if (map.kind !== 'sequence')
365
+ return map;
366
+ return {
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).
377
+ layers: map.layers.map((l) => ({ ...l, rank: -l.rank })),
378
+ edges: map.edges.map((e) => ({ from: e.to, to: e.from, ...(e.label !== undefined ? { label: e.label } : {}) })),
379
+ };
380
+ }
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Layer 1a — the state-file FORMAT of a MellosMap, pure of any I/O.
3
+ *
4
+ * This module owns the on-disk vocabulary (version, page-id grammar) and the
5
+ * two format promises every consumer relies on:
6
+ *
7
+ * P1. Whatever parseMap accepts satisfies the structural invariants of
8
+ * Layer 0. Parsing is done by REPLAYING the raw data through the domain
9
+ * operations, so a hand-edited or corrupted file can never smuggle an
10
+ * invariant violation into the process (validate at the boundary,
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.
17
+ * F1. serializeMap is the inverse of parseMap for valid maps.
18
+ *
19
+ * No node:* imports — this module must load in a browser as-is. Filesystem
20
+ * concerns (atomic writes, page file listing, focus requests) live in
21
+ * ./store.ts, the Node-side half.
22
+ */
23
+ import { type InvalidId, type MapError, type MellosMap, type Result } from '../domain/types.js';
24
+ /** On-disk format version. Bump only with a documented migration. */
25
+ export declare const STATE_FILE_VERSION = 1;
26
+ export type PageId = string & {
27
+ readonly __brand: 'PageId';
28
+ };
29
+ export declare function makePageId(raw: string): Result<PageId, InvalidId>;
30
+ export type StoreError = {
31
+ readonly kind: 'not-found';
32
+ readonly path: string;
33
+ } | {
34
+ readonly kind: 'malformed-json';
35
+ readonly path: string;
36
+ readonly detail: string;
37
+ } | {
38
+ readonly kind: 'bad-shape';
39
+ readonly path: string;
40
+ readonly detail: string;
41
+ } | {
42
+ readonly kind: 'invariant-violation';
43
+ readonly path: string;
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;
57
+ };
58
+ export declare function describeStoreError(e: StoreError): string;
59
+ /**
60
+ * Rebuild a MellosMap from untrusted raw data by replaying it through the
61
+ * Layer 0 operations (P1). Field order in the file does not matter; replay
62
+ * order (layers -> lanes -> groups -> nodes -> edges) supplies the required
63
+ * declaration order.
64
+ */
65
+ export declare function parseMap(raw: unknown, path: string): Result<MellosMap, StoreError>;
66
+ /** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
67
+ export declare function serializeMap(map: MellosMap): string;