paper-motion 0.2.4 → 0.2.5

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
@@ -2,7 +2,16 @@
2
2
 
3
3
  All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
4
4
 
5
- ## 0.2.3 · unreleased
5
+ ## 0.2.5 · unreleased
6
+
7
+ - Font pairs keep their own stylesheet when the family is unchanged, so self-hosted faces work: the new `geist-serif` pair (Geist Serif headlines served by paper-motion.app, Geist text, Geist Mono labels) is the site's own look.
8
+
9
+ ## 0.2.4 · 2026-11-09
10
+
11
+ - `pamo board --url` waits for the user's choice by default (`--no-wait`, `--wait <minutes>`); `--choose 3` or `--choose 1-3` lets them pick several options, and the reply line lists them (`A+C · type-on · scramble`). `parseReply` returns `picks`.
12
+ - llms.txt: read the installed skill at once, the decisions that always go through a board, every chosen move appears in the piece.
13
+
14
+ ## 0.2.3 · 2026-11-09
6
15
 
7
16
  - Boards get short links and a return channel: `pamo board --url` stores the board on the site (`/b/<id>`, 90 days) and falls back to the long link; `--wait [minutes]` prints the reply line when the user chooses in the browser; `--long`, `--forget <id>`. `lib/live-board.js` gains `shortenBoard`, `awaitChoice`, `forgetBoard`; the search filters move to `lib/find.mjs` (shared with the site's MCP at `/mcp`).
8
17
 
package/bin/pamo.mjs CHANGED
@@ -327,9 +327,15 @@ async function main() {
327
327
  const { parseSet, setArgs } = await import('../lib/customize.mjs');
328
328
  const reg = resolveRegistry(args.registry), { items } = await getIndex(reg), index = Object.fromEntries(items.map((i) => [i.id, i]));
329
329
  const opts = process.argv.flatMap((a, i, all) => (a === '--option' ? [all[i + 1]] : a.startsWith('--option=') ? [a.slice(9)] : [])).filter(Boolean);
330
- if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette,font] --option id[:key=value,…] [--note "why"] … [--kit] [--wait [minutes]] [--long] [--site url]\n board --forget <id>\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n --wait keeps running until the user chooses in the browser (10 min by default) and prints the reply line, the same one they could paste. --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
330
+ if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette,font] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --forget <id>\n On a recipe board an option may be a structure of your own: --option "@Title | 18 | Hook 0-2 one line; Proof 2-12 three shots; Close 12-18 the action".\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n With a short link the command keeps running until the user chooses in the browser (10 min; --wait 3 changes it, --no-wait returns at once) and prints the reply line, the same one they could paste. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
331
331
  const LABELS = ['A', 'B', 'C', 'D'];
332
332
  const parseOne = async (spec) => {
333
+ if (spec.startsWith('@')) {
334
+ // a structure of the agent's own: "@Title | 18 | Hook 0-2 one line; Proof 2-12 three shots; Close 12-18 the action"
335
+ const [title, dur, beatsSpec] = spec.slice(1).split('|').map((x) => x.trim());
336
+ const beats = (beatsSpec ?? '').split(';').map((x) => x.trim()).filter(Boolean).map((x) => { const m = x.match(/^(.+?)\s+(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\s+(.+)$/); if (!m) throw new Error(`structure beat "${x}" must read "Name from-to what"`); return { name: m[1], from: Number(m[2]), to: Number(m[3]), what: m[4] }; });
337
+ return { structure: { title, duration: Number(dur), beats } };
338
+ }
333
339
  const [id, kv] = spec.split(':'), it = index[id];
334
340
  if (!it) throw new Error(`"${id}" is not in the registry (try pamo search)`);
335
341
  if (!kv) return { id, values: {} };
@@ -346,20 +352,22 @@ async function main() {
346
352
  }
347
353
  const look = {};
348
354
  if (typeof args.look === 'string') for (const spec of args.look.split(',')) { const o = await parseOne(spec.trim()); const t = index[o.id]?.type; if (t === 'palette') look.palette = o; else if (t === 'font-pair') look.font = o; else throw new Error(`--look takes a palette and a font pair, not "${o.id}"`); }
349
- const board = { v: 1, kind: args.kit ? 'kit' : 'resource', title: String(args.title), ...(args.ask ? { ask: String(args.ask) } : {}), ...(args.reply ? { reply: String(args.reply) } : {}), ...(Object.keys(look).length ? { look } : {}), options };
355
+ const choose = args.choose === undefined ? undefined : /^\d+-\d+$/.test(String(args.choose)) ? String(args.choose).split('-').map(Number) : Number(args.choose);
356
+ const board = { v: 1, kind: args.kit ? 'kit' : 'resource', title: String(args.title), ...(args.ask ? { ask: String(args.ask) } : {}), ...(args.reply ? { reply: String(args.reply) } : {}), ...(Object.keys(look).length ? { look } : {}), ...(choose !== undefined ? { choose } : {}), options };
350
357
  const site = typeof args.site === 'string' ? args.site : SITE;
351
358
  let frag, short = null;
352
359
  try { frag = await encodeBoard(board, index); } catch (e) { console.error(e.message); process.exit(1); }
353
360
  // the short link when the site stores it; the long one (the board in the fragment) when it cannot
354
361
  if (!args.long) { try { short = await shortenBoard(site, board); } catch (e) { console.error(`(${e.message}; sharing the long link)`); } }
355
362
  console.log(short ? short.url : boardUrl(site, frag));
356
- if (short && args.wait) {
357
- const minutes = Number.isFinite(Number(args.wait)) && args.wait !== true ? Number(args.wait) : 10;
363
+ const wait = !args['no-wait']; // waiting is the default: the choice comes back without the user pasting anything
364
+ if (short && wait) {
365
+ const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 10;
358
366
  console.error(`waiting up to ${minutes} min for the choice on ${short.id} (the user can still paste the line)…`);
359
367
  const choice = await awaitChoice(site, short.id, { timeout: minutes * 60 * 1000 });
360
368
  if (!choice) { console.error('no choice yet; ask the user to paste the line from the board'); process.exit(3); }
361
369
  console.log(choice.line);
362
- } else if (!short && args.wait) console.error('--wait needs the short link; the user will paste the line instead');
370
+ } else if (!short && wait) console.error('(no short link, so no waiting: the user will paste the line from the board)');
363
371
  break;
364
372
  }
365
373
  if (args.forget) {
package/lib/customize.mjs CHANGED
@@ -130,7 +130,8 @@ function paletteTokens(item, values) {
130
130
  function fontTokens(item, values) {
131
131
  const e = item.install.entry ?? {};
132
132
  const f = e.fonts ?? {}, w = values.displayWeight ?? e.type?.display?.weight ?? 400, tr = `${values.tracking ?? parseFloat(e.type?.display?.tracking ?? '0')}em`;
133
- const pick = (role, weights) => { const family = values[role] ?? f[role]?.family; return { ...f[role], family, weights, css: gf(family, weights) }; };
133
+ // a pair's own css (self-hosted families such as Geist Serif) stays while its family is unchanged; a new family loads from Google Fonts
134
+ const pick = (role, weights) => { const family = values[role] ?? f[role]?.family; const own = f[role]?.css && family === f[role]?.family && !/fonts\.googleapis\.com/.test(f[role].css); return { ...f[role], family, weights, css: own ? f[role].css : gf(family, weights) }; };
134
135
  const fonts = { display: pick('display', [w]), text: pick('text', f.text?.weights ?? [400, 500]), mono: pick('mono', f.mono?.weights ?? [400]) };
135
136
  const type = { ...e.type, display: { ...e.type?.display, weight: w, tracking: tr }, title: { ...e.type?.title, weight: w, tracking: tr } };
136
137
  return { fonts, type };
package/lib/live-board.js CHANGED
@@ -14,7 +14,10 @@ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palet
14
14
  /**
15
15
  * @typedef {{ v: 1, kind: 'resource'|'kit', title: string, ask?: string, reply?: string,
16
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
17
+ * choose?: number | [number, number], // how many options the user may pick: 1 (default), 3, or [min, max]
18
+ * options: Array<{ label: string, id?: string, values?: object, items?: Array<{ id: string, values?: object }>, note?: string,
19
+ * structure?: { title: string, duration: number, beats: Array<{ name: string, from: number, to: number, what: string }> } }> }} Board
20
+ * An option with `structure` (recipe boards only) is a structure the agent proposes for this brief instead of a recipe.
18
21
  */
19
22
 
20
23
  /** Checks a board's shape; returns a list of problems (empty when valid). `index` (registry items by id) adds id checks. */
@@ -25,6 +28,7 @@ export function validateBoard(b, index) {
25
28
  if (!['resource', 'kit'].includes(b.kind)) errs.push('kind must be "resource" or "kit"');
26
29
  if (!b.title || typeof b.title !== 'string') errs.push('title is required');
27
30
  if (!Array.isArray(b.options) || b.options.length < 2 || b.options.length > 4) errs.push('2 to 4 options');
31
+ if (b.choose !== undefined) { const [lo, hi] = chooseRange(b); if (!(Number.isInteger(lo) && Number.isInteger(hi) && lo >= 1 && hi >= lo && hi <= 4)) errs.push('choose must be 1–4 or [min, max] within 1–4'); }
28
32
  const seen = new Set();
29
33
  for (const [i, o] of (b.options ?? []).entries()) {
30
34
  const at = `options[${i}]`;
@@ -32,7 +36,14 @@ export function validateBoard(b, index) {
32
36
  if (seen.has(o.label)) errs.push(`${at}.label "${o.label}" repeats`);
33
37
  seen.add(o.label);
34
38
  if (b.kind === 'resource') {
35
- if (!o.id) errs.push(`${at}.id is required`);
39
+ if (o.structure) {
40
+ const st = o.structure;
41
+ if (o.id) errs.push(`${at}: either id or structure, not both`);
42
+ if (!st.title || typeof st.title !== 'string') errs.push(`${at}.structure.title is required`);
43
+ if (!(Number(st.duration) > 0)) errs.push(`${at}.structure.duration (seconds) is required`);
44
+ if (!Array.isArray(st.beats) || st.beats.length < 2 || st.beats.length > 8) errs.push(`${at}.structure.beats: 2 to 8 beats`);
45
+ for (const [j, bt] of (st.beats ?? []).entries()) if (!bt?.name || typeof bt.from !== 'number' || typeof bt.to !== 'number' || bt.to <= bt.from || !bt.what) errs.push(`${at}.structure.beats[${j}] needs name, from < to (seconds) and what`);
46
+ } else if (!o.id) errs.push(`${at}.id is required`);
36
47
  else if (index && !index[o.id]) errs.push(`${at}.id "${o.id}" is not in the registry`);
37
48
  } else {
38
49
  if (!Array.isArray(o.items) || !o.items.length) errs.push(`${at}.items is required for a kit board`);
@@ -41,13 +52,16 @@ export function validateBoard(b, index) {
41
52
  if (o.values && typeof o.values !== 'object') errs.push(`${at}.values must be an object`);
42
53
  }
43
54
  if (b.kind === 'resource' && index) {
44
- const types = new Set(b.options.map((o) => index[o.id]?.type).filter(Boolean));
55
+ const types = new Set(b.options.map((o) => (o.structure ? 'recipe' : index[o.id]?.type)).filter(Boolean));
45
56
  if (types.size > 1) errs.push(`a resource board shows one type at a time (got ${[...types].join(', ')})`);
46
57
  }
47
58
  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
59
  return errs;
49
60
  }
50
61
 
62
+ /** How many options a board lets the user pick: [min, max]. Defaults to [1, 1]. */
63
+ export const chooseRange = (b) => (Array.isArray(b?.choose) ? [Number(b.choose[0]), Number(b.choose[1])] : [1, Math.max(1, Number(b?.choose ?? 1))]);
64
+
51
65
  // ── compression, both runtimes ──────────────────────────────────
52
66
 
53
67
  const b64url = {
@@ -134,14 +148,18 @@ export async function forgetBoard(site, id) {
134
148
  // paper-motion: kit B · cobalt geist mesh-graphite whip-pan a kit board choice
135
149
  // `reply` (the agent's name for the decision) is appended as "(bg-1)" when present.
136
150
 
151
+ // paper-motion: A+C · type-on · scramble --set speed=2 several picks on a board with choose > 1
152
+ // paper-motion: D · structure "Three cuts" a structure the agent proposed, chosen
153
+ // `choice` is { label, setArgs? } for one pick, { picks: [{ label, setArgs? }, …] } for several, or { none, text }.
154
+
137
155
  export function replyLine(board, choice) {
138
156
  const tag = board.reply ? ` (${board.reply})` : '';
139
157
  if (choice.none) return `paper-motion: none of ${board.reply ?? `"${board.title}"`}${choice.text ? `, ${choice.text}` : ''}`;
140
- const o = board.options.find((x) => x.label === choice.label);
141
- if (!o) throw new Error(`no option ${choice.label}`);
142
- if (board.kind === 'kit') return `paper-motion: kit ${o.label} · ${o.items.map((i) => i.id).join(' ')}${tag}`;
143
- const sets = (choice.setArgs ?? []).join(' ');
144
- return `paper-motion: ${o.label} · ${o.id}${sets ? ` ${sets}` : ''}${tag}`;
158
+ const picks = choice.picks ?? [choice];
159
+ const found = picks.map((c) => { const o = board.options.find((x) => x.label === c.label); if (!o) throw new Error(`no option ${c.label}`); return { o, sets: (c.setArgs ?? []).join(' ') }; });
160
+ const labels = found.map((f) => f.o.label).join('+');
161
+ if (board.kind === 'kit') return `paper-motion: kit ${labels} · ${found.map((f) => f.o.items.map((i) => i.id).join(' ')).join(' + ')}${tag}`;
162
+ return `paper-motion: ${labels} · ${found.map((f) => (f.o.structure ? `structure "${f.o.structure.title.replace(/"/g, "'")}"` : `${f.o.id}${f.sets ? ` ${f.sets}` : ''}`)).join(' · ')}${tag}`;
145
163
  }
146
164
 
147
165
  /** Parses a reply line the user pasted. Returns null when the text is not one. */
@@ -150,10 +168,17 @@ export function parseReply(text) {
150
168
  if (!m) return null;
151
169
  const body = m[1].trim(), tag = body.match(/\(([^()]+)\)\s*$/)?.[1] ?? null, core = body.replace(/\s*\([^()]+\)\s*$/, '');
152
170
  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() }; }
153
- const kit = core.match(/^kit ([A-D]) · (.+)$/);
154
- if (kit) return { kind: 'kit', label: kit[1], ids: kit[2].split(/\s+/), reply: tag };
155
- const one = core.match(/^([A-D]) · (\S+)(.*)$/);
156
- if (!one) return null;
157
- const sets = [...one[3].matchAll(/--set\s+('([^']*)'|(\S+))/g)].map((x) => x[2] ?? x[3]);
158
- return { kind: 'resource', label: one[1], id: one[2], set: sets, reply: tag };
171
+ const kit = core.match(/^kit ([A-D](?:\+[A-D])*) · (.+)$/);
172
+ if (kit) { const labels = kit[1].split('+'), kits = kit[2].split(' + ').map((k) => k.split(/\s+/)); return { kind: 'kit', label: labels[0], labels, ids: kits[0], kits, reply: tag }; }
173
+ const many = core.match(/^([A-D](?:\+[A-D])*) · (.+)$/);
174
+ if (!many) return null;
175
+ const labels = many[1].split('+');
176
+ const picks = many[2].split(' · ').map((part, i) => {
177
+ const st = part.trim().match(/^structure "(.+)"$/);
178
+ if (st) return { label: labels[i], structure: st[1], set: [] };
179
+ const m = part.trim().match(/^(\S+)(.*)$/);
180
+ return { label: labels[i], id: m[1], set: [...m[2].matchAll(/--set\s+('([^']*)'|(\S+))/g)].map((x) => x[2] ?? x[3]) };
181
+ });
182
+ // one pick keeps the original shape (label, id, set); several add `picks`
183
+ return { kind: 'resource', label: picks[0].label, id: picks[0].id, set: picks[0].set, labels, picks, reply: tag };
159
184
  }
package/lib/registry.mjs CHANGED
@@ -110,19 +110,27 @@ export function llmsTxt(items, site, { full = false } = {}) {
110
110
  ## The flow (for agents)
111
111
 
112
112
  1. **Set up.** \`npx skills add solanodz/paper-motion\` (the skills) and \`npx paper-motion setup\` (the engine: Node 18+,
113
- ffmpeg, Chrome). The CLI is \`npx paper-motion\` (short: \`pamo\`). Every command answers \`--help\`.
113
+ ffmpeg, Chrome). The CLI is \`npx paper-motion\` (short: \`pamo\`). Every command answers \`--help\`. Agents load
114
+ skills and MCP servers when a session starts, so after installing the skills **read
115
+ \`.claude/skills/make-video/SKILL.md\` now** (or \`.agents/skills/make-video/SKILL.md\`) and follow it in this
116
+ session; tell the user that the MCP at ${site}/mcp, if they want it, is added before opening a session.
114
117
  2. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
115
118
  line; what this video is for and where it plays (format: 16:9, 9:16, 1:1); its ground, text and accent colors or
116
119
  "no brand yet"; three words for the feel and one thing it must never look like; the one action at the end.
117
- Mirror it back before proposing anything.
120
+ Mirror it back before proposing anything. **You never choose a look for the user.** These decisions always go
121
+ through a board, in this order, one board each: palette · font pair · background(s) · moves by role (how things
122
+ enter, how one thing becomes the next, how text arrives; let them pick several with \`choose\`) · recipe. Skip a
123
+ board only when the brand folder already settles that decision, and say so.
118
124
  3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
119
125
  this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
120
126
  (format below): a short link (\`${site}/b/<id>\`, stored for 90 days) when the site answers, the long form
121
127
  with the board in the URL fragment otherwise. The user opens it, sees the options live in their look, may tune one,
122
- and chooses. The choice comes back as one line: \`paper-motion: B · mesh-sage --set speed=0.8 (bg-1)\`, or
123
- \`paper-motion: none of bg-1, calmer ones\`. With \`--wait\` the command keeps running and prints that line the
124
- moment they choose (10 min by default); without it, they paste the line in the chat. The reply carries every value
125
- that differs from the defaults, your pre-tuning included, so installing it reproduces what they saw.
128
+ and chooses. The command keeps running and prints the choice the moment they choose (10 min by default;
129
+ \`--no-wait\` returns at once and they paste the line instead). It is one line: \`paper-motion: B · mesh-sage --set
130
+ speed=0.8 (bg-1)\`, \`paper-motion: A+C · type-on · scramble (text-1)\` when the board let them pick several
131
+ (\`--choose 1-3\`: use it for text effects and transitions), or \`paper-motion: none of bg-1, calmer ones\`. The
132
+ reply carries every value that differs from the defaults, your pre-tuning included, so installing it reproduces what
133
+ they saw. Never ask the user to paste anything while a command or the MCP is waiting: the choice arrives on its own.
126
134
  4. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
127
135
  \`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
128
136
  fonts to \`brand/tokens.json\`, recipes to \`pieces/<id>/\`. Their choices also accumulate in a kit at ${site}/kit,
@@ -130,7 +138,8 @@ export function llmsTxt(items, site, { full = false } = {}) {
130
138
  the user re-chooses or re-tunes something you installed before, pass \`--force\`. A recipe also reads
131
139
  \`brand/motion.json\` (pace) and \`brand/sound.json\` (cues; without it the piece is silent) and the brand's name
132
140
  from \`tokens.json\` \`brand\`: write those from the interview before rendering.
133
- 5. **Make the video.** Build \`scene.html\` (a pure function of time), check stills with \`pamo still\`, render with
141
+ 5. **Make the video.** Every move the user chose appears in the piece, and the brief says where each one plays;
142
+ a recipe's own moves are placeholders to swap for theirs. Build \`scene.html\` (a pure function of time), check stills with \`pamo still\`, render with
134
143
  \`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
135
144
 
136
145
  ## Choosing well: the metadata
@@ -146,7 +155,10 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
146
155
  reply line carries the resulting values. Without a board, \`pamo add <background> --palette <palette-id|brand>\`
147
156
  installs it that way (\`brand\` reads \`brand/tokens.json\`). So choose backgrounds by \`motion\`, \`mood\` and
148
157
  texture first; the color comes from the palette.
149
- - Font pairs: \`voice\` (serif, sans, display, mono). Recipes: \`duration\`, \`formats\`.
158
+ - Font pairs: \`voice\` (serif, sans, display, mono). Recipes: \`duration\`, \`formats\` and \`beats\` (the structure as a
159
+ timeline: name, from, to, what). A recipe board shows the beats, so the user chooses a rhythm, not a name; you may add
160
+ one option that is a **structure of your own** for this brief (\`structure\` instead of \`id\`, format below), which you
161
+ then build from scratch.
150
162
 
151
163
  \`pamo search\` filters by them: \`pamo search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff"\`
152
164
  sorts backgrounds by color fit (the mean distance from each of theirs to the nearest of yours; lower is closer; pass
@@ -163,7 +175,7 @@ one-line \`--note\` for each; share the link.
163
175
 
164
176
  The same registry as tools, with no install: add \`${site}/mcp\` (streamable HTTP, no auth) to your agent. Tools:
165
177
  \`search\` (the filters above), \`resource\` (one record in full), \`recolor\` (a background's values in a palette),
166
- \`board\` (creates a short link and, with \`wait\`, returns the user's choice) and \`board_choice\`. Installing is
178
+ \`board\` (creates a short link and waits for the user's choice, which it returns as \`choices\`) and \`board_choice\`. Installing is
167
179
  still \`npx paper-motion add\` in the user's project, or your own file tools from the record.
168
180
 
169
181
  ## The board link format
@@ -172,7 +184,7 @@ still \`npx paper-motion add\` in the user's project, or your own file tools fro
172
184
 
173
185
  \`\`\`json
174
186
  { "v": 1, "kind": "resource", "title": "Backgrounds for Kiln", "ask": "Which one feels like the product?",
175
- "reply": "bg-1",
187
+ "reply": "bg-1", "choose": 1,
176
188
  "look": { "palette": { "id": "cobalt", "values": {} }, "font": { "id": "geist", "values": {} } },
177
189
  "options": [
178
190
  { "label": "A", "id": "mesh-graphite", "values": {}, "note": "quiet, graphite" },
@@ -181,10 +193,19 @@ still \`npx paper-motion add\` in the user's project, or your own file tools fro
181
193
  { "label": "D", "id": "grain-slate", "values": {} } ] }
182
194
  \`\`\`
183
195
 
196
+ A recipe board may hold one option that is a structure of your own for this brief; if chosen, you build it from scratch:
197
+
198
+ \`\`\`json
199
+ { "label": "D", "structure": { "title": "Three cuts", "duration": 18, "beats": [
200
+ { "name": "Hook", "from": 0, "to": 2, "what": "one line over the field" },
201
+ { "name": "Proof", "from": 2, "to": 12, "what": "three shots of the product" },
202
+ { "name": "Close", "from": 12, "to": 18, "what": "the wordmark and the action" } ] } }
203
+ \`\`\`
204
+
184
205
  Short links: \`POST ${site}/api/boards\` with \`{ "board": … }\` returns \`{ id, url }\`; \`GET /api/boards/<id>/choice\`
185
206
  answers 204 until the user chooses, then \`{ line, label }\`. Boards expire after 90 days; \`DELETE /api/boards/<id>\` forgets one.
186
207
 
187
- Rules: 2–4 options, labels A–D, one resource type per \`resource\` board; \`kind: "kit"\` makes each option
208
+ Rules: 2–4 options, labels A–D, \`choose\` 1 (default), a count or \`[min, max]\`, one resource type per \`resource\` board; \`kind: "kit"\` makes each option
188
209
  \`{ "label", "items": [{ "id", "values" }], "note" }\` (a whole kit); \`values\` hold only what differs from the
189
210
  defaults; the JSON stays under 8 KB. \`pamo board --url\` builds and validates it for you.
190
211
 
@@ -234,7 +255,7 @@ export function build({ dir = resolveRegistry().dir, out, site = SITE } = {}) {
234
255
  for (const it of items) if (it.preview) { const have = Object.fromEntries(Object.entries(it.preview).filter(([, p]) => typeof p !== 'string' || fs.existsSync(path.join(out, p)))); it.preview = Object.values(have).some((p) => typeof p === 'string') ? have : undefined; }
235
256
  const index = {
236
257
  name: 'paper-motion', site, schema: `${site}/schema.json`, count: items.length, types: TYPES.filter((t) => items.some((i) => i.type === t)),
237
- items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, params }) => ({ id, type, title, summary, tags, version, url: `${site}/r/${id}.json`, ...Object.fromEntries(Object.entries({ when, mood, energy, role, colors, mode, motion, voice, duration, formats, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
258
+ items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, params }) => ({ id, type, title, summary, tags, version, url: `${site}/r/${id}.json`, ...Object.fromEntries(Object.entries({ when, mood, energy, role, colors, mode, motion, voice, duration, formats, beats, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
238
259
  };
239
260
  fs.writeFileSync(path.join(out, 'registry.json'), JSON.stringify(index, null, 2));
240
261
  fs.mkdirSync(path.join(out, 'files'), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paper-motion",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Frame-exact video renderer for HTML scenes built with Paper Shaders: the engine behind the paper-motion agent skills.",
5
5
  "keywords": [
6
6
  "video",