paper-motion 0.3.0 → 0.4.0

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/bin/pamo.mjs +221 -20
  3. package/examples/breath/stills/contact-sheet.jpg +0 -0
  4. package/examples/breath/stills/t000.00.png +0 -0
  5. package/examples/breath/stills/t002.00.png +0 -0
  6. package/examples/breath/stills/t004.00.png +0 -0
  7. package/examples/breath/stills/t006.00.png +0 -0
  8. package/examples/breath/stills/t008.00.png +0 -0
  9. package/examples/breath/stills/t010.00.png +0 -0
  10. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  11. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  12. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  13. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  14. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  15. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  16. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  17. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  18. package/examples/halo-explainer/stills/t000.00.png +0 -0
  19. package/examples/halo-explainer/stills/t006.00.png +0 -0
  20. package/examples/halo-explainer/stills/t012.00.png +0 -0
  21. package/examples/halo-explainer/stills/t018.00.png +0 -0
  22. package/examples/halo-explainer/stills/t024.00.png +0 -0
  23. package/examples/halo-explainer/stills/t030.00.png +0 -0
  24. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  25. package/examples/install-loop/stills/t000.00.png +0 -0
  26. package/examples/install-loop/stills/t001.00.png +0 -0
  27. package/examples/install-loop/stills/t002.00.png +0 -0
  28. package/examples/install-loop/stills/t003.00.png +0 -0
  29. package/examples/install-loop/stills/t004.00.png +0 -0
  30. package/examples/install-loop/stills/t005.00.png +0 -0
  31. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  32. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  33. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  34. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  35. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  36. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  37. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  38. package/examples/signal/stills/contact-sheet.jpg +0 -0
  39. package/examples/signal/stills/t000.00.png +0 -0
  40. package/examples/signal/stills/t001.67.png +0 -0
  41. package/examples/signal/stills/t003.33.png +0 -0
  42. package/examples/signal/stills/t005.00.png +0 -0
  43. package/examples/signal/stills/t006.67.png +0 -0
  44. package/examples/signal/stills/t008.33.png +0 -0
  45. package/lib/chain.mjs +75 -7
  46. package/lib/customize.mjs +40 -2
  47. package/lib/edit.mjs +326 -0
  48. package/lib/gather.mjs +241 -0
  49. package/lib/host.mjs +25 -0
  50. package/lib/inventory.mjs +523 -0
  51. package/lib/piece.mjs +93 -17
  52. package/lib/registry.mjs +22 -6
  53. package/lib/studio.mjs +347 -33
  54. package/package.json +1 -1
  55. package/runtime/player.js +6 -3
  56. package/runtime/swatch.html +21 -0
  57. package/studio/fonts/GeistPixel-Square.woff2 +0 -0
  58. package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
  59. package/studio/index.html +439 -327
package/lib/gather.mjs ADDED
@@ -0,0 +1,241 @@
1
+ // Gathering an inventory: reads what the user already has (their site, a README, the brand's Facts, a folder of
2
+ // assets) and proposes items for pieces/<piece>/inventory.json. Everything it finds is a proposal: it carries its
3
+ // source and waits for the user, who keeps or removes each one in the studio. Nothing here guesses a claim; it only
4
+ // copies what is written or shown, and says what a proposal still lacks (a command's real output, for one).
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
10
+ const slug = (s, n = 4) => clean(s).toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').trim().split(/\s+/).filter(Boolean).slice(0, n).join('-') || 'item';
11
+ const CLI = /^(npx|npm|pnpm|yarn|bun|bunx|pip|pipx|uv|brew|curl|wget|git|docker|cargo|go|deno|gem|apt|composer|make)\b/;
12
+ const NUM = /^([~≈<>+]?\$?)(\d[\d.,]*)\s?(%|[kKmMbB]|x|×|ms|s|min|h|hrs?|\+)?(\+?)$/;
13
+ const IMG = /\.(jpe?g|png|webp)$/i, VID = /\.(mp4|mov|webm)$/i, AUD = /\.(wav|mp3|m4a|aac|flac)$/i;
14
+
15
+ /** A number as written ("1,284", "2 min", "87%") into a figure or a share. */
16
+ function figureFrom(raw, label, source) {
17
+ const m = clean(raw).match(NUM); if (!m || !clean(label)) return null;
18
+ const n = Number(m[2].replace(/,/g, '')); if (!Number.isFinite(n)) return null;
19
+ if (m[3] === '%' && n >= 0 && n <= 100) return { kind: 'share', value: n, label: clean(label), source };
20
+ const unit = `${m[3] ? (/^[a-z]/i.test(m[3]) && m[3].length > 1 ? ` ${m[3]}` : m[3]) : ''}${m[4] ?? ''}`;
21
+ return { kind: 'figure', value: n, ...(unit ? { unit } : {}), label: clean(label), source };
22
+ }
23
+ const commandFrom = (line, source) => ({ kind: 'command', command: clean(line.replace(/^\$\s*/, '')), source });
24
+ // a command worth showing runs the product; cloning a repository or moving between folders shows nothing of it
25
+ const showable = (line) => { const c = line.replace(/^\$\s*/, ''); return CLI.test(c) && !/^(git\s+(clone|pull|checkout|init)|make\s*$|apt\b)/.test(c) && c.length <= 110; };
26
+ // a sentence a screen can hold; longer ones are for reading, not for showing
27
+ const LINE_MAX = 110;
28
+
29
+ // ── a web page ──────────────────────────────────────────────────────────────────────────────────────────────────
30
+
31
+ // runs in the page: what is written on it, as plain data
32
+ function readPage() {
33
+ const vis = (el) => { const r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) > 0.05; };
34
+ const txt = (el) => (el.innerText ?? '').replace(/\s+/g, ' ').trim();
35
+ const all = (q) => [...document.querySelectorAll(q)].filter(vis);
36
+ const NUM = /^([~≈<>+]?\$?)(\d[\d.,]*)\s?(%|[kKmMbB]|x|×|ms|s|min|h|hrs?|\+)?(\+?)$/;
37
+ // figures: a short number set large, and the words next to it
38
+ const stats = [];
39
+ for (const el of all('body *')) {
40
+ if (el.children.length || stats.length >= 12) continue;
41
+ const t = txt(el); if (!t || t.length > 12 || !NUM.test(t) || parseFloat(getComputedStyle(el).fontSize) < 20) continue;
42
+ let label = '', up = el;
43
+ // the label: the words around the number, without what can be clicked (a button's text is not what the figure counts)
44
+ for (let i = 0; i < 3 && !label && up.parentElement; i++) {
45
+ up = up.parentElement;
46
+ let rest = txt(up); // innerText: only what is really drawn (a hidden twin of the number is not)
47
+ for (const b of up.querySelectorAll('button, a, [role="button"]')) rest = rest.replace(txt(b), '');
48
+ rest = rest.replace(t, '').replace(/\s+/g, ' ').trim();
49
+ if (rest.length >= 3 && rest.length <= 70 && /[a-z]{3}/i.test(rest)) label = rest;
50
+ }
51
+ // a lone word after a number is a step or a tab ("1 Brief"), not a figure
52
+ if (label && label.split(' ').length >= 2) stats.push([t, label]);
53
+ }
54
+ const pres = all('pre').map((p) => p.innerText.trim()).filter((t) => t && t.length <= 900).slice(0, 8);
55
+ const inline = all('code').filter((c) => !c.closest('pre')).map(txt).filter((t) => t.length >= 6 && t.length <= 140);
56
+ const lists = all('ul,ol').map((l) => [...l.children].filter((c) => c.tagName === 'LI').map(txt)).filter((it) => it.length >= 3 && it.length <= 6 && it.every((x) => x.length >= 2 && x.length <= 28));
57
+ const h1 = all('h1').map(txt).find(Boolean) ?? '';
58
+ const lead = all('main p, header p, section p, p').map(txt).find((t) => t.length >= 40 && t.length <= 240) ?? '';
59
+ const icon = document.querySelector('link[rel~="icon"][type="image/svg+xml"], link[rel~="icon"][href$=".svg"]')?.href ?? null;
60
+ return {
61
+ title: document.title, site: document.querySelector('meta[property="og:site_name"]')?.content ?? '', desc: document.querySelector('meta[name="description"]')?.content ?? '',
62
+ h1, lead, h2: all('h2').map(txt).filter((t) => t.length >= 10 && t.length <= 90).slice(0, 5), stats, pres, inline, lists: lists.slice(0, 3), icon,
63
+ height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
64
+ };
65
+ }
66
+
67
+ /** Proposals from a live page: what it says, its figures, its commands, and screenshots of it on a desktop and a phone. */
68
+ export async function fromUrl(url, { cwd = process.cwd(), assets = 'brand/assets' } = {}) {
69
+ const { launch } = await import('./browser.mjs');
70
+ const u = new URL(url), where = `${u.host}${u.pathname === '/' ? '' : u.pathname}`, name = slug(where.replace(/[./]/g, ' '), 5);
71
+ const out = [], dir = path.join(cwd, assets);
72
+ fs.mkdirSync(dir, { recursive: true });
73
+ const browser = await launch();
74
+ try {
75
+ const shot = async (file, viewport, mobile, maxH) => {
76
+ const ctx = await browser.newContext({ viewport, deviceScaleFactor: mobile ? 2 : 1, isMobile: mobile, hasTouch: mobile });
77
+ const page = await ctx.newPage();
78
+ await page.goto(url, { waitUntil: 'load', timeout: 45000 });
79
+ await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
80
+ await page.waitForTimeout(900);
81
+ // numbers that count up on load are read once they stop moving: a figure caught mid-count would be a false claim
82
+ let data = await page.evaluate(readPage);
83
+ for (let i = 0; i < 10; i++) { await page.waitForTimeout(700); const again = await page.evaluate(readPage); const same = JSON.stringify(again.stats) === JSON.stringify(data.stats) && again.h1 === data.h1; data = again; if (same) break; }
84
+ await page.screenshot({ path: path.join(dir, file), type: 'jpeg', quality: 86, clip: { x: 0, y: 0, width: viewport.width, height: Math.min(data.height, maxH) } });
85
+ await ctx.close();
86
+ return data;
87
+ };
88
+ const d = await shot(`${name}-desktop.jpg`, { width: 1440, height: 900 }, false, 3200);
89
+ await shot(`${name}-phone.jpg`, { width: 390, height: 844 }, true, 2200);
90
+ const src = (part) => `${where}, ${part}`, siteName = clean(d.site) || clean(d.title).split(/\s[·|—–-]\s/)[0];
91
+ if (d.h1 && d.h1.length <= LINE_MAX) out.push({ kind: 'line', text: clean(d.h1), source: src('the headline') });
92
+ const about = [[clean(d.desc), 'its description'], [d.lead, 'the first paragraph']].find(([t]) => t && t !== clean(d.h1) && t.length <= LINE_MAX);
93
+ if (about) out.push({ kind: 'line', text: about[0], source: src(about[1]) });
94
+ for (const h of d.h2.filter((x) => x !== d.h1).slice(0, 2)) out.push({ kind: 'line', text: h, source: src('a heading') });
95
+ for (const [n, label] of d.stats.slice(0, 6)) { const f = figureFrom(n, label, src('a number on the page')); if (f) out.push(f); }
96
+ for (const pre of d.pres) {
97
+ const lines = pre.split('\n').map((l) => l.trim()).filter(Boolean), cmd = lines.find(showable);
98
+ if (cmd && lines.length <= 4) out.push(commandFrom(cmd, src('a command on the page')));
99
+ }
100
+ for (const c of d.inline.filter(showable).slice(0, 3)) out.push(commandFrom(c, src('a command on the page')));
101
+ for (const l of d.lists) out.push({ kind: 'words', items: l, source: src('a list') });
102
+ out.push({ kind: 'screen', image: `${assets}/${name}-desktop.jpg`, url: where, title: siteName, ...(d.h1 ? { caption: clean(d.h1) } : {}), device: 'desktop', source: `a screenshot of ${where}` });
103
+ out.push({ kind: 'screen', image: `${assets}/${name}-phone.jpg`, url: where, title: siteName, device: 'phone', source: `a screenshot of ${where} on a phone` });
104
+ if (d.icon) { try { const svg = await (await fetch(d.icon, { signal: AbortSignal.timeout(8000) })).text(); if (/<svg[\s>]/.test(svg) && svg.length < 20000) out.push({ kind: 'logo', svg: svg.trim(), name: siteName, source: src('its icon') }); } catch { /* no icon to read */ } }
105
+ } finally { await browser.close(); }
106
+ return out;
107
+ }
108
+
109
+ // ── a markdown file (a README, a doc) ───────────────────────────────────────────────────────────────────────────
110
+
111
+ const EXT = { js: 'js', javascript: 'js', ts: 'ts', typescript: 'ts', tsx: 'tsx', jsx: 'jsx', py: 'py', python: 'py', rb: 'rb', go: 'go', rust: 'rs', rs: 'rs', json: 'json', yaml: 'yml', yml: 'yml', html: 'html', css: 'css' };
112
+ const bare = (s) => clean(s.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/<[^>]+>/g, '').replace(/[*_`]/g, ''));
113
+
114
+ export function fromMarkdown(file, { cwd = process.cwd() } = {}) {
115
+ const rel = path.relative(cwd, path.resolve(cwd, file)), md = fs.readFileSync(path.resolve(cwd, file), 'utf8'), out = [], src = (p) => `${rel}, ${p}`;
116
+ const lines = md.split('\n');
117
+ let lead = false, cmds = 0, codes = 0, figs = 0;
118
+ for (let i = 0; i < lines.length; i++) {
119
+ const l = lines[i];
120
+ const fence = l.match(/^```+\s*([\w+-]*)/);
121
+ if (fence) {
122
+ const body = []; i++;
123
+ while (i < lines.length && !/^```+\s*$/.test(lines[i])) body.push(lines[i++]);
124
+ const lang = fence[1].toLowerCase(), real = body.map((x) => x.trim()).filter((x) => x && !x.startsWith('#'));
125
+ const shell = ['sh', 'bash', 'shell', 'zsh', 'console', 'terminal'].includes(lang) || real.some((x) => x.startsWith('$ ')) || (lang === '' && real.every((x) => CLI.test(x)));
126
+ if (shell) { for (const c of real.filter(showable).slice(0, 2)) if (cmds++ < 4) out.push(commandFrom(c, src('a command'))); }
127
+ else if (EXT[lang] && real.length >= 3 && real.length <= 18 && codes++ < 2) out.push({ kind: 'code', file: `example${EXT[lang] ? `.${EXT[lang]}` : ''}`, code: body.join('\n').trim(), source: src('a code example') });
128
+ continue;
129
+ }
130
+ if (/^\s*\|.+\|\s*$/.test(l) && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1] ?? '')) {
131
+ const rows = []; i += 2;
132
+ const cells = [];
133
+ while (i < lines.length && /^\s*\|.+\|\s*$/.test(lines[i])) { cells.push(lines[i].split('|').slice(1, -1).map(bare)); i++; }
134
+ // a first column that only counts the rows (1, 2, 3…) is not a name: the pair starts after it
135
+ const skip = cells.length && cells.every((c) => /^\d+\.?$/.test(c[0] ?? '')) ? 1 : 0;
136
+ for (const c of cells) if (c[skip] && c[skip + 1]) rows.push([c[skip], c[skip + 1]]);
137
+ i--; if (rows.length >= 2 && rows.length <= 8 && rows.every((r) => r[0].length <= 32 && r[1].length <= 32)) out.push({ kind: 'table', rows, source: src('a table') });
138
+ continue;
139
+ }
140
+ if (/^\s*[-*+]\s+/.test(l)) {
141
+ const items = [];
142
+ while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) items.push(bare(lines[i++].replace(/^\s*[-*+]\s+/, '')));
143
+ i--;
144
+ if (items.length >= 3 && items.length <= 6 && items.every((x) => x.length >= 2 && x.length <= 28)) out.push({ kind: 'words', items, source: src('a list') });
145
+ for (const it of items) { const m = it.match(/^([~≈<>+]?\$?\d[\d.,]*\s?(?:%|[kKmMbB]|x|×|ms|s|min|h|hrs?|\+)?\+?)\s+(.{3,60})$/); const f = m && figs < 6 ? figureFrom(m[1], m[2].replace(/[.;,]$/, ''), src('a list')) : null; if (f) { figs++; out.push(f); } }
146
+ continue;
147
+ }
148
+ const t = bare(l);
149
+ if (!lead && t.length >= 30 && t.length <= LINE_MAX && !/^#/.test(l) && !/^\s*[>|]/.test(l)) { lead = true; out.push({ kind: 'line', text: t, source: src('the first paragraph') }); }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** The brand's Facts (IDENTITY.md): its true claims, as lines, figures and commands. */
155
+ export function fromFacts(file, { cwd = process.cwd() } = {}) {
156
+ const abs = path.resolve(cwd, file); if (!fs.existsSync(abs)) return [];
157
+ const md = fs.readFileSync(abs, 'utf8'), m = md.match(/^##\s+Facts\s*\n([\s\S]*?)(?=^##\s|(?![\s\S]))/m);
158
+ if (!m) return [];
159
+ const out = [], source = "your brand's facts";
160
+ for (const raw of m[1].split('\n').filter((l) => /^\s*[-*+]\s+/.test(l))) {
161
+ const l = raw.replace(/^\s*[-*+]\s+/, '');
162
+ for (const c of [...l.matchAll(/`([^`]+)`/g)].map((x) => x[1]).filter(showable)) out.push(commandFrom(c, source));
163
+ const t = bare(l).replace(/\.$/, ''), n = t.match(/^([~≈<>+]?\$?\d[\d.,]*\s?(?:%|[kKmMbB]|x|×|ms|s|min|h|hrs?|\+)?\+?)\s+(.{3,60})$/);
164
+ const f = n ? figureFrom(n[1], n[2], source) : null;
165
+ if (f) out.push(f); else if (t.length >= 12 && t.length <= LINE_MAX) out.push({ kind: 'line', text: t, source });
166
+ }
167
+ return out;
168
+ }
169
+
170
+ // ── a folder of the user's own material ─────────────────────────────────────────────────────────────────────────
171
+
172
+ // width and height of a JPEG or PNG, read from its header (no image library needed)
173
+ function imageSize(file) {
174
+ try {
175
+ const b = fs.readFileSync(file);
176
+ if (b.readUInt32BE(0) === 0x89504e47) return [b.readUInt32BE(16), b.readUInt32BE(20)];
177
+ if (b.readUInt16BE(0) === 0xffd8) { let i = 2; while (i < b.length) { const marker = b.readUInt16BE(i), len = b.readUInt16BE(i + 2); if (marker >= 0xffc0 && marker <= 0xffcf && ![0xffc4, 0xffc8, 0xffcc].includes(marker)) return [b.readUInt16BE(i + 7), b.readUInt16BE(i + 5)]; i += 2 + len; } }
178
+ } catch { /* not readable */ }
179
+ return null;
180
+ }
181
+
182
+ export function fromFolder(folder, { cwd = process.cwd() } = {}) {
183
+ const abs = path.resolve(cwd, folder); if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return [];
184
+ const out = [];
185
+ for (const f of fs.readdirSync(abs).sort()) {
186
+ const full = path.join(abs, f), rel = path.relative(cwd, full).split(path.sep).join('/');
187
+ if (f.startsWith('.') || fs.statSync(full).isDirectory()) continue;
188
+ const source = `your file ${f}`, base = f.replace(/\.\w+$/, '');
189
+ if (IMG.test(f)) {
190
+ const size = imageSize(full), tall = size && size[1] / size[0] > 1.5;
191
+ // a picture named like a capture, or shaped like a page, is the product; anything else is a photograph
192
+ if (/screen|shot|capture|site|page|app|ui|dashboard|desktop|phone|mobile/i.test(f) || (size && size[1] / size[0] > 1.9)) out.push({ kind: 'screen', image: rel, title: clean(base.replace(/[-_]+/g, ' ')), device: tall && size[0] < 900 ? 'phone' : 'desktop', source });
193
+ else if (!/wordmark|logo|favicon|icon/i.test(f)) out.push({ kind: 'photo', image: rel, source }); // the brand's own marks are not photographs
194
+ } else if (/\.svg$/i.test(f) && /logo|mark|icon|brand/i.test(f)) { const svg = fs.readFileSync(full, 'utf8').trim(); if (svg.length < 30000) out.push({ kind: 'logo', svg, source }); }
195
+ else if (VID.test(f)) out.push({ kind: 'clip', file: rel, source });
196
+ else if (AUD.test(f)) out.push({ kind: 'music', file: rel, source });
197
+ }
198
+ return out;
199
+ }
200
+
201
+ // ── putting it together ─────────────────────────────────────────────────────────────────────────────────────────
202
+
203
+ /** What makes two items the same thing, however they were found. */
204
+ export function keyOf(it) {
205
+ const k = { line: it.text, figure: `${it.value}|${it.label}`, share: `${it.value}|${it.label}`, change: `${it.from}|${it.to}`, command: it.command, code: it.code, screen: it.image, photo: it.image, clip: it.file, music: it.file ?? it.bpm, logo: (it.svg ?? '').slice(0, 200), words: (it.items ?? []).join('|'), table: JSON.stringify(it.rows ?? []) }[it.kind];
206
+ return `${it.kind}|${clean(k ?? JSON.stringify(it)).toLowerCase()}`;
207
+ }
208
+ const idFor = (it) => ({ line: slug(it.text ?? '', 3), figure: slug(it.label ?? '', 3), share: slug(it.label ?? '', 3), command: `run-${slug((it.command ?? '').replace(CLI, ''), 2)}`, screen: `${it.device ?? 'screen'}-${slug(it.url ?? it.title ?? it.image ?? '', 3)}`, photo: slug(path.basename(it.image ?? 'photo').replace(/\.\w+$/, ''), 3), clip: slug(path.basename(it.file ?? 'clip').replace(/\.\w+$/, ''), 3), words: slug((it.items ?? []).join(' '), 3), code: slug(it.file ?? 'code', 2), table: 'table', logo: 'logo', music: 'music' }[it.kind] ?? it.kind);
209
+
210
+ /**
211
+ * Gathers proposals from every source: a URL, a .md file, a folder, or "brand" (the brand's Facts and its assets).
212
+ * Returns what is new against `existing` (anything already there, kept or removed, is not proposed again).
213
+ * `plain` leaves commands and code out, for a brand whose audience does not code.
214
+ */
215
+ export async function gather(sources, { cwd = process.cwd(), existing = [], plain = false, log = () => {} } = {}) {
216
+ const found = [], notes = [];
217
+ for (const s of sources) {
218
+ try {
219
+ let got;
220
+ if (/^https?:\/\//i.test(s)) { log(`reading ${s} …`); got = await fromUrl(s, { cwd }); }
221
+ else if (s === 'brand') got = [...fromFacts('brand/IDENTITY.md', { cwd }), ...fromFolder('brand/assets', { cwd })];
222
+ else if (!fs.existsSync(path.resolve(cwd, s))) { notes.push(`${s}: not found`); continue; }
223
+ else if (fs.statSync(path.resolve(cwd, s)).isDirectory()) got = fromFolder(s, { cwd });
224
+ else if (/IDENTITY\.md$/i.test(s)) got = fromFacts(s, { cwd });
225
+ else got = fromMarkdown(s, { cwd });
226
+ // a brand whose audience does not code: commands and code are left out, however many its files hold
227
+ if (plain) got = got.filter((i) => !['command', 'code', 'rewrite'].includes(i.kind));
228
+ notes.push(`${s}: ${got.length} found`);
229
+ found.push(...got);
230
+ } catch (e) { notes.push(`${s}: ${e.message.split('\n')[0]}`); }
231
+ }
232
+ const have = new Set(existing.map(keyOf)), ids = new Set(existing.map((i) => i.id)), fresh = [];
233
+ for (const it of found) {
234
+ const k = keyOf(it); if (have.has(k)) continue;
235
+ have.add(k);
236
+ let id = idFor(it), n = 2; while (ids.has(id)) id = `${idFor(it)}-${n++}`;
237
+ ids.add(id);
238
+ fresh.push({ id, ...it, status: 'proposed' });
239
+ }
240
+ return { fresh, notes };
241
+ }
package/lib/host.mjs ADDED
@@ -0,0 +1,25 @@
1
+ // The app the agent runs in, so the studio can send the user back to it ("your agent asked you something").
2
+ // On macOS the CLI walks up its own process tree to the first .app bundle (Claude, Cursor, a terminal…); the studio
3
+ // brings that app to the front with `open -a`. Anywhere, PAPER_MOTION_RETURN (or --return) gives a link instead:
4
+ // the page of a browser-based agent.
5
+ import path from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
7
+
8
+ export function hostApp({ url } = {}) {
9
+ const link = url ?? process.env.PAPER_MOTION_RETURN;
10
+ if (link && /^https?:\/\//i.test(link)) return { url: link };
11
+ if (process.platform !== 'darwin') return null;
12
+ // the topmost .app above this process is the window the user sees (Claude, Cursor, a terminal); bundles further
13
+ // down are helpers and command-line tools packed as apps
14
+ let pid = process.ppid, found = null;
15
+ for (let i = 0; i < 30 && pid > 1; i++) {
16
+ let out;
17
+ try { out = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { break; }
18
+ const m = out.match(/^(\d+)\s+(.*)$/);
19
+ if (!m) break;
20
+ const app = m[2].match(/^(.*?\.app)\//);
21
+ if (app) found = { app: path.basename(app[1], '.app'), path: app[1] };
22
+ pid = Number(m[1]);
23
+ }
24
+ return found;
25
+ }