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.
- package/README.md +360 -63
- package/README.zh-CN.md +314 -54
- package/dist/hook-session-start.mjs +239 -0
- package/dist/mmap.mjs +338 -0
- package/dist/server.mjs +1614 -809
- package/dist/store-paths.mjs +107 -0
- package/dist/watch.mjs +1391 -760
- package/lib/domain/ops.d.ts +71 -12
- package/lib/domain/ops.js +145 -14
- package/lib/domain/types.d.ts +47 -6
- package/lib/domain/types.js +34 -3
- 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 +32 -46
- package/lib/render/render.js +58 -789
- 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 +53 -4
- package/lib/semantics/semantics.js +130 -6
- package/lib/semantics/vocabulary.d.ts +79 -0
- package/lib/semantics/vocabulary.js +112 -0
- package/lib/store/format.d.ts +17 -0
- package/lib/store/format.js +185 -66
- package/lib/store/store.d.ts +220 -20
- package/lib/store/store.js +491 -38
- package/package.json +12 -4
- 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,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
|
|
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
|
|
83
|
-
*
|
|
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
|
|
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
|
-
//
|
|
75
|
-
//
|
|
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
|
|
188
|
-
*
|
|
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
|
+
}
|
package/lib/store/format.d.ts
CHANGED
|
@@ -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
|
/**
|