@adia-ai/a2ui-compose 0.7.19 → 0.7.20

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog — @adia-ai/a2ui-compose
2
2
 
3
+ ## [0.7.20] — 2026-06-10
4
+
5
+ ### Fixed
6
+ - **Corpus retrieval works on STATICALLY-served sites (no vite).** The browser corpus loaders (`strategies/zettel/composition-library.js`, `strategies/_shared/chunk-loader.js`) used `import.meta.glob` — a vite-only mechanism — so the corpus-first path silently loaded 0 chunks on the deployed static site and every intent fell through to the LLM. New **third runtime branch** (Node / vite-glob / **static-fetch**): one `_index.json` request carries the full scoring metadata; matched chunk bodies hydrate lazily. Detection = attempting the glob *call* in try/catch (`typeof import.meta.glob` is undefined in BOTH vite-dev and raw ESM — only the rewritten call discriminates). A parity guard in `generator-adapter` keeps static results identical to Node (template-less partials fall through instead of emitting empty surfaces). Verified: a plain static server with **no LLM** retrieves `contact-form-card` verbatim (4 labeled fields, 92/100, ~8 ms).
7
+
8
+ ### Added
9
+ - **Anti-pattern fold-in for generated output** — `foldInAntiPatterns(messages, validation)` projects the flat component messages into an HTML sketch (via the runtime tag registry), runs the anti-pattern registry over it, and folds violations into the validation result (one warning each + a 10-point score dock). Wired into all monolithic generate paths (instant / thinking / pro — 5 call sites), so the canvas's validation badge now reflects anti-patterns: the incident's junk contact form scores 65 with 3 named violations instead of badging clean. Fails open — the fold-in can never break generation.
10
+
3
11
  ## [0.7.19] — 2026-06-10
4
12
 
5
13
  ### Maintenance
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.7.19",
3
+ "version": "0.7.20",
4
4
  "description": "AdiaUI A2UI compose engine \u2014 framework-agnostic. Takes natural-language intents + a catalog and produces A2UI protocol messages. Pairs with `@adia-ai/a2ui-retrieval` (intent classification, catalog lookup) and `@adia-ai/a2ui-validator` (schema + semantic checks).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,7 +28,13 @@ let _state = null; // { chunks: Map<name, record>, byKind: Map<kind, Set<n
28
28
  let _loadPromise = null;
29
29
 
30
30
  // ── Browser glob ───────────────────────────────────────────────────
31
- // Vite resolves this at build time; in Node the variable is unused.
31
+ // Vite rewrites this CALL at transform time. On the static deploy (raw
32
+ // source, no Vite) the call hits the real `import.meta.glob` — which is
33
+ // undefined at runtime — and throws → caught → `_globModules` null → fetch
34
+ // branch. `import.meta.glob` is a compile-time macro, NOT a runtime
35
+ // function, so `typeof import.meta.glob` is undefined in BOTH Vite and
36
+ // static — the only reliable discriminator is whether this CALL yielded
37
+ // loaders. See composition-library.js for the full tri-mode rationale.
32
38
  let _globModules = null;
33
39
  if (!IS_NODE) {
34
40
  try {
@@ -37,9 +43,18 @@ if (!IS_NODE) {
37
43
  import: 'default',
38
44
  });
39
45
  } catch {
40
- // Not in a Vite context — fall back to empty corpus
46
+ // No Vite transform here (static deploy) — fall back to the fetch branch.
41
47
  }
42
48
  }
49
+ const HAS_VITE_GLOB =
50
+ !IS_NODE && !!_globModules && Object.keys(_globModules).length > 0;
51
+
52
+ // Relative-to-module corpus base for the static branch (same path the glob
53
+ // trusts; mount-point-independent). Verified 2026-06-10.
54
+ const _CORPUS_CHUNKS_BASE =
55
+ !IS_NODE && !HAS_VITE_GLOB
56
+ ? new URL('../../../corpus/chunks/', import.meta.url)
57
+ : null;
43
58
 
44
59
  // ── Public API ─────────────────────────────────────────────────────
45
60
  async function _load() {
@@ -48,14 +63,55 @@ async function _load() {
48
63
  _loadPromise = (async () => {
49
64
  if (IS_NODE) {
50
65
  _state = await _loadNode();
51
- } else {
66
+ } else if (HAS_VITE_GLOB) {
52
67
  _state = await _loadBrowser();
68
+ } else {
69
+ _state = await _loadStatic();
53
70
  }
54
71
  return _state;
55
72
  })();
56
73
  return _loadPromise;
57
74
  }
58
75
 
76
+ /**
77
+ * Static-browser loader — fetch `_index.json` for keyword scoring. The
78
+ * index carries name/kind/primary/nested/pages (everything `searchChunks`
79
+ * ranks on); chunk BODIES (html / template) are fetched lazily by
80
+ * `getChunk` so page-load is one request, not 339. Degrades to an empty
81
+ * corpus (not a throw) on fetch / parse failure.
82
+ */
83
+ async function _loadStatic() {
84
+ const chunks = new Map();
85
+ const byKind = new Map();
86
+ if (!_CORPUS_CHUNKS_BASE) return { chunks, byKind };
87
+ let index;
88
+ try {
89
+ const res = await fetch(new URL('_index.json', _CORPUS_CHUNKS_BASE));
90
+ if (!res.ok) return { chunks, byKind };
91
+ index = await res.json();
92
+ } catch {
93
+ return { chunks, byKind };
94
+ }
95
+ const entries = Array.isArray(index?.chunks) ? index.chunks : [];
96
+ for (const entry of entries) {
97
+ if (!entry?.name) continue;
98
+ // Normalize the index entry to the per-chunk record shape `searchChunks`
99
+ // expects: `pages` (array) → `source`/`page` (string). `_bodyUrl` +
100
+ // `_hydrated` mark the record for lazy body-fetch in getChunk.
101
+ const rec = {
102
+ ...entry,
103
+ source: Array.isArray(entry.pages) ? entry.pages[0] : (entry.source || entry.page || ''),
104
+ _bodyUrl: new URL(`${entry.name}.json`, _CORPUS_CHUNKS_BASE).href,
105
+ _hydrated: false,
106
+ };
107
+ chunks.set(rec.name, rec);
108
+ const kind = rec.kind || 'block';
109
+ if (!byKind.has(kind)) byKind.set(kind, new Set());
110
+ byKind.get(kind).add(rec.name);
111
+ }
112
+ return { chunks, byKind };
113
+ }
114
+
59
115
  async function _loadNode() {
60
116
  const fs = await import(/* @vite-ignore */ 'node:fs/promises');
61
117
  const path = await import(/* @vite-ignore */ 'node:path');
@@ -174,7 +230,24 @@ export async function searchChunks(query, { kind, limit = 20 } = {}) {
174
230
  export async function getChunk(name) {
175
231
  if (!name) return null;
176
232
  const { chunks } = await _load();
177
- return chunks.get(name) || null;
233
+ const rec = chunks.get(name) || null;
234
+ if (!rec) return null;
235
+ // Static-browser: the record came from `_index.json` (scoring fields only).
236
+ // Fetch + merge the full body (html / template / slots / attrs) on first
237
+ // access, then cache. No-op in Node / Vite (full record already loaded).
238
+ if (rec._bodyUrl && !rec._hydrated) {
239
+ try {
240
+ const res = await fetch(rec._bodyUrl);
241
+ if (res.ok) {
242
+ const body = await res.json();
243
+ Object.assign(rec, body);
244
+ }
245
+ } catch {
246
+ // Leave the scoring-only record; consumers must tolerate a missing body.
247
+ }
248
+ rec._hydrated = true; // don't retry on every access, even if the fetch failed
249
+ }
250
+ return rec;
178
251
  }
179
252
 
180
253
  /**
@@ -8,6 +8,8 @@
8
8
  */
9
9
 
10
10
  import { listPatterns } from '../../core/reference.js';
11
+ import { resolveTag } from '../../../runtime/registry.js';
12
+ import { checkAllAntiPatterns } from '../../../retrieval/anti-patterns.js';
11
13
  import { store } from '../../core/state.js';
12
14
  import { checkIntentAlignment } from '../../../retrieval/intent/intent-alignment.js';
13
15
  import { composeSubtasks } from '../../../retrieval/intent/decomposer.js';
@@ -1560,3 +1562,58 @@ function prettyComponentName(name) {
1560
1562
  const article = /^[aeiou]/i.test(spaced) ? 'an' : 'a';
1561
1563
  return `${article} ${spaced}`;
1562
1564
  }
1565
+
1566
+ /**
1567
+ * Anti-pattern fold-in for generated output (the gen-UI contact-form
1568
+ * incident, 2026-06-10): validateSchema scores structure but never ran the
1569
+ * anti-pattern registry, so a generated form with a text-ui "label" inside
1570
+ * field-ui, display="none" attributes, and input-ui[type=textarea] badged
1571
+ * a clean score in the canvas.
1572
+ *
1573
+ * Projects the flat component messages into a light HTML sketch (open tags
1574
+ * + attrs, tree-ordered via children id refs) — enough surface for the
1575
+ * regex/function checks in retrieval/anti-patterns.js — runs
1576
+ * checkAllAntiPatterns over it, then folds violations into the validation
1577
+ * object: one warning per violation and a 10-point score dock each
1578
+ * (floored at 0). Mutates + returns `validation`.
1579
+ *
1580
+ * @param {object[]} messages — A2UI messages (flat adjacency components)
1581
+ * @param {{score: number, warnings?: string[]}} validation — from validateSchema
1582
+ * @returns {typeof validation}
1583
+ */
1584
+ export function foldInAntiPatterns(messages, validation) {
1585
+ try {
1586
+ const components = (messages || []).flatMap((m) => m.components || []);
1587
+ if (!components.length || !validation) return validation;
1588
+ const byId = new Map(components.map((c) => [c.id, c]));
1589
+ const referenced = new Set(components.flatMap((c) => c.children || []));
1590
+ const roots = components.filter((c) => !referenced.has(c.id));
1591
+ const sketch = [];
1592
+ const emit = (c, depth) => {
1593
+ if (!c || depth > 12) return;
1594
+ const tag = resolveTag(c.type) || String(c.type || '').toLowerCase();
1595
+ const attrs = Object.entries(c.props || {})
1596
+ .filter(([, v]) => v !== false && v != null && typeof v !== 'object')
1597
+ .map(([k, v]) => ` ${k}="${String(v).replace(/"/g, '&quot;')}"`)
1598
+ .join('');
1599
+ sketch.push(`<${tag}${attrs}>`);
1600
+ if (typeof (c.props || {}).textContent === 'string') sketch.push((c.props || {}).textContent);
1601
+ for (const id of c.children || []) emit(byId.get(id), depth + 1);
1602
+ sketch.push(`</${tag}>`);
1603
+ };
1604
+ for (const r of roots) emit(r, 0);
1605
+ const violations = checkAllAntiPatterns(sketch.join('\n'));
1606
+ if (violations.length) {
1607
+ validation.warnings = [
1608
+ ...(validation.warnings || []),
1609
+ ...violations.map((v) => `anti-pattern ${v.name}: ${v.description} Fix: ${v.fix}`),
1610
+ ];
1611
+ validation.antiPatterns = violations.map((v) => v.name);
1612
+ validation.score = Math.max(0, (validation.score || 0) - 10 * violations.length);
1613
+ }
1614
+ return validation;
1615
+ } catch {
1616
+ // The fold-in must never break generation — fail open.
1617
+ return validation;
1618
+ }
1619
+ }
@@ -13,8 +13,7 @@ import { feedbackStore } from '../../../retrieval/feedback/feedback-store.js';
13
13
  import { store, engine } from '../../core/state.js';
14
14
  import { isRecording } from '../../../retrieval/feedback/dialog-recorder.js';
15
15
  import {
16
- generateSuggestions,
17
- } from './_shared.js';
16
+ generateSuggestions, foldInAntiPatterns } from './_shared.js';
18
17
 
19
18
  export async function generateInstant({ intent, executionId, storeId, analysis, priorComponentsFromPayload, context }) {
20
19
  // Forward-compat: instant mode is pattern-match-only and doesn't iterate
@@ -168,7 +167,7 @@ export async function generateInstant({ intent, executionId, storeId, analysis,
168
167
  });
169
168
 
170
169
  // ── Validate stage ──
171
- const validation = validateSchema(messages, { intent, context });
170
+ const validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
172
171
  engine.submitStage(execId, 'validate', {
173
172
  ...validation,
174
173
  confidence: validation.score / 100,
@@ -22,8 +22,7 @@ import {
22
22
  buildCanvasDiffPrompt,
23
23
  parseA2UIResponse,
24
24
  buildRepairPrompt,
25
- generateSuggestions,
26
- } from './_shared.js';
25
+ generateSuggestions, foldInAntiPatterns } from './_shared.js';
27
26
 
28
27
  export async function generatePro({ intent, executionId, storeId, llmAdapter, analysis, priorComponentsFromPayload, context }) {
29
28
  const execId = executionId;
@@ -404,7 +403,7 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
404
403
  });
405
404
 
406
405
  // ── Stage 5: Validate + repair ──
407
- let validation = validateSchema(messages, { intent, context });
406
+ let validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
408
407
  if (!validation.valid) {
409
408
  try {
410
409
  const failedChecks = validation.checks.filter(c => !c.passed);
@@ -413,7 +412,7 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
413
412
  systemPrompt,
414
413
  });
415
414
  messages = parseA2UIResponse(repairResponse.content, { executionId: execId, intent, mode: 'pro', stopReason: repairResponse.stopReason });
416
- validation = validateSchema(messages, { intent, context });
415
+ validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
417
416
  } catch { /* keep original */ }
418
417
  }
419
418
  engine.submitStage(execId, 'validate', { ...validation, confidence: validation.score / 100 });
@@ -18,8 +18,7 @@ import {
18
18
  buildChatMessages,
19
19
  parseA2UIResponse,
20
20
  buildRepairPrompt,
21
- generateSuggestions,
22
- } from './_shared.js';
21
+ generateSuggestions, foldInAntiPatterns } from './_shared.js';
23
22
 
24
23
  export async function generateThinking({ intent, executionId, storeId, llmAdapter, analysis, priorComponentsFromPayload, context }) {
25
24
  // Note: thinking mode currently composes from scratch each turn. The
@@ -129,7 +128,7 @@ export async function generateThinking({ intent, executionId, storeId, llmAdapte
129
128
  // Either failing triggers a repair attempt. Catalog failures are typically
130
129
  // small (unknown prop, wrong enum, missing required id) and fix cleanly in
131
130
  // one repair round. Three-attempt cap preserved from the original loop.
132
- let validation = validateSchema(messages, { intent, context });
131
+ let validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
133
132
  let catalogValidation = await validateCatalog(messages);
134
133
  let attempts = 0;
135
134
 
@@ -146,7 +145,7 @@ export async function generateThinking({ intent, executionId, storeId, llmAdapte
146
145
  });
147
146
  messages = parseA2UIResponse(repairResponse.content, { executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason });
148
147
  if (isRecording()) { lastRawResponse = repairResponse.content; lastTokens = repairResponse.usage || lastTokens; }
149
- validation = validateSchema(messages, { intent, context });
148
+ validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
150
149
  catalogValidation = await validateCatalog(messages);
151
150
  } catch {
152
151
  break; // stop repair loop on adapter error
@@ -28,6 +28,31 @@
28
28
  // `import 'node:fs'` poisoned the browser bundle the moment any
29
29
  // app/playground reached `core/reference.js` → composition-library.
30
30
  // Pattern mirrors `retrieval/component-catalog.js`.
31
+ //
32
+ // 2026-06-10: TRI-mode loader. The original dual-mode pattern assumed
33
+ // every browser context is a Vite dev server. The STATIC deploy
34
+ // (ui-kit.exe.xyz) serves the raw source JS — there is no Vite to transform
35
+ // the glob — so the glob `try/catch` swallowed silently and loaded 0 chunks.
36
+ // Every canvas intent then "composition-retrieval missed" on prod even
37
+ // though the chunk scored 66 in Node. Third branch: static-browser → fetch
38
+ // the corpus over HTTP.
39
+ // - Node (fs) — server / eval / smoke
40
+ // - Vite browser (glob) — `npm run dev`
41
+ // - Static browser (fetch)— deployed dist (no Vite, no /api/generate)
42
+ //
43
+ // CRUX — how we tell Vite-browser from static-browser (2026-06-10):
44
+ // `import.meta.glob` is a Vite **compile-time macro on the CALL expression**,
45
+ // NOT a runtime function. Vite rewrites `import.meta.glob('…')` into an
46
+ // object literal at transform time, but a bare reference like
47
+ // `typeof import.meta.glob` is left untouched — so at RUNTIME
48
+ // `import.meta.glob` is `undefined` in BOTH Vite-dev and static contexts
49
+ // (verified empirically 2026-06-10). The only reliable discriminator is to
50
+ // ATTEMPT the call in a try/catch: in Vite it was already rewritten to the
51
+ // object literal (succeeds, returns N loaders); on the raw static site the
52
+ // call hits the real `import.meta.glob` which is undefined → throws
53
+ // `import.meta.glob is not a function` → caught → we take the fetch branch.
54
+ // (This is parse-safe: `import.meta` is a real object in every ES module;
55
+ // the throw is a runtime TypeError, not a SyntaxError.)
31
56
  const IS_NODE =
32
57
  typeof process !== 'undefined' &&
33
58
  typeof process.versions?.node === 'string';
@@ -35,7 +60,8 @@ const IS_NODE =
35
60
  /** @type {Map<string, object>} */
36
61
  const compositions = new Map();
37
62
 
38
- // Vite resolves this at build time; at runtime in Node the variable is unused.
63
+ // Vite rewrites this call at transform time; on the static site the call
64
+ // throws (no Vite) → `_globChunkModules` stays null → fetch branch runs.
39
65
  let _globChunkModules = null;
40
66
  if (!IS_NODE) {
41
67
  try {
@@ -45,20 +71,27 @@ if (!IS_NODE) {
45
71
  eager: false,
46
72
  });
47
73
  } catch {
48
- // Not in a Vite contextno chunk data in this realm.
74
+ // No Vite transform here (static deploy) fall through to the fetch branch.
49
75
  }
50
76
  }
77
+ // `HAS_VITE_GLOB` is now decided by whether the glob CALL produced loaders,
78
+ // not by an (always-undefined-at-runtime) `typeof` probe.
79
+ const HAS_VITE_GLOB =
80
+ !IS_NODE && !!_globChunkModules && Object.keys(_globChunkModules).length > 0;
51
81
 
52
82
  /**
53
83
  * Promote an annotated chunk record to composition shape. Lifts
54
84
  * `metadata.{domain,description,keywords,related,tags}` to top-level
55
85
  * fields so the existing searchAll scorer can read them uniformly.
56
86
  */
57
- function chunkToComposition(chunkDoc, sourcePath) {
87
+ function chunkToComposition(chunkDoc, sourcePath, { bodyUrl = null } = {}) {
58
88
  const meta = chunkDoc.metadata || {};
59
89
  return {
60
90
  _kind: 'annotated-chunk',
61
91
  _sourceFile: sourcePath,
92
+ // Static-browser only: URL to fetch the chunk body (template) on demand.
93
+ // null in Node / Vite, where `template` is already populated eagerly.
94
+ _bodyUrl: bodyUrl,
62
95
  name: chunkDoc.name,
63
96
  kind: 'composition', // honest with the searchAll filter
64
97
  domain: meta.domain,
@@ -66,7 +99,7 @@ function chunkToComposition(chunkDoc, sourcePath) {
66
99
  keywords: meta.keywords || [],
67
100
  related: meta.related || [],
68
101
  tags: meta.tags || {},
69
- template: chunkDoc.template,
102
+ template: chunkDoc.template ?? null,
70
103
  };
71
104
  }
72
105
 
@@ -91,6 +124,28 @@ function registerChunk(doc, sourcePath) {
91
124
  return 1;
92
125
  }
93
126
 
127
+ /**
128
+ * Static-browser only: register a chunk from its `_index.json` entry for
129
+ * SCORING (name + metadata.{domain,description,keywords} — verified at
130
+ * 2026-06-10 to be byte-identical to the per-chunk file's metadata). The
131
+ * `template` is NOT in the index; it is fetched lazily by
132
+ * `hydrateComposition(name)` when a strong match is actually emitted, so
133
+ * page-load costs one request (the 281 KB index) instead of 339.
134
+ *
135
+ * Index entries carry no `template`, so the eager `registerChunk` gate
136
+ * (which requires a non-empty template) would reject every one. We apply
137
+ * the SAME metadata gate (domain OR keywords present) but defer the
138
+ * template requirement to hydration time.
139
+ */
140
+ function registerIndexEntry(entry, bodyUrl) {
141
+ if (!entry?.metadata || !entry.name) return 0;
142
+ const meta = entry.metadata;
143
+ if (!meta.domain && !(meta.keywords && meta.keywords.length > 0)) return 0;
144
+ // template: null here — hydrated on demand. searchAll never reads it.
145
+ compositions.set(entry.name, chunkToComposition(entry, entry.name, { bodyUrl }));
146
+ return 1;
147
+ }
148
+
94
149
  async function _loadAllNode() {
95
150
  const fs = await import(/* @vite-ignore */ 'node:fs');
96
151
  const path = await import(/* @vite-ignore */ 'node:path');
@@ -141,14 +196,61 @@ async function _loadAllBrowser() {
141
196
  return annotatedChunkCount;
142
197
  }
143
198
 
199
+ // Base URL for the corpus on a static deploy. Relative-to-module
200
+ // (`import.meta.url`) — NOT an absolute `/packages/...` path — deliberately:
201
+ // 1. It is the SAME path the Vite glob trusts ('../../../corpus/chunks/*'),
202
+ // so dev and prod resolve through one mental model.
203
+ // 2. It is mount-point-independent: works whether dist is served at `/`,
204
+ // under a sub-path, or via the dist symlink layout
205
+ // (dist/node_modules/@adia-ai/a2ui-compose → packages/a2ui/compose),
206
+ // because `import.meta.url` always reflects the actually-served path.
207
+ // Verified 2026-06-10: `new URL('../../../corpus/chunks/_index.json',
208
+ // import.meta.url)` → `<root>/packages/a2ui/corpus/chunks/_index.json`.
209
+ const _CORPUS_CHUNKS_BASE =
210
+ !IS_NODE && !HAS_VITE_GLOB
211
+ ? new URL('../../../corpus/chunks/', import.meta.url)
212
+ : null;
213
+
214
+ /**
215
+ * Static-browser loader. Fetches `_index.json` ONCE and registers every
216
+ * chunk for scoring. Bodies (templates) are fetched lazily by
217
+ * `hydrateComposition` so page-load stays at a single request, not 339.
218
+ *
219
+ * Degrades to 0 chunks (not a throw) on any fetch / parse failure — same
220
+ * graceful-empty contract as the Node and Vite branches.
221
+ */
222
+ async function _loadAllStatic() {
223
+ if (!_CORPUS_CHUNKS_BASE) return 0;
224
+ let index;
225
+ try {
226
+ const res = await fetch(new URL('_index.json', _CORPUS_CHUNKS_BASE));
227
+ if (!res.ok) return 0;
228
+ index = await res.json();
229
+ } catch {
230
+ return 0;
231
+ }
232
+ const entries = Array.isArray(index?.chunks) ? index.chunks : [];
233
+ let count = 0;
234
+ for (const entry of entries) {
235
+ if (!entry?.name) continue;
236
+ const bodyUrl = new URL(`${entry.name}.json`, _CORPUS_CHUNKS_BASE).href;
237
+ count += registerIndexEntry(entry, bodyUrl);
238
+ }
239
+ return count;
240
+ }
241
+
144
242
  /**
145
243
  * Load all annotated chunks into the in-memory map. Returns a stats
146
- * snapshot. In Node, runs synchronously via `fs.readFileSync`; in the
147
- * browser, dispatches the Vite glob loaders in parallel.
244
+ * snapshot. In Node, runs synchronously via `fs.readFileSync`; in the Vite
245
+ * browser, dispatches the glob loaders in parallel; on a static deploy,
246
+ * fetches the index (bodies lazy-loaded on emit).
148
247
  */
149
248
  export async function loadAll() {
150
249
  compositions.clear();
151
- const annotatedChunkCount = IS_NODE ? await _loadAllNode() : await _loadAllBrowser();
250
+ let annotatedChunkCount;
251
+ if (IS_NODE) annotatedChunkCount = await _loadAllNode();
252
+ else if (HAS_VITE_GLOB) annotatedChunkCount = await _loadAllBrowser();
253
+ else annotatedChunkCount = await _loadAllStatic();
152
254
  _autoLoaded = true;
153
255
  _loadStats = {
154
256
  compositionCount: compositions.size,
@@ -158,6 +260,40 @@ export async function loadAll() {
158
260
  return _loadStats;
159
261
  }
160
262
 
263
+ /**
264
+ * Ensure a composition's `template` (body) is loaded before a consumer
265
+ * resolves it. No-op in Node / Vite (the eager load already populated
266
+ * `template`). On a static deploy, fetches `<name>.json` once, patches the
267
+ * in-memory record's `template`, and caches it for subsequent calls.
268
+ *
269
+ * The zettel adapter's STRONG-MATCH branch awaits this immediately before
270
+ * `getComposition(name)` so verbatim emission has a body. Index-only
271
+ * scoring + lazy single-body hydration is what keeps the static page-load
272
+ * to one request — `searchAll` itself never needs the template.
273
+ *
274
+ * @param {string} name
275
+ * @returns {Promise<object|undefined>} the (now-hydrated) composition record
276
+ */
277
+ export async function hydrateComposition(name) {
278
+ const rec = compositions.get(name);
279
+ if (!rec) return undefined;
280
+ // Already has a body (Node / Vite, or a prior hydration) — done.
281
+ if (Array.isArray(rec.template) && rec.template.length > 0) return rec;
282
+ if (!rec._bodyUrl) return rec; // not a static-deferred record; nothing to fetch
283
+ try {
284
+ const res = await fetch(rec._bodyUrl);
285
+ if (!res.ok) return rec;
286
+ const doc = await res.json();
287
+ if (Array.isArray(doc?.template) && doc.template.length > 0) {
288
+ rec.template = doc.template;
289
+ }
290
+ } catch {
291
+ // Leave template null — resolveComposition handles an empty template
292
+ // (emits nothing) rather than throwing.
293
+ }
294
+ return rec;
295
+ }
296
+
161
297
  // Eager top-level load so synchronous getters (getComposition,
162
298
  // getAllCompositions, searchAll) work for consumers that don't call
163
299
  // loadAll themselves — test harnesses, smoke scripts, and the
@@ -172,6 +308,10 @@ if (IS_NODE && !_autoLoaded) {
172
308
  await loadAll();
173
309
  } else if (!IS_NODE && !_autoLoaded) {
174
310
  // Fire-and-forget; do not block module import on the network round-trips.
311
+ // Covers BOTH browser branches (Vite glob + static fetch). Consumers that
312
+ // need the map populated before a synchronous searchAll MUST `await
313
+ // loadAll()` (or await the adapter's own boot). The canvas does: its
314
+ // generateViaZettel awaits a fresh loadAll on the static-browser path.
175
315
  loadAll().catch(() => { /* swallow; browsers that don't need this path won't trip */ });
176
316
  }
177
317
 
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import {
31
31
  getComposition,
32
+ hydrateComposition,
32
33
  searchAll,
33
34
  } from './composition-library.js';
34
35
  import { resolveComposition, templateToMessages } from './composer.js';
@@ -121,8 +122,25 @@ export async function generateZettel({ intent, mode = 'instant', llmAdapter = nu
121
122
 
122
123
  // ── Strong retrieval match: emit verbatim (turn 1 only — iteration branch above handles repeats) ──
123
124
  if (strongMatch) {
125
+ // On a static deploy the composition was registered from `_index.json`
126
+ // for SCORING only — its `template` is fetched lazily here, right before
127
+ // we resolve it. No-op in Node / Vite (template already populated). This
128
+ // keeps the static page-load to a single index request; only the
129
+ // actually-emitted match pays for its body. 2026-06-10.
130
+ await hydrateComposition(composition.name);
124
131
  const comp = getComposition(composition.name);
125
132
  const template = resolveComposition(comp);
133
+ // Parity guard: Node's `registerChunk` excludes template-less chunks, so
134
+ // they never reach this branch server-side. The static index-scoring gate
135
+ // is looser (it can't see templates in `_index.json`), so a handful of
136
+ // body-less partials (auth-card-header, onb-step-*, …) COULD surface as a
137
+ // strong match on a static deploy. If hydration produced no usable
138
+ // template, treat it as a non-match and fall through — keeping the
139
+ // static result identical to what Node would emit (synthesis / atoms),
140
+ // never an empty verbatim surface.
141
+ if (!Array.isArray(template) || template.length === 0) {
142
+ // fall through to the weak/no-match paths below
143
+ } else {
126
144
  const messages = toUpdateComponentsMessages(template);
127
145
  const validation = validateSchema(messages, { intent });
128
146
  const rank = hits.findIndex((h) => h.type === 'composition' && h.name === composition.name) + 1;
@@ -147,6 +165,7 @@ export async function generateZettel({ intent, mode = 'instant', llmAdapter = nu
147
165
  candidates: hits,
148
166
  sessionTurns: priorTurns.length + 1,
149
167
  };
168
+ } // end else (template present)
150
169
  }
151
170
 
152
171
  // ── Weak/no retrieval: bridge to chunk-zettel synthesis (the §37 successor) ──