paper-motion 0.1.0 → 0.2.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 (75) hide show
  1. package/README.md +14 -0
  2. package/bin/pamo.mjs +134 -4
  3. package/examples/halo-explainer/audio/script.txt +8 -0
  4. package/examples/halo-explainer/audio/words.json +554 -0
  5. package/examples/halo-explainer/brand/IDENTITY.md +24 -0
  6. package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
  7. package/examples/halo-explainer/brand/motion.json +9 -0
  8. package/examples/halo-explainer/brand/shader-set/set.json +104 -0
  9. package/examples/halo-explainer/brand/sound.json +33 -0
  10. package/examples/halo-explainer/brand/tokens.json +97 -0
  11. package/examples/halo-explainer/brief.md +34 -0
  12. package/examples/halo-explainer/poster.jpg +0 -0
  13. package/examples/halo-explainer/scene.html +372 -0
  14. package/examples/orbit-keynote/audio/script.txt +7 -0
  15. package/examples/orbit-keynote/audio/words.json +494 -0
  16. package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
  17. package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
  18. package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
  19. package/examples/orbit-keynote/brand/motion.json +9 -0
  20. package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
  21. package/examples/orbit-keynote/brand/sound.json +45 -0
  22. package/examples/orbit-keynote/brand/tokens.json +106 -0
  23. package/examples/orbit-keynote/brief.md +25 -0
  24. package/examples/orbit-keynote/poster.jpg +0 -0
  25. package/examples/orbit-keynote/scene.html +323 -0
  26. package/lib/customize.mjs +162 -0
  27. package/lib/live-board.js +128 -0
  28. package/lib/registry.mjs +363 -0
  29. package/lib/render.mjs +3 -3
  30. package/lib/server.mjs +1 -0
  31. package/lib/sfx.mjs +5 -3
  32. package/package.json +1 -1
  33. package/runtime/core.js +269 -0
  34. package/runtime/index.js +3 -260
  35. package/runtime/kit.js +98 -0
  36. package/runtime/moves/bar-reveal.js +19 -0
  37. package/runtime/moves/blur-in.js +7 -0
  38. package/runtime/moves/cells.js +17 -0
  39. package/runtime/moves/crossfade.js +6 -0
  40. package/runtime/moves/cut.js +3 -0
  41. package/runtime/moves/dive-in.js +21 -0
  42. package/runtime/moves/draw-on.js +12 -0
  43. package/runtime/moves/drift.js +7 -0
  44. package/runtime/moves/flash.js +15 -0
  45. package/runtime/moves/flip-3d.js +9 -0
  46. package/runtime/moves/flip-in.js +12 -0
  47. package/runtime/moves/flip-lift.js +24 -0
  48. package/runtime/moves/glitch.js +16 -0
  49. package/runtime/moves/iris.js +12 -0
  50. package/runtime/moves/magic-move.js +49 -0
  51. package/runtime/moves/mask-reveal.js +10 -0
  52. package/runtime/moves/odometer.js +32 -0
  53. package/runtime/moves/orbit.js +8 -0
  54. package/runtime/moves/pop-in.js +12 -0
  55. package/runtime/moves/punch.js +9 -0
  56. package/runtime/moves/push.js +14 -0
  57. package/runtime/moves/rgb-split.js +7 -0
  58. package/runtime/moves/roll.js +20 -0
  59. package/runtime/moves/scan-reveal.js +10 -0
  60. package/runtime/moves/scramble.js +21 -0
  61. package/runtime/moves/shake.js +15 -0
  62. package/runtime/moves/slices.js +14 -0
  63. package/runtime/moves/snap-scroll.js +23 -0
  64. package/runtime/moves/squash-drop.js +21 -0
  65. package/runtime/moves/tile-swirl.js +29 -0
  66. package/runtime/moves/track-in.js +7 -0
  67. package/runtime/moves/type-on.js +41 -0
  68. package/runtime/moves/unfold.js +20 -0
  69. package/runtime/moves/whip-pan.js +21 -0
  70. package/runtime/moves/whip.js +14 -0
  71. package/runtime/moves/wipe.js +9 -0
  72. package/runtime/moves/word-rise.js +10 -0
  73. package/runtime/moves/zoom-through.js +13 -0
  74. package/runtime/moves.js +66 -146
  75. package/templates/move-demo/scene.html +4 -3
@@ -0,0 +1,162 @@
1
+ // Customizing a resource: the code for a set of param values. One module for both sides, so what the site shows
2
+ // (its code tabs, "Copy for your agent") and what `pamo add <id> --set k=v` installs are always the same.
3
+ // Plain ESM with no Node imports: the site copies this file and runs it in the browser.
4
+
5
+ export const MOVE_TYPES = ['component', 'transition', 'text', 'camera'];
6
+
7
+ /** Param defaults by name. */
8
+ export const defaults = (item) => Object.fromEntries((item.params ?? []).map((p) => [p.name, p.default]));
9
+
10
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
11
+ /** Params whose value differs from the default. */
12
+ export const changed = (item, values) => (item.params ?? []).filter((p) => p.name in values && !same(values[p.name], p.default));
13
+
14
+ /** A JS literal: strings in single quotes, `ease.*` left as code, arrays and objects inline. */
15
+ export function literal(v) {
16
+ if (typeof v === 'string') return /^ease\.\w+$/.test(v) ? v : `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
17
+ if (Array.isArray(v)) return `[${v.map(literal).join(', ')}]`;
18
+ if (v && typeof v === 'object') return `{ ${Object.entries(v).map(([k, x]) => `${k}: ${literal(x)}`).join(', ')} }`;
19
+ return String(v);
20
+ }
21
+
22
+ /** `--set key=value` arguments, shell-safe. */
23
+ export function setArgs(item, values) {
24
+ return changed(item, values).map((p) => {
25
+ const v = values[p.name], raw = typeof v === 'string' ? v : JSON.stringify(v);
26
+ const arg = `${p.name}=${raw}`;
27
+ return /^[\w.=#,:/@+-]+$/.test(arg) ? `--set ${arg}` : `--set '${arg.replace(/'/g, "'\\''")}'`;
28
+ });
29
+ }
30
+
31
+ /** Parses `key=value` pairs (from the CLI) into values, JSON where it parses, strings otherwise. */
32
+ export function parseSet(item, pairs) {
33
+ const out = defaults(item);
34
+ for (const pair of pairs) {
35
+ const i = pair.indexOf('=');
36
+ if (i < 0) throw new Error(`--set needs key=value, got "${pair}"`);
37
+ const k = pair.slice(0, i), raw = pair.slice(i + 1);
38
+ if (!(item.params ?? []).some((p) => p.name === k)) throw new Error(`"${item.id}" has no param "${k}" (params: ${(item.params ?? []).map((p) => p.name).join(', ')})`);
39
+ try { out[k] = JSON.parse(raw); } catch { out[k] = raw; }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ // ── per type ────────────────────────────────────────────────────
45
+
46
+ const gf = (family, weights) => `https://fonts.googleapis.com/css2?family=${family.replace(/ /g, '+')}${weights?.length ? `:wght@${[...new Set(weights)].sort((a, b) => a - b).join(';')}` : ''}&display=swap`;
47
+
48
+ /** A move file with the values written in as its defaults (only inside the exported function's options). */
49
+ function moveFile(item, content, values) {
50
+ const name = item.export, start = content.indexOf(`export function ${name}(`);
51
+ if (start < 0) return content;
52
+ const end = content.indexOf(') {', start);
53
+ let sig = content.slice(start, end);
54
+ for (const p of changed(item, values)) sig = sig.replace(new RegExp(`(\\b${p.name}\\s*=\\s*)(\\[[^\\]]*\\]|'[^']*'|"[^"]*"|[^,}]+?)(\\s*)(?=[,}])`), `$1${literal(values[p.name])}$3`);
55
+ return content.slice(0, start) + sig + content.slice(end);
56
+ }
57
+
58
+ function backgroundEntry(item, values) {
59
+ const e = item.install.entry ?? {};
60
+ return { ...e, props: { ...e.props, ...Object.fromEntries((item.params ?? []).map((p) => [p.name, values[p.name] ?? p.default])) } };
61
+ }
62
+
63
+ function paletteTokens(item, values) {
64
+ const e = item.install.entry ?? {};
65
+ const colors = Object.fromEntries(Object.entries(e.colors ?? {}).map(([role, c]) => [role, { ...c, hex: values[role] ?? c.hex }]));
66
+ return { ...e, colors, ramp: values.ramp ?? e.ramp };
67
+ }
68
+
69
+ function fontTokens(item, values) {
70
+ const e = item.install.entry ?? {};
71
+ const f = e.fonts ?? {}, w = values.displayWeight ?? e.type?.display?.weight ?? 400, tr = `${values.tracking ?? parseFloat(e.type?.display?.tracking ?? '0')}em`;
72
+ const pick = (role, weights) => { const family = values[role] ?? f[role]?.family; return { ...f[role], family, weights, css: gf(family, weights) }; };
73
+ const fonts = { display: pick('display', [w]), text: pick('text', f.text?.weights ?? [400, 500]), mono: pick('mono', f.mono?.weights ?? [400]) };
74
+ const type = { ...e.type, display: { ...e.type?.display, weight: w, tracking: tr }, title: { ...e.type?.title, weight: w, tracking: tr } };
75
+ return { fonts, type };
76
+ }
77
+
78
+ /** A recipe's CONFIG object from flat values (proof1Title… → proof[0].title, "1920x1080" → [1920, 1080]). */
79
+ function recipeConfig(values) {
80
+ const cfg = {}, groups = {};
81
+ for (const [k, v] of Object.entries(values)) {
82
+ const m = k.match(/^(proof|step)(\d)(Label|Title|Body)$/);
83
+ if (m) { const g = m[1] === 'proof' ? 'proof' : 'steps'; (groups[g] ??= [])[m[2] - 1] ??= {}; groups[g][m[2] - 1][m[3].toLowerCase()] = v; continue; }
84
+ if (k === 'format') cfg.format = String(v).split('x').map(Number);
85
+ else cfg[k] = v === '' ? null : v;
86
+ }
87
+ return { ...cfg, ...groups };
88
+ }
89
+
90
+ function recipeScene(content, values) {
91
+ const cfg = recipeConfig(values);
92
+ const start = content.indexOf('const CONFIG = {');
93
+ if (start < 0) return content;
94
+ const end = content.indexOf('\n};', start) + 3;
95
+ const body = Object.entries(cfg).map(([k, v]) => (Array.isArray(v) && v.length && typeof v[0] === 'object'
96
+ ? ` ${k}: [\n${v.map((o) => ` ${literal(o)},`).join('\n')}\n ],`
97
+ : ` ${k}: ${v === null ? 'null' : literal(v)},`)).join('\n');
98
+ return `${content.slice(0, start)}const CONFIG = {\n${body}\n};${content.slice(end)}`;
99
+ }
100
+
101
+ // ── the public API ──────────────────────────────────────────────
102
+
103
+ /**
104
+ * Applies values to a resolved resource (files with content): returns the files and install entry that
105
+ * `pamo add --set` writes, the same ones the site's File / entry tabs show.
106
+ */
107
+ export function apply(item, values) {
108
+ const v = { ...defaults(item), ...values };
109
+ const files = (item.files ?? []).map((f) => {
110
+ if (f.content === undefined) return f;
111
+ if (MOVE_TYPES.includes(item.type)) return { ...f, content: moveFile(item, f.content, v) };
112
+ if (item.type === 'recipe' && /scene\.html$/.test(f.path)) return { ...f, content: recipeScene(f.content, v) };
113
+ return f;
114
+ });
115
+ let entry = item.install?.entry;
116
+ if (item.type === 'background') entry = backgroundEntry(item, v);
117
+ if (item.type === 'palette') entry = paletteTokens(item, v);
118
+ if (item.type === 'font-pair') entry = fontTokens(item, v);
119
+ return { files, install: { ...item.install, ...(entry ? { entry } : {}) } };
120
+ }
121
+
122
+ /** The code tabs, the change count and the agent prompt for a resource at these values. */
123
+ export function code(item, values, { showAll = false, site = 'https://paper-motion.vercel.app' } = {}) {
124
+ const v = { ...defaults(item), ...values }, diff = changed(item, v), out = apply(item, v);
125
+ const args = setArgs(item, v);
126
+ const agent = `Use the paper-motion resource "${item.id}" (${site}/r/${item.id}.json) in my video. Install it with: npx paper-motion add ${item.id}${args.length ? ` ${args.join(' ')}` : ''}`;
127
+ let tabs = [];
128
+ if (MOVE_TYPES.includes(item.type)) {
129
+ const listed = showAll ? (item.params ?? []).filter((p) => !p.hidden) : diff;
130
+ const head = ((item.signature ?? '').match(/\(([^{)]*)/)?.[1] ?? 'a, b, p').replace(/,\s*$/, '').trim();
131
+ const file = out.files.find((f) => f.content !== undefined);
132
+ const opts = listed.length ? `, { ${listed.map((p) => `${p.name}: ${literal(v[p.name])}`).join(', ')} }` : '';
133
+ tabs = [
134
+ { key: 'usage', label: 'Usage', lang: 'js', code: `import { ${item.export} } from './${file?.path ?? `moves/${item.id}.js`}';\n\n// in s.update(t)\n${item.export}(${head}${opts});` },
135
+ { key: 'file', label: 'File', lang: 'js', code: file?.content ?? '' },
136
+ ];
137
+ } else if (item.type === 'background') {
138
+ const e = out.install.entry, shader = e.shader;
139
+ tabs = [
140
+ { key: 'entry', label: 'set.json entry', lang: 'json', code: JSON.stringify({ id: item.id, ...e }, null, 2) },
141
+ { key: 'usage', label: 'Usage', lang: 'js', code: `import { loadBrand } from 'paper-motion/brand';\n\nconst B = await loadBrand();\nB.shader(s, '#bg', '${item.id}');` },
142
+ { key: 'react', label: 'Paper React', lang: 'jsx', code: `import { ${shader} } from '@paper-design/shaders-react';\n\n<${shader}\n${Object.entries(e.props).map(([k, x]) => ` ${k}={${typeof x === 'string' ? JSON.stringify(x) : JSON.stringify(x)}}`).join('\n')}\n style={{ width: '100%', height: '100%' }}\n/>` },
143
+ ];
144
+ } else if (item.type === 'palette') {
145
+ const e = out.install.entry;
146
+ tabs = [
147
+ { key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify({ colors: e.colors, ramp: e.ramp, mode: e.mode }, null, 2) },
148
+ { key: 'css', label: 'CSS variables', lang: 'css', code: `:root {\n${Object.entries(e.colors).map(([r, c]) => ` --${r}: ${c.hex};`).join('\n')}\n${(e.ramp ?? []).map((h, i) => ` --ramp-${i + 1}: ${h};`).join('\n')}\n}` },
149
+ ];
150
+ } else if (item.type === 'font-pair') {
151
+ const e = out.install.entry, f = e.fonts;
152
+ const cat = (fam) => (/mono|code/i.test(fam) ? 'monospace' : /serif|playfair|fraunces|garamond|newsreader|instrument/i.test(fam) && !/sans/i.test(fam) ? 'serif' : 'sans-serif');
153
+ tabs = [
154
+ { key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify(e, null, 2) },
155
+ { key: 'css', label: 'CSS variables', lang: 'css', code: `${Object.values(f).map((x) => `@import url('${x.css}');`).filter((x, i, a) => a.indexOf(x) === i).join('\n')}\n\n:root {\n${Object.entries(f).map(([r, x]) => ` --font-${r}: '${x.family}', ${cat(x.family)};`).join('\n')}\n --display-weight: ${e.type.display.weight};\n --display-tracking: ${e.type.display.tracking};\n}` },
156
+ ];
157
+ } else if (item.type === 'recipe') {
158
+ const scene = out.files.find((f) => /scene\.html$/.test(f.path));
159
+ tabs = [{ key: 'scene', label: 'scene.html', lang: 'html', code: scene?.content ?? '' }];
160
+ }
161
+ return { tabs, changed: diff.length, agent, setArgs: args };
162
+ }
@@ -0,0 +1,128 @@
1
+ // A board: 2–4 labelled options the agent proposes, carried in a paper-motion.app link so the user can see them
2
+ // live in their own look, tune one and choose. Nothing reaches a server: the payload travels in the URL fragment.
3
+ // https://paper-motion.vercel.app/b#<base64url(deflate-raw(JSON))>
4
+ // This module runs in Node (`pamo board --url`) and in the browser (the /b page), so it has no Node imports: it
5
+ // picks zlib or CompressionStream at call time. Plain ESM; the site copies it next to customize.mjs.
6
+
7
+ // (lib/board.mjs is the other kind of board: the rendered one, stills and clips composed by ffmpeg.)
8
+
9
+ export const BOARD_VERSION = 1;
10
+ export const MAX_PAYLOAD = 8 * 1024;
11
+ const LABELS = ['A', 'B', 'C', 'D'];
12
+ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
13
+
14
+ /**
15
+ * @typedef {{ v: 1, kind: 'resource'|'kit', title: string, ask?: string, reply?: string,
16
+ * look?: { palette?: { id: string, values?: object }, font?: { id: string, values?: object } },
17
+ * options: Array<{ label: string, id?: string, values?: object, items?: Array<{ id: string, values?: object }>, note?: string }> }} Board
18
+ */
19
+
20
+ /** Checks a board's shape; returns a list of problems (empty when valid). `index` (registry items by id) adds id checks. */
21
+ export function validateBoard(b, index) {
22
+ const errs = [];
23
+ if (!b || typeof b !== 'object') return ['not an object'];
24
+ if (b.v !== BOARD_VERSION) errs.push(`v must be ${BOARD_VERSION}`);
25
+ if (!['resource', 'kit'].includes(b.kind)) errs.push('kind must be "resource" or "kit"');
26
+ if (!b.title || typeof b.title !== 'string') errs.push('title is required');
27
+ if (!Array.isArray(b.options) || b.options.length < 2 || b.options.length > 4) errs.push('2 to 4 options');
28
+ const seen = new Set();
29
+ for (const [i, o] of (b.options ?? []).entries()) {
30
+ const at = `options[${i}]`;
31
+ if (!LABELS.includes(o.label)) errs.push(`${at}.label must be A–D`);
32
+ if (seen.has(o.label)) errs.push(`${at}.label "${o.label}" repeats`);
33
+ seen.add(o.label);
34
+ if (b.kind === 'resource') {
35
+ if (!o.id) errs.push(`${at}.id is required`);
36
+ else if (index && !index[o.id]) errs.push(`${at}.id "${o.id}" is not in the registry`);
37
+ } else {
38
+ if (!Array.isArray(o.items) || !o.items.length) errs.push(`${at}.items is required for a kit board`);
39
+ for (const it of o.items ?? []) if (index && !index[it.id]) errs.push(`${at}: "${it.id}" is not in the registry`);
40
+ }
41
+ if (o.values && typeof o.values !== 'object') errs.push(`${at}.values must be an object`);
42
+ }
43
+ if (b.kind === 'resource' && index) {
44
+ const types = new Set(b.options.map((o) => index[o.id]?.type).filter(Boolean));
45
+ if (types.size > 1) errs.push(`a resource board shows one type at a time (got ${[...types].join(', ')})`);
46
+ }
47
+ for (const k of ['palette', 'font']) { const l = b.look?.[k]; if (l && index && !index[l.id]) errs.push(`look.${k} "${l.id}" is not in the registry`); }
48
+ return errs;
49
+ }
50
+
51
+ // ── compression, both runtimes ──────────────────────────────────
52
+
53
+ const b64url = {
54
+ enc: (bytes) => (typeof Buffer !== 'undefined' ? Buffer.from(bytes).toString('base64url') : btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')),
55
+ dec: (s) => { const b = s.replace(/-/g, '+').replace(/_/g, '/'); if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b, 'base64')); const bin = atob(b); return Uint8Array.from(bin, (c) => c.charCodeAt(0)); },
56
+ };
57
+
58
+ async function deflate(bytes) {
59
+ if (typeof CompressionStream === 'function') {
60
+ const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'));
61
+ return new Uint8Array(await new Response(stream).arrayBuffer());
62
+ }
63
+ const { deflateRawSync } = await import('node:zlib');
64
+ return new Uint8Array(deflateRawSync(bytes));
65
+ }
66
+ async function inflate(bytes) {
67
+ if (typeof DecompressionStream === 'function') {
68
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
69
+ return new Uint8Array(await new Response(stream).arrayBuffer());
70
+ }
71
+ const { inflateRawSync } = await import('node:zlib');
72
+ return new Uint8Array(inflateRawSync(bytes));
73
+ }
74
+
75
+ /** Board → the fragment string (no leading #). Throws when invalid or over the size budget. */
76
+ export async function encodeBoard(board, index) {
77
+ const errs = validateBoard(board, index);
78
+ if (errs.length) throw new Error(`board: ${errs.join('; ')}`);
79
+ const json = JSON.stringify(board);
80
+ if (json.length > MAX_PAYLOAD) throw new Error(`board: ${json.length} bytes of JSON, the limit is ${MAX_PAYLOAD}`);
81
+ return b64url.enc(await deflate(new TextEncoder().encode(json)));
82
+ }
83
+
84
+ /** Fragment string → board. Throws on a bad or oversized payload. */
85
+ export async function decodeBoard(fragment) {
86
+ const s = fragment.replace(/^#/, '');
87
+ if (!s) throw new Error('empty board');
88
+ if (s.length > MAX_PAYLOAD * 2) throw new Error('board link too long');
89
+ const json = new TextDecoder().decode(await inflate(b64url.dec(s)));
90
+ const board = JSON.parse(json);
91
+ const errs = validateBoard(board);
92
+ if (errs.length) throw new Error(`board: ${errs.join('; ')}`);
93
+ return board;
94
+ }
95
+
96
+ export const boardUrl = (site, fragment) => `${site.replace(/\/$/, '')}/b#${fragment}`;
97
+
98
+ // ── the reply line ──────────────────────────────────────────────
99
+ // What the user pastes back in the chat. One line, starts with "paper-motion:", so an agent can spot and parse it.
100
+ // paper-motion: A · mesh-sage chose A as proposed
101
+ // paper-motion: B · mesh-sage --set speed=0.8 --set colors=[…] chose B, tuned
102
+ // paper-motion: none of bg-1, show me calmer ones none; free text after the comma
103
+ // paper-motion: kit B · cobalt geist mesh-graphite whip-pan a kit board choice
104
+ // `reply` (the agent's name for the decision) is appended as "(bg-1)" when present.
105
+
106
+ export function replyLine(board, choice) {
107
+ const tag = board.reply ? ` (${board.reply})` : '';
108
+ if (choice.none) return `paper-motion: none of ${board.reply ?? `"${board.title}"`}${choice.text ? `, ${choice.text}` : ''}`;
109
+ const o = board.options.find((x) => x.label === choice.label);
110
+ if (!o) throw new Error(`no option ${choice.label}`);
111
+ if (board.kind === 'kit') return `paper-motion: kit ${o.label} · ${o.items.map((i) => i.id).join(' ')}${tag}`;
112
+ const sets = (choice.setArgs ?? []).join(' ');
113
+ return `paper-motion: ${o.label} · ${o.id}${sets ? ` ${sets}` : ''}${tag}`;
114
+ }
115
+
116
+ /** Parses a reply line the user pasted. Returns null when the text is not one. */
117
+ export function parseReply(text) {
118
+ const m = String(text).match(/paper-motion:\s*(.+)$/m);
119
+ if (!m) return null;
120
+ const body = m[1].trim(), tag = body.match(/\(([^()]+)\)\s*$/)?.[1] ?? null, core = body.replace(/\s*\([^()]+\)\s*$/, '');
121
+ if (core.startsWith('none of ')) { const rest = core.slice(8); const i = rest.indexOf(','); return { none: true, reply: tag ?? rest.slice(0, i < 0 ? undefined : i).trim(), text: i < 0 ? '' : rest.slice(i + 1).trim() }; }
122
+ const kit = core.match(/^kit ([A-D]) · (.+)$/);
123
+ if (kit) return { kind: 'kit', label: kit[1], ids: kit[2].split(/\s+/), reply: tag };
124
+ const one = core.match(/^([A-D]) · (\S+)(.*)$/);
125
+ if (!one) return null;
126
+ const sets = [...one[3].matchAll(/--set\s+('([^']*)'|(\S+))/g)].map((x) => x[2] ?? x[3]);
127
+ return { kind: 'resource', label: one[1], id: one[2], set: sets, reply: tag };
128
+ }