paper-motion 0.2.1 → 0.2.3

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 ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
4
+
5
+ ## 0.2.3 · unreleased
6
+
7
+ - 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
+
9
+ ## 0.2.2 · 2026-11-09
10
+
11
+ - Every background is a color family: `recolor(item, palette)` in `lib/customize.mjs`, `pamo add <background> --palette <id|brand>`, boards with a `--look` show backgrounds recolored and their reply lines carry the values. A background's CSS `background` is a param; `B.shader` paints it.
12
+ - Two recipes: `product-demo` and `release`.
13
+ - Six halftone backgrounds filter `material-b.jpg`, rendered by the engine, instead of an image hosted by Paper.
14
+
15
+ ## 0.2.1 · 2026-11-08
16
+
17
+ Fixes found by making paper-motion's own launch video as a fresh user.
18
+
19
+ - The CLI's default site is `https://paper-motion.vercel.app` until the `paper-motion.app` domain is connected, so `search`, `add` and `board --url` work without `PAPER_MOTION_SITE`.
20
+ - `pamo add` scaffolds what a recipe reads but boards never settle: `brand/motion.json` (pace) and `brand/sound.json` (cues) with defaults when missing, and the brand's name through `--brand "Name"`. Without a name it says so instead of rendering "brand".
21
+ - `llms.txt` step 4 says that `add` never overwrites a value already in the project and that re-choosing needs `--force`.
22
+ - Recipes dim the background layer behind type (`CONFIG.backgroundOpacity`, with a vignette on launch, explainer and end-card) and size card bodies and the action line for phones.
23
+ - Registry items can set `preview.at`, the poster time in seconds; the five recipes use it so their posters land on a readable frame. `registry build` keeps non-string preview fields when it drops missing files.
24
+
25
+ ## 0.2.0 · 2026-11-08
26
+
27
+ paper-motion.app: a registry of named resources and the commands that use it.
28
+
29
+ - `pamo registry build` publishes `registry.json`, `r/<id>.json`, `llms.txt` and `llms-full.txt` from `registry/`; `pamo registry previews` renders a clip and a poster per move and recipe, and `build` publishes only previews that exist.
30
+ - `pamo add <id…>` installs resources as code the user owns: moves into `moves/`, backgrounds into `brand/shader-set/set.json`, palettes and font pairs into `brand/tokens.json`, recipes into `pieces/<id>/`. `--set key=value` writes the user's values through the same generator the site uses (`lib/customize.mjs`); `paper-motion.json` records what was installed. `pamo list` and `pamo search` read the index; `search` filters by the choosing metadata (when, mood, energy, role, colors, motion…) and `--near` ranks by color fit.
31
+ - `pamo board --url` builds and validates live board links (`/b#payload`, deflate + base64url, nothing reaches a server): 2–4 labelled options the user compares in their own look, tunes and chooses; the reply is one line the agent parses with `lib/live-board.js`. `lib/board.mjs` remains the rendered board.
32
+ - `paper-motion/moves` grows from 12 to 38: transitions (whip, cells, slices, iris, glitch, flip3D, flash), text (scramble, popIn, flipIn, roll, odometer, barReveal), camera and energy (shake, punch, orbit, rgbSplit, drawOn) and component transitions (magicMove, whipPan, diveIn, flipLift, squashDrop, snapScroll, unfold, tileSwirl). Each move is its own file under `runtime/moves/`; shared helpers are `paper-motion/kit`; one whip blur filter per stage.
33
+ - `pamo new --move` accepts any transition in the library; `pamo board --motion-blur n` renders option boards with real motion blur.
34
+ - `PAPER_MOTION_SITE` overrides the site URL everywhere the engine writes links. OG images are rendered by the engine.
35
+ - Examples: the Halo explainer and the Orbit keynote. Sound effects keep their gains next to a voice.
36
+ - The engine ships no audio. A user's own recording (`pamo keys`) gives real keystrokes; without one they are synthesized.
37
+
38
+ ## 0.1.0 · 2026-11-08
39
+
40
+ First public release of the engine as the unscoped npm package `paper-motion` (previously `@paper-motion/engine`, private).
41
+
42
+ - `pamo` CLI: `setup`, `doctor`, `new`, `still`, `render` (mp4, prores, gif, webm; `--motion-blur`, `--dpr`, `--loudness`, `--audio`), `preview`, `board`, `shaders`, `examples`, `status`, `keys`, `shader-set`.
43
+ - Runtime: scenes as pure functions of time, Paper Shaders by name with their React props, seamless shader loops, `paper-motion/brand` (`loadBrand`, `B.shader`, `B.style`, `B.cue`, `B.sound`), `paper-motion/moves` (12 moves), synthesized sound effects mixed into renders.
44
+ - Dependencies are found wherever npm puts them, so an install does not re-install them inside the package. Four bundled examples: breath, signal, install-loop, chrome-endcard.
package/README.md CHANGED
@@ -55,6 +55,10 @@ All 30 [Paper Shaders](https://shaders.paper.design), a library of moves (wipes,
55
55
  frame, terminal typing), synthesized sound effects or real keystrokes split from your own recording, option boards,
56
56
  wordmarks and the brand sheet.
57
57
 
58
+ ## Changes
59
+
60
+ [CHANGELOG.md](./CHANGELOG.md) lists what each npm release changed.
61
+
58
62
  ## License
59
63
 
60
64
  MIT. Paper Shaders are © Paper Design (Apache-2.0).
package/bin/pamo.mjs CHANGED
@@ -322,12 +322,12 @@ async function main() {
322
322
  case 'board': {
323
323
  if (args.url) {
324
324
  // the live form: a paper-motion.app link the user opens to compare, tune and choose
325
- const { encodeBoard, boardUrl } = await import('../lib/live-board.js');
325
+ const { encodeBoard, boardUrl, shortenBoard, awaitChoice } = await import('../lib/live-board.js');
326
326
  const { getIndex, resolveRegistry, SITE, getItem } = await import('../lib/registry.mjs');
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] [--site url]\n Prints a live board link. Values after the colon are your adjustments; the reply line the user pastes carries every non-default value.'); 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] [--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); }
331
331
  const LABELS = ['A', 'B', 'C', 'D'];
332
332
  const parseOne = async (spec) => {
333
333
  const [id, kv] = spec.split(':'), it = index[id];
@@ -347,7 +347,26 @@ async function main() {
347
347
  const look = {};
348
348
  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
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 };
350
- try { const frag = await encodeBoard(board, index); console.log(boardUrl(typeof args.site === 'string' ? args.site : SITE, frag)); }
350
+ const site = typeof args.site === 'string' ? args.site : SITE;
351
+ let frag, short = null;
352
+ try { frag = await encodeBoard(board, index); } catch (e) { console.error(e.message); process.exit(1); }
353
+ // the short link when the site stores it; the long one (the board in the fragment) when it cannot
354
+ if (!args.long) { try { short = await shortenBoard(site, board); } catch (e) { console.error(`(${e.message}; sharing the long link)`); } }
355
+ 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;
358
+ console.error(`waiting up to ${minutes} min for the choice on ${short.id} (the user can still paste the line)…`);
359
+ const choice = await awaitChoice(site, short.id, { timeout: minutes * 60 * 1000 });
360
+ if (!choice) { console.error('no choice yet; ask the user to paste the line from the board'); process.exit(3); }
361
+ console.log(choice.line);
362
+ } else if (!short && args.wait) console.error('--wait needs the short link; the user will paste the line instead');
363
+ break;
364
+ }
365
+ if (args.forget) {
366
+ const { forgetBoard } = await import('../lib/live-board.js');
367
+ const { SITE } = await import('../lib/registry.mjs');
368
+ const id = String(args.forget);
369
+ try { await forgetBoard(typeof args.site === 'string' ? args.site : SITE, id); console.log(`forgot board ${id}`); }
351
370
  catch (e) { console.error(e.message); process.exit(1); }
352
371
  break;
353
372
  }
@@ -579,10 +598,10 @@ async function main() {
579
598
 
580
599
  case 'add': {
581
600
  const ids = args._.slice(1);
582
- if (!ids.length || args.help) { console.error('Usage: add <id> [<id>...] [--set key=value ...] [--force] [--name piece] [--brand "Name"] [--registry url|dir]\n --set writes your values into the installed copy (with several ids: --set id:key=value). --force replaces values already in the project. --brand names the brand in brand/tokens.json; palettes, fonts and recipes also write brand/motion.json and brand/sound.json defaults when missing. --registry picks where resources come from (default: the site).'); process.exit(args.help ? 0 : 1); }
601
+ if (!ids.length || args.help) { console.error('Usage: add <id> [<id>...] [--set key=value ...] [--palette id|brand] [--force] [--name piece] [--brand "Name"] [--registry url|dir]\n --set writes your values into the installed copy (with several ids: --set id:key=value). --palette installs backgrounds recolored into a palette (a registry palette, or "brand" for brand/tokens.json); --set still wins. --force replaces values already in the project. --brand names the brand in brand/tokens.json; palettes, fonts and recipes also write brand/motion.json and brand/sound.json defaults when missing. --registry picks where resources come from (default: the site).'); process.exit(args.help ? 0 : 1); }
583
602
  const { add } = await import('../lib/registry.mjs');
584
603
  const set = process.argv.flatMap((a, i, all) => (a === '--set' ? [all[i + 1]] : a.startsWith('--set=') ? [a.slice(6)] : [])).filter(Boolean);
585
- const report = await add(ids.filter((x) => !set.includes(x)), { registry: args.registry, force: !!args.force, name: typeof args.name === 'string' ? args.name : undefined, brand: typeof args.brand === 'string' ? args.brand : undefined, set });
604
+ const report = await add(ids.filter((x) => !set.includes(x) && x !== args.palette), { registry: args.registry, force: !!args.force, name: typeof args.name === 'string' ? args.name : undefined, brand: typeof args.brand === 'string' ? args.brand : undefined, set, palette: typeof args.palette === 'string' ? args.palette : undefined });
586
605
  for (const r of report) {
587
606
  console.log(`✓ ${r.id} (${r.type})${r.wrote.length ? ` → ${[...new Set(r.wrote)].join(', ')}` : ''}`);
588
607
  for (const f of r.skipped) console.log(` kept your ${f} (it differs; --force to replace)`);
package/lib/customize.mjs CHANGED
@@ -4,12 +4,71 @@
4
4
 
5
5
  export const MOVE_TYPES = ['component', 'transition', 'text', 'camera'];
6
6
 
7
+ /**
8
+ * A resource's params. A background whose entry paints a CSS `background` behind the shader gets it as a virtual
9
+ * color param, so recoloring, `--set background=#…` and the reply line can carry it like any other value.
10
+ */
11
+ export const paramsOf = (item) => {
12
+ const ps = item.params ?? [];
13
+ const bg = item.type === 'background' ? item.install?.entry?.background : undefined;
14
+ return typeof bg === 'string' && !ps.some((p) => p.name === 'background') ? [...ps, { name: 'background', type: 'color', default: bg, description: 'the color behind the shader', group: 'main' }] : ps;
15
+ };
16
+
7
17
  /** Param defaults by name. */
8
- export const defaults = (item) => Object.fromEntries((item.params ?? []).map((p) => [p.name, p.default]));
18
+ export const defaults = (item) => Object.fromEntries(paramsOf(item).map((p) => [p.name, p.default]));
9
19
 
10
20
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
11
21
  /** 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));
22
+ export const changed = (item, values) => paramsOf(item).filter((p) => p.name in values && !same(values[p.name], p.default));
23
+
24
+ // ── color families: a background in a palette ──────────────────
25
+
26
+ const HEX = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i;
27
+ const srgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
28
+ const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
29
+ const unlin = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055);
30
+ const lum = (h) => { const [r, g, b] = srgb(h).map(lin); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
31
+ const hex = (rgbLin) => `#${rgbLin.map((c) => Math.round(Math.min(1, Math.max(0, unlin(c))) * 255).toString(16).padStart(2, '0')).join('')}`;
32
+ /** `color` moved toward black or white until its luminance is `y`: the hue stays, the tone changes. */
33
+ function atLuminance(color, y) {
34
+ const c = srgb(color).map(lin), yc = lum(color);
35
+ if (Math.abs(yc - y) < 1e-4) return color.slice(0, 7);
36
+ if (y > yc) { const t = yc >= 1 ? 0 : (y - yc) / (1 - yc); return hex(c.map((v) => v + t * (1 - v))); }
37
+ const t = yc <= 0 ? 0 : 1 - y / yc;
38
+ return hex(c.map((v) => v * (1 - t)));
39
+ }
40
+
41
+ /** The hexes of a palette (the values of a palette resource, or `brand/tokens.json`'s colors with hexes). */
42
+ export function paletteColors(pal) {
43
+ const out = [];
44
+ for (const k of ['ground', 'figure', 'muted', 'surface', 'accent']) { const v = pal?.[k]; const h = typeof v === 'string' ? v : v?.hex; if (HEX.test(h ?? '')) out.push(h); }
45
+ for (const h of pal?.ramp ?? []) if (HEX.test(h)) out.push(h);
46
+ return [...new Set(out.map((h) => h.slice(0, 7).toLowerCase()))];
47
+ }
48
+
49
+ /**
50
+ * A background's color values in a palette: every color the resource uses keeps its own tone (a dark background
51
+ * stays dark, a bright highlight stays bright, transparency stays) and takes the hue of the palette color nearest in
52
+ * tone, so each background becomes a family: the same material in any brand's colors. Returns the values to apply.
53
+ */
54
+ export function recolor(item, pal) {
55
+ const ladder = paletteColors(pal).map((h) => ({ h, y: lum(h) }));
56
+ if (item.type !== 'background' || !ladder.length) return {};
57
+ const one = (v) => {
58
+ const m = typeof v === 'string' && v.match(HEX);
59
+ if (!m) return v;
60
+ if (m[2] && parseInt(m[2], 16) === 0) return v; // fully transparent: nothing to color
61
+ const y = lum(v);
62
+ const near = ladder.reduce((a, b) => (Math.abs(b.y - y) < Math.abs(a.y - y) ? b : a));
63
+ return atLuminance(near.h, y) + (m[2] ? m[2] : '');
64
+ };
65
+ const out = {};
66
+ for (const p of paramsOf(item)) {
67
+ if (p.type === 'color') out[p.name] = one(p.default);
68
+ else if (p.type === 'colors' && Array.isArray(p.default)) out[p.name] = p.default.map(one);
69
+ }
70
+ return out;
71
+ }
13
72
 
14
73
  /** A JS literal: strings in single quotes, `ease.*` left as code, arrays and objects inline. */
15
74
  export function literal(v) {
@@ -35,7 +94,7 @@ export function parseSet(item, pairs) {
35
94
  const i = pair.indexOf('=');
36
95
  if (i < 0) throw new Error(`--set needs key=value, got "${pair}"`);
37
96
  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(', ')})`);
97
+ if (!paramsOf(item).some((p) => p.name === k)) throw new Error(`"${item.id}" has no param "${k}" (params: ${paramsOf(item).map((p) => p.name).join(', ')})`);
39
98
  try { out[k] = JSON.parse(raw); } catch { out[k] = raw; }
40
99
  }
41
100
  return out;
@@ -57,7 +116,9 @@ function moveFile(item, content, values) {
57
116
 
58
117
  function backgroundEntry(item, values) {
59
118
  const e = item.install.entry ?? {};
60
- return { ...e, props: { ...e.props, ...Object.fromEntries((item.params ?? []).map((p) => [p.name, values[p.name] ?? p.default])) } };
119
+ const props = { ...e.props, ...Object.fromEntries((item.params ?? []).map((p) => [p.name, values[p.name] ?? p.default])) };
120
+ const bg = typeof e.background === 'string' && typeof values.background === 'string' ? values.background : e.background;
121
+ return { ...e, props, ...(bg !== undefined ? { background: bg } : {}) };
61
122
  }
62
123
 
63
124
  function paletteTokens(item, values) {
@@ -79,8 +140,8 @@ function fontTokens(item, values) {
79
140
  function recipeConfig(values) {
80
141
  const cfg = {}, groups = {};
81
142
  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; }
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; }
84
145
  if (k === 'format') cfg.format = String(v).split('x').map(Number);
85
146
  else cfg[k] = v === '' ? null : v;
86
147
  }
package/lib/find.mjs ADDED
@@ -0,0 +1,28 @@
1
+ // Finding resources in the index: free words plus the choosing metadata. Plain ESM with no Node imports, so the
2
+ // same function serves `pamo search` and the site's MCP (`search` tool); the site copies this file next to customize.mjs.
3
+
4
+ const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
5
+ /** Distance between two hex colors (0 = same, ~441 = black to white). */
6
+ export const colorDistance = (a, b) => { const x = rgb(a), y = rgb(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
7
+ /** How far a resource's colors sit from a brand's: the mean nearest distance (lower is closer). */
8
+ export const colorFit = (item, brand) => {
9
+ if (!item.colors?.length || !brand.length) return null;
10
+ const mean = item.colors.reduce((s, c) => s + Math.min(...brand.map((b) => colorDistance(c, b))), 0) / item.colors.length;
11
+ // a resource that declares one or two colors is thin evidence of a match: nudge it behind real palettes
12
+ return mean + (item.colors.length < 3 ? 40 * (3 - item.colors.length) : 0);
13
+ };
14
+
15
+ /**
16
+ * Filters index items: free words over id/title/summary/tags/when/mood, plus type, mode (light|dark), mood,
17
+ * motion (still|gentle|lively), role, energy ("3", "2-4", "<=3", ">=2") and near (hex colors, comma-separated:
18
+ * sorts by closeness and adds `fit`).
19
+ */
20
+ export function find(items, query, opts = {}) {
21
+ const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
22
+ const hit = (i) => { const hay = [i.id, i.type, i.title, i.summary, ...(i.tags ?? []), ...(i.when ?? []), ...(i.mood ?? [])].join(' ').toLowerCase(); return words.every((w) => hay.includes(w)); };
23
+ const en = (v) => { if (!opts.energy) return true; const e = String(opts.energy), m = e.match(/^(\d)-(\d)$/); if (m) return v >= +m[1] && v <= +m[2]; if (e.startsWith('<=')) return v <= +e.slice(2); if (e.startsWith('>=')) return v >= +e.slice(2); return v === +e; };
24
+ const near = opts.near ? String(opts.near).split(',').map((c) => c.trim()).filter((c) => /^#[0-9a-f]{6}$/i.test(c)) : [];
25
+ let out = items.filter((i) => (!opts.type || i.type === opts.type) && (!opts.mode || i.mode === opts.mode) && (!opts.mood || (i.mood ?? []).includes(opts.mood)) && (!opts.motion || i.motion === opts.motion) && (!opts.role || i.role === opts.role) && (i.energy === undefined || en(i.energy)) && hit(i));
26
+ if (near.length) out = out.map((i) => ({ ...i, fit: colorFit(i, near) })).filter((i) => i.fit !== null).sort((a, b) => a.fit - b.fit);
27
+ return out;
28
+ }
package/lib/live-board.js CHANGED
@@ -95,6 +95,37 @@ export async function decodeBoard(fragment) {
95
95
 
96
96
  export const boardUrl = (site, fragment) => `${site.replace(/\/$/, '')}/b#${fragment}`;
97
97
 
98
+ /**
99
+ * Stores a board on the site and returns its short link `{ id, url, expires }` (`POST /api/boards`). Throws when
100
+ * the site refuses or is unreachable; callers fall back to the fragment link.
101
+ */
102
+ export async function shortenBoard(site, board) {
103
+ const r = await fetch(`${site.replace(/\/$/, '')}/api/boards`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ board }) });
104
+ if (!r.ok) { let msg = `${r.status}`; try { msg = (await r.json()).error ?? msg; } catch { /* keep the status */ } throw new Error(`the site did not store the board (${msg})`); }
105
+ return r.json();
106
+ }
107
+
108
+ /**
109
+ * Waits for the choice made on a stored board: polls `GET /api/boards/<id>/choice` every `every` ms until one
110
+ * exists or `timeout` ms pass. Resolves `{ line, label?, none?, text?, at }`, or null on timeout.
111
+ */
112
+ export async function awaitChoice(site, id, { timeout = 10 * 60 * 1000, every = 2000, signal } = {}) {
113
+ const base = `${site.replace(/\/$/, '')}/api/boards/${id}/choice`, until = Date.now() + timeout;
114
+ while (Date.now() < until && !signal?.aborted) {
115
+ const r = await fetch(base, { cache: 'no-store' });
116
+ if (r.status === 200) return r.json();
117
+ if (r.status !== 204) throw new Error(`the site answered ${r.status} while waiting for the choice`);
118
+ await new Promise((res) => setTimeout(res, every));
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /** Forgets a stored board and its choice (`DELETE /api/boards/<id>`). */
124
+ export async function forgetBoard(site, id) {
125
+ const r = await fetch(`${site.replace(/\/$/, '')}/api/boards/${id}`, { method: 'DELETE' });
126
+ if (!r.ok && r.status !== 404) throw new Error(`the site answered ${r.status}`);
127
+ }
128
+
98
129
  // ── the reply line ──────────────────────────────────────────────
99
130
  // What the user pastes back in the chat. One line, starts with "paper-motion:", so an agent can spot and parse it.
100
131
  // paper-motion: A · mesh-sage chose A as proposed
package/lib/registry.mjs CHANGED
@@ -5,7 +5,8 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { apply as applyValues, parseSet } from './customize.mjs';
8
+ import { apply as applyValues, defaults, paletteColors, parseSet, recolor } from './customize.mjs';
9
+ import { find } from './find.mjs';
9
10
 
10
11
  const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
12
  const REPO = path.resolve(ENGINE, '..', '..');
@@ -116,9 +117,12 @@ export function llmsTxt(items, site, { full = false } = {}) {
116
117
  Mirror it back before proposing anything.
117
118
  3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
118
119
  this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
119
- (format below). The user opens it, sees the options live in their look, may tune one, and pastes back one line:
120
- \`paper-motion: B · mesh-sage --set speed=0.8 (bg-1)\`, or \`paper-motion: none of bg-1, calmer ones\`. The reply
121
- carries every value that differs from the defaults, your pre-tuning included, so installing it reproduces what they saw.
120
+ (format below): a short link (\`${site}/b/<id>\`, stored for 90 days) when the site answers, the long form
121
+ 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.
122
126
  4. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
123
127
  \`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
124
128
  fonts to \`brand/tokens.json\`, recipes to \`pieces/<id>/\`. Their choices also accumulate in a kit at ${site}/kit,
@@ -137,6 +141,11 @@ ${site}/registry.json is the index; every item carries, beside id, type, title,
137
141
  - \`mood\`: calm, bold, playful, technical, editorial, warm, minimal, luxury, raw.
138
142
  - Moves: \`energy\` 1 (still) to 5 (violent) and \`role\` (enter, exit, swap, emphasis, camera, text).
139
143
  - Backgrounds and palettes: \`colors\` (dominant hex) and \`mode\` (light, dark); backgrounds also \`motion\` (still, gentle, lively).
144
+ **Every background is a color family**: a board with a \`look.palette\` shows each background recolored into that
145
+ palette (each of its colors keeps its tone and takes the nearest palette hue, so a dark one stays dark), and the
146
+ reply line carries the resulting values. Without a board, \`pamo add <background> --palette <palette-id|brand>\`
147
+ installs it that way (\`brand\` reads \`brand/tokens.json\`). So choose backgrounds by \`motion\`, \`mood\` and
148
+ texture first; the color comes from the palette.
140
149
  - Font pairs: \`voice\` (serif, sans, display, mono). Recipes: \`duration\`, \`formats\`.
141
150
 
142
151
  \`pamo search\` filters by them: \`pamo search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff"\`
@@ -150,6 +159,13 @@ a lively one); prefer
150
159
  \`mood\` overlapping the brand's words; tune one toward the accent (\`dither-slate:colorFront=#3d6bff\`); write a
151
160
  one-line \`--note\` for each; share the link.
152
161
 
162
+ ## The MCP server
163
+
164
+ The same registry as tools, with no install: add \`${site}/mcp\` (streamable HTTP, no auth) to your agent. Tools:
165
+ \`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
167
+ still \`npx paper-motion add\` in the user's project, or your own file tools from the record.
168
+
153
169
  ## The board link format
154
170
 
155
171
  \`${site}/b#<payload>\` where payload = base64url(deflate-raw(JSON)). The JSON:
@@ -165,6 +181,9 @@ one-line \`--note\` for each; share the link.
165
181
  { "label": "D", "id": "grain-slate", "values": {} } ] }
166
182
  \`\`\`
167
183
 
184
+ Short links: \`POST ${site}/api/boards\` with \`{ "board": … }\` returns \`{ id, url }\`; \`GET /api/boards/<id>/choice\`
185
+ answers 204 until the user chooses, then \`{ line, label }\`. Boards expire after 90 days; \`DELETE /api/boards/<id>\` forgets one.
186
+
168
187
  Rules: 2–4 options, labels A–D, one resource type per \`resource\` board; \`kind: "kit"\` makes each option
169
188
  \`{ "label", "items": [{ "id", "values" }], "note" }\` (a whole kit); \`values\` hold only what differs from the
170
189
  defaults; the JSON stays under 8 KB. \`pamo board --url\` builds and validates it for you.
@@ -275,8 +294,19 @@ function mergeInto(base, frag, force, at = '', conflicts = []) {
275
294
  const MOTION_DEFAULT = { pace: 'medium', camera: 'still', text: 'rise', color: 'constant', ending: 'wordmark', timing: null, moves: [] };
276
295
  const SOUND_DEFAULT = { palette: 'soft', lowpass: 2400, cues: { transition: { type: 'sweep', dur: 0.7, gain: 0.07 }, land: { type: 'thud', gain: 0.09 }, text: { type: 'tick', freq: 900, gain: 0.04 }, bed: null }, keys: { bank: null, style: 'creamy', gain: 0.12 }, music: null, loudness: -14 };
277
296
 
278
- export async function add(ids, { cwd = process.cwd(), registry, force = false, name, brand, set = [] } = {}) {
297
+ export async function add(ids, { cwd = process.cwd(), registry, force = false, name, brand, set = [], palette } = {}) {
279
298
  const reg = resolveRegistry(registry), done = new Map(), report = [];
299
+ // --palette <id|brand>: backgrounds install recolored into that palette (the registry's, or brand/tokens.json)
300
+ let pal = null;
301
+ if (palette === 'brand') {
302
+ const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {});
303
+ pal = { ...(tokens.colors ?? {}), ramp: tokens.ramp };
304
+ if (!paletteColors(pal).length) throw new Error('--palette brand: brand/tokens.json has no colors yet (pamo add <palette> first)');
305
+ } else if (palette) {
306
+ const p = await getItem(palette, reg);
307
+ if (p.type !== 'palette') throw new Error(`--palette takes a palette id (or "brand"), not "${palette}" (${p.type})`);
308
+ pal = defaults(p);
309
+ }
280
310
  const scaffold = (r) => {
281
311
  // a brand folder a recipe can run on: its name, a pace and sound cues (silent pieces are the usual surprise)
282
312
  const tokensFile = path.join(cwd, 'brand/tokens.json'), tokens = readJson(tokensFile, {});
@@ -295,7 +325,10 @@ export async function add(ids, { cwd = process.cwd(), registry, force = false, n
295
325
  for (const dep of it.requires?.items ?? []) await one(dep);
296
326
  // --set key=value (or --set id:key=value with several ids): the same output the site's code tabs show
297
327
  const pairs = set.filter((x) => (x.includes(':') && x.indexOf(':') < x.indexOf('=') ? x.startsWith(`${id}:`) : ids.length === 1 && ids[0] === id)).map((x) => (x.startsWith(`${id}:`) ? x.slice(id.length + 1) : x));
298
- if (pairs.length) it = { ...it, ...applyValues(it, parseSet(it, pairs)) };
328
+ if (pairs.length || (pal && it.type === 'background')) {
329
+ const parsed = parseSet(it, pairs), explicit = Object.fromEntries(pairs.map((x) => x.slice(0, x.indexOf('='))).map((k) => [k, parsed[k]]));
330
+ it = { ...it, ...applyValues(it, { ...parsed, ...(pal ? recolor(it, pal) : {}), ...explicit }) };
331
+ }
299
332
  const r = { id, type: it.type, wrote: [], skipped: [], conflicts: [], usage: it.usage, notes: it.notes ?? [] };
300
333
  const kind = it.install?.kind ?? 'code';
301
334
  const base = kind === 'scene' ? path.join('pieces', name ?? it.id) : '';
@@ -336,16 +369,7 @@ export async function add(ids, { cwd = process.cwd(), registry, force = false, n
336
369
  return report;
337
370
  }
338
371
 
339
- const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
340
- /** Distance between two hex colors (0 = same, ~441 = black to white). */
341
- export const colorDistance = (a, b) => { const x = rgb(a), y = rgb(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
342
- /** How far a resource's colors sit from a brand's: the mean nearest distance (lower is closer). */
343
- export const colorFit = (item, brand) => {
344
- if (!item.colors?.length || !brand.length) return null;
345
- const mean = item.colors.reduce((s, c) => s + Math.min(...brand.map((b) => colorDistance(c, b))), 0) / item.colors.length;
346
- // a resource that declares one or two colors is thin evidence of a match: nudge it behind real palettes
347
- return mean + (item.colors.length < 3 ? 40 * (3 - item.colors.length) : 0);
348
- };
372
+ export { colorDistance, colorFit } from './find.mjs';
349
373
 
350
374
  /**
351
375
  * Finds resources: free words over id/title/summary/tags/when, plus filters: type, mode (light|dark), mood,
@@ -353,11 +377,5 @@ export const colorFit = (item, brand) => {
353
377
  */
354
378
  export async function search(query, opts = {}) {
355
379
  const { items } = await getIndex(resolveRegistry(opts.registry));
356
- const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
357
- const hit = (i) => { const hay = [i.id, i.type, i.title, i.summary, ...(i.tags ?? []), ...(i.when ?? []), ...(i.mood ?? [])].join(' ').toLowerCase(); return words.every((w) => hay.includes(w)); };
358
- const en = (v) => { if (!opts.energy) return true; const e = String(opts.energy), m = e.match(/^(\d)-(\d)$/); if (m) return v >= +m[1] && v <= +m[2]; if (e.startsWith('<=')) return v <= +e.slice(2); if (e.startsWith('>=')) return v >= +e.slice(2); return v === +e; };
359
- const near = opts.near ? String(opts.near).split(',').map((c) => c.trim()).filter((c) => /^#[0-9a-f]{6}$/i.test(c)) : [];
360
- let out = items.filter((i) => (!opts.type || i.type === opts.type) && (!opts.mode || i.mode === opts.mode) && (!opts.mood || (i.mood ?? []).includes(opts.mood)) && (!opts.motion || i.motion === opts.motion) && (!opts.role || i.role === opts.role) && (i.energy === undefined || en(i.energy)) && hit(i));
361
- if (near.length) out = out.map((i) => ({ ...i, fit: colorFit(i, near) })).filter((i) => i.fit !== null).sort((a, b) => a.fit - b.fit);
362
- return out;
380
+ return find(items, query, opts);
363
381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paper-motion",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",
@@ -35,7 +35,8 @@
35
35
  "templates/",
36
36
  "examples/",
37
37
  "!examples/**/renders/",
38
- "README.md"
38
+ "README.md",
39
+ "CHANGELOG.md"
39
40
  ],
40
41
  "engines": {
41
42
  "node": ">=18.17"
package/runtime/brand.js CHANGED
@@ -53,6 +53,8 @@ export async function loadBrand(root = '/brand') {
53
53
  if (!e?.shader) throw new Error(`paper-motion/brand: no shader entry "${id}" in brand/shader-set/set.json`);
54
54
  const props = { ...e.props };
55
55
  if (e.image) props.image = e.image === 'wordmark' ? B.wordmark.onDark ?? B.wordmark.onLight : e.image === 'wordmark-dark' ? B.wordmark.onLight ?? B.wordmark.onDark : `${root}/${e.image}`;
56
+ // the color behind the shader (entries with a transparent colorBack rely on it)
57
+ if (e.background) { const node = typeof el === 'string' ? document.querySelector(el) : el; if (node && !node.style.background) node.style.background = e.background; }
56
58
  const layer = s.shader(el, e.shader, props, { ...(e.loop ? { loop: e.loop } : {}), ...options });
57
59
  // the entry's periodic motion, as in its rendered loop (static shaders stay alive)
58
60
  if (e.motion) { const D = set.format?.duration ?? s.duration, { param, from, to, cycles = 1 } = e.motion; s.update((t) => layer.set({ [param]: from + (to - from) * (0.5 - 0.5 * Math.cos((2 * Math.PI * cycles * t) / D)) })); }