paper-motion 0.2.3 → 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 +10 -1
- package/bin/pamo.mjs +13 -5
- package/lib/customize.mjs +2 -1
- package/lib/live-board.js +39 -14
- package/lib/registry.mjs +33 -12
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
*
|
|
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 (
|
|
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
|
|
141
|
-
if (!o) throw new Error(`no option ${
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return `paper-motion: ${
|
|
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:
|
|
155
|
-
const
|
|
156
|
-
if (!
|
|
157
|
-
const
|
|
158
|
-
|
|
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
|
|
123
|
-
\`
|
|
124
|
-
|
|
125
|
-
|
|
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.**
|
|
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
|
|
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 });
|