@hraness/wordcell 0.21.3 → 0.22.1

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 (93) hide show
  1. package/README.md +170 -64
  2. package/dist/authoring.js +5 -3
  3. package/dist/{authority-4sp9d552.js → authority-06eqhqam.js} +57 -12
  4. package/dist/benchmark.js +4 -4
  5. package/dist/capture.js +6 -5
  6. package/dist/cli.js +39 -33
  7. package/dist/clip/acquire.js +3 -2
  8. package/dist/clip/cli.js +6 -5
  9. package/dist/clip/doctor.js +4 -3
  10. package/dist/clip/jobs.js +3 -3
  11. package/dist/clip/refresh.js +1 -1
  12. package/dist/evaluation-builder.js +20 -19
  13. package/dist/evaluation-kb.js +16 -15
  14. package/dist/graph-authority.js +5 -5
  15. package/dist/graph-percolation.js +7 -7
  16. package/dist/graph.js +1 -1
  17. package/dist/{index-pbr6rv47.js → index-1k595z6v.js} +6 -6
  18. package/dist/{index-0k2x4nn9.js → index-233z9wmn.js} +17 -5
  19. package/dist/index-3agn8scn.js +306 -0
  20. package/dist/index-4knsp9qj.js +34 -0
  21. package/dist/index-66pshdtx.js +341 -0
  22. package/dist/{index-59ssmqqm.js → index-76dbn6pe.js} +1 -1
  23. package/dist/{index-0rfcqab6.js → index-810nfzdj.js} +617 -66
  24. package/dist/index-9rf81m0p.js +368 -0
  25. package/dist/{index-51bap0mq.js → index-bqtqeak3.js} +10 -10
  26. package/dist/{index-9zcpdbjg.js → index-de2w8crk.js} +13 -13
  27. package/dist/{index-bgfzwt4h.js → index-djrcc1yf.js} +6 -6
  28. package/dist/{index-6sw24nvv.js → index-ecqpgr5y.js} +53 -2
  29. package/dist/{index-van48n3g.js → index-fc4dr114.js} +4 -2
  30. package/dist/{index-jzcws0zn.js → index-g5vsqmdy.js} +27 -57
  31. package/dist/index-hmw17zaa.js +1985 -0
  32. package/dist/index-j70m75wd.js +217 -0
  33. package/dist/{index-nd6nynv2.js → index-jk1sgx36.js} +1 -1
  34. package/dist/{index-3ehtt59c.js → index-n386evxj.js} +1 -1
  35. package/dist/{index-vdw5yjfa.js → index-q7tbg07z.js} +110 -20
  36. package/dist/{index-ekpwvbra.js → index-qbssx940.js} +1 -2
  37. package/dist/{index-115b07ap.js → index-rr9kzq4n.js} +5 -5
  38. package/dist/{index-11621h23.js → index-tf4wzjew.js} +1 -1
  39. package/dist/{index-why54q5p.js → index-vnybgywh.js} +4 -4
  40. package/dist/{index-bcfn9xah.js → index-xv80vzfp.js} +7 -7
  41. package/dist/index.js +289 -95
  42. package/dist/percolate.js +2 -2
  43. package/dist/portfolio.js +16 -15
  44. package/dist/publish-model.js +36 -0
  45. package/dist/publish-reader/reader.css +1042 -0
  46. package/dist/publish-reader/reader.js +1534 -0
  47. package/dist/publish-reader/theme.js +92 -0
  48. package/dist/publish-search.js +52 -0
  49. package/dist/publish.js +36 -0
  50. package/dist/rerank-typesafe.js +13 -0
  51. package/dist/rerank.js +14 -0
  52. package/dist/sdk.js +16 -13
  53. package/dist/search.js +3 -3
  54. package/dist/semantic.js +5 -5
  55. package/dist/{support-vm4mkw0k.js → support-1rh2d7mt.js} +38 -32
  56. package/dist/workflows/decision-context.js +15 -14
  57. package/dist/workflows/index.js +15 -14
  58. package/package.json +65 -6
  59. package/skills/wordcell/AGENTS.md +1 -0
  60. package/skills/wordcell/SKILL.md +2 -1
  61. package/skills/wordcell/agents/openai.yaml +1 -1
  62. package/skills/wordcell/references/publish.md +127 -0
  63. package/skills/wordcell/references/query.md +22 -0
  64. package/skills/wordcell/references/refresh.md +14 -0
  65. package/src/authoring-model.ts +29 -0
  66. package/src/authoring-program.ts +38 -3
  67. package/src/authoring.ts +17 -3
  68. package/src/cli-program.ts +441 -12
  69. package/src/graph.ts +1 -1
  70. package/src/index.ts +4 -0
  71. package/src/oh/authority.ts +14 -5
  72. package/src/oh/canonical-rust.ts +127 -0
  73. package/src/oh/projection-rust.ts +63 -0
  74. package/src/oh-adoption.ts +7 -1
  75. package/src/publish-graph.ts +0 -0
  76. package/src/publish-index.ts +195 -0
  77. package/src/publish-markdown.ts +617 -0
  78. package/src/publish-model.ts +599 -0
  79. package/src/publish-nav.ts +204 -0
  80. package/src/publish-pages.ts +382 -0
  81. package/src/publish-reader/reader.css +1042 -0
  82. package/src/publish-reader/reader.ts +1017 -0
  83. package/src/publish-reader/theme.ts +97 -0
  84. package/src/publish-search.ts +418 -0
  85. package/src/publish-select.ts +419 -0
  86. package/src/publish-theme.ts +106 -0
  87. package/src/publish.ts +904 -0
  88. package/src/rerank-credentials.ts +56 -0
  89. package/src/rerank-typesafe.ts +472 -0
  90. package/src/rerank.ts +389 -0
  91. package/src/sdk.ts +157 -6
  92. package/src/serve.ts +180 -0
  93. package/src/vault.ts +23 -9
@@ -0,0 +1,1017 @@
1
+ import {
2
+ parseSiteCatalogV1,
3
+ parseSiteDocsV1,
4
+ parseSiteGraphV1,
5
+ parseSiteManifestV1,
6
+ parseSiteNoteV1,
7
+ parseSitePostingsV1,
8
+ parseSiteTermsV1,
9
+ WORDCELL_SITE_LIMITS_V1,
10
+ type WordcellSiteCatalogV1,
11
+ type WordcellSiteDocV1,
12
+ type WordcellSiteManifestV1,
13
+ type WordcellSiteNoteV1,
14
+ } from "../publish-model.js";
15
+ import {
16
+ layoutSiteGraph,
17
+ siteGraphDegrees,
18
+ siteGraphSeed,
19
+ WORDCELL_SITE_GRAPH_VIEW_LIMIT,
20
+ type SiteGraphLayoutEdge,
21
+ type SiteGraphPoint,
22
+ } from "../publish-graph.js";
23
+ import {
24
+ comparePublishScores,
25
+ MAX_PUBLISH_PREFIX_EXPANSIONS,
26
+ publishDocMatchesFilters,
27
+ publishMarkRanges,
28
+ publishPrefixTerms,
29
+ publishQuery,
30
+ publishQueryParts,
31
+ publishShardName,
32
+ publishSnippet,
33
+ scorePublishDocument,
34
+ type PublishQuery,
35
+ type PublishQueryFilters,
36
+ type PublishScore,
37
+ } from "../publish-search.js";
38
+ import {
39
+ wordcellAppearanceBridge,
40
+ wordcellAppearanceModeLabels,
41
+ wordcellAppearanceModes,
42
+ wordcellPaletteLabels,
43
+ wordcellPalettes,
44
+ } from "../publish-theme.js";
45
+
46
+ /**
47
+ * Zero-dependency reference reader bundled into every published site. It
48
+ * progressively enhances static pages with a search overlay powered by the
49
+ * precomputed exact-search index — including tag:/type:/path: field filters
50
+ * and <mark> highlighting — and an interactive pan/zoom map over graph.json
51
+ * on the graph page. Everything runs against the minimal DOM shim below so
52
+ * the package stays on the ES2023 lib — no design-system or framework
53
+ * dependency, and the bundle imports only the pure contract, layout, and
54
+ * search modules.
55
+ */
56
+
57
+ type FetchResponse = {
58
+ readonly ok: boolean;
59
+ readonly status: number;
60
+ json(): Promise<unknown>;
61
+ };
62
+
63
+ type ReaderText = { readonly kind: "text" };
64
+ type ReaderNode = ReaderElement | ReaderText;
65
+
66
+ type CanvasContext2d = {
67
+ fillStyle: string;
68
+ strokeStyle: string;
69
+ lineWidth: number;
70
+ font: string;
71
+ textAlign: string;
72
+ textBaseline: string;
73
+ globalAlpha: number;
74
+ setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void;
75
+ clearRect(x: number, y: number, width: number, height: number): void;
76
+ fillRect(x: number, y: number, width: number, height: number): void;
77
+ beginPath(): void;
78
+ moveTo(x: number, y: number): void;
79
+ lineTo(x: number, y: number): void;
80
+ arc(x: number, y: number, radius: number, start: number, end: number): void;
81
+ fill(): void;
82
+ stroke(): void;
83
+ fillText(text: string, x: number, y: number): void;
84
+ setLineDash(segments: number[]): void;
85
+ };
86
+
87
+ type ReaderRect = { readonly left: number; readonly top: number; readonly width: number; readonly height: number };
88
+
89
+ type ReaderElement = {
90
+ className: string;
91
+ textContent: string | null;
92
+ innerHTML: string;
93
+ href: string;
94
+ type: string;
95
+ name: string;
96
+ checked: boolean;
97
+ placeholder: string;
98
+ value: string;
99
+ hidden: boolean;
100
+ width: number;
101
+ height: number;
102
+ readonly style: { cursor: string };
103
+ setAttribute(name: string, value: string): void;
104
+ getAttribute(name: string): string | null;
105
+ querySelector(selector: string): ReaderElement | null;
106
+ getContext(kind: "2d"): CanvasContext2d | null;
107
+ getBoundingClientRect(): ReaderRect;
108
+ appendChild(child: ReaderNode): ReaderNode;
109
+ remove(): void;
110
+ focus(): void;
111
+ addEventListener(name: string, listener: (event: ReaderEvent) => void): void;
112
+ };
113
+
114
+ type ReaderEvent = {
115
+ readonly key?: string;
116
+ readonly clientX?: number;
117
+ readonly clientY?: number;
118
+ readonly deltaY?: number;
119
+ readonly shiftKey?: boolean;
120
+ readonly target?: {
121
+ readonly value?: string;
122
+ readonly tagName?: string;
123
+ closest?(selector: string): ReaderElement | null;
124
+ } | null;
125
+ preventDefault(): void;
126
+ };
127
+
128
+ type ReaderDocument = {
129
+ readonly body: { appendChild(child: ReaderNode): ReaderNode };
130
+ readonly documentElement: ReaderElement;
131
+ querySelector(selector: string): ReaderElement | null;
132
+ querySelectorAll(selector: string): ArrayLike<ReaderElement>;
133
+ createElement(tag: string): ReaderElement;
134
+ createTextNode(text: string): ReaderText;
135
+ addEventListener(name: string, listener: (event: ReaderEvent) => void): void;
136
+ };
137
+
138
+ type ReaderMediaQuery = {
139
+ readonly matches: boolean;
140
+ addEventListener(name: "change", listener: () => void): void;
141
+ };
142
+
143
+ type ReaderLocation = {
144
+ readonly hash?: string;
145
+ assign(url: string): void;
146
+ };
147
+
148
+ const dom = globalThis as unknown as {
149
+ readonly document: ReaderDocument;
150
+ readonly location: ReaderLocation;
151
+ readonly devicePixelRatio?: number;
152
+ fetch(url: string): Promise<FetchResponse>;
153
+ setTimeout(callback: () => void, ms: number): number;
154
+ clearTimeout(id: number): void;
155
+ requestAnimationFrame?(callback: () => void): number;
156
+ getComputedStyle?(element: ReaderElement): { getPropertyValue(name: string): string };
157
+ matchMedia?(query: string): ReaderMediaQuery;
158
+ addEventListener?(name: string, listener: (event: ReaderEvent) => void): void;
159
+ };
160
+
161
+ type SiteIndex = {
162
+ readonly manifest: WordcellSiteManifestV1;
163
+ readonly docs: readonly WordcellSiteDocV1[];
164
+ readonly terms: readonly string[];
165
+ readonly postings: Map<string, Map<string, readonly number[]>>;
166
+ readonly notes: Map<string, WordcellSiteNoteV1>;
167
+ /** Lazily loaded for type: filters; absent catalog disables type matching. */
168
+ catalog: WordcellSiteCatalogV1 | undefined;
169
+ };
170
+
171
+ async function fetchJson(base: string, path: string): Promise<unknown | undefined> {
172
+ const response = await dom.fetch(`${base}${path}`);
173
+ if (!response.ok) return undefined;
174
+ return response.json() as Promise<unknown>;
175
+ }
176
+
177
+ async function loadIndex(base: string): Promise<SiteIndex | undefined> {
178
+ const manifestRaw = await fetchJson(base, "manifest.json");
179
+ if (manifestRaw === undefined) return undefined;
180
+ const manifest = parseSiteManifestV1(manifestRaw);
181
+ const docsRaw = await fetchJson(base, manifest.paths.docs);
182
+ const termsRaw = await fetchJson(base, manifest.paths.terms);
183
+ if (docsRaw === undefined || termsRaw === undefined) return undefined;
184
+ const docs = parseSiteDocsV1(docsRaw);
185
+ const terms = parseSiteTermsV1(termsRaw);
186
+ return {
187
+ manifest,
188
+ docs: docs.docs,
189
+ terms: terms.terms,
190
+ postings: new Map(),
191
+ notes: new Map(),
192
+ catalog: undefined,
193
+ };
194
+ }
195
+
196
+ async function loadShard(
197
+ index: SiteIndex,
198
+ base: string,
199
+ shard: string,
200
+ ): Promise<Map<string, readonly number[]>> {
201
+ const cached = index.postings.get(shard);
202
+ if (cached !== undefined) return cached;
203
+ const raw = await fetchJson(base, `${index.manifest.paths.postingsPrefix}${shard}.json`);
204
+ const postings = raw === undefined
205
+ ? new Map<string, readonly number[]>()
206
+ : new Map(Object.entries(parseSitePostingsV1(raw).postings));
207
+ index.postings.set(shard, postings);
208
+ return postings;
209
+ }
210
+
211
+ async function loadCatalog(
212
+ index: SiteIndex,
213
+ base: string,
214
+ ): Promise<WordcellSiteCatalogV1 | undefined> {
215
+ if (index.catalog !== undefined) return index.catalog;
216
+ const raw = await fetchJson(base, index.manifest.paths.catalog);
217
+ if (raw === undefined) return undefined;
218
+ index.catalog = parseSiteCatalogV1(raw);
219
+ return index.catalog;
220
+ }
221
+
222
+ async function hydrate(
223
+ index: SiteIndex,
224
+ base: string,
225
+ slug: string,
226
+ ): Promise<WordcellSiteNoteV1 | undefined> {
227
+ const cached = index.notes.get(slug);
228
+ if (cached !== undefined) return cached;
229
+ const path = slug === "" ? "index.json" : `${index.manifest.paths.notePrefix}${slug}.json`;
230
+ const raw = await fetchJson(base, path);
231
+ if (raw === undefined) return undefined;
232
+ const note = parseSiteNoteV1(raw);
233
+ index.notes.set(slug, note);
234
+ return note;
235
+ }
236
+
237
+ type ScoredHit = {
238
+ readonly doc: WordcellSiteDocV1;
239
+ readonly score: PublishScore;
240
+ };
241
+
242
+ const FILTER_ONLY_SCORE: PublishScore = Object.freeze({
243
+ score: 0,
244
+ identity: false,
245
+ phraseMatched: false,
246
+ matchedTerms: 0,
247
+ });
248
+
249
+ function hasFilters(filters: PublishQueryFilters): boolean {
250
+ return filters.tags.length + filters.types.length + filters.paths.length > 0;
251
+ }
252
+
253
+ async function search(index: SiteIndex, base: string, raw: string): Promise<ScoredHit[]> {
254
+ const { filters, text } = publishQueryParts(raw);
255
+ const filtered = hasFilters(filters);
256
+ const query = publishQuery(text);
257
+ if (!filtered && query.terms.length === 0 && query.normalized === "") return [];
258
+
259
+ // type: filters need the catalog's raw type values; fetch it lazily.
260
+ let typeByIndex: ReadonlyMap<number, string> | undefined;
261
+ if (filters.types.length > 0) {
262
+ const catalog = await loadCatalog(index, base);
263
+ typeByIndex = new Map(
264
+ (catalog?.entries ?? []).map((entry) => [entry.i, entry.type ?? "note"]),
265
+ );
266
+ }
267
+
268
+ // Content term admission: inline scans doc.x directly; shard mode expands
269
+ // each query term through the sorted dictionary prefix index, then unions
270
+ // the matching postings. Both reproduce the exact lane's content.includes
271
+ // semantics — a query term matches content containing it as a substring.
272
+ const postingsHits = new Map<number, Set<string>>();
273
+ if (index.manifest.search.content === "shards" && query.terms.length > 0) {
274
+ const expandedByTerm = new Map<string, readonly string[]>();
275
+ const shards = new Set<string>();
276
+ for (const term of query.terms) {
277
+ const expanded = publishPrefixTerms(index.terms, term, MAX_PUBLISH_PREFIX_EXPANSIONS);
278
+ expandedByTerm.set(term, expanded);
279
+ for (const candidate of expanded) shards.add(publishShardName(candidate));
280
+ }
281
+ const tables = new Map<string, Map<string, readonly number[]>>();
282
+ for (const shard of shards) tables.set(shard, await loadShard(index, base, shard));
283
+ for (const doc of index.docs) {
284
+ const hits = new Set<string>();
285
+ for (const term of query.terms) {
286
+ const matched = (expandedByTerm.get(term) ?? []).some((candidate) =>
287
+ tables.get(publishShardName(candidate))?.get(candidate)?.includes(doc.i) === true);
288
+ if (matched) hits.add(term);
289
+ }
290
+ postingsHits.set(doc.i, hits);
291
+ }
292
+ }
293
+
294
+ const hits: ScoredHit[] = [];
295
+ for (const doc of index.docs) {
296
+ if (!publishDocMatchesFilters(doc, filters, typeByIndex?.get(doc.i))) continue;
297
+ if (query.terms.length === 0 && query.normalized === "") {
298
+ // Filter-only query: admit every matching document in catalog order.
299
+ hits.push({ doc, score: FILTER_ONLY_SCORE });
300
+ continue;
301
+ }
302
+ const inline = index.manifest.search.content === "inline" ? doc.x : undefined;
303
+ const contentTerms = inline !== undefined
304
+ ? new Set(query.terms.filter((term) => inline.includes(term)))
305
+ : postingsHits.get(doc.i) ?? new Set<string>();
306
+ const scored = scorePublishDocument(
307
+ { doc, contentTerms, ...(inline === undefined ? {} : { contentText: inline }) },
308
+ query,
309
+ );
310
+ if (scored === null) continue;
311
+ hits.push({ doc, score: scored });
312
+ }
313
+ hits.sort((left, right) =>
314
+ comparePublishScores({ i: left.doc.i, ...left.score }, { i: right.doc.i, ...right.score }));
315
+ return hits.slice(0, WORDCELL_SITE_LIMITS_V1.searchResults);
316
+ }
317
+
318
+ function resultHref(base: string, slug: string): string {
319
+ return slug === "" ? base : `${base}n/${encodeURI(slug)}/`;
320
+ }
321
+
322
+ function snippetFor(index: SiteIndex, doc: WordcellSiteDocV1, query: PublishQuery): string {
323
+ const hydrated = index.notes.get(doc.s);
324
+ const text = hydrated?.text ?? doc.x;
325
+ return text === undefined ? doc.p : publishSnippet(text, query, doc.p);
326
+ }
327
+
328
+ /**
329
+ * Append text with matched terms wrapped in <mark>. Ranges come from the pure
330
+ * search module and every span is a DOM text node — no innerHTML, so hostile
331
+ * note text can never inject markup.
332
+ */
333
+ function appendMarked(parent: ReaderElement, text: string, terms: readonly string[]): void {
334
+ const display = text.normalize("NFC");
335
+ const ranges = publishMarkRanges(display, terms);
336
+ if (ranges.length === 0) {
337
+ parent.appendChild(dom.document.createTextNode(display));
338
+ return;
339
+ }
340
+ let cursor = 0;
341
+ for (const range of ranges) {
342
+ if (range.start > cursor) {
343
+ parent.appendChild(dom.document.createTextNode(display.slice(cursor, range.start)));
344
+ }
345
+ const mark = dom.document.createElement("mark");
346
+ mark.textContent = display.slice(range.start, range.end);
347
+ parent.appendChild(mark);
348
+ cursor = range.end;
349
+ }
350
+ if (cursor < display.length) {
351
+ parent.appendChild(dom.document.createTextNode(display.slice(cursor)));
352
+ }
353
+ }
354
+
355
+ function buildOverlay(base: string): {
356
+ readonly open: () => void;
357
+ readonly close: () => void;
358
+ } {
359
+ const overlay = dom.document.createElement("div");
360
+ overlay.className = "wordcell-search-overlay";
361
+ overlay.setAttribute("role", "dialog");
362
+ overlay.setAttribute("aria-modal", "true");
363
+ overlay.setAttribute("aria-label", "Search");
364
+
365
+ const panel = dom.document.createElement("div");
366
+ panel.className = "wordcell-search-panel";
367
+
368
+ const input = dom.document.createElement("input");
369
+ input.type = "search";
370
+ input.placeholder = "Search this site… (tag:, type:, path:)";
371
+ input.setAttribute("aria-label", "Search this site");
372
+ input.setAttribute("autocomplete", "off");
373
+ input.setAttribute("spellcheck", "false");
374
+
375
+ const status = dom.document.createElement("div");
376
+ status.className = "wordcell-search-status";
377
+ status.setAttribute("role", "status");
378
+
379
+ const list = dom.document.createElement("ul");
380
+ list.className = "wordcell-search-results";
381
+
382
+ panel.appendChild(input);
383
+ panel.appendChild(status);
384
+ panel.appendChild(list);
385
+ overlay.appendChild(panel);
386
+
387
+ let index: SiteIndex | undefined;
388
+ let loading: Promise<SiteIndex | undefined> | undefined;
389
+ let hits: ScoredHit[] = [];
390
+ let active = -1;
391
+ let debounce = 0;
392
+
393
+ const render = (): void => {
394
+ list.innerHTML = "";
395
+ let query: PublishQuery | undefined;
396
+ try {
397
+ query = publishQuery(publishQueryParts(input.value).text);
398
+ } catch {
399
+ query = undefined;
400
+ }
401
+ const terms = query?.terms ?? [];
402
+ for (const [position, hit] of hits.entries()) {
403
+ const item = dom.document.createElement("li");
404
+ const link = dom.document.createElement("a");
405
+ link.href = resultHref(base, hit.doc.s);
406
+ link.className = position === active ? "active" : "";
407
+ const title = dom.document.createElement("span");
408
+ title.className = "title";
409
+ appendMarked(title, hit.doc.t, terms);
410
+ const snippet = dom.document.createElement("span");
411
+ snippet.className = "snippet";
412
+ const snippetText = index === undefined || query === undefined || query.terms.length === 0
413
+ ? hit.doc.p
414
+ : snippetFor(index, hit.doc, query);
415
+ appendMarked(snippet, snippetText, terms);
416
+ link.appendChild(title);
417
+ link.appendChild(snippet);
418
+ item.appendChild(link);
419
+ list.appendChild(item);
420
+ }
421
+ status.textContent = hits.length === 0
422
+ ? (input.value.trim() === "" ? "Type to search. Filter with tag:, type:, path:." : "No results.")
423
+ : `${hits.length} result${hits.length === 1 ? "" : "s"}`;
424
+ };
425
+
426
+ const run = (): void => {
427
+ void (async () => {
428
+ if (index === undefined) {
429
+ loading ??= loadIndex(base);
430
+ index = await loading;
431
+ if (index === undefined) {
432
+ status.textContent = "Search is unavailable for this site.";
433
+ return;
434
+ }
435
+ }
436
+ try {
437
+ hits = await search(index, base, input.value);
438
+ } catch {
439
+ hits = [];
440
+ }
441
+ active = hits.length === 0 ? -1 : 0;
442
+ render();
443
+ // Hydrate the top hits so postings-mode results still show snippets.
444
+ const current = index;
445
+ void Promise.all(
446
+ hits.slice(0, WORDCELL_SITE_LIMITS_V1.searchHydration)
447
+ .map((hit) => hydrate(current, base, hit.doc.s)),
448
+ ).then(() => render());
449
+ })();
450
+ };
451
+
452
+ input.addEventListener("input", () => {
453
+ dom.clearTimeout(debounce);
454
+ debounce = dom.setTimeout(run, 80);
455
+ });
456
+ input.addEventListener("keydown", (event) => {
457
+ if (event.key === "ArrowDown") {
458
+ event.preventDefault();
459
+ if (hits.length > 0) {
460
+ active = Math.min(hits.length - 1, active + 1);
461
+ render();
462
+ }
463
+ } else if (event.key === "ArrowUp") {
464
+ event.preventDefault();
465
+ if (hits.length > 0) {
466
+ active = Math.max(0, active - 1);
467
+ render();
468
+ }
469
+ } else if (event.key === "Enter") {
470
+ const hit = hits[active];
471
+ if (hit !== undefined) {
472
+ event.preventDefault();
473
+ dom.location.assign(resultHref(base, hit.doc.s));
474
+ }
475
+ } else if (event.key === "Escape") {
476
+ event.preventDefault();
477
+ overlay.remove();
478
+ }
479
+ });
480
+ overlay.addEventListener("click", (event) => {
481
+ if (event.target === overlay) overlay.remove();
482
+ });
483
+
484
+ return {
485
+ open() {
486
+ dom.document.body.appendChild(overlay);
487
+ hits = [];
488
+ active = -1;
489
+ render();
490
+ input.focus();
491
+ if (index === undefined || input.value.trim() !== "") run();
492
+ },
493
+ close() {
494
+ overlay.remove();
495
+ },
496
+ };
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // Interactive graph map over graph.json: seeded deterministic layout, canvas
501
+ // rendering, drag pan, wheel zoom, click-to-navigate, hash focus.
502
+
503
+ type GraphTheme = {
504
+ readonly bg: string;
505
+ readonly fg: string;
506
+ readonly muted: string;
507
+ readonly border: string;
508
+ readonly accent: string;
509
+ };
510
+
511
+ type GraphView = {
512
+ /** Screen (CSS px) = world * scale + translate. */
513
+ scale: number;
514
+ tx: number;
515
+ ty: number;
516
+ hover: number;
517
+ focus: number;
518
+ };
519
+
520
+ const GRAPH_MIN_SCALE = 0.05;
521
+ const GRAPH_MAX_SCALE = 8;
522
+
523
+ function cssVar(shell: ReaderElement, name: string, fallback: string): string {
524
+ const value = dom.getComputedStyle?.(shell).getPropertyValue(name).trim();
525
+ return value === undefined || value === "" ? fallback : value;
526
+ }
527
+
528
+ function graphTheme(shell: ReaderElement): GraphTheme {
529
+ return {
530
+ bg: cssVar(shell, "--wordcell-bg", "#ffffff"),
531
+ fg: cssVar(shell, "--wordcell-fg", "#1a1a1a"),
532
+ muted: cssVar(shell, "--wordcell-muted", "#5f6368"),
533
+ border: cssVar(shell, "--wordcell-border", "#e1e4e8"),
534
+ accent: cssVar(shell, "--wordcell-accent", "#0b5bd3"),
535
+ };
536
+ }
537
+
538
+ function fitView(
539
+ points: readonly SiteGraphPoint[],
540
+ width: number,
541
+ height: number,
542
+ ): GraphView {
543
+ let minX = 0;
544
+ let minY = 0;
545
+ let maxX = 0;
546
+ let maxY = 0;
547
+ for (const point of points) {
548
+ minX = Math.min(minX, point.x);
549
+ maxX = Math.max(maxX, point.x);
550
+ minY = Math.min(minY, point.y);
551
+ maxY = Math.max(maxY, point.y);
552
+ }
553
+ const spanX = Math.max(maxX - minX, 40);
554
+ const spanY = Math.max(maxY - minY, 40);
555
+ const margin = 48;
556
+ const scale = Math.min(
557
+ GRAPH_MAX_SCALE,
558
+ Math.max(
559
+ GRAPH_MIN_SCALE,
560
+ Math.min((width - margin * 2) / spanX, (height - margin * 2) / spanY),
561
+ ),
562
+ );
563
+ return {
564
+ scale,
565
+ tx: width / 2 - ((minX + maxX) / 2) * scale,
566
+ ty: height / 2 - ((minY + maxY) / 2) * scale,
567
+ hover: -1,
568
+ focus: -1,
569
+ };
570
+ }
571
+
572
+ function drawGraph(
573
+ context: CanvasContext2d,
574
+ ratio: number,
575
+ theme: GraphTheme,
576
+ view: GraphView,
577
+ points: readonly SiteGraphPoint[],
578
+ titles: readonly string[],
579
+ degrees: readonly number[],
580
+ edges: readonly { s: number; t: number; kind: "link" | "relation" }[],
581
+ neighborFocus: ReadonlySet<number>,
582
+ ): void {
583
+ const { scale, tx, ty } = view;
584
+ context.setTransform(scale * ratio, 0, 0, scale * ratio, tx * ratio, ty * ratio);
585
+ const inv = 1 / scale;
586
+
587
+ for (const edge of edges) {
588
+ const a = points[edge.s];
589
+ const b = points[edge.t];
590
+ if (a === undefined || b === undefined) continue;
591
+ const focused = view.focus >= 0 && (edge.s === view.focus || edge.t === view.focus);
592
+ const hovered = view.hover >= 0 && (edge.s === view.hover || edge.t === view.hover);
593
+ context.beginPath();
594
+ context.moveTo(a.x, a.y);
595
+ context.lineTo(b.x, b.y);
596
+ context.lineWidth = (focused || hovered ? 1.8 : 1) * inv;
597
+ context.strokeStyle = focused || hovered ? theme.accent : theme.border;
598
+ context.globalAlpha = focused || hovered ? 1 : 0.7;
599
+ context.setLineDash(edge.kind === "relation" ? [4 * inv, 3 * inv] : []);
600
+ context.stroke();
601
+ }
602
+ context.setLineDash([]);
603
+ context.globalAlpha = 1;
604
+
605
+ const labelEverywhere = points.length <= 48;
606
+ for (const [index, point] of points.entries()) {
607
+ const degree = degrees[index] ?? 0;
608
+ const radius = Math.min(9, 3 + degree * 0.8) * inv;
609
+ const active = index === view.hover || index === view.focus || neighborFocus.has(index);
610
+ context.beginPath();
611
+ context.arc(point.x, point.y, radius, 0, Math.PI * 2);
612
+ context.fillStyle = active ? theme.accent : theme.muted;
613
+ context.fill();
614
+ context.lineWidth = 1.2 * inv;
615
+ context.strokeStyle = theme.bg;
616
+ context.stroke();
617
+ }
618
+
619
+ context.font = `${11 / scale}px ui-sans-serif, system-ui, sans-serif`;
620
+ context.textAlign = "center";
621
+ context.textBaseline = "top";
622
+ for (const [index, point] of points.entries()) {
623
+ const degree = degrees[index] ?? 0;
624
+ const active = index === view.hover || index === view.focus || neighborFocus.has(index);
625
+ if (!labelEverywhere && !active && degree < 3) continue;
626
+ context.fillStyle = active ? theme.fg : theme.muted;
627
+ context.fillText(titles[index] ?? "", point.x, point.y + (Math.min(9, 3 + degree * 0.8) + 4) * inv);
628
+ }
629
+ }
630
+
631
+ function initGraph(base: string): void {
632
+ const shell = dom.document.querySelector("[data-wordcell-graph]");
633
+ if (shell === null) return;
634
+ const canvas = shell.querySelector("[data-wordcell-graph-canvas]");
635
+ const status = shell.querySelector("[data-wordcell-graph-status]");
636
+ const reset = shell.querySelector("[data-wordcell-graph-reset]");
637
+ if (canvas === null) return;
638
+ const context = canvas.getContext("2d");
639
+ if (context === null) {
640
+ if (status !== null) status.textContent = "Canvas is unavailable; every note is listed below.";
641
+ return;
642
+ }
643
+
644
+ void (async () => {
645
+ const manifestRaw = await fetchJson(base, "manifest.json");
646
+ if (manifestRaw === undefined) throw new Error("manifest fetch failed");
647
+ const manifest = parseSiteManifestV1(manifestRaw);
648
+ const [catalogRaw, graphRaw] = await Promise.all([
649
+ fetchJson(base, manifest.paths.catalog),
650
+ fetchJson(base, manifest.paths.graph),
651
+ ]);
652
+ if (catalogRaw === undefined || graphRaw === undefined) throw new Error("graph data fetch failed");
653
+ const catalog = parseSiteCatalogV1(catalogRaw);
654
+ const graph = parseSiteGraphV1(graphRaw);
655
+
656
+ const nodes = catalog.entries;
657
+ if (nodes.length > WORDCELL_SITE_GRAPH_VIEW_LIMIT) {
658
+ if (status !== null) {
659
+ status.textContent =
660
+ `This site has ${nodes.length} notes — over the ${WORDCELL_SITE_GRAPH_VIEW_LIMIT}-node interactive limit. Every note is listed below.`;
661
+ }
662
+ return;
663
+ }
664
+
665
+ const edges: SiteGraphLayoutEdge[] = [];
666
+ const drawn: { s: number; t: number; kind: "link" | "relation" }[] = [];
667
+ for (const edge of graph.edges) {
668
+ if (edge.s === edge.t || edge.s < 0 || edge.t < 0) continue;
669
+ if (edge.s >= nodes.length || edge.t >= nodes.length) continue;
670
+ edges.push({ s: edge.s, t: edge.t });
671
+ drawn.push({ s: edge.s, t: edge.t, kind: edge.k });
672
+ }
673
+
674
+ const slugs = nodes.map((entry) => entry.s);
675
+ const titles = nodes.map((entry) => entry.t);
676
+ const seed = siteGraphSeed(slugs, edges);
677
+ const points = layoutSiteGraph(nodes.length, edges, { seed });
678
+ const degrees = siteGraphDegrees(nodes.length, edges);
679
+ const hrefFor = (index: number): string => resultHref(base, slugs[index] ?? "");
680
+
681
+ const rect = canvas.getBoundingClientRect();
682
+ const ratio = dom.devicePixelRatio ?? 1;
683
+ const cssWidth = Math.max(320, rect.width || 960);
684
+ const cssHeight = Math.max(280, rect.height || 560);
685
+ canvas.width = Math.round(cssWidth * ratio);
686
+ canvas.height = Math.round(cssHeight * ratio);
687
+
688
+ let view = fitView(points, cssWidth, cssHeight);
689
+ const theme = { current: graphTheme(shell) };
690
+ let focusNeighbors: ReadonlySet<number> = new Set();
691
+ const neighborsOf = (index: number): Set<number> => {
692
+ const neighbors = new Set<number>();
693
+ for (const edge of drawn) {
694
+ if (edge.s === index) neighbors.add(edge.t);
695
+ if (edge.t === index) neighbors.add(edge.s);
696
+ }
697
+ return neighbors;
698
+ };
699
+
700
+ const draw = (): void => {
701
+ context.setTransform(1, 0, 0, 1, 0, 0);
702
+ context.clearRect(0, 0, canvas.width, canvas.height);
703
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
704
+ context.fillStyle = theme.current.bg;
705
+ context.fillRect(0, 0, cssWidth, cssHeight);
706
+ drawGraph(context, ratio, theme.current, view, points, titles, degrees, drawn, focusNeighbors);
707
+ };
708
+
709
+ let scheduled = false;
710
+ const redraw = (): void => {
711
+ if (dom.requestAnimationFrame === undefined) {
712
+ draw();
713
+ return;
714
+ }
715
+ if (scheduled) return;
716
+ scheduled = true;
717
+ dom.requestAnimationFrame(() => {
718
+ scheduled = false;
719
+ draw();
720
+ });
721
+ };
722
+
723
+ const nodeAt = (clientX: number, clientY: number): number => {
724
+ const bounds = canvas.getBoundingClientRect();
725
+ const px = clientX - bounds.left;
726
+ const py = clientY - bounds.top;
727
+ let best = -1;
728
+ let bestDistance = 14;
729
+ for (const [index, point] of points.entries()) {
730
+ const sx = point.x * view.scale + view.tx;
731
+ const sy = point.y * view.scale + view.ty;
732
+ const distance = Math.sqrt((sx - px) * (sx - px) + (sy - py) * (sy - py));
733
+ if (distance < bestDistance) {
734
+ bestDistance = distance;
735
+ best = index;
736
+ }
737
+ }
738
+ return best;
739
+ };
740
+
741
+ const focusSlug = (): void => {
742
+ const hash = dom.location.hash ?? "";
743
+ const match = /^#n=(.+)$/u.exec(hash);
744
+ if (match === null) return;
745
+ let slug = match[1] ?? "";
746
+ try {
747
+ slug = decodeURIComponent(slug);
748
+ } catch {
749
+ return;
750
+ }
751
+ const index = slugs.indexOf(slug);
752
+ if (index === -1) return;
753
+ view.focus = index;
754
+ focusNeighbors = neighborsOf(index);
755
+ const point = points[index];
756
+ if (point !== undefined) {
757
+ view.tx = cssWidth / 2 - point.x * view.scale;
758
+ view.ty = cssHeight / 2 - point.y * view.scale;
759
+ }
760
+ redraw();
761
+ };
762
+
763
+ let dragFrom: { x: number; y: number } | undefined;
764
+ let dragged = false;
765
+
766
+ canvas.addEventListener("pointerdown", (event) => {
767
+ dragFrom = { x: event.clientX ?? 0, y: event.clientY ?? 0 };
768
+ dragged = false;
769
+ canvas.style.cursor = "grabbing";
770
+ });
771
+ canvas.addEventListener("pointermove", (event) => {
772
+ const clientX = event.clientX ?? 0;
773
+ const clientY = event.clientY ?? 0;
774
+ if (dragFrom !== undefined) {
775
+ const deltaX = clientX - dragFrom.x;
776
+ const deltaY = clientY - dragFrom.y;
777
+ if (Math.abs(deltaX) + Math.abs(deltaY) > 4) dragged = true;
778
+ if (dragged) {
779
+ view.tx += deltaX;
780
+ view.ty += deltaY;
781
+ dragFrom = { x: clientX, y: clientY };
782
+ redraw();
783
+ }
784
+ return;
785
+ }
786
+ const hit = nodeAt(clientX, clientY);
787
+ if (hit !== view.hover) {
788
+ view.hover = hit;
789
+ canvas.style.cursor = hit >= 0 ? "pointer" : "grab";
790
+ redraw();
791
+ }
792
+ });
793
+ canvas.addEventListener("pointerup", (event) => {
794
+ canvas.style.cursor = "grab";
795
+ const wasDrag = dragged;
796
+ dragFrom = undefined;
797
+ dragged = false;
798
+ if (wasDrag) return;
799
+ const hit = nodeAt(event.clientX ?? 0, event.clientY ?? 0);
800
+ if (hit >= 0) dom.location.assign(hrefFor(hit));
801
+ });
802
+ canvas.addEventListener("pointerleave", () => {
803
+ dragFrom = undefined;
804
+ dragged = false;
805
+ if (view.hover !== -1) {
806
+ view.hover = -1;
807
+ redraw();
808
+ }
809
+ });
810
+ canvas.addEventListener("wheel", (event) => {
811
+ event.preventDefault();
812
+ const bounds = canvas.getBoundingClientRect();
813
+ const px = (event.clientX ?? 0) - bounds.left;
814
+ const py = (event.clientY ?? 0) - bounds.top;
815
+ const factor = Math.exp(-(event.deltaY ?? 0) * 0.0015);
816
+ const next = Math.min(GRAPH_MAX_SCALE, Math.max(GRAPH_MIN_SCALE, view.scale * factor));
817
+ const applied = next / view.scale;
818
+ view.tx = px - (px - view.tx) * applied;
819
+ view.ty = py - (py - view.ty) * applied;
820
+ view.scale = next;
821
+ redraw();
822
+ });
823
+ canvas.addEventListener("keydown", (event) => {
824
+ const panStep = 48;
825
+ if (event.key === "ArrowLeft") view.tx += panStep;
826
+ else if (event.key === "ArrowRight") view.tx -= panStep;
827
+ else if (event.key === "ArrowUp") view.ty += panStep;
828
+ else if (event.key === "ArrowDown") view.ty -= panStep;
829
+ else if (event.key === "+" || event.key === "=") {
830
+ view.scale = Math.min(GRAPH_MAX_SCALE, view.scale * 1.25);
831
+ } else if (event.key === "-" || event.key === "_") {
832
+ view.scale = Math.max(GRAPH_MIN_SCALE, view.scale / 1.25);
833
+ } else if (event.key === "0" || event.key === "Escape") {
834
+ view = fitView(points, cssWidth, cssHeight);
835
+ } else if (event.key === "Enter") {
836
+ const target = view.hover >= 0 ? view.hover : view.focus;
837
+ if (target < 0) return;
838
+ dom.location.assign(hrefFor(target));
839
+ } else {
840
+ return;
841
+ }
842
+ event.preventDefault();
843
+ redraw();
844
+ });
845
+ reset?.addEventListener("click", () => {
846
+ view = fitView(points, cssWidth, cssHeight);
847
+ redraw();
848
+ });
849
+ const repaintTheme = (): void => {
850
+ theme.current = graphTheme(shell);
851
+ redraw();
852
+ };
853
+ wordcellAppearanceBridge()?.subscribe(repaintTheme);
854
+ dom.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", repaintTheme);
855
+
856
+ if (status !== null) status.hidden = true;
857
+ dom.addEventListener?.("hashchange", focusSlug);
858
+ focusSlug();
859
+ draw();
860
+ })().catch(() => {
861
+ if (status !== null) {
862
+ status.hidden = false;
863
+ status.textContent = "The graph data could not be loaded; every note is listed below.";
864
+ }
865
+ });
866
+ }
867
+
868
+ /**
869
+ * The appearance control is injected by the reader so published pages carry
870
+ * no dead control without JavaScript. It is a single icon button appended
871
+ * as the final header action; its menu holds real radio inputs for keyboard
872
+ * and screen-reader parity, and it drives the synchronous `theme.js`
873
+ * bridge so a selection repaints instantly and persists under the shared
874
+ * `hraness-design-palette-v1` key.
875
+ */
876
+ function initAppearance(): void {
877
+ const bridge = wordcellAppearanceBridge();
878
+ const actions = dom.document.querySelector(".site-actions");
879
+ if (bridge === undefined || actions === null) return;
880
+
881
+ const wrap = dom.document.createElement("div");
882
+ wrap.className = "appearance";
883
+
884
+ const trigger = dom.document.createElement("button");
885
+ trigger.type = "button";
886
+ trigger.className = "appearance-trigger";
887
+ trigger.setAttribute("aria-haspopup", "dialog");
888
+ trigger.setAttribute("aria-expanded", "false");
889
+ trigger.setAttribute("aria-label", "Appearance");
890
+ trigger.innerHTML = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" aria-hidden=\"true\">"
891
+ + "<circle cx=\"8\" cy=\"8\" r=\"6.6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"/>"
892
+ + "<path d=\"M8 1.4a6.6 6.6 0 0 1 0 13.2z\" fill=\"currentColor\"/></svg>";
893
+
894
+ const menu = dom.document.createElement("div");
895
+ menu.className = "appearance-menu";
896
+ menu.hidden = true;
897
+ menu.setAttribute("role", "dialog");
898
+ menu.setAttribute("aria-label", "Appearance");
899
+
900
+ const paletteInputs = new Map<string, ReaderElement>();
901
+ const modeInputs = new Map<string, ReaderElement>();
902
+
903
+ const modeGroup = dom.document.createElement("div");
904
+ modeGroup.className = "appearance-group";
905
+ const modeLabel = dom.document.createElement("div");
906
+ modeLabel.className = "appearance-label";
907
+ modeLabel.textContent = "Appearance";
908
+ modeGroup.appendChild(modeLabel);
909
+ for (const mode of wordcellAppearanceModes) {
910
+ const label = dom.document.createElement("label");
911
+ label.className = "appearance-option";
912
+ const input = dom.document.createElement("input");
913
+ input.type = "radio";
914
+ input.name = "wordcell-appearance-mode";
915
+ input.value = mode;
916
+ input.addEventListener("change", () => {
917
+ bridge.set({ palette: bridge.get().palette, mode });
918
+ });
919
+ modeInputs.set(mode, input);
920
+ const text = dom.document.createElement("span");
921
+ text.textContent = wordcellAppearanceModeLabels[mode];
922
+ label.appendChild(input);
923
+ label.appendChild(text);
924
+ modeGroup.appendChild(label);
925
+ }
926
+
927
+ const paletteGroup = dom.document.createElement("div");
928
+ paletteGroup.className = "appearance-group";
929
+ const paletteLabel = dom.document.createElement("div");
930
+ paletteLabel.className = "appearance-label";
931
+ paletteLabel.textContent = "Palette";
932
+ paletteGroup.appendChild(paletteLabel);
933
+ for (const palette of wordcellPalettes) {
934
+ const label = dom.document.createElement("label");
935
+ label.className = "appearance-option";
936
+ const input = dom.document.createElement("input");
937
+ input.type = "radio";
938
+ input.name = "wordcell-appearance-palette";
939
+ input.value = palette;
940
+ input.addEventListener("change", () => {
941
+ bridge.set({ palette, mode: bridge.get().mode });
942
+ });
943
+ paletteInputs.set(palette, input);
944
+ const swatch = dom.document.createElement("span");
945
+ swatch.className = `swatch swatch-${palette}`;
946
+ const text = dom.document.createElement("span");
947
+ text.textContent = wordcellPaletteLabels[palette];
948
+ label.appendChild(input);
949
+ label.appendChild(swatch);
950
+ label.appendChild(text);
951
+ paletteGroup.appendChild(label);
952
+ }
953
+
954
+ menu.appendChild(modeGroup);
955
+ menu.appendChild(paletteGroup);
956
+ wrap.appendChild(trigger);
957
+ wrap.appendChild(menu);
958
+ actions.appendChild(wrap);
959
+
960
+ const sync = (): void => {
961
+ const preference = bridge.get();
962
+ for (const [mode, input] of modeInputs) input.checked = mode === preference.mode;
963
+ for (const [palette, input] of paletteInputs) input.checked = palette === preference.palette;
964
+ };
965
+
966
+ const close = (): void => {
967
+ menu.hidden = true;
968
+ trigger.setAttribute("aria-expanded", "false");
969
+ };
970
+ const open = (): void => {
971
+ sync();
972
+ menu.hidden = false;
973
+ trigger.setAttribute("aria-expanded", "true");
974
+ paletteInputs.get(bridge.get().palette)?.focus();
975
+ };
976
+ trigger.addEventListener("click", () => {
977
+ if (menu.hidden) open();
978
+ else close();
979
+ });
980
+ dom.document.addEventListener("keydown", (event) => {
981
+ if (event.key === "Escape" && !menu.hidden) {
982
+ close();
983
+ trigger.focus();
984
+ }
985
+ });
986
+ dom.document.addEventListener("click", (event) => {
987
+ if (!menu.hidden && event.target?.closest?.(".appearance") == null) close();
988
+ });
989
+ bridge.subscribe(sync);
990
+ sync();
991
+ }
992
+
993
+ function start(): void {
994
+ const content = dom.document
995
+ .querySelector('meta[name="wordcell:base"]')
996
+ ?.getAttribute("content") ?? "./";
997
+ const base = content.endsWith("/") ? content : `${content}/`;
998
+ initAppearance();
999
+ const overlay = buildOverlay(base);
1000
+ for (const button of Array.from(
1001
+ dom.document.querySelectorAll("[data-wordcell-search]"),
1002
+ )) {
1003
+ button.addEventListener("click", () => overlay.open());
1004
+ }
1005
+ dom.document.addEventListener("keydown", (event) => {
1006
+ if (event.key === "/" && !/INPUT|TEXTAREA|SELECT/u.test(event.target?.tagName ?? "")) {
1007
+ event.preventDefault();
1008
+ overlay.open();
1009
+ }
1010
+ if (event.key === "Escape") overlay.close();
1011
+ });
1012
+ initGraph(base);
1013
+ }
1014
+
1015
+ if (dom.document.querySelector("[data-wordcell-search]") !== null) {
1016
+ start();
1017
+ }