klypix-mcp 1.82.2 → 1.83.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.
@@ -32,7 +32,7 @@ import crypto from 'crypto';
32
32
  import { fileURLToPath } from 'url';
33
33
  import { z } from 'zod';
34
34
  import {
35
- resolveVault, resolveCanvas, getEmbedder, shouldPrewarmSemantic, cardSchema, connSchema,
35
+ resolveVault, resolveCanvas, getEmbedder, shouldPrewarmSemantic, cardSchema, connSchema, groupSchema,
36
36
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
37
37
  opBrainInsights, opBrainConnect, opCreateCanvas, opAddToCanvas, opBrainNote,
38
38
  } from '../src/klypix-core.mjs';
@@ -221,6 +221,7 @@ const a2aCardSchema = cardSchema.extend({
221
221
  });
222
222
  const cardsArg = z.array(a2aCardSchema).min(1).max(500);
223
223
  const connsArg = z.array(connSchema).max(1_000).optional();
224
+ const groupsArg = z.array(groupSchema).max(100).optional();
224
225
 
225
226
  // Compatibility spellings accepted by the dispatcher but deliberately omitted
226
227
  // from the Agent Card. The smoke test asserts every switch case is either
@@ -312,7 +313,9 @@ async function runSkill(skill, args, text, via) {
312
313
  }
313
314
  const conns = connsArg.safeParse(args.connections);
314
315
  if (!conns.success) return needInput('connections must be `[{ "from": <index|title>, "to": <index|title> }]`.');
315
- return await opCreateCanvas({ vault: VAULT, title: args.title ?? 'Untitled board', cards: parsed.data, connections: conns.data, filename: args.filename });
316
+ const groups = groupsArg.safeParse(args.groups);
317
+ if (!groups.success) return needInput('groups must be `[{ "title": "…", "cards": [<index|title|id>, …] }]` — cards listed in reading order.');
318
+ return await opCreateCanvas({ vault: VAULT, title: args.title ?? 'Untitled board', cards: parsed.data, connections: conns.data, groups: groups.data, filename: args.filename });
316
319
  }
317
320
  case 'remember':
318
321
  case 'learn_skill': {
@@ -25,7 +25,7 @@ import { z } from 'zod';
25
25
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
26
26
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
27
27
  import {
28
- resolveVault, getEmbedder, shouldPrewarmSemantic, buildKlypixMap, cardSchema, connSchema,
28
+ resolveVault, getEmbedder, shouldPrewarmSemantic, buildKlypixMap, cardSchema, connSchema, groupSchema,
29
29
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
30
30
  opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk, opBrainChallenge, opCanvasView, opBrainLens,
31
31
  opBrainTaskContext,
@@ -652,14 +652,15 @@ server.registerTool('brain_garden', {
652
652
 
653
653
  server.registerTool('create_canvas', {
654
654
  title: 'Create a KLYPIX canvas',
655
- description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in the KLYPIX app (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows.',
655
+ description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in the KLYPIX app (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows. For anything a person reads IN ORDER — steps, phases, checklists, sections — put the cards in `groups`: each group becomes a titled box with its cards stacked in the order given, boxes left-to-right; the loose grid follows arrows, not reading order, and scatters a sequence.',
656
656
  inputSchema: {
657
657
  title: z.string().describe('Canvas title (also the filename).'),
658
- cards: z.array(cardSchema).min(1).describe('The cards. 5-12 atomic cards is ideal.'),
658
+ cards: z.array(cardSchema).min(1).describe('The cards. 5-12 atomic cards is ideal for a mind-map; a checklist can be longer when grouped.'),
659
659
  connections: z.array(connSchema).optional().describe('Arrows between cards.'),
660
+ groups: z.array(groupSchema).optional().describe('Titled boxes, each listing its member cards in reading order (index, title, or id). Ungrouped cards form a band above the boxes — good for the title card, a link, a legend.'),
660
661
  filename: z.string().optional().describe('Override the output filename (without extension).'),
661
662
  },
662
- }, async ({ title, cards, connections, filename }) => toContent(await opCreateCanvas({ vault: mcpPresence.vault, title, cards, connections, filename })));
663
+ }, async ({ title, cards, connections, groups, filename }) => toContent(await opCreateCanvas({ vault: mcpPresence.vault, title, cards, connections, groups, filename })));
663
664
 
664
665
  server.registerTool('add_to_canvas', {
665
666
  title: 'Add cards to an existing canvas',
@@ -9,11 +9,15 @@
9
9
  // cat spec.json | node scripts/write-klypix.mjs --out board.klypix
10
10
  //
11
11
  // Spec:
12
- // { "title": "...", "cards": [{ "text": "...", "heading"?, "color"? }],
13
- // "connections": [{ "from": 0, "to": 1, "relationship"? }] }
14
- // from/to reference a card by INDEX, generated id, or its title (first line).
15
- // relationship leads_to | depends_on | relates_to | conflicts_with |
16
- // supports | questions | costs | blocks.
12
+ // { "title": "...", "cards": [{ "text": "...", "heading"?, "color"?, "group"? }],
13
+ // "connections": [{ "from": 0, "to": 1, "relationship"? }],
14
+ // "groups": [{ "title": "Part 1", "cards": [0, 1, 2], "color"?, "columns"?, "width"? }] }
15
+ // from/to (and group members) reference a card by INDEX, id, or its title
16
+ // (first line). relationship ∈ leads_to | depends_on | relates_to |
17
+ // conflicts_with | supports | questions | costs | blocks.
18
+ // groups: anything read IN ORDER (steps, phases, sections) — each becomes a
19
+ // titled box with its cards stacked in the order listed, boxes left-to-right.
20
+ // Loose cards keep the connection-driven grid, as a band above the boxes.
17
21
 
18
22
  import fs from 'fs';
19
23
  import { buildKlypix, atomicWrite } from '../src/klypix-format.mjs';
@@ -41,5 +45,6 @@ const outPath = outArg || `${(spec.title || 'untitled').replace(/[^\w\- ]+/g, ''
41
45
  await atomicWrite(outPath, buf);
42
46
  const cardCount = spec.cards.length;
43
47
  const connCount = Array.isArray(spec.connections) ? spec.connections.length : 0;
44
- console.log(`Wrote ${outPath} ${cardCount} cards, ${connCount} connections.`);
48
+ const groupCount = Array.isArray(spec.groups) ? spec.groups.length : 0;
49
+ console.log(`Wrote ${outPath} — ${cardCount} cards, ${connCount} connections${groupCount ? `, ${groupCount} group box${groupCount === 1 ? '' : 'es'}` : ''}.`);
45
50
  console.log(`Open it in the KLYPIX app (Canvas → Open), or verify: node scripts/read-klypix.mjs "${outPath}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.82.2",
3
+ "version": "1.83.0",
4
4
  "mcpName": "io.github.dahshanlabs/klypix-mcp",
5
5
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "bench": "node bin/klypix-mcp.mjs bench",
85
85
  "test:bench": "node test/bench.mjs",
86
86
  "pretest": "node test/publish-workflow.mjs",
87
- "test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
87
+ "test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
88
88
  "test:memory": "node test/memory-runtime.mjs",
89
89
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
90
90
  "runtime": "node bin/klypix-runtime.mjs"
@@ -55,6 +55,7 @@ export const cardSchema = z.object({
55
55
  text: z.string().describe('Card text. First line is the card title.'),
56
56
  heading: z.boolean().optional().describe('Bold title card for the main goal/topic.'),
57
57
  color: z.string().optional().describe('Hex color, e.g. #ef4444 for a risk/blocker.'),
58
+ group: z.string().optional().describe('Put this card inside the titled box of that name (created if missing), in card order. Alternative to listing it in `groups`.'),
58
59
  });
59
60
  export const connSchema = z.object({
60
61
  from: z.union([z.number(), z.string()]).describe('Source card: index (0-based), title, or id.'),
@@ -62,6 +63,17 @@ export const connSchema = z.object({
62
63
  relationship: z.string().optional().describe('leads_to|depends_on|relates_to|conflicts_with|supports|questions|costs|blocks'),
63
64
  label: z.string().optional(),
64
65
  });
66
+ // Groups (2026-09-05): a titled box whose cards stack top-to-bottom IN THE ORDER
67
+ // GIVEN, boxes left-to-right. The plain grid follows arrows, not reading order,
68
+ // which scatters step 1 far from step 2 — for anything a reader follows in
69
+ // sequence, put the cards in groups.
70
+ export const groupSchema = z.object({
71
+ title: z.string().describe('Box header, e.g. "Part 1 · Get ready". Also the card\'s area.'),
72
+ cards: z.array(z.union([z.number(), z.string()])).min(1).describe('Member cards IN READING ORDER — index (0-based), title, or id. They stack top-to-bottom inside the box.'),
73
+ color: z.string().optional().describe('Hex border color for the box (default emerald).'),
74
+ columns: z.number().int().min(1).max(4).optional().describe('Split a long list into N columns, filled top-to-bottom then next column. Default 1 (2 when more than 12 cards).'),
75
+ width: z.number().int().min(200).max(640).optional().describe('Card width inside the box in px (default 340). Use ~520 for long prose cards so they do not become skyscrapers.'),
76
+ });
65
77
 
66
78
  // ── Vault discovery / resolution ─────────────────────────────────────────────
67
79
  export const IS_CANVAS = /\.(klypix|any)$/i;
@@ -1154,21 +1166,22 @@ export async function opBrainConnect({ vault, canvas, apply = false, max = 24, t
1154
1166
  }, { brain: true });
1155
1167
  }
1156
1168
 
1157
- export async function opCreateCanvas({ vault, title, cards, connections, filename }) {
1169
+ export async function opCreateCanvas({ vault, title, cards, connections, groups, filename }) {
1158
1170
  if (!fs.existsSync(vault)) { try { fs.mkdirSync(vault, { recursive: true }); } catch { /* ignore */ } }
1159
1171
  // Locked on the VAULT: safeName picks a free name by probing the directory, so
1160
1172
  // two concurrent creates of the same title would both see it free and the second
1161
1173
  // atomicWrite would silently replace the first canvas.
1162
1174
  return withVaultCreateLock(vault, async () => {
1163
1175
  try {
1164
- const buf = await buildKlypix({ title, cards, connections });
1176
+ const buf = await buildKlypix({ title, cards, connections, groups });
1165
1177
  const name = filename ? safeName(vault, filename.replace(IS_CANVAS, '')) : safeName(vault, title);
1166
1178
  const out = path.join(vault, name);
1167
1179
  await atomicWrite(out, buf);
1168
1180
  let detail = '', struct;
1169
1181
  try { ({ struct } = await parseKlypix(buf)); detail = cardDetailBlock(struct); } catch { /* detail is optional */ }
1182
+ const groupNote = Array.isArray(groups) && groups.length ? `, ${groups.length} group box${groups.length === 1 ? '' : 'es'}` : '';
1170
1183
  return {
1171
- blocks: [text(`Created ${out} — ${cards.length} cards, ${(connections || []).length} connections. Open it in the KLYPIX app (Canvas → Open).${detail}`)],
1184
+ blocks: [text(`Created ${out} — ${cards.length} cards, ${(connections || []).length} connections${groupNote}. Open it in the KLYPIX app (Canvas → Open).${detail}`)],
1172
1185
  file: { name, buffer: buf }, struct,
1173
1186
  };
1174
1187
  } catch (e) {
@@ -420,10 +420,14 @@ const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', '
420
420
 
421
421
  /**
422
422
  * Build a real .klypix v4 file (nodebuffer) from a simple spec:
423
- * { title, cards: [{id?, type?, text, heading?, color?, x?, y?, w?}], connections: [{from, to, relationship?, label?}] }
424
- * from/to reference a card by INDEX, generated id, or its title (first line).
425
- * Cards are content-sized and laid out on a BFS-ordered grid so linked cards
426
- * land near each other.
423
+ * { title, cards: [{id?, type?, text, heading?, color?, group?, x?, y?, w?}],
424
+ * connections: [{from, to, relationship?, label?}],
425
+ * groups?: [{title, cards: [ref…], color?, columns?, width?}], layout?: {groupsPerRow?} }
426
+ * from/to (and group member refs) reference a card by INDEX, generated id, or
427
+ * its title (first line). Loose cards are content-sized and laid out on a
428
+ * BFS-ordered grid so linked cards land near each other. Grouped cards render
429
+ * inside a titled container in the ORDER LISTED, boxes left-to-right — use
430
+ * groups for anything read in sequence (steps, phases, sections).
427
431
  */
428
432
  export async function buildKlypix(spec) {
429
433
  if (!spec || !Array.isArray(spec.cards) || spec.cards.length === 0) {
@@ -458,14 +462,62 @@ export async function buildKlypix(spec) {
458
462
  };
459
463
  }).filter(Boolean);
460
464
 
461
- // BFS order so connected cards land near each other.
462
- const adj = new Map(idByIndex.map(id => [id, []]));
463
- for (const c of connections) { adj.get(c.fromId)?.push(c.toId); adj.get(c.toId)?.push(c.fromId); }
464
- const indeg = new Map(idByIndex.map(id => [id, 0]));
465
- for (const c of connections) indeg.set(c.toId, (indeg.get(c.toId) || 0) + 1);
465
+ // ── Groups (2026-09-05): titled boxes whose cards stack IN THE ORDER GIVEN.
466
+ // The BFS grid below is the right shape for a mind-map and the wrong shape
467
+ // for anything a person reads in sequence the founder's 27-step App
468
+ // Review checklist came out with step 1 at the bottom-left, Part 2's steps
469
+ // scattered, and arrows crossing the whole board, because BFS follows
470
+ // arrows, not reading order. A group renders as a KLYPIX container (the
471
+ // same item shape the brain's areas use) with its member cards laid out
472
+ // top-to-bottom in the order the spec lists them, boxes left-to-right in
473
+ // spec order. Cards may also name their group inline (`group: "Part 1"`),
474
+ // which appends them to that box in card order. Ungrouped cards keep the
475
+ // BFS grid, placed as a band ABOVE the boxes (title card, links, legend).
476
+ const groupDefs = [];
477
+ const groupByKey = new Map();
478
+ const groupOf = new Map(); // cardId → index into groupDefs
479
+ const groupKey = (t) => String(t ?? '').trim().toLowerCase();
480
+ const ensureGroup = (g) => {
481
+ const key = groupKey(g.title);
482
+ if (!key) throw new Error('every group needs a non-empty "title"');
483
+ if (groupByKey.has(key)) return groupByKey.get(key);
484
+ const def = { title: String(g.title).trim(), color: g.color, columns: g.columns, width: g.width, kids: [], _id: `ctn_${rand()}_${groupDefs.length}` };
485
+ groupByKey.set(key, groupDefs.length);
486
+ groupDefs.push(def);
487
+ return groupDefs.length - 1;
488
+ };
489
+ // First membership wins — a card listed in two groups stays in the first.
490
+ const joinGroup = (gi, id) => { if (groupOf.has(id)) return; groupOf.set(id, gi); groupDefs[gi].kids.push(id); };
491
+ if (spec.groups != null && !Array.isArray(spec.groups)) throw new Error('"groups" must be an array of { title, cards: [...] }');
492
+ (spec.groups || []).forEach((g, gi0) => {
493
+ if (!g || typeof g !== 'object') throw new Error(`groups[${gi0}] must be an object with a title and cards`);
494
+ const gi = ensureGroup(g);
495
+ (Array.isArray(g.cards) ? g.cards : []).forEach((ref, ci) => {
496
+ const id = resolveRef(ref);
497
+ // Loud, not silent: a typo'd ref would otherwise drop the step out of
498
+ // its box onto the loose grid and the reader would never know.
499
+ if (!id) throw new Error(`groups[${gi0}] ("${g.title}").cards[${ci}] = ${JSON.stringify(ref)} does not match any card — use its index, id, or exact title`);
500
+ joinGroup(gi, id);
501
+ });
502
+ });
503
+ cards.forEach(c => { if (c.group != null && String(c.group).trim()) joinGroup(ensureGroup({ title: c.group }), c._id); });
504
+ const hasGroups = groupDefs.length > 0;
505
+
506
+ // BFS order so connected LOOSE cards land near each other. Grouped cards are
507
+ // excluded from both the walk and the degree count — with no groups this is
508
+ // byte-for-byte the previous behaviour.
509
+ const loose = idByIndex.filter(id => !groupOf.has(id));
510
+ const looseSet = new Set(loose);
511
+ const adj = new Map(loose.map(id => [id, []]));
512
+ const indeg = new Map(loose.map(id => [id, 0]));
513
+ for (const c of connections) {
514
+ if (!looseSet.has(c.fromId) || !looseSet.has(c.toId)) continue;
515
+ adj.get(c.fromId).push(c.toId); adj.get(c.toId).push(c.fromId);
516
+ indeg.set(c.toId, indeg.get(c.toId) + 1);
517
+ }
466
518
  const visited = new Set();
467
519
  const order = [];
468
- const starts = [...idByIndex].sort((a, b) => (indeg.get(a) - indeg.get(b)) || (idByIndex.indexOf(a) - idByIndex.indexOf(b)));
520
+ const starts = [...loose].sort((a, b) => (indeg.get(a) - indeg.get(b)) || (idByIndex.indexOf(a) - idByIndex.indexOf(b)));
469
521
  for (const s of starts) {
470
522
  if (visited.has(s)) continue;
471
523
  const q = [s];
@@ -478,11 +530,13 @@ export async function buildKlypix(spec) {
478
530
  }
479
531
 
480
532
  const FONT = 20, PAD = 28, LINE_H = FONT * 1.35;
481
- const sizeFor = (card) => {
482
- if (card.x != null && card.w != null) return { w: card.w, h: card.h ?? 40 };
533
+ // forcedW: a grouped card takes its box's column width so the column reads
534
+ // as one aligned list (and so a one-line step doesn't shrink to a stub).
535
+ const sizeFor = (card, forcedW = null) => {
536
+ if (forcedW == null && card.x != null && card.w != null) return { w: card.w, h: card.h ?? 40 };
483
537
  const lines = String(card.text ?? '').split('\n');
484
538
  const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
485
- const w = Math.max(160, Math.min(360, Math.round(longest * (FONT * 0.55)) + PAD));
539
+ const w = forcedW ?? Math.max(160, Math.min(360, Math.round(longest * (FONT * 0.55)) + PAD));
486
540
  // Wrap-aware height (76eea3f contract): the app renders at width w and
487
541
  // wraps at ~0.5em/char; its observer only GROWS an under-estimate, so a
488
542
  // long single-line card used to measure 40px and render 150+ → overlap.
@@ -515,6 +569,49 @@ export async function buildKlypix(spec) {
515
569
  };
516
570
  });
517
571
 
572
+ // Group boxes: below the loose band, left-to-right in spec order, wrapping
573
+ // after `layout.groupsPerRow` (default 4). Inside a box the kids fill one
574
+ // column top-to-bottom; a long list (>12) or an explicit `columns` splits
575
+ // into N columns filled column-major, so reading order survives (finish
576
+ // column 1, then column 2) — never the row-major/masonry orders that put
577
+ // step 2 beside step 1 instead of under it.
578
+ const containerItems = new Map(); // ctnId → item json
579
+ if (hasGroups) {
580
+ const G = { TITLE_BAR: 44, PAD: 16, GAP: 12, KID_W: 340, COL_GAP: 60, ROW_GAP: 80 };
581
+ const perRow = Math.max(1, Math.min(8, Math.round(Number(spec.layout?.groupsPerRow)) || 4));
582
+ const looseBottom = order.length ? Math.max(...order.map(id => positions[id].y + positions[id].h)) : START;
583
+ let gx = START, gy = order.length ? looseBottom + G.ROW_GAP : START, rowH = 0, inRow = 0;
584
+ for (const g of groupDefs) {
585
+ const n = g.kids.length;
586
+ const kidW = Math.max(200, Math.min(640, Math.round(Number(g.width)) || G.KID_W));
587
+ const gcols = Math.max(1, Math.min(4, Math.round(Number(g.columns)) || (n > 12 ? 2 : 1)));
588
+ const perCol = Math.max(1, Math.ceil(n / gcols));
589
+ const kidSizes = g.kids.map(id => sizeFor(cards[idByIndex.indexOf(id)], kidW));
590
+ const colHs = new Array(gcols).fill(0);
591
+ kidSizes.forEach((sz, i) => { colHs[Math.floor(i / perCol)] += sz.h + G.GAP; });
592
+ const bodyH = Math.max(0, Math.max(0, ...colHs) - G.GAP);
593
+ const w = G.PAD * 2 + gcols * kidW + (gcols - 1) * G.GAP;
594
+ const h = G.TITLE_BAR + G.PAD + bodyH + G.PAD;
595
+ if (inRow >= perRow) { gx = START; gy += rowH + G.ROW_GAP; rowH = 0; inRow = 0; }
596
+ positions[g._id] = { x: gx, y: gy, w, h, zKey: nextZKey(), zIndex: zi++, parentId: null };
597
+ order.push(g._id);
598
+ containerItems.set(g._id, {
599
+ type: 'container', locked: false, createdAt: now, createdBy: 'agent', ...authorField(),
600
+ title: g.title, collapsed: false, scopeLocked: false,
601
+ borderColor: (typeof g.color === 'string' && g.color.trim()) ? g.color.trim() : '#10b981',
602
+ });
603
+ const colY = new Array(gcols).fill(gy + G.TITLE_BAR + G.PAD);
604
+ g.kids.forEach((id, i) => {
605
+ const c = Math.floor(i / perCol);
606
+ const { w: kw, h: kh } = kidSizes[i];
607
+ positions[id] = { x: gx + G.PAD + c * (kidW + G.GAP), y: colY[c], w: kw, h: kh, zKey: nextZKey(), zIndex: zi++, parentId: g._id };
608
+ colY[c] += kh + G.GAP;
609
+ order.push(id);
610
+ });
611
+ gx += w + G.COL_GAP; rowH = Math.max(rowH, h); inRow++;
612
+ }
613
+ }
614
+
518
615
  const itemJson = (card, w) => {
519
616
  if (card.type === 'text') {
520
617
  return {
@@ -562,6 +659,7 @@ export async function buildKlypix(spec) {
562
659
  zip.file('manifest.json', JSON.stringify(manifest));
563
660
  zip.file('canvas.json', JSON.stringify(canvasJson));
564
661
  for (const id of order) {
662
+ if (containerItems.has(id)) { zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify(containerItems.get(id))); continue; }
565
663
  const card = cards[idByIndex.indexOf(id)];
566
664
  zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify(itemJson(card, positions[id]?.w)));
567
665
  }