@adia-ai/a2ui-compose 0.4.9 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,24 @@ generator graph.
12
12
 
13
13
  _No pending changes._
14
14
 
15
+ ## [0.5.0] - 2026-05-13
16
+
17
+ ### Added — Free-form composer auto-grouping (§103, v0.5.0)
18
+
19
+ Free-form composer (`strategies/free-form-composer/`) now accepts an optional `layout: "column" | "row" | "grid"` field on the LLM's plan, picking the root container instead of always wrapping in `Column`. System prompt teaches the schema; `transpile.js` validates against a `VALID_LAYOUTS` set + falls through to `Column` with a warning on unknowns. Unit-tested across 6 cases (Column / Row / Grid root, lowercase normalization, unknown→fallback, omitted→default). Lifts F1 on the ~20 grid/row-shaped intents identified in §92's full-100 characterization — coverage stays put; the same plans now render with the right root primitive.
20
+
21
+ Plan-schema change is non-recursive — only the ROOT container choice. Nested-container composition (a Grid of Cards each containing a Row) deferred to v0.5.x+.
22
+
23
+ ### Added — Free-form structural substitutions (§104, v0.5.0)
24
+
25
+ Extends `applyTextSubstitutions()` beyond `Text.textContent` to Button.text, Badge.text, Tag.text, Icon.name, Image.alt, Link.href, and Kbd.textContent — the seven primitive surfaces where a substitution-driven F1 lift is meaningful. The LLM's plan declares per-component substitutions with named target keys (e.g. `Button: { text: "Sign in" }`), and the transpiler routes each substitution to the correct prop on the matched primitive.
26
+
27
+ Schema validation rejects unknown target keys; downgrade warnings fire when a target-key doesn't match the matched ingredient's primitive (e.g. consumer asked for Icon.name but the matched ingredient is a Button — falls through to Button.text with a diagnostic so the eval harness can surface the miss).
28
+
29
+ ### Coverage at v0.5.0 cut
30
+
31
+ After §93 (layout regrowth) + §94 (forms regrowth) + §103 (auto-grouping) + §104 (structural substitutions + v0.5.1 deferred regrowth fold-in), free-form composer coverage measured at **92% on the 100-intent held-out set** (up from 21% at §92 baseline). New AGENTS.md regression threshold floor: `cov≥80%, avg≥85, F1≥0.45` (§97 rebaseline).
32
+
15
33
  ## [0.4.9] - 2026-05-13
16
34
 
17
35
  _No pending changes._
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "description": "AdiaUI A2UI compose engine — 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": {
@@ -34,9 +34,9 @@
34
34
  "directory": "packages/a2ui/compose"
35
35
  },
36
36
  "dependencies": {
37
- "@adia-ai/a2ui-runtime": "^0.4.0",
38
- "@adia-ai/a2ui-retrieval": "^0.4.0",
39
- "@adia-ai/a2ui-validator": "^0.4.0",
40
- "@adia-ai/llm": "^0.4.0"
37
+ "@adia-ai/a2ui-runtime": "^0.5.0",
38
+ "@adia-ai/a2ui-retrieval": "^0.5.0",
39
+ "@adia-ai/a2ui-validator": "^0.5.0",
40
+ "@adia-ai/llm": "^0.5.0"
41
41
  }
42
42
  }
@@ -80,15 +80,15 @@ describe('free-form-composer', () => {
80
80
  expect(ids.has('demo-signup__title')).toBe(true);
81
81
  });
82
82
 
83
- it('applies text substitutions on Text nodes; warns on non-Text or unknown keys', async () => {
83
+ it('applies text substitutions on Text nodes; routes Button.text via SUBSTITUTABLE_ATTRS; warns on unknown keys', async () => {
84
84
  const fakeLLM = {
85
85
  complete: async () => ({
86
86
  content: JSON.stringify({
87
87
  ingredients: [{
88
88
  name: 'demo-login',
89
89
  substitutions: {
90
- title: 'Welcome back', // OK title is Text
91
- submit: 'Sign in now', // skippedsubmit is Button (not Text)
90
+ title: 'Welcome back', // Text textContent
91
+ submit: 'Sign in now', // Button → text (v0.5.0 §105 was warned in v0.4.8)
92
92
  missing: 'foo', // skipped — no such id in template
93
93
  },
94
94
  }],
@@ -105,8 +105,7 @@ describe('free-form-composer', () => {
105
105
  const titleNode = result.messages[0].components.find(c => c.id === 'demo-login__title');
106
106
  expect(titleNode.textContent).toBe('Welcome back');
107
107
  const submitNode = result.messages[0].components.find(c => c.id === 'demo-login__submit');
108
- expect(submitNode.text).toBe('Continue'); // unchanged substitution skipped
109
- expect(result.warnings.some(w => w.includes('submit'))).toBe(true);
108
+ expect(submitNode.text).toBe('Sign in now'); // Button.text now substituted
110
109
  expect(result.warnings.some(w => w.includes('missing'))).toBe(true);
111
110
  });
112
111
 
@@ -210,19 +209,209 @@ describe('transpilePlan — direct unit', () => {
210
209
  });
211
210
  });
212
211
 
212
+ describe('transpilePlan — root container (§103 auto-grouping)', () => {
213
+ const lookup = new Map(FIXTURE_VOCAB.map(c => [c.name, c]));
214
+
215
+ it('defaults to Column when plan.layout is missing (v0.4.8 back-compat)', () => {
216
+ const result = transpilePlan({
217
+ ingredients: [{ name: 'demo-login' }],
218
+ }, lookup);
219
+ const root = result.messages[0].components[0];
220
+ expect(root.component).toBe('Column');
221
+ expect(root.id).toBe('free-form-root');
222
+ expect(root.wrap).toBeUndefined();
223
+ expect(root.columns).toBeUndefined();
224
+ });
225
+
226
+ it('emits Row root with wrap=true when plan.layout="row"', () => {
227
+ const result = transpilePlan({
228
+ ingredients: [{ name: 'demo-login' }, { name: 'demo-signup' }],
229
+ layout: 'row',
230
+ }, lookup);
231
+ const root = result.messages[0].components[0];
232
+ expect(root.component).toBe('Row');
233
+ expect(root.wrap).toBe(true);
234
+ expect(root.children).toEqual(['demo-login__card', 'demo-signup__card']);
235
+ });
236
+
237
+ it('emits Grid root with columns from plan when plan.layout="grid"', () => {
238
+ const result = transpilePlan({
239
+ ingredients: [{ name: 'demo-login' }, { name: 'demo-signup' }],
240
+ layout: 'grid',
241
+ columns: '2',
242
+ }, lookup);
243
+ const root = result.messages[0].components[0];
244
+ expect(root.component).toBe('Grid');
245
+ expect(root.columns).toBe('2');
246
+ });
247
+
248
+ it('emits Grid root with columns="auto-fill" when plan.layout="grid" omits columns', () => {
249
+ const result = transpilePlan({
250
+ ingredients: [{ name: 'demo-login' }],
251
+ layout: 'grid',
252
+ }, lookup);
253
+ const root = result.messages[0].components[0];
254
+ expect(root.component).toBe('Grid');
255
+ expect(root.columns).toBe('auto-fill');
256
+ });
257
+
258
+ it('falls back to Column with a warning on unknown layout values', () => {
259
+ const result = transpilePlan({
260
+ ingredients: [{ name: 'demo-login' }],
261
+ layout: 'flex',
262
+ }, lookup);
263
+ const root = result.messages[0].components[0];
264
+ expect(root.component).toBe('Column');
265
+ expect(result.warnings.some(w => w.toLowerCase().includes('unknown layout'))).toBe(true);
266
+ });
267
+
268
+ it('normalizes layout values to lowercase', () => {
269
+ const result = transpilePlan({
270
+ ingredients: [{ name: 'demo-login' }],
271
+ layout: 'ROW',
272
+ }, lookup);
273
+ const root = result.messages[0].components[0];
274
+ expect(root.component).toBe('Row');
275
+ });
276
+ });
277
+
213
278
  describe('buildFreeFormSystemPrompt', () => {
214
- it('includes ingredient names + descriptions + text-node hints', () => {
279
+ it('includes ingredient names + descriptions + substitutables hints', () => {
215
280
  const prompt = buildFreeFormSystemPrompt(FIXTURE_VOCAB);
216
281
  expect(prompt).toContain('demo-login');
217
282
  expect(prompt).toContain('demo-signup');
218
283
  expect(prompt).toContain('A demo login card');
219
- expect(prompt).toContain('id="title" current="Sign in"');
284
+ expect(prompt).toContain('substitutables:');
285
+ expect(prompt).toContain('title.textContent="Sign in"');
220
286
  expect(prompt).toContain('INGREDIENTS AVAILABLE');
221
287
  expect(prompt).toContain('OUTPUT SCHEMA');
222
288
  });
223
289
 
290
+ it('surfaces Button / Icon / Image / Link substitutables with their target attribute', () => {
291
+ const vocab = [{
292
+ name: 'demo-hero',
293
+ domain: 'marketing',
294
+ description: 'Hero block with CTA + logo',
295
+ keywords: ['hero'],
296
+ template: [
297
+ { id: 'card', component: 'Card', children: ['title', 'cta', 'logo', 'docs'] },
298
+ { id: 'title', component: 'Text', textContent: 'Welcome' },
299
+ { id: 'cta', component: 'Button' }, // no preset text
300
+ { id: 'logo', component: 'Image', src: '/x.png', alt: 'AdiaUI' },
301
+ { id: 'docs', component: 'Link', href: '/docs' },
302
+ ],
303
+ }];
304
+ const prompt = buildFreeFormSystemPrompt(vocab);
305
+ expect(prompt).toContain('title.textContent="Welcome"');
306
+ expect(prompt).toContain('cta.text'); // no `="..."` since unset
307
+ expect(prompt).not.toMatch(/cta\.text=".*"/);
308
+ expect(prompt).toContain('logo.alt="AdiaUI"');
309
+ expect(prompt).toContain('docs.href="/docs"');
310
+ });
311
+
224
312
  it('handles empty vocabulary without throwing', () => {
225
313
  const prompt = buildFreeFormSystemPrompt([]);
226
314
  expect(prompt).toContain('INGREDIENTS AVAILABLE (0)');
227
315
  });
228
316
  });
317
+
318
+ describe('transpilePlan — multi-attribute substitutions (§105 v0.5.0)', () => {
319
+ const VOCAB_RICH = [{
320
+ name: 'demo-hero',
321
+ domain: 'marketing',
322
+ description: 'Hero with CTA and badge',
323
+ keywords: ['hero'],
324
+ template: [
325
+ { id: 'card', component: 'Card', children: ['title', 'submit', 'badge', 'tag', 'icon', 'logo', 'docs', 'shortcut'] },
326
+ { id: 'title', component: 'Text', textContent: 'Welcome' },
327
+ { id: 'submit', component: 'Button' },
328
+ { id: 'badge', component: 'Badge' },
329
+ { id: 'tag', component: 'Tag' },
330
+ { id: 'icon', component: 'Icon' },
331
+ { id: 'logo', component: 'Image', src: '/x.png', alt: 'old' },
332
+ { id: 'docs', component: 'Link', href: '/old' },
333
+ { id: 'shortcut', component: 'Kbd' },
334
+ ],
335
+ }];
336
+
337
+ it('routes Button.text via SUBSTITUTABLE_ATTRS', () => {
338
+ const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
339
+ const result = transpilePlan({
340
+ ingredients: [{ name: 'demo-hero', substitutions: { submit: 'Get started' } }],
341
+ }, lookup);
342
+ const node = result.messages[0].components.find(n => n.id === 'demo-hero__submit');
343
+ expect(node.text).toBe('Get started');
344
+ expect(result.warnings).toEqual([]);
345
+ });
346
+
347
+ it('routes Badge.text + Tag.text', () => {
348
+ const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
349
+ const result = transpilePlan({
350
+ ingredients: [{
351
+ name: 'demo-hero',
352
+ substitutions: { badge: 'New', tag: 'Beta' },
353
+ }],
354
+ }, lookup);
355
+ const badge = result.messages[0].components.find(n => n.id === 'demo-hero__badge');
356
+ const tag = result.messages[0].components.find(n => n.id === 'demo-hero__tag');
357
+ expect(badge.text).toBe('New');
358
+ expect(tag.text).toBe('Beta');
359
+ });
360
+
361
+ it('routes Icon.name', () => {
362
+ const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
363
+ const result = transpilePlan({
364
+ ingredients: [{ name: 'demo-hero', substitutions: { icon: 'rocket' } }],
365
+ }, lookup);
366
+ const node = result.messages[0].components.find(n => n.id === 'demo-hero__icon');
367
+ expect(node.name).toBe('rocket');
368
+ });
369
+
370
+ it('routes Image.alt + overwrites existing alt', () => {
371
+ const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
372
+ const result = transpilePlan({
373
+ ingredients: [{ name: 'demo-hero', substitutions: { logo: 'AdiaUI logo' } }],
374
+ }, lookup);
375
+ const node = result.messages[0].components.find(n => n.id === 'demo-hero__logo');
376
+ expect(node.alt).toBe('AdiaUI logo');
377
+ expect(node.src).toBe('/x.png'); // other attrs preserved
378
+ });
379
+
380
+ it('routes Link.href + overwrites existing href', () => {
381
+ const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
382
+ const result = transpilePlan({
383
+ ingredients: [{ name: 'demo-hero', substitutions: { docs: '/v2/docs' } }],
384
+ }, lookup);
385
+ const node = result.messages[0].components.find(n => n.id === 'demo-hero__docs');
386
+ expect(node.href).toBe('/v2/docs');
387
+ });
388
+
389
+ it('routes Kbd.textContent (same attribute as Text)', () => {
390
+ const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
391
+ const result = transpilePlan({
392
+ ingredients: [{ name: 'demo-hero', substitutions: { shortcut: '⌘K' } }],
393
+ }, lookup);
394
+ const node = result.messages[0].components.find(n => n.id === 'demo-hero__shortcut');
395
+ expect(node.textContent).toBe('⌘K');
396
+ });
397
+
398
+ it('warns + skips substitution on non-substitutable component (Card)', () => {
399
+ const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
400
+ const result = transpilePlan({
401
+ ingredients: [{ name: 'demo-hero', substitutions: { card: 'should be ignored' } }],
402
+ }, lookup);
403
+ const node = result.messages[0].components.find(n => n.id === 'demo-hero__card');
404
+ expect(node.text).toBeUndefined();
405
+ expect(node.textContent).toBeUndefined();
406
+ expect(result.warnings.some(w => w.includes('SUBSTITUTABLE_ATTRS map'))).toBe(true);
407
+ });
408
+
409
+ it('coerces non-string substitution values via String()', () => {
410
+ const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
411
+ const result = transpilePlan({
412
+ ingredients: [{ name: 'demo-hero', substitutions: { badge: 42 } }],
413
+ }, lookup);
414
+ const node = result.messages[0].components.find(n => n.id === 'demo-hero__badge');
415
+ expect(node.text).toBe('42');
416
+ });
417
+ });
@@ -3,45 +3,56 @@
3
3
  *
4
4
  * Treats the annotated-chunk corpus as an INGREDIENT VOCABULARY. The LLM
5
5
  * sees a catalog of available ingredients with their domain + description +
6
- * keywords, plus the per-ingredient TEXT NODES that can be substituted, then
7
- * outputs an ordered ingredient list + per-ingredient substitutions.
6
+ * keywords, plus the per-ingredient SUBSTITUTABLE NODES (text-bearing
7
+ * primitives whose label / name / href the LLM can tailor per-intent),
8
+ * then outputs an ordered ingredient list + per-ingredient substitutions.
8
9
  *
9
10
  * The transpiler stitches the resulting plan into a net-new A2UI tree that
10
11
  * echoes the chunks' shapes without copying them verbatim.
11
12
  *
12
13
  * Design choice: substitutions key on the chunk's INNER node IDs. Showing
13
14
  * the IDs in the prompt makes the substitution surface explicit + grep-able.
14
- * Only `Text`-component nodes with a `textContent` field are
15
- * substitution-eligible (the v0.4.8 "text-only" scope); other components
16
- * are surfaced for ordering context but locked at their declared values.
15
+ * The transpiler's `SUBSTITUTABLE_ATTRS` map names which components are
16
+ * substitution-eligible + which attribute receives the value
17
+ * (Text.textContent, Button.text, Badge.text, Tag.text, Kbd.textContent,
18
+ * Icon.name, Image.alt, Link.href). v0.4.8 §88 shipped Text-only; v0.5.0
19
+ * §105 expanded to the seven listed.
17
20
  *
18
21
  * Vocabulary refresh cadence: the catalog is rebuilt from
19
22
  * `composition-library.getAllCompositions()` on every prompt build —
20
- * cheap (in-memory map of 28 entries today) and ensures the LLM sees the
23
+ * cheap (in-memory map of 64 entries today) and ensures the LLM sees the
21
24
  * current corpus state without explicit cache invalidation.
22
25
  */
23
26
 
27
+ import { SUBSTITUTABLE_ATTRS } from './transpile.js';
28
+
24
29
  const MAX_DESCRIPTION_LEN = 140;
25
30
  const MAX_TEXT_PREVIEW = 60;
31
+ const MAX_SUBSTITUTABLES_PER_INGREDIENT = 8;
26
32
 
27
33
  /**
28
- * Extract `Text` nodes with `textContent` from a chunk template. Returns a
29
- * compact `[{ id, preview }]` array — the LLM uses `id` as the substitution
30
- * key; `preview` is the current value (truncated for prompt economy).
34
+ * Extract substitutable nodes from a chunk template. Returns a compact
35
+ * `[{ id, attr, preview }]` array — the LLM uses `id` as the substitution
36
+ * key; `attr` names the target attribute (text / textContent / name /
37
+ * alt / href); `preview` is the current value truncated for prompt
38
+ * economy (empty string when the node carries no current value, which is
39
+ * common for Button.text / Badge.text / Icon.name in the corpus).
31
40
  *
32
41
  * @param {object[]} template — flat A2UI node list
33
- * @returns {Array<{id: string, preview: string}>}
42
+ * @returns {Array<{id: string, attr: string, preview: string}>}
34
43
  */
35
- function extractTextNodes(template) {
44
+ function extractSubstitutables(template) {
36
45
  if (!Array.isArray(template)) return [];
37
46
  const nodes = [];
38
47
  for (const node of template) {
39
- if (node?.component !== 'Text') continue;
40
- if (typeof node.textContent !== 'string' || !node.textContent.length) continue;
41
- const preview = node.textContent.length > MAX_TEXT_PREVIEW
42
- ? node.textContent.slice(0, MAX_TEXT_PREVIEW - 1) + ''
43
- : node.textContent;
44
- nodes.push({ id: node.id, preview });
48
+ const attr = SUBSTITUTABLE_ATTRS[node?.component];
49
+ if (!attr) continue;
50
+ const raw = node[attr];
51
+ const value = typeof raw === 'string' ? raw : '';
52
+ const preview = value.length > MAX_TEXT_PREVIEW
53
+ ? value.slice(0, MAX_TEXT_PREVIEW - 1) + '…'
54
+ : value;
55
+ nodes.push({ id: node.id, attr, preview });
45
56
  }
46
57
  return nodes;
47
58
  }
@@ -51,9 +62,11 @@ function extractTextNodes(template) {
51
62
  *
52
63
  * - <name> (<domain>): <description (truncated)>
53
64
  * keywords: a, b, c
54
- * text-nodes: id="..." current="...", id="..." current="..."
65
+ * substitutables: title.textContent="Sign in"; submit.text; logo.alt="Adia Logo"
55
66
  *
56
- * Text-nodes line is omitted when the chunk has zero Text nodes.
67
+ * Substitutables line is omitted when the chunk has zero substitutable
68
+ * nodes. Entries with a current value show `id.attr="..."`; entries
69
+ * without (Button/Icon nodes with no preset text/name) show just `id.attr`.
57
70
  */
58
71
  function renderIngredient(c) {
59
72
  const desc = (c.description || '').slice(0, MAX_DESCRIPTION_LEN);
@@ -61,10 +74,13 @@ function renderIngredient(c) {
61
74
  if (Array.isArray(c.keywords) && c.keywords.length > 0) {
62
75
  lines.push(` keywords: ${c.keywords.slice(0, 8).join(', ')}`);
63
76
  }
64
- const textNodes = extractTextNodes(c.template);
65
- if (textNodes.length > 0) {
66
- const compact = textNodes.slice(0, 6).map(t => `id="${t.id}" current="${t.preview}"`).join('; ');
67
- lines.push(` text-nodes: ${compact}`);
77
+ const subs = extractSubstitutables(c.template);
78
+ if (subs.length > 0) {
79
+ const compact = subs
80
+ .slice(0, MAX_SUBSTITUTABLES_PER_INGREDIENT)
81
+ .map(s => s.preview ? `${s.id}.${s.attr}="${s.preview}"` : `${s.id}.${s.attr}`)
82
+ .join('; ');
83
+ lines.push(` substitutables: ${compact}`);
68
84
  }
69
85
  return lines.join('\n');
70
86
  }
@@ -89,7 +105,7 @@ export function buildFreeFormSystemPrompt(compositions) {
89
105
 
90
106
  return `You are a UI composer for the AdiaUI design system. You work by picking INGREDIENTS — pre-built UI regions that have been annotated for retrieval — and arranging them in order to satisfy the user's intent.
91
107
 
92
- You DO NOT write A2UI JSON directly. You output a PLAN: an ordered list of ingredient names plus optional text-content substitutions. A separate transpiler turns your plan into A2UI messages.
108
+ You DO NOT write A2UI JSON directly. You output a PLAN: an ordered list of ingredient names plus optional text-content substitutions and an optional root container choice. A separate transpiler turns your plan into A2UI messages.
93
109
 
94
110
  INGREDIENTS AVAILABLE (${ingredients.length}):
95
111
 
@@ -98,11 +114,21 @@ ${catalog}
98
114
  CONSTRAINTS:
99
115
 
100
116
  1. Use ONLY ingredient names from the list above. Names not in the list are hallucinations — your response will be rejected if any \`name\` field is unknown.
101
- 2. Order your ingredients in the sequence they should appear top-to-bottom in the rendered UI. The transpiler wraps your list in a vertical Column.
102
- 3. Each ingredient may carry a \`substitutions\` object. KEYS are text-node IDs (from the \`text-nodes:\` line in the catalog above). VALUES are the new \`textContent\` strings. Only \`Text\` nodes can be substituted in this version; other components stay at their declared values.
117
+ 2. Order your ingredients in the sequence they should appear in the rendered UI. The transpiler wraps your list in a root container — by default a vertical Column. Override via the optional \`layout\` field (see below).
118
+ 3. Each ingredient may carry a \`substitutions\` object. KEYS are node IDs from the \`substitutables:\` line in the catalog above (e.g. \`"title"\`, \`"submit"\`, \`"logo"\`). VALUES are the new content strings. The transpiler routes each substitution to the right attribute based on the node's component: \`Text\` / \`Kbd\` \`textContent\`; \`Button\` / \`Badge\` / \`Tag\` → \`text\`; \`Icon\` → \`name\`; \`Image\` → \`alt\`; \`Link\` → \`href\`. Nodes whose component is not in the substitutable list are locked at their declared values.
103
119
  4. If you can't satisfy the intent with the available ingredients, return \`{ "ingredients": [] }\` and a rationale explaining what's missing.
104
120
  5. Output ONLY the JSON object below, no explanation outside the JSON.
105
121
 
122
+ LAYOUT (optional root container):
123
+
124
+ The transpiler stacks your ingredients inside a single root container. The default is \`Column\` (vertical stack). For horizontal or grid arrangements set \`layout\` to one of:
125
+
126
+ - \`"column"\` — vertical stack (default; omit \`layout\` for this shape). Use for: forms stacking fields, content sections stacking top-to-bottom, hero → features → footer pages.
127
+ - \`"row"\` — horizontal stack with automatic wrapping. Use for: nav items, inline button groups, small tag clusters, 2-3 cards side-by-side.
128
+ - \`"grid"\` — responsive grid. Use for: pricing tiers (3 cards), KPI dashboards (4 stat tiles), feature grids, image galleries, testimonial grids. Add an optional \`columns\` field with a string like \`"3"\`, \`"4"\`, or \`"auto-fill"\`; default is \`"auto-fill"\` (responsive density).
129
+
130
+ When the intent reads as "side by side" / "in a row" / "horizontally", pick \`"row"\`. When it reads as "grid of N" / "tiles" / "cards in columns", pick \`"grid"\`. When in doubt, omit \`layout\` and let the default Column apply.
131
+
106
132
  OUTPUT SCHEMA (strict):
107
133
 
108
134
  \`\`\`json
@@ -113,11 +139,13 @@ OUTPUT SCHEMA (strict):
113
139
  "substitutions": { "<text-node-id>": "<new text>" }
114
140
  }
115
141
  ],
142
+ "layout": "column" | "row" | "grid",
143
+ "columns": "<grid columns: number string '1'..'6' or 'auto-fill'>",
116
144
  "rationale": "<one short sentence: why this arrangement>"
117
145
  }
118
146
  \`\`\`
119
147
 
120
- The \`substitutions\` field is optional and can be omitted or set to \`{}\`.`;
148
+ The \`substitutions\`, \`layout\`, and \`columns\` fields are all optional.`;
121
149
  }
122
150
 
123
151
  /**
@@ -16,24 +16,68 @@
16
16
  * - The root `Column` enumerates the prefixed ROOT id of each ingredient
17
17
  * as its children (defined by the first node in each chunk template).
18
18
  *
19
- * Substitution rules (v0.4.8text-only scope):
19
+ * Substitution rules (v0.5.0 §105 multi-attribute scope):
20
20
  * - Substitution key = chunk-internal node ID (the same `id` field the
21
- * prompt surfaces in the `text-nodes:` line per ingredient).
22
- * - Only nodes with `component === 'Text'` AND a `textContent` field are
23
- * substitution-eligible. Other components are ignored silently.
24
- * - Unknown substitution keys produce a `warnings[]` entry in the result
25
- * but do not fail the transpile. Future passes can promote warnings to
26
- * strict errors via an option.
27
- *
28
- * Out of scope for v0.4.8:
21
+ * prompt surfaces in the `substitutables:` line per ingredient).
22
+ * - Each substitutable component has ONE target attribute defined in
23
+ * `SUBSTITUTABLE_ATTRS` below. The transpiler maps node.component
24
+ * target attribute name automatically; the substitution value lands
25
+ * on that attribute regardless of whether the node currently carries
26
+ * one.
27
+ * - Nodes whose `component` isn't in the map produce a `warnings[]`
28
+ * entry; the substitution is dropped.
29
+ * - Unknown substitution keys (no node with that ID) produce a
30
+ * `warnings[]` entry; transpile continues.
31
+ *
32
+ * The v0.4.8 §88 ship of this file restricted substitutions to
33
+ * Text.textContent only. The v0.5.0 §105 expansion adds Button.text,
34
+ * Badge.text, Tag.text, Icon.name, Image.alt, Link.href, Kbd.textContent —
35
+ * the 7 most common consumer-tweaked text-bearing attributes per the
36
+ * v0.5.0 plan §99-rev table. Non-text "structural" substitutions
37
+ * (swapping Card → Section, etc.) remain out of scope.
38
+ *
39
+ * Root container picker (v0.5.0 §103, auto-grouping):
40
+ * - Plan may carry `layout: "column" | "row" | "grid"` to override the
41
+ * default `Column` root. `"row"` wraps in a Row with wrap=true (for
42
+ * horizontal nav / inline-card sequences). `"grid"` wraps in a Grid
43
+ * with `columns` from the plan (or `"auto-fill"` fallback) for
44
+ * responsive tile / pricing-tier layouts. Missing / unknown `layout`
45
+ * value falls through to `Column` — back-compat for v0.4.8 plans.
46
+ *
47
+ * Out of scope:
29
48
  * - Slot-conflict resolution (two ingredients claiming the same slot).
30
- * The current stitch is sequential-stacking inside a Column; slot
49
+ * The current stitch is sequential-stacking inside the root; slot
31
50
  * conflicts cannot arise because no shared slot surface exists.
32
51
  * - Nested-composition (ingredients-inside-ingredients).
33
52
  * - Cross-chunk merge (fusing two chunks rather than stacking).
34
53
  * - Structural substitutions (swap Card → Section, etc.).
35
54
  */
36
55
 
56
+ const VALID_LAYOUTS = new Set(['column', 'row', 'grid']);
57
+
58
+ /**
59
+ * Maps component name → the single attribute that consumer substitutions
60
+ * target on that component. The set was chosen from the v0.5.0 §99-rev
61
+ * plan table: text-bearing primitives' "primary text attribute" + the
62
+ * two URL/alt-text attributes consumers tweak most often.
63
+ *
64
+ * Components NOT in this map are non-substitutable from a free-form
65
+ * plan: substitution keys pointing at e.g. a Card or Column node are
66
+ * reported as warnings.
67
+ *
68
+ * @type {Record<string, string>}
69
+ */
70
+ export const SUBSTITUTABLE_ATTRS = {
71
+ Text: 'textContent',
72
+ Button: 'text',
73
+ Badge: 'text',
74
+ Tag: 'text',
75
+ Kbd: 'textContent',
76
+ Icon: 'name',
77
+ Image: 'alt',
78
+ Link: 'href',
79
+ };
80
+
37
81
  /**
38
82
  * Apply text substitutions to a single chunk's template. Returns a fresh
39
83
  * cloned array with prefixed IDs + substituted textContent where keys match.
@@ -59,10 +103,11 @@ function applyIngredient(template, prefix, substitutions = {}) {
59
103
  }
60
104
  if (Object.hasOwn(substitutions, node.id)) {
61
105
  usedSubKeys.add(node.id);
62
- if (node.component === 'Text' && typeof node.textContent === 'string') {
63
- cloned.textContent = String(substitutions[node.id]);
106
+ const targetAttr = SUBSTITUTABLE_ATTRS[node.component];
107
+ if (targetAttr) {
108
+ cloned[targetAttr] = String(substitutions[node.id]);
64
109
  } else {
65
- warnings.push(`substitution skipped: "${node.id}" in "${prefix}" is component=${node.component ?? '?'}, only Text supported in v0.4.8`);
110
+ warnings.push(`substitution skipped: "${node.id}" in "${prefix}" is component=${node.component ?? '?'}, not in SUBSTITUTABLE_ATTRS map`);
66
111
  }
67
112
  }
68
113
  return cloned;
@@ -140,12 +185,7 @@ export function transpilePlan(plan, chunkLookup) {
140
185
  return { messages: [], warnings, usedIngredients };
141
186
  }
142
187
 
143
- const root = {
144
- id: 'free-form-root',
145
- component: 'Column',
146
- gap: '6',
147
- children: rootChildren,
148
- };
188
+ const root = buildRoot(plan, rootChildren, warnings);
149
189
 
150
190
  return {
151
191
  messages: [{
@@ -158,6 +198,45 @@ export function transpilePlan(plan, chunkLookup) {
158
198
  };
159
199
  }
160
200
 
201
+ /**
202
+ * Build the root container node for the transpiled plan. Reads
203
+ * `plan.layout` and, for grids, `plan.columns`. Defaults to Column when
204
+ * `layout` is missing or unknown (back-compat with v0.4.8 plans).
205
+ *
206
+ * @param {object} plan — the parsed LLM plan
207
+ * @param {string[]} rootChildren — prefixed root IDs from each ingredient
208
+ * @param {string[]} warnings — mutated; receives invalid-layout warnings
209
+ * @returns {object} the root A2UI node
210
+ */
211
+ function buildRoot(plan, rootChildren, warnings) {
212
+ const requested = typeof plan?.layout === 'string' ? plan.layout.toLowerCase() : null;
213
+ let layout = 'column';
214
+ if (requested) {
215
+ if (VALID_LAYOUTS.has(requested)) {
216
+ layout = requested;
217
+ } else {
218
+ warnings.push(`unknown layout: "${plan.layout}" — falling back to Column`);
219
+ }
220
+ }
221
+
222
+ const base = {
223
+ id: 'free-form-root',
224
+ gap: '6',
225
+ children: rootChildren,
226
+ };
227
+
228
+ if (layout === 'row') {
229
+ return { ...base, component: 'Row', wrap: true };
230
+ }
231
+ if (layout === 'grid') {
232
+ const columns = typeof plan?.columns === 'string' && plan.columns.length > 0
233
+ ? plan.columns
234
+ : 'auto-fill';
235
+ return { ...base, component: 'Grid', columns };
236
+ }
237
+ return { ...base, component: 'Column' };
238
+ }
239
+
161
240
  /**
162
241
  * Parse a raw LLM response into a plan object. The LLM is asked to emit
163
242
  * strict JSON; we accept either a bare JSON object or one wrapped in a