paper-motion 0.2.2 → 0.2.4
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 -0
- package/bin/pamo.mjs +22 -3
- package/lib/find.mjs +28 -0
- package/lib/live-board.js +31 -0
- package/lib/registry.mjs +19 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
|
|
4
4
|
|
|
5
|
+
## 0.2.3 · unreleased
|
|
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
|
+
|
|
5
15
|
## 0.2.1 · 2026-11-08
|
|
6
16
|
|
|
7
17
|
Fixes found by making paper-motion's own launch video as a fresh user.
|
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
|
|
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
|
-
|
|
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
|
}
|
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
|
@@ -6,6 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
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)
|
|
120
|
-
|
|
121
|
-
|
|
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,
|
|
@@ -155,6 +159,13 @@ a lively one); prefer
|
|
|
155
159
|
\`mood\` overlapping the brand's words; tune one toward the accent (\`dither-slate:colorFront=#3d6bff\`); write a
|
|
156
160
|
one-line \`--note\` for each; share the link.
|
|
157
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
|
+
|
|
158
169
|
## The board link format
|
|
159
170
|
|
|
160
171
|
\`${site}/b#<payload>\` where payload = base64url(deflate-raw(JSON)). The JSON:
|
|
@@ -170,6 +181,9 @@ one-line \`--note\` for each; share the link.
|
|
|
170
181
|
{ "label": "D", "id": "grain-slate", "values": {} } ] }
|
|
171
182
|
\`\`\`
|
|
172
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
|
+
|
|
173
187
|
Rules: 2–4 options, labels A–D, one resource type per \`resource\` board; \`kind: "kit"\` makes each option
|
|
174
188
|
\`{ "label", "items": [{ "id", "values" }], "note" }\` (a whole kit); \`values\` hold only what differs from the
|
|
175
189
|
defaults; the JSON stays under 8 KB. \`pamo board --url\` builds and validates it for you.
|
|
@@ -355,16 +369,7 @@ export async function add(ids, { cwd = process.cwd(), registry, force = false, n
|
|
|
355
369
|
return report;
|
|
356
370
|
}
|
|
357
371
|
|
|
358
|
-
|
|
359
|
-
/** Distance between two hex colors (0 = same, ~441 = black to white). */
|
|
360
|
-
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]); };
|
|
361
|
-
/** How far a resource's colors sit from a brand's: the mean nearest distance (lower is closer). */
|
|
362
|
-
export const colorFit = (item, brand) => {
|
|
363
|
-
if (!item.colors?.length || !brand.length) return null;
|
|
364
|
-
const mean = item.colors.reduce((s, c) => s + Math.min(...brand.map((b) => colorDistance(c, b))), 0) / item.colors.length;
|
|
365
|
-
// a resource that declares one or two colors is thin evidence of a match: nudge it behind real palettes
|
|
366
|
-
return mean + (item.colors.length < 3 ? 40 * (3 - item.colors.length) : 0);
|
|
367
|
-
};
|
|
372
|
+
export { colorDistance, colorFit } from './find.mjs';
|
|
368
373
|
|
|
369
374
|
/**
|
|
370
375
|
* Finds resources: free words over id/title/summary/tags/when, plus filters: type, mode (light|dark), mood,
|
|
@@ -372,11 +377,5 @@ export const colorFit = (item, brand) => {
|
|
|
372
377
|
*/
|
|
373
378
|
export async function search(query, opts = {}) {
|
|
374
379
|
const { items } = await getIndex(resolveRegistry(opts.registry));
|
|
375
|
-
|
|
376
|
-
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)); };
|
|
377
|
-
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; };
|
|
378
|
-
const near = opts.near ? String(opts.near).split(',').map((c) => c.trim()).filter((c) => /^#[0-9a-f]{6}$/i.test(c)) : [];
|
|
379
|
-
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));
|
|
380
|
-
if (near.length) out = out.map((i) => ({ ...i, fit: colorFit(i, near) })).filter((i) => i.fit !== null).sort((a, b) => a.fit - b.fit);
|
|
381
|
-
return out;
|
|
380
|
+
return find(items, query, opts);
|
|
382
381
|
}
|