@smartmemory/wiki-components 0.5.0 → 0.6.0

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 (62) hide show
  1. package/README.md +14 -2
  2. package/package.json +26 -4
  3. package/src/data-client.d.ts +27 -1
  4. package/src/data-client.jsx +3 -0
  5. package/src/graph/components/wiki/DocCard.jsx +46 -0
  6. package/src/graph/components/wiki/GraphBreadcrumb.jsx +23 -0
  7. package/src/graph/components/wiki/GraphCanvas.jsx +663 -0
  8. package/src/graph/components/wiki/GraphCanvas3D.jsx +1366 -0
  9. package/src/graph/components/wiki/GraphDocPane.jsx +56 -0
  10. package/src/graph/components/wiki/GraphLegend.jsx +119 -0
  11. package/src/graph/components/wiki/GraphLevelPicker.jsx +35 -0
  12. package/src/graph/components/wiki/graph/FilterChips.jsx +45 -0
  13. package/src/graph/components/wiki/graph/Omnibox.jsx +74 -0
  14. package/src/graph/components/wiki/graph/badges.js +46 -0
  15. package/src/graph/components/wiki/graph/bubbles3d.js +95 -0
  16. package/src/graph/components/wiki/graph/canvasBase.js +138 -0
  17. package/src/graph/components/wiki/graph/containment.js +49 -0
  18. package/src/graph/components/wiki/graph/overlay.js +134 -0
  19. package/src/graph/components/wiki/graph/pixiBase.js +210 -0
  20. package/src/graph/components/wiki/graph/threeBase.js +231 -0
  21. package/src/graph/hooks/useDebounced.js +11 -0
  22. package/src/graph/host.jsx +56 -0
  23. package/src/graph/index.d.ts +26 -0
  24. package/src/graph/index.js +4 -0
  25. package/src/graph/lib/areaFamilies.js +32 -0
  26. package/src/graph/lib/camFollow.js +30 -0
  27. package/src/graph/lib/canvasPort.js +93 -0
  28. package/src/graph/lib/cardVisibility.js +58 -0
  29. package/src/graph/lib/docCard.js +28 -0
  30. package/src/graph/lib/forceWorker.js +123 -0
  31. package/src/graph/lib/forceWorker3d.js +136 -0
  32. package/src/graph/lib/graph3d.js +330 -0
  33. package/src/graph/lib/graphArrows.js +108 -0
  34. package/src/graph/lib/graphExplore.js +546 -0
  35. package/src/graph/lib/graphFind.js +46 -0
  36. package/src/graph/lib/graphLasso.js +44 -0
  37. package/src/graph/lib/graphLevels.js +367 -0
  38. package/src/graph/lib/graphOrient.js +145 -0
  39. package/src/graph/lib/graphPath.js +86 -0
  40. package/src/graph/lib/graphPin.js +48 -0
  41. package/src/graph/lib/graphPress.js +27 -0
  42. package/src/graph/lib/graphShell.js +296 -0
  43. package/src/graph/lib/graphTheme.js +50 -0
  44. package/src/graph/lib/graphView.js +343 -0
  45. package/src/graph/lib/graphViews.js +103 -0
  46. package/src/graph/lib/groupBubbles.js +128 -0
  47. package/src/graph/lib/hubLines.js +51 -0
  48. package/src/graph/lib/idleTurn.js +31 -0
  49. package/src/graph/lib/labelDepth.js +59 -0
  50. package/src/graph/lib/layoutClient.js +69 -0
  51. package/src/graph/lib/layoutEngine.js +184 -0
  52. package/src/graph/lib/linkWeight.js +67 -0
  53. package/src/graph/lib/orphanPull.js +10 -0
  54. package/src/graph/lib/perf.js +14 -0
  55. package/src/graph/lib/reducedMotion.js +36 -0
  56. package/src/graph/lib/robustFit.js +62 -0
  57. package/src/graph/lib/wikiGraph.js +298 -0
  58. package/src/graph/pages/GraphPage.jsx +703 -0
  59. package/src/index.d.ts +6 -1
  60. package/src/index.js +2 -1
  61. package/src/lib.js +3 -0
  62. package/src/parts/Markdown.jsx +22 -0
@@ -0,0 +1,27 @@
1
+ // WIKI-GRAPH-3D-PAN-MODIFIER-1: what a mouse press on the 3D surface does, decided ONCE from the pointerdown event's own flags and
2
+ // held for the whole drag (OrbitControls fixes its state at pointerdown and never re-reads a modifier on move). Pure: no three import.
3
+ // Plain left turns; Cmd, Ctrl, Option/Alt, Option+Cmd or Shift (the lasso takes Shift+left first) pan; middle pans; right does nothing
4
+ // (it stays the node menu). OrbitControls swaps ROTATE<->PAN whenever ctrl/meta/shift is down, so we pick the button action that
5
+ // comes out as the wanted one after that swap, instead of depending on it.
6
+
7
+ // three's MOUSE enum values (ROTATE 0, DOLLY 1, PAN 2), kept as numbers so this file stays pure: a test pins them to three.
8
+ export const ROTATE = 0;
9
+ export const PAN = 2;
10
+
11
+ /** Whether OrbitControls (three r17x) applies its own modifier swap to this press (ctrl, meta or shift down). */
12
+ export const swapActive = (e) => !!(e.ctrlKey || e.metaKey || e.shiftKey);
13
+
14
+ /** "pan" | "turn" for a mouse press `{button, shiftKey, altKey, ctrlKey, metaKey}` (touch is not decided here). Any modifier pans. */
15
+ export const pressMode = (e) => {
16
+ if ((e.button ?? 0) === 1) return "pan";
17
+ return e.shiftKey || e.altKey || e.ctrlKey || e.metaKey ? "pan" : "turn";
18
+ };
19
+
20
+ /** The OrbitControls action to put on a button for this press so that, after its own swap, the drag does `want` ("pan" | "turn"). */
21
+ export const actionFor = (e, want) => ((want === "pan") === swapActive(e) ? ROTATE : PAN);
22
+
23
+ /** `{LEFT, MIDDLE}` OrbitControls actions for this press. Middle is always a pan, whatever modifiers are down. */
24
+ export const buttonActions = (e) => ({ LEFT: actionFor(e, pressMode({ button: 0, shiftKey: e.shiftKey, altKey: e.altKey, ctrlKey: e.ctrlKey, metaKey: e.metaKey })), MIDDLE: actionFor(e, "pan") });
25
+
26
+ /** The mouse mapping in plain words (gizmo tooltip). */
27
+ export const MOUSE_HELP = "drag turns, Cmd/Option+drag pans, Shift+drag selects";
@@ -0,0 +1,296 @@
1
+ // WIKI-GRAPH-EXPLORE-1b-SHELL (part A): the pure half of the Graph page's omnibox and filter chips (no DOM, no React, no canvas).
2
+ // The omnibox reads a verb ("in", "links to", "orphans", ...) or a title, and offers docs to fly to, folders, filter verbs and the
3
+ // recent docs; picking a filter appends a step to the explore state (graphExplore.js, 1a), which the page encodes in the `x` URL
4
+ // param and runs to get the visible docs. Ported from the EXPLORE-1b branch's graphShell.js (a44298e, omnibox grammar, chip
5
+ // edits, small stores) and narrowed to part A: the search lens is not offered (nothing creates one), the path lens is (round 3 of 1c-UI:
6
+ // the `path A to B` verb, graphPath.js), part B adds
7
+ // the doc card, the View drawer and Back as undo. Design: smart-memory-docs docs/features/WIKI-GRAPH-EXPLORE-1/design.md §3, §5, §6.
8
+ import { emptyState, encodeX, normaliseStep, parseExploreParams, pathChipText, phraseOf } from "./graphExplore";
9
+ import { findMatches } from "./graphFind";
10
+ import { splitPathArgs } from "./graphPath";
11
+
12
+ // ------------------------------------------------------------------------------------------------------------- chip edits
13
+
14
+ const sameStep = (a, b) => JSON.stringify(a) === JSON.stringify(b);
15
+
16
+ /** Append a step (validated). An identical last step is not added twice. Returns the same state when nothing changes. */
17
+ export function addStep(state, raw) {
18
+ const step = normaliseStep(raw);
19
+ if (!step) return state;
20
+ const last = state.steps[state.steps.length - 1];
21
+ if (last && sameStep(last, step)) return state;
22
+ return { ...state, steps: [...state.steps, step] };
23
+ }
24
+ /** Remove chip `i` (out of range: the same state). */
25
+ export const removeStep = (state, i) => (i < 0 || i >= state.steps.length ? state : { ...state, steps: state.steps.filter((_, j) => j !== i) });
26
+ /** Every chip and lens gone. */
27
+ export const clearAll = (state) => (state.steps.length || state.lenses.length ? { ...state, steps: [], lenses: [] } : state);
28
+
29
+ /** The explore state the URL holds. Only `x` is read: the legacy `hide` / `local` / `hops` params stay owned by today's legend,
30
+ * Local graph box and hops stepper (parseExploreParams would turn `hide` into a `not area` chip, which is wrong while the legend
31
+ * colours by folder or by scope, where `hide` holds folder or scope names). `x`: the param's value, or null when absent.
32
+ * Returns `{ state, ignored, notice }`. */
33
+ export function readExplore(x) {
34
+ if (typeof x !== "string") return { state: emptyState(), ignored: 0, notice: "" };
35
+ const { state, ignored, notice } = parseExploreParams(new URLSearchParams({ x }));
36
+ return { state, ignored, notice };
37
+ }
38
+
39
+ /** A copy of `params` with `x` set to `state` (removed when the state is empty); every other param, the legacy ones included, is
40
+ * kept (graphExplore writeExploreParams drops `hide` / `local` / `hops`, which today's legend and hops stepper still own). */
41
+ export function withExplore(params, state) {
42
+ const sp = new URLSearchParams(typeof params === "string" ? params : params.toString());
43
+ const x = encodeX(state);
44
+ if (x) sp.set("x", x); else sp.delete("x");
45
+ return sp;
46
+ }
47
+
48
+ /** True when writing `state` would leave the URL's `x` as it is: such an edit must not push a history entry, or Back would "undo" nothing. */
49
+ export function exploreUnchanged(params, state) {
50
+ const sp = typeof params === "string" ? new URLSearchParams(params) : params;
51
+ return withExplore(sp, state).toString() === sp.toString();
52
+ }
53
+
54
+ // ------------------------------------------------------------------------------------------------------------ the omnibox
55
+
56
+ /** The omnibox's verbs (design §3). `arg`: what it asks for next ("folder", "doc", "folder-or-doc", "number", "area", "docs" (two docs, the path lens), "none").
57
+ * `make(arg, extra)` gives the step for the chosen argument. A leading "not " is read by `parseVerb` and sets `not`. Owner
58
+ * decision 2: "children of" a doc is linked either way (`near`, 1 hop). */
59
+ export const OMNI_VERBS = [
60
+ { key: "children", label: "children of …", hint: "a folder's contents, or the docs linked to a doc either way", arg: "folder-or-doc", words: ["children of", "children"],
61
+ make: (a) => (a.kind === "folder" ? { op: "in", path: a.path } : { op: "near", id: a.id, hops: 1 }) },
62
+ { key: "in", label: "in …", hint: "docs inside a folder", arg: "folder", words: ["in", "inside", "folder"], make: (a) => ({ op: "in", path: a.path }) },
63
+ { key: "from", label: "links from …", hint: "docs a doc links to", arg: "doc", words: ["links from", "linked from", "from"], make: (a) => ({ op: "from", id: a.id, hops: 1 }) },
64
+ { key: "to", label: "links to …", hint: "docs that link to a doc (backlinks)", arg: "doc", words: ["backlinks of", "backlinks", "links to", "linking to", "to"],
65
+ make: (a) => ({ op: "to", id: a.id, hops: 1 }) },
66
+ { key: "near", label: "near … [n]", hint: "within n links of a doc, either way", arg: "doc", words: ["near", "within", "nearby"], make: (a, n) => ({ op: "near", id: a.id, hops: n || 2 }) },
67
+ { key: "min", label: "linked ≥ n", hint: "docs with at least n links", arg: "number", words: ["linked >=", "linked ≥", "linked at least", "min links"], make: (n) => ({ op: "degree", min: n }) },
68
+ { key: "max", label: "linked ≤ n", hint: "docs with at most n links", arg: "number", words: ["linked <=", "linked ≤", "linked at most", "max links"], make: (n) => ({ op: "degree", max: n }) },
69
+ { key: "hubs", label: "hubs", hint: "the top 1% by links", arg: "none", words: ["hubs", "hub"], make: (_a, t) => ({ op: "degree", min: t }) },
70
+ { key: "orphans", label: "orphans", hint: "docs with no links", arg: "none", words: ["orphans", "orphan"], make: () => ({ op: "orphans" }) },
71
+ { key: "path", label: "path … to …", hint: "the shortest chain of links between two docs, lit on the graph", arg: "docs", lens: true, words: ["path", "path from", "path between"] },
72
+ { key: "area", label: "area …", hint: "docs in an area", arg: "area", words: ["area"], make: (a) => ({ op: "area", names: [a] }) },
73
+ ];
74
+
75
+ /** Read what the reader typed: `{ not, verb, rest }` when the text starts with a verb word (longest word first, then a space or the
76
+ * end; a word ending in ≥ ≤ = may run straight into the number), else `{ not: false, verb: null, rest: text }`. */
77
+ export function parseVerb(text) {
78
+ let t = String(text || "").replace(/^\s+/, "");
79
+ let not = false;
80
+ if (/^not\s+/i.test(t)) { not = true; t = t.replace(/^not\s+/i, ""); }
81
+ const lower = t.toLowerCase();
82
+ let best = null;
83
+ for (const v of OMNI_VERBS) {
84
+ for (const w of v.words) {
85
+ if ((lower === w || lower.startsWith(w + " ") || (/[≥≤=]$/.test(w) && lower.startsWith(w))) && (!best || w.length > best.w.length)) best = { v, w };
86
+ }
87
+ }
88
+ if (!best) return { not: false, verb: null, rest: String(text || "").trim() };
89
+ return { not, verb: best.v, rest: t.slice(best.w.length).trim() };
90
+ }
91
+
92
+ /** The verbs whose label or words start with (or contain) the typed text, best first; empty text lists none. */
93
+ export function verbsMatching(text, limit = 5) {
94
+ const q = String(text || "").trim().toLowerCase().replace(/^not\s+/, "");
95
+ if (!q) return [];
96
+ const score = (v) => {
97
+ let s = Infinity;
98
+ for (const w of [v.label.toLowerCase(), ...v.words]) {
99
+ const at = w.indexOf(q);
100
+ if (at === 0) s = Math.min(s, 0);
101
+ else if (at > 0) s = Math.min(s, 1);
102
+ }
103
+ return s;
104
+ };
105
+ return OMNI_VERBS.map((v) => [score(v), v]).filter(([s]) => s < Infinity).sort((a, b) => a[0] - b[0]).slice(0, limit).map(([, v]) => v);
106
+ }
107
+
108
+ /** Docs per folder path, counting every ancestor (a doc in a/b counts for a and a/b). */
109
+ export function folderCounts(nodes) {
110
+ const out = new Map();
111
+ for (const n of nodes) {
112
+ const segs = String(n.folder || "").split("/").filter(Boolean);
113
+ for (let i = 1; i <= segs.length; i++) { const p = segs.slice(0, i).join("/"); out.set(p, (out.get(p) || 0) + 1); }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /** Folders whose path holds `q` (case-insensitive): path prefixes first, then a match at a segment start, then shallower, then
119
+ * bigger, then by path. Empty `q` lists the biggest top-level folders. `counts` maps path -> docs. Returns `[{ path, count }]`. */
120
+ export function searchFolders(counts, q, limit = 6) {
121
+ const needle = String(q || "").trim().toLowerCase().replace(/^\/+|\/+$/g, "");
122
+ const hits = [];
123
+ for (const [path, n] of counts) {
124
+ if (!path) continue;
125
+ const depth = path.split("/").length;
126
+ if (!needle) { if (depth === 1) hits.push([0, depth, -n, path]); continue; }
127
+ const p = path.toLowerCase();
128
+ const at = p.indexOf(needle);
129
+ if (at < 0) continue;
130
+ hits.push([at === 0 ? 0 : p[at - 1] === "/" ? 1 : 2, depth, -n, path]);
131
+ }
132
+ hits.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3].localeCompare(b[3]));
133
+ return hits.slice(0, limit).map((h) => ({ path: h[3], count: -h[2] }));
134
+ }
135
+
136
+ /** The trailing hop count of a `near` argument ("Plan v2 3" gives 3), 1..4, else null. */
137
+ export function trailingHops(rest) {
138
+ const m = /\s([1-4])$/.exec(String(rest || ""));
139
+ return m ? Number(m[1]) : null;
140
+ }
141
+
142
+ /** The degree a doc needs to be a hub: the top 1% by links (at least 1 link). `idx`: graphExplore buildIndex. */
143
+ export function hubThreshold(idx) {
144
+ if (!idx.n) return 1;
145
+ const d = Array.from(idx.degree).sort((a, b) => b - a);
146
+ return Math.max(1, d[Math.max(0, Math.ceil(idx.n * 0.01) - 1)]);
147
+ }
148
+
149
+ /** The chip text a step adds (with `not `), for the omnibox preview and the chip row. */
150
+ export const chipText = (step, idx) => (step.not ? "not " : "") + phraseOf(step, idx);
151
+
152
+ /** The area names of the docs, sorted. */
153
+ export const areaNames = (nodes) => [...new Set(nodes.map((n) => n.area).filter(Boolean))].sort((a, b) => a.localeCompare(b));
154
+
155
+ export const OMNI_DOCS = 8; // docs offered per query (the old find list's FIND_LIST)
156
+ export const OMNI_FOLDERS = 4; // folders offered beside the docs when no verb is typed
157
+ export const OMNI_PREVIEW = 6; // filter rows that show how many docs they would leave (one explorer run each)
158
+
159
+ /** The omnibox's rows for what the reader typed. Every row is `{ kind, key, label, detail, action }`; `action` is
160
+ * `{ type: "fly", id }` (go to a doc), `{ type: "step", step }` (add that filter chip), `{ type: "lens", lens }` (set the path lens), `{ type: "view", name }` (apply that saved view), `{ type: "verb", fill }` (put the verb in
161
+ * the box and ask for its argument), `{ type: "clear" }` (remove every filter), or null for a row that only says why nothing is
162
+ * offered. Kinds: recent | view | doc | folder | verb | filter | clear | empty.
163
+ * - empty text: the recent docs still in view, then the saved views, then "Clear all filters" when there are chips;
164
+ * - no verb: docs by title (the visible ones, searchNodes' order), then folders (an `in` chip), then the verbs that match;
165
+ * - a verb: its argument's candidates as filter rows (`not ` kept), or one `empty` row saying what it needs.
166
+ * `opts`: `{ text, nodes, visible, idx, counts, recent, steps, explorer, byId }`. With `explorer`, the first OMNI_PREVIEW filter
167
+ * rows carry `detail` = how many docs the stack would leave with that chip added. */
168
+ export function omniRows({ text = "", nodes = [], visible, idx = null, counts = new Map(), recent = [], views = [], steps = [], explorer = null, byId = null }) {
169
+ const rows = [];
170
+ const t = String(text || "");
171
+ const docRows = (q, make) => findMatches(nodes, q, visible).slice(0, OMNI_DOCS).map(make);
172
+ if (!t.trim()) {
173
+ for (const id of Array.isArray(recent) ? recent : []) {
174
+ if (typeof id !== "string") continue;
175
+ const n = byId ? byId.get(id) : nodes.find((m) => m.id === id);
176
+ if (n && (!visible || visible(n.id))) rows.push({ kind: "recent", key: `recent:${id}`, label: n.title, detail: n.folder || "(root)", action: { type: "fly", id } });
177
+ }
178
+ for (const v of Array.isArray(views) ? views : []) {
179
+ if (v && typeof v.name === "string") rows.push({ kind: "view", key: `view:${v.name}`, label: v.name, detail: "saved view", action: { type: "view", name: v.name } });
180
+ }
181
+ if (steps.length) rows.push({ kind: "clear", key: "clear", label: "Clear all filters", detail: "", action: { type: "clear" } });
182
+ return rows;
183
+ }
184
+ const { not, verb, rest } = parseVerb(t);
185
+ if (!verb) {
186
+ rows.push(...docRows(t, (n) => ({ kind: "doc", key: `doc:${n.id}`, label: n.title, detail: n.folder || "(root)", action: { type: "fly", id: n.id } })));
187
+ for (const f of searchFolders(counts, t, OMNI_FOLDERS)) rows.push(filterRow({ op: "in", path: f.path }, `folder:${f.path}`, "folder"));
188
+ for (const v of verbsMatching(t)) {
189
+ rows.push(v.arg === "none"
190
+ ? filterRow(v.make(null, idx ? hubThreshold(idx) : 1), `verb:${v.key}`, "verb", v.hint)
191
+ : { kind: "verb", key: `verb:${v.key}`, label: v.label, detail: v.hint, action: { type: "verb", fill: `${v.words[0]} ` } });
192
+ }
193
+ } else {
194
+ const mark = (s) => (not ? { ...s, not: true } : s);
195
+ const add = (raw, key) => { const r = filterRow(mark(raw), key, "filter"); if (r) rows.push(r); };
196
+ if (verb.arg === "folder" || verb.arg === "folder-or-doc") {
197
+ for (const f of searchFolders(counts, rest)) add(verb.make({ kind: "folder", path: f.path }), `in:${f.path}`);
198
+ }
199
+ if (verb.arg === "doc" || verb.arg === "folder-or-doc") {
200
+ const hops = verb.key === "near" ? trailingHops(rest) : null;
201
+ const q = hops ? rest.replace(/\s[1-4]$/, "") : rest;
202
+ if (q.trim()) for (const r of docRows(q, (n) => n)) add(verb.make({ kind: "doc", id: r.id }, hops), `${verb.key}:${r.id}`);
203
+ }
204
+ if (verb.arg === "number") {
205
+ const n = /^\d{1,6}$/.test(rest) ? Number(rest) : NaN;
206
+ if (Number.isFinite(n)) add(verb.make(n), `${verb.key}:${n}`);
207
+ }
208
+ if (verb.arg === "none") add(verb.make(null, idx ? hubThreshold(idx) : 1), verb.key);
209
+ if (verb.arg === "docs") rows.push(...pathRows(rest));
210
+ if (verb.arg === "area") {
211
+ const q = rest.toLowerCase();
212
+ for (const a of areaNames(nodes).filter((a) => a.toLowerCase().includes(q))) add(verb.make(a), `area:${a}`);
213
+ }
214
+ if (!rows.length) rows.push({ kind: "empty", key: "empty", label: emptyText(verb, rest), detail: verb.hint, action: null });
215
+ }
216
+ if (explorer) {
217
+ let shown = 0;
218
+ for (const r of rows) {
219
+ if (r.action?.type !== "step" || shown >= OMNI_PREVIEW) continue;
220
+ shown++;
221
+ const n = explorer.run([...steps, r.action.step]).count;
222
+ r.detail = `${n.toLocaleString("en-US")} ${n === 1 ? "doc" : "docs"}`;
223
+ }
224
+ explorer.run(steps); // leave the prefix cache on the live stack
225
+ }
226
+ return rows;
227
+
228
+ /** `path A to B`: while the first doc is being typed, its docs as verb rows that fill `path <title> to `; once there is a ` to `,
229
+ * one lens row per second-doc match, labelled with the chip it makes. */
230
+ function pathRows(rest) {
231
+ const { from, left, right, hasTo } = splitPathArgs(rest, nodes, visible);
232
+ if (!left) return [];
233
+ if (!hasTo) return findMatches(nodes, left, visible).slice(0, OMNI_DOCS).map((n) => ({ kind: "verb", key: `path-from:${n.id}`, label: n.title, detail: "then type to …", action: { type: "verb", fill: `path ${n.title} to ` } }));
234
+ if (!from) return [];
235
+ if (!right) return [{ kind: "empty", key: "path-to", label: `Path from ${from.title}: type the second doc`, detail: "", action: null }];
236
+ return findMatches(nodes, right, visible).slice(0, OMNI_DOCS).map((n) => {
237
+ const lens = { lens: "path", from: from.id, to: n.id };
238
+ return { kind: "filter", key: `path:${from.id}:${n.id}`, label: pathChipText(lens, idx), detail: "", action: { type: "lens", lens } };
239
+ });
240
+ }
241
+
242
+ function filterRow(raw, key, kind, detail = "") {
243
+ const step = normaliseStep(raw);
244
+ return step ? { kind, key, label: chipText(step, idx), detail, action: { type: "step", step } } : null;
245
+ }
246
+ }
247
+
248
+ function emptyText(verb, rest) {
249
+ if (verb.arg === "number") return rest ? `"${rest}" is not a number` : "Type a number";
250
+ if (verb.arg === "area") return rest ? `No area matches "${rest}"` : "No areas in this workspace";
251
+ if (verb.arg === "folder") return rest ? `No folder matches "${rest}"` : "No folders in this workspace";
252
+ if (verb.arg === "docs") return rest ? `No doc title matches "${rest}"` : "Type the first doc, then to, then the second";
253
+ return rest ? `No doc title matches "${rest}"` : "Type a doc title";
254
+ }
255
+
256
+ /** Which row a key moves to: `cursor` is the active row (-1 none), `rows` the omnibox rows (rows with no action are skipped). */
257
+ export function moveCursor(rows, cursor, d) {
258
+ const live = rows.map((r, i) => (r.action ? i : -1)).filter((i) => i >= 0);
259
+ if (!live.length) return -1;
260
+ const at = live.indexOf(cursor);
261
+ if (at < 0) return d > 0 ? live[0] : live[live.length - 1];
262
+ return live[(at + d + live.length) % live.length];
263
+ }
264
+
265
+ // ------------------------------------------------------------------------------------------------------------ small stores
266
+
267
+ /** Read / write a small JSON value in localStorage; every access in try/catch (a browser that refuses storage still works). */
268
+ export function readStore(key, fallback) {
269
+ try { const v = globalThis.localStorage?.getItem(key); return v === null || v === undefined ? fallback : JSON.parse(v); } catch { return fallback; }
270
+ }
271
+ export function writeStore(key, value) {
272
+ try { globalThis.localStorage?.setItem(key, JSON.stringify(value)); return true; } catch { return false; }
273
+ }
274
+ export const RECENT_MAX = 6;
275
+ /** A recent list read back from storage (untrusted): a non-array is `[]`, an entry that is not a non-empty string is dropped, repeats go,
276
+ * at most RECENT_MAX stay. Never throws. */
277
+ export const cleanRecent = (raw) => (Array.isArray(raw) ? [...new Set(raw.filter((x) => typeof x === "string" && x))].slice(0, RECENT_MAX) : []);
278
+ /** The recent-docs list: `id` moved to the front, at most `max`. */
279
+ export const pushRecent = (list, id, max = RECENT_MAX) => [id, ...(Array.isArray(list) ? list : []).filter((x) => x !== id)].slice(0, max);
280
+ /** The recent-docs store key, one list per workspace. */
281
+ export const recentKey = (workspace) => `wiki.graph.recent.${workspace}`;
282
+ /** Fixed warning text, per feature: never the workspace, the stored payload, a name, a url or an id. */
283
+ export const RECENT_DISCARDED = "Graph: recent docs saved in this browser were malformed and were dropped.";
284
+ /** A stored list (WIKI-GRAPH-STORAGE-WARNING-FU-1): `[]` when the key is missing or storage is refused (both normal and silent); when a value
285
+ * is there but is not JSON, not an array, or holds an entry `whole` rejects, ONE console.warn with `text` and the cleaned list. */
286
+ export function readStoredList(key, text, whole, clean) {
287
+ let raw;
288
+ try { raw = globalThis.localStorage?.getItem(key); } catch { return []; }
289
+ if (raw === null || raw === undefined) return [];
290
+ let v;
291
+ try { v = JSON.parse(raw); } catch { console.warn(text); return []; }
292
+ if (!Array.isArray(v) || !v.every(whole)) console.warn(text);
293
+ return clean(v);
294
+ }
295
+ /** The stored recent list, shape-checked (WIKI-GRAPH-RECENT-SHAPE-FU-1): valid JSON of any other shape reads as empty, with the fixed warning. */
296
+ export const readRecent = (workspace) => readStoredList(recentKey(workspace), RECENT_DISCARDED, (x) => typeof x === "string" && x, cleanRecent);
@@ -0,0 +1,50 @@
1
+ // UI-LIGHT-MODE-LM8-1: the graph's non-data colours by colour mode, one place for the 2D (canvas / pixi) and 3D (three) bases and the
2
+ // overlay above them. DARK is exactly what these files hard-coded before this unit (so dark does not change). Node hues (wikiGraph
3
+ // PALETTE, OTHER, OUTSIDE, UNMAPPED_COLOR) stay the data identity (dark hex, what graphLevels and the legend compare on); WIKI-GRAPH-LIGHT-HUES-1
4
+ // translates them to the light set at the four places that draw them (`hueFor`: both canvases, the legend, the linked-docs dot).
5
+ // The mode is module state set by the page (`setGraphMode(resolved)`, from useColorMode) before the canvases render; the canvases are
6
+ // keyed by the mode, so a switch rebuilds the bases, and every draw reads `graphTheme()` fresh, never a copy taken at import.
7
+ export const GRAPH_DARK = {
8
+ field: "#0a0f1a", // www `ground`
9
+ label: "#cbd5e1", labelOutline: "#0f172a",
10
+ hot: "#2bb1ff", focusRing: "#ffffff", selectRing: "#facc15",
11
+ contain: "#94a3b8", linkRgb: "148,163,184", edgeHex: 0x94a3b8,
12
+ halo: "#ffffff", haloHex: 0xffffff,
13
+ bubbleLabel: "rgba(226,232,240,0.75)",
14
+ gizmoDot: "rgba(255,255,255,0.85)", lassoFill: "rgba(255,255,255,0.08)",
15
+ };
16
+ export const GRAPH_LIGHT = {
17
+ field: "#f6f8fb", // LANDING light ground
18
+ label: "#334155", labelOutline: "#f6f8fb",
19
+ hot: "#0369a1", focusRing: "#0b1526", selectRing: "#b45309",
20
+ contain: "#64748b", linkRgb: "100,116,139", edgeHex: 0x64748b,
21
+ halo: "#0b1526", haloHex: 0x0b1526,
22
+ bubbleLabel: "rgba(51,65,85,0.85)",
23
+ gizmoDot: "rgba(11,21,38,0.55)", lassoFill: "rgba(3,105,161,0.10)",
24
+ };
25
+ let current = GRAPH_DARK;
26
+ /** The colours in force now. */
27
+ export const graphTheme = () => current;
28
+ /** `light` selects the light set, anything else (dark, a bad value) the dark one. */
29
+ export function setGraphMode(mode) { current = mode === "light" ? GRAPH_LIGHT : GRAPH_DARK; return current; }
30
+
31
+ // WIKI-GRAPH-LIGHT-HUES-1: the light hue per node hue, same hue family, deeper and slightly less chroma (HSL saturation x0.9, lightness lowered
32
+ // until the fill reaches >= 3.2:1 against the light field `#f6f8fb`, the WCAG 1.4.11 non-text 3:1 plus margin). The design-system kit has no
33
+ // categorical or chart tokens (grep of tokens.cjs / tokens.css / colors_and_type.css, 2026-10-25), so these are derived, not tokens.
34
+ // Rows: [name, dark (the data hex in wikiGraph / graphLevels), light]. OUTSIDE is "dim" by intent: its light value is a pale slate (about the
35
+ // same 1.6-2:1 dimness against its field as the dark one has), so it is exempt from the 3:1 target.
36
+ export const NODE_HUES = [
37
+ ["amber", "#E69F00", "#b6810a"],
38
+ ["sky", "#56B4E9", "#2692cf"],
39
+ ["green", "#009E73", "#08966f"],
40
+ ["yellow", "#F0E442", "#958c14"],
41
+ ["blue", "#0072B2", "#0970a9"],
42
+ ["vermilion", "#D55E00", "#ca5f0b"],
43
+ ["pink", "#CC79A7", "#c26f9d"],
44
+ ["other", "#7f8ea5", "#6f7e94"],
45
+ ["unmapped", "#B9A7FF", "#9176f8"],
46
+ ["outside", "#3b4556", "#c4cddb"],
47
+ ];
48
+ const LIGHT_OF = new Map(NODE_HUES.map(([, dark, light]) => [dark.toLowerCase(), light]));
49
+ /** `hex` (a data hue, `#rrggbb`) as drawn in the mode in force: itself in dark, its light variant in light. A hex that is not a node hue is returned as is. */
50
+ export const hueFor = (hex) => (current === GRAPH_LIGHT && typeof hex === "string" ? LIGHT_OF.get(hex.toLowerCase()) ?? hex : hex);