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.
- package/README.md +366 -56
- package/README.zh-CN.md +319 -47
- package/dist/hook-session-start.mjs +239 -0
- package/dist/mmap.mjs +338 -0
- package/dist/server.mjs +1737 -897
- package/dist/store-paths.mjs +107 -0
- package/dist/watch.mjs +1612 -902
- package/lib/domain/ops.d.ts +171 -0
- package/lib/domain/ops.js +384 -0
- package/lib/domain/types.d.ts +283 -0
- package/lib/domain/types.js +153 -0
- package/lib/render/canvas.d.ts +50 -0
- package/lib/render/canvas.js +210 -0
- package/lib/render/draw.d.ts +37 -0
- package/lib/render/draw.js +111 -0
- package/lib/render/layout.d.ts +89 -0
- package/lib/render/layout.js +200 -0
- package/lib/render/options.d.ts +39 -0
- package/lib/render/options.js +10 -0
- package/lib/render/render.d.ts +88 -0
- package/lib/render/render.js +128 -0
- package/lib/render/routing.d.ts +56 -0
- package/lib/render/routing.js +244 -0
- package/lib/render/skins.d.ts +54 -0
- package/lib/render/skins.js +99 -0
- package/lib/render/width.d.ts +24 -0
- package/lib/render/width.js +139 -0
- package/lib/render/zoom-geometry.d.ts +52 -0
- package/lib/render/zoom-geometry.js +56 -0
- package/lib/semantics/semantics.d.ts +169 -0
- package/lib/semantics/semantics.js +380 -0
- package/lib/semantics/vocabulary.d.ts +79 -0
- package/lib/semantics/vocabulary.js +112 -0
- package/lib/store/format.d.ts +67 -0
- package/lib/store/format.js +334 -0
- package/lib/store/store.d.ts +296 -0
- package/lib/store/store.js +734 -0
- package/package.json +41 -5
- package/scripts/codex-register.mjs +89 -20
- package/scripts/install-mmap-command.mjs +293 -0
- package/scripts/mmap.mjs +213 -0
- package/scripts/open-pane.mjs +115 -254
- 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;
|