paper-motion 0.2.4 → 0.2.6

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,24 @@
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.6 · 2026-11-10
6
+
7
+ - `camera()` in `paper-motion/kit`: a 3D world for a piece (`place`, `at`, `travel` with keyframes, `focus` with depth of field and gradual exits). Every recipe is rebuilt on it: a corridor the camera travels (launch, manifesto), a column it descends (three-things), a line it dollies along with a pull-back recap (explainer), a window it tilts into and punches (product-demo), a slab it swings around (before-after), a sleeve it orbits (release).
8
+ - A new resource type, **shots**: 3–6 s camera gestures (`focus-pull`, `pass-over`, `cursor-guide`, `push-in`, `turn-in`), installed like recipes, chained before one or used alone.
9
+ - `scramble` measures letters by layout width, so it works inside a 3D world.
10
+
11
+ ## 0.2.5 · unreleased
12
+
13
+ - Recipes declare `beats` (the structure as a timeline); boards draw them, and a recipe board may hold a structure of the agent's own (`--option "@Title | 18 | Hook 0-2 …"`), which `parseReply` returns as `structure`.
14
+ - Three recipes: `manifesto` (words only, three statements and a turn), `three-things` (three cards, three mechanics) and `before-after` (two states, one wipe).
15
+ - 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.
16
+
17
+ ## 0.2.4 · 2026-11-09
18
+
19
+ - `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`.
20
+ - llms.txt: read the installed skill at once, the decisions that always go through a board, every chosen move appears in the piece.
21
+
22
+ ## 0.2.3 · 2026-11-09
6
23
 
7
24
  - 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
25
 
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) {
@@ -639,18 +647,22 @@ async function main() {
639
647
  const only = typeof args.only === 'string' ? args.only.split(',') : null;
640
648
  const out = path.resolve(args.out ?? path.join(reg.dir, '..', 'apps', 'web', 'public', 'previews'));
641
649
  fs.mkdirSync(out, { recursive: true });
642
- const items = loadItems(reg.dir).filter((i) => ['component', 'transition', 'text', 'camera', 'recipe'].includes(i.type) && (!only || only.includes(i.id)));
643
- const POSTER = { component: 1.5, transition: 1.4, text: 2.6, camera: 0.9, recipe: 2.5 };
650
+ const items = loadItems(reg.dir).filter((i) => ['component', 'transition', 'text', 'camera', 'recipe', 'shot'].includes(i.type) && (!only || only.includes(i.id)));
651
+ const POSTER = { component: 1.5, transition: 1.4, text: 2.6, camera: 0.9, recipe: 2.5, shot: 1.8 };
644
652
  const demo = path.join(reg.dir, '_demo', 'index.html');
645
653
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-previews-'));
646
654
  try {
647
655
  for (const [k, it] of items.entries()) {
648
656
  process.stdout.write(`\r${k + 1}/${items.length} ${it.id.padEnd(16)}`);
649
657
  const mp4 = path.join(tmp, `${it.id}.mp4`);
650
- if (it.type === 'recipe') {
658
+ if (it.type === 'recipe' || it.type === 'shot') {
651
659
  // a recipe renders whole, wired to the demo brand in registry/_demo/brand (served as /brand)
652
660
  const here = process.cwd(), copy = path.join(reg.dir, '_demo', `.${it.id}.recipe.html`);
653
- fs.copyFileSync(path.join(reg.dir, 'recipe', it.id, 'scene.html'), copy);
661
+ // the demo brand's copy goes in through the same generator `pamo add --set` uses (preview.values)
662
+ const { apply: applyValues } = await import('../lib/customize.mjs');
663
+ const sceneFile = { path: 'scene.html', content: fs.readFileSync(path.join(reg.dir, it.type, it.id, 'scene.html'), 'utf8') };
664
+ const out = applyValues({ ...it, files: [sceneFile] }, it.preview?.values ?? {});
665
+ fs.writeFileSync(copy, out.files[0].content);
654
666
  process.chdir(path.join(reg.dir, '_demo'));
655
667
  try { await withServer(copy, (url) => renderVideo({ url: url('?render'), out: mp4, sfx: false, crf: 18, motionBlur: Number(args['motion-blur'] ?? 3) })); }
656
668
  finally { process.chdir(here); fs.rmSync(copy, { force: true }); }
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 };
@@ -140,8 +141,12 @@ function fontTokens(item, values) {
140
141
  function recipeConfig(values) {
141
142
  const cfg = {}, groups = {};
142
143
  for (const [k, v] of Object.entries(values)) {
143
- const m = k.match(/^(proof|step|shot)(\d)(Label|Title|Body|Image|Caption)$/);
144
- if (m) { const g = { proof: 'proof', step: 'steps', shot: 'shots' }[m[1]]; (groups[g] ??= [])[m[2] - 1] ??= {}; groups[g][m[2] - 1][m[3].toLowerCase()] = v; continue; }
144
+ const m = k.match(/^(proof|step|shot|thing)(\d)(Label|Title|Body|Image|Caption)$/);
145
+ if (m) { const g = { proof: 'proof', step: 'steps', shot: 'shots', thing: 'things' }[m[1]]; (groups[g] ??= [])[m[2] - 1] ??= {}; groups[g][m[2] - 1][m[3].toLowerCase()] = v; continue; }
146
+ const ba = k.match(/^(before|after)(Tag|Line|Image)$/);
147
+ if (ba) { (cfg[ba[1]] ??= {})[ba[2].toLowerCase()] = v; continue; }
148
+ const st = k.match(/^statement(\d)$/);
149
+ if (st) { (cfg.statements ??= [])[st[1] - 1] = v; continue; }
145
150
  if (k === 'format') cfg.format = String(v).split('x').map(Number);
146
151
  else cfg[k] = v === '' ? null : v;
147
152
  }
@@ -153,10 +158,17 @@ function recipeScene(content, values) {
153
158
  const start = content.indexOf('const CONFIG = {');
154
159
  if (start < 0) return content;
155
160
  const end = content.indexOf('\n};', start) + 3;
156
- const body = Object.entries(cfg).map(([k, v]) => (Array.isArray(v) && v.length && typeof v[0] === 'object'
161
+ // the original entries, by key (an entry runs until the next top-level key), so settings that are not params
162
+ // (backgroundOpacity, turnWord, a card's label…) survive a --set
163
+ const original = content.slice(start + 'const CONFIG = {\n'.length, end - 3).split(/\n(?= \w+: )/);
164
+ const kept = new Map(original.map((e) => [e.match(/^ (\w+):/)?.[1], e]).filter(([k]) => k));
165
+ const render = (k, v) => (Array.isArray(v) && v.length && typeof v[0] === 'object'
157
166
  ? ` ${k}: [\n${v.map((o) => ` ${literal(o)},`).join('\n')}\n ],`
158
- : ` ${k}: ${v === null ? 'null' : literal(v)},`)).join('\n');
159
- return `${content.slice(0, start)}const CONFIG = {\n${body}\n};${content.slice(end)}`;
167
+ : ` ${k}: ${v === null ? 'null' : literal(v)},`);
168
+ const lines = [];
169
+ for (const [k, e] of kept) lines.push(k in cfg ? render(k, cfg[k]) : e);
170
+ for (const [k, v] of Object.entries(cfg)) if (!kept.has(k)) lines.push(render(k, v));
171
+ return `${content.slice(0, start)}const CONFIG = {\n${lines.join('\n')}\n};${content.slice(end)}`;
160
172
  }
161
173
 
162
174
  // ── the public API ──────────────────────────────────────────────
@@ -170,7 +182,7 @@ export function apply(item, values) {
170
182
  const files = (item.files ?? []).map((f) => {
171
183
  if (f.content === undefined) return f;
172
184
  if (MOVE_TYPES.includes(item.type)) return { ...f, content: moveFile(item, f.content, v) };
173
- if (item.type === 'recipe' && /scene\.html$/.test(f.path)) return { ...f, content: recipeScene(f.content, v) };
185
+ if ((item.type === 'recipe' || item.type === 'shot') && /scene\.html$/.test(f.path)) return { ...f, content: recipeScene(f.content, v) };
174
186
  return f;
175
187
  });
176
188
  let entry = item.install?.entry;
@@ -215,7 +227,7 @@ export function code(item, values, { showAll = false, site = 'https://paper-moti
215
227
  { key: 'tokens', label: 'tokens.json', lang: 'json', code: JSON.stringify(e, null, 2) },
216
228
  { 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}` },
217
229
  ];
218
- } else if (item.type === 'recipe') {
230
+ } else if (item.type === 'recipe' || item.type === 'shot') {
219
231
  const scene = out.files.find((f) => /scene\.html$/.test(f.path));
220
232
  tabs = [{ key: 'scene', label: 'scene.html', lang: 'html', code: scene?.content ?? '' }];
221
233
  }
package/lib/live-board.js CHANGED
@@ -9,12 +9,15 @@
9
9
  export const BOARD_VERSION = 1;
10
10
  export const MAX_PAYLOAD = 8 * 1024;
11
11
  const LABELS = ['A', 'B', 'C', 'D'];
12
- const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
12
+ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe', 'shot'];
13
13
 
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
@@ -12,8 +12,8 @@ const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
12
  const REPO = path.resolve(ENGINE, '..', '..');
13
13
  // until the paper-motion.app domain is connected, the live site is the vercel URL
14
14
  export const SITE = (process.env.PAPER_MOTION_SITE ?? 'https://paper-motion.vercel.app').replace(/\/$/, '');
15
- const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
16
- const TYPE_TITLES = { component: 'Component transitions', transition: 'Transitions', text: 'Text effects', camera: 'Camera and energy', background: 'Backgrounds', palette: 'Palettes', 'font-pair': 'Font pairs', recipe: 'Recipes' };
15
+ const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe', 'shot'];
16
+ const TYPE_TITLES = { component: 'Component transitions', transition: 'Transitions', text: 'Text effects', camera: 'Camera and energy', background: 'Backgrounds', palette: 'Palettes', 'font-pair': 'Font pairs', recipe: 'Recipes', shot: 'Shots' };
17
17
 
18
18
  /** The registry to use: an explicit folder or URL, $PAPER_MOTION_REGISTRY, this repo's registry/ when present, else the site. */
19
19
  export function resolveRegistry(given) {
@@ -110,19 +110,29 @@ 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 or shots
123
+ (shots are 3–6 s camera gestures: a close-up, a pass over a component, a cursor that clicks; they chain before a
124
+ recipe or stand alone). Skip a
125
+ board only when the brand folder already settles that decision, and say so.
118
126
  3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
119
127
  this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
120
128
  (format below): a short link (\`${site}/b/<id>\`, stored for 90 days) when the site answers, the long form
121
129
  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.
130
+ and chooses. The command keeps running and prints the choice the moment they choose (10 min by default;
131
+ \`--no-wait\` returns at once and they paste the line instead). It is one line: \`paper-motion: B · mesh-sage --set
132
+ speed=0.8 (bg-1)\`, \`paper-motion: A+C · type-on · scramble (text-1)\` when the board let them pick several
133
+ (\`--choose 1-3\`: use it for text effects and transitions), or \`paper-motion: none of bg-1, calmer ones\`. The
134
+ reply carries every value that differs from the defaults, your pre-tuning included, so installing it reproduces what
135
+ they saw. Never ask the user to paste anything while a command or the MCP is waiting: the choice arrives on its own.
126
136
  4. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
127
137
  \`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
128
138
  fonts to \`brand/tokens.json\`, recipes to \`pieces/<id>/\`. Their choices also accumulate in a kit at ${site}/kit,
@@ -130,7 +140,8 @@ export function llmsTxt(items, site, { full = false } = {}) {
130
140
  the user re-chooses or re-tunes something you installed before, pass \`--force\`. A recipe also reads
131
141
  \`brand/motion.json\` (pace) and \`brand/sound.json\` (cues; without it the piece is silent) and the brand's name
132
142
  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
143
+ 5. **Make the video.** Every move the user chose appears in the piece, and the brief says where each one plays;
144
+ 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
145
  \`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
135
146
 
136
147
  ## Choosing well: the metadata
@@ -146,7 +157,13 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
146
157
  reply line carries the resulting values. Without a board, \`pamo add <background> --palette <palette-id|brand>\`
147
158
  installs it that way (\`brand\` reads \`brand/tokens.json\`). So choose backgrounds by \`motion\`, \`mood\` and
148
159
  texture first; the color comes from the palette.
149
- - Font pairs: \`voice\` (serif, sans, display, mono). Recipes: \`duration\`, \`formats\`.
160
+ - Font pairs: \`voice\` (serif, sans, display, mono). Recipes and shots: \`duration\`, \`formats\` and \`beats\` (the structure as a
161
+ timeline: name, from, to, what). A recipe board plays each structure as a sketch, so the user chooses a rhythm, not a
162
+ name; you may add one option that is a **structure of your own** for this brief (\`structure\` instead of \`id\`,
163
+ format below), which you then build from scratch. Give the recipe board \`choose: [1, 3]\`: picking several recipes in
164
+ order means **one piece made of those blocks in that order**, each block's closing beat dropped except the last; the
165
+ reply reads \`A+C · manifesto · three-things\`, and you build that sequence from scratch, using each recipe's scene as
166
+ the reference for its block.
150
167
 
151
168
  \`pamo search\` filters by them: \`pamo search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff"\`
152
169
  sorts backgrounds by color fit (the mean distance from each of theirs to the nearest of yours; lower is closer; pass
@@ -163,7 +180,7 @@ one-line \`--note\` for each; share the link.
163
180
 
164
181
  The same registry as tools, with no install: add \`${site}/mcp\` (streamable HTTP, no auth) to your agent. Tools:
165
182
  \`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
183
+ \`board\` (creates a short link and waits for the user's choice, which it returns as \`choices\`) and \`board_choice\`. Installing is
167
184
  still \`npx paper-motion add\` in the user's project, or your own file tools from the record.
168
185
 
169
186
  ## The board link format
@@ -172,7 +189,7 @@ still \`npx paper-motion add\` in the user's project, or your own file tools fro
172
189
 
173
190
  \`\`\`json
174
191
  { "v": 1, "kind": "resource", "title": "Backgrounds for Kiln", "ask": "Which one feels like the product?",
175
- "reply": "bg-1",
192
+ "reply": "bg-1", "choose": 1,
176
193
  "look": { "palette": { "id": "cobalt", "values": {} }, "font": { "id": "geist", "values": {} } },
177
194
  "options": [
178
195
  { "label": "A", "id": "mesh-graphite", "values": {}, "note": "quiet, graphite" },
@@ -181,10 +198,19 @@ still \`npx paper-motion add\` in the user's project, or your own file tools fro
181
198
  { "label": "D", "id": "grain-slate", "values": {} } ] }
182
199
  \`\`\`
183
200
 
201
+ A recipe board may hold one option that is a structure of your own for this brief; if chosen, you build it from scratch:
202
+
203
+ \`\`\`json
204
+ { "label": "D", "structure": { "title": "Three cuts", "duration": 18, "beats": [
205
+ { "name": "Hook", "from": 0, "to": 2, "what": "one line over the field" },
206
+ { "name": "Proof", "from": 2, "to": 12, "what": "three shots of the product" },
207
+ { "name": "Close", "from": 12, "to": 18, "what": "the wordmark and the action" } ] } }
208
+ \`\`\`
209
+
184
210
  Short links: \`POST ${site}/api/boards\` with \`{ "board": … }\` returns \`{ id, url }\`; \`GET /api/boards/<id>/choice\`
185
211
  answers 204 until the user chooses, then \`{ line, label }\`. Boards expire after 90 days; \`DELETE /api/boards/<id>\` forgets one.
186
212
 
187
- Rules: 2–4 options, labels A–D, one resource type per \`resource\` board; \`kind: "kit"\` makes each option
213
+ 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
214
  \`{ "label", "items": [{ "id", "values" }], "note" }\` (a whole kit); \`values\` hold only what differs from the
189
215
  defaults; the JSON stays under 8 KB. \`pamo board --url\` builds and validates it for you.
190
216
 
@@ -234,7 +260,7 @@ export function build({ dir = resolveRegistry().dir, out, site = SITE } = {}) {
234
260
  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
261
  const index = {
236
262
  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)) })),
263
+ 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
264
  };
239
265
  fs.writeFileSync(path.join(out, 'registry.json'), JSON.stringify(index, null, 2));
240
266
  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.6",
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",
package/runtime/brand.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // const s = scene({ ..., background: B.color('ground') });
6
6
  // B.style(title, 'display'); B.shader(s, '#bg', 'mesh-ground'); B.cue(s, 'transition', 2.4); B.sound(s);
7
7
  import { PACE } from './moves.js';
8
+ import { mixColor } from './core.js';
8
9
 
9
10
  const get = (p, json) => fetch(p).then((r) => (r.ok ? (json ? r.json() : r.text()) : null)).catch(() => null);
10
11
  const sections = (md) => Object.fromEntries((md ?? '').split(/^## /m).slice(1).map((b) => { const i = b.indexOf('\n'); return [b.slice(0, i).trim(), b.slice(i + 1).trim()]; }));
@@ -33,6 +34,15 @@ export async function loadBrand(root = '/brand') {
33
34
  name: wm.text ?? tokens?.brand ?? 'brand',
34
35
  /** A color by role (ground, figure, muted, surface, accent…), with a fallback. */
35
36
  color: (role, fallback = '#888888') => C[role]?.hex ?? fallback,
37
+ /**
38
+ * The surface for cards and panels, lifted toward the figure when the brand's own surface is too close to the
39
+ * ground to read (a black brand with a near-black surface would draw black cards on black).
40
+ */
41
+ surface: (lift = 0.14) => {
42
+ const g = C.ground?.hex ?? '#000000', s = C.surface?.hex ?? g, f = C.figure?.hex ?? '#ffffff';
43
+ const lum = (h) => { const n = parseInt(h.slice(1, 7), 16); return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255; };
44
+ return Math.abs(lum(s) - lum(g)) < 0.06 ? mixColor(g, f, lift) : s;
45
+ },
36
46
  ramp: tokens?.ramp ?? [C.ground?.hex, C.surface?.hex, C.accent?.hex].filter(Boolean),
37
47
  /** CSS font-family for a font key (display, text, mono). */
38
48
  font: fam,
package/runtime/kit.js CHANGED
@@ -96,3 +96,76 @@ export function copy(el) {
96
96
  }
97
97
 
98
98
  export const partsOf = (el) => new Map([...el.querySelectorAll('[data-part]')].map((e) => [e.dataset.part, e]));
99
+
100
+ // ── a 3D camera over a world ────────────────────────────────────
101
+ // A recipe is a world the camera moves through, not a deck of cards. `stage` gets the perspective; `world` holds
102
+ // children placed in space with `place()`; the camera's position is applied to the world as its inverse.
103
+ // const cam = camera(stage, world, { perspective: 2000 });
104
+ // cam.place(title, { z: -1200, yaw: 8 });
105
+ // s.update((t) => { cam.travel(t, [[0, { z: 1400 }], [3, { z: -200, yaw: 6 }, ease.inOutCubic]]); cam.focus(0, 900); });
106
+ // Rules: never set opacity < 1 on `world` (it flattens its children); fade the placed elements instead.
107
+ const CAM = { x: 0, y: 0, z: 0, yaw: 0, pitch: 0, roll: 0, zoom: 1 };
108
+ export function camera(stage, world, { perspective = 2000, origin = '50% 50%' } = {}) {
109
+ Object.assign(stage.style, { perspective: `${perspective}px`, perspectiveOrigin: origin });
110
+ Object.assign(world.style, { position: 'absolute', inset: 0, transformStyle: 'preserve-3d', transformOrigin: '50% 50%' });
111
+ const placed = [];
112
+ let pos = { ...CAM };
113
+ const P = perspective;
114
+ const cam = {
115
+ /** Where the camera is (the last `at`/`travel`). */
116
+ get pos() { return pos; },
117
+ /** Puts an element in the world, centred on its own box: { x, y, z, yaw, pitch, roll, scale }. */
118
+ place(el, p = {}) {
119
+ const q = { x: 0, y: 0, z: 0, yaw: 0, pitch: 0, roll: 0, scale: 1, ...p };
120
+ Object.assign(el.style, { position: 'absolute', left: '50%', top: '50%', transformStyle: 'preserve-3d', backfaceVisibility: 'hidden' });
121
+ el.style.transform = `translate(-50%, -50%) translate3d(${q.x}px, ${q.y}px, ${q.z}px) rotateY(${q.yaw}deg) rotateX(${q.pitch}deg) rotateZ(${q.roll}deg) scale(${q.scale})`;
122
+ el._pmPlace = q;
123
+ if (!placed.includes(el)) placed.push(el);
124
+ return el;
125
+ },
126
+ /** Moves the camera: the world gets the inverse transform. Fields missing keep the current value. */
127
+ at(p) {
128
+ pos = { ...pos, ...p };
129
+ world.style.transform = `scale(${pos.zoom}) rotateZ(${-pos.roll}deg) rotateX(${-pos.pitch}deg) rotateY(${-pos.yaw}deg) translate3d(${-pos.x}px, ${-pos.y}px, ${-pos.z}px)`;
130
+ return pos;
131
+ },
132
+ /** Keyframes for the camera: [[t, { x, y, z, yaw, pitch, roll, zoom }, ease?], …]. Fields carry over between frames. */
133
+ travel(t, frames) {
134
+ const full = [];
135
+ let acc = { ...CAM, ...frames[0][1] };
136
+ for (const [time, p, e] of frames) { acc = { ...acc, ...p }; full.push([time, acc, e]); }
137
+ if (t <= full[0][0]) return cam.at(full[0][1]);
138
+ for (let i = 1; i < full.length; i++) {
139
+ const [t1, v1, e = ease.inOutCubic] = full[i], [t0, v0] = full[i - 1];
140
+ if (t <= t1) { const q = e(clamp((t - t0) / (t1 - t0))); return cam.at(Object.fromEntries(Object.keys(CAM).map((k) => [k, lerp(v0[k], v1[k], q)]))); }
141
+ }
142
+ return cam.at(full[full.length - 1][1]);
143
+ },
144
+ /**
145
+ * Depth of field and presence. Blur follows DEPTH: distance from the focal plane `z` ahead of the camera (sharp
146
+ * within a third of `range`, up to `max` px). Presence follows DISTANCE in 3D from the point the camera looks at:
147
+ * a place is whole within `fade`·0.4 and gone by `fade` (default `range`·0.85), so the next place is never
148
+ * peeking into frame while the camera rests, and while it travels the two overlap instead of leaving an empty
149
+ * frame. A place passing the lens softens and fades over the last stretch: it finishes its exit, never cuts.
150
+ * `fade: Infinity` keeps everything present (a focus pull where only the blur should change).
151
+ */
152
+ focus(z = 0, range = 800, max = 12, { fade = range * 0.85 } = {}) {
153
+ const fx = pos.x, fy = pos.y, fz = pos.z - z; // the point in focus
154
+ for (const el of placed) {
155
+ const q = el._pmPlace;
156
+ const blur = clamp((Math.abs(q.z - fz) - range * 0.33) / range) * max;
157
+ const dist = Math.hypot(q.x - fx, q.y - fy, q.z - fz);
158
+ const present = fade === Infinity ? 1 : 1 - clamp((dist - fade * 0.4) / Math.max(1, fade * 0.6));
159
+ const toLens = pos.z - q.z; // distance left before it passes the camera
160
+ const near = clamp((toLens - P * 0.03) / (P * 0.1)); // the last stretch before the lens, whatever the focus range
161
+ const b = Math.max(blur, (1 - near) * max, (1 - present) * max * 0.6);
162
+ el.style.filter = b > 0.3 ? `blur(${b.toFixed(1)}px)` : 'none';
163
+ const o = Math.min(near, present);
164
+ el.style.opacity = String(o);
165
+ el.style.visibility = o < 0.005 ? 'hidden' : '';
166
+ }
167
+ },
168
+ };
169
+ cam.at({});
170
+ return cam;
171
+ }
@@ -6,7 +6,8 @@ export function scramble(el, t, { start = 0, duration = 1.4, fps = 30, rate = 2,
6
6
  const cs = chars(el);
7
7
  if (!el._labText) {
8
8
  el._labText = cs.map((c) => c.textContent);
9
- cs.forEach((c) => { c.style.width = `${c.getBoundingClientRect().width}px`; c.style.textAlign = 'center'; });
9
+ // layout width, not the on-screen rect: inside a 3D world the rect is scaled by perspective
10
+ cs.forEach((c) => { c.style.width = `${c.offsetWidth}px`; c.style.textAlign = 'center'; });
10
11
  const r = rng(seed); el._labJit = cs.map(() => r.next());
11
12
  }
12
13
  const n = cs.length, tick = Math.floor((t * fps) / rate);
@@ -5,6 +5,8 @@ import { words } from '../kit.js';
5
5
  export function wordRise(el, t, { start = 0, each = 0.08, duration = 0.5, curve = ease.outExpo } = {}) {
6
6
  words(el).forEach((w, i) => {
7
7
  const q = curve(progress(t, start + i * each, start + i * each + duration));
8
- w.style.transform = `translateY(${(1 - q) * 110}%)`;
8
+ // fully below the mask line, and hidden until it moves: tall glyphs overflow a tight line-height box
9
+ w.style.transform = `translateY(${(1 - q) * 125}%)`;
10
+ w.style.opacity = q > 0.001 ? 1 : 0;
9
11
  });
10
12
  }