paper-motion 0.0.0-stage → 0.2.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 (113) hide show
  1. package/README.md +59 -2
  2. package/bin/pamo.mjs +664 -0
  3. package/examples/breath/brief.md +11 -0
  4. package/examples/breath/poster.jpg +0 -0
  5. package/examples/breath/scene.html +59 -0
  6. package/examples/chrome-endcard/brief.md +7 -0
  7. package/examples/chrome-endcard/fonts/GeistMono-Variable.woff2 +0 -0
  8. package/examples/chrome-endcard/fonts/GeistSerif-Regular.woff2 +0 -0
  9. package/examples/chrome-endcard/poster.jpg +0 -0
  10. package/examples/chrome-endcard/scene.html +68 -0
  11. package/examples/chrome-endcard/wordmark.js +16 -0
  12. package/examples/halo-explainer/audio/script.txt +8 -0
  13. package/examples/halo-explainer/audio/words.json +554 -0
  14. package/examples/halo-explainer/brand/IDENTITY.md +24 -0
  15. package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
  16. package/examples/halo-explainer/brand/motion.json +9 -0
  17. package/examples/halo-explainer/brand/shader-set/set.json +104 -0
  18. package/examples/halo-explainer/brand/sound.json +33 -0
  19. package/examples/halo-explainer/brand/tokens.json +97 -0
  20. package/examples/halo-explainer/brief.md +34 -0
  21. package/examples/halo-explainer/poster.jpg +0 -0
  22. package/examples/halo-explainer/scene.html +372 -0
  23. package/examples/install-loop/brief.md +7 -0
  24. package/examples/install-loop/fonts/GeistMono-Variable.woff2 +0 -0
  25. package/examples/install-loop/fonts/GeistSerif-Regular.woff2 +0 -0
  26. package/examples/install-loop/poster.jpg +0 -0
  27. package/examples/install-loop/scene.html +46 -0
  28. package/examples/orbit-keynote/audio/script.txt +7 -0
  29. package/examples/orbit-keynote/audio/words.json +494 -0
  30. package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
  31. package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
  32. package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
  33. package/examples/orbit-keynote/brand/motion.json +9 -0
  34. package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
  35. package/examples/orbit-keynote/brand/sound.json +45 -0
  36. package/examples/orbit-keynote/brand/tokens.json +106 -0
  37. package/examples/orbit-keynote/brief.md +25 -0
  38. package/examples/orbit-keynote/poster.jpg +0 -0
  39. package/examples/orbit-keynote/scene.html +323 -0
  40. package/examples/signal/brief.md +10 -0
  41. package/examples/signal/poster.jpg +0 -0
  42. package/examples/signal/scene.html +50 -0
  43. package/lib/board.mjs +95 -0
  44. package/lib/browser.mjs +88 -0
  45. package/lib/catalog.mjs +77 -0
  46. package/lib/customize.mjs +162 -0
  47. package/lib/keys.mjs +93 -0
  48. package/lib/live-board.js +128 -0
  49. package/lib/modules.mjs +23 -0
  50. package/lib/react-hooks.mjs +9 -0
  51. package/lib/registry.mjs +344 -0
  52. package/lib/render.mjs +193 -0
  53. package/lib/server.mjs +89 -0
  54. package/lib/sfx.mjs +287 -0
  55. package/lib/shaderset.mjs +65 -0
  56. package/lib/wordmark.mjs +64 -0
  57. package/package.json +46 -4
  58. package/runtime/brand.js +71 -0
  59. package/runtime/core.js +269 -0
  60. package/runtime/index.js +349 -0
  61. package/runtime/jsx-shim.js +3 -0
  62. package/runtime/kit.js +98 -0
  63. package/runtime/moves/bar-reveal.js +19 -0
  64. package/runtime/moves/blur-in.js +7 -0
  65. package/runtime/moves/cells.js +17 -0
  66. package/runtime/moves/crossfade.js +6 -0
  67. package/runtime/moves/cut.js +3 -0
  68. package/runtime/moves/dive-in.js +21 -0
  69. package/runtime/moves/draw-on.js +12 -0
  70. package/runtime/moves/drift.js +7 -0
  71. package/runtime/moves/flash.js +15 -0
  72. package/runtime/moves/flip-3d.js +9 -0
  73. package/runtime/moves/flip-in.js +12 -0
  74. package/runtime/moves/flip-lift.js +24 -0
  75. package/runtime/moves/glitch.js +16 -0
  76. package/runtime/moves/iris.js +12 -0
  77. package/runtime/moves/magic-move.js +49 -0
  78. package/runtime/moves/mask-reveal.js +10 -0
  79. package/runtime/moves/odometer.js +32 -0
  80. package/runtime/moves/orbit.js +8 -0
  81. package/runtime/moves/pop-in.js +12 -0
  82. package/runtime/moves/punch.js +9 -0
  83. package/runtime/moves/push.js +14 -0
  84. package/runtime/moves/rgb-split.js +7 -0
  85. package/runtime/moves/roll.js +20 -0
  86. package/runtime/moves/scan-reveal.js +10 -0
  87. package/runtime/moves/scramble.js +21 -0
  88. package/runtime/moves/shake.js +15 -0
  89. package/runtime/moves/slices.js +14 -0
  90. package/runtime/moves/snap-scroll.js +23 -0
  91. package/runtime/moves/squash-drop.js +21 -0
  92. package/runtime/moves/tile-swirl.js +29 -0
  93. package/runtime/moves/track-in.js +7 -0
  94. package/runtime/moves/type-on.js +41 -0
  95. package/runtime/moves/unfold.js +20 -0
  96. package/runtime/moves/whip-pan.js +21 -0
  97. package/runtime/moves/whip.js +14 -0
  98. package/runtime/moves/wipe.js +9 -0
  99. package/runtime/moves/word-rise.js +10 -0
  100. package/runtime/moves/zoom-through.js +13 -0
  101. package/runtime/moves.js +94 -0
  102. package/runtime/player.js +69 -0
  103. package/runtime/react-shim.js +15 -0
  104. package/templates/blank/brief.md +39 -0
  105. package/templates/blank/scene.html +44 -0
  106. package/templates/brand-sheet/index.html +241 -0
  107. package/templates/move-demo/brief.md +4 -0
  108. package/templates/move-demo/scene.html +158 -0
  109. package/templates/piece/brief.md +39 -0
  110. package/templates/piece/scene.html +62 -0
  111. package/templates/shader-set/shot.js +47 -0
  112. package/templates/still/brief.md +3 -0
  113. package/templates/still/scene.html +56 -0
@@ -0,0 +1,241 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>brand sheet</title>
7
+ <!--
8
+ The brand sheet: the whole brand on one page, built from the brand folder (one level up).
9
+ Reads ../tokens.json, ../IDENTITY.md, ../VISUAL.md, ../MOTION.md, ../SOUND.md and, when they exist,
10
+ ../motion.json, ../sound/sounds.json, ../shader-set/set.json. Sections without data are left out.
11
+ This copy belongs to the user: edit copy, order or layout freely; `pamo sheet` re-exports it.
12
+ -->
13
+ <style>
14
+ :root { --ground: #f2f2f2; --figure: #1a1a1a; --muted: #6b6b6b; --surface: #e4e4e4; --accent: #1a1a1a; --line: rgba(0,0,0,.12);
15
+ --display: system-ui, sans-serif; --text: system-ui, sans-serif; --mono: ui-monospace, Menlo, monospace; --radius: 12px; }
16
+ * { box-sizing: border-box; }
17
+ html, body { margin: 0; background: var(--ground); color: var(--figure); font-family: var(--text); -webkit-font-smoothing: antialiased; }
18
+ .wrap { width: min(1440px, 100%); margin: 0 auto; padding: 0 64px; }
19
+ section { padding: 120px 0 140px; border-top: 1px solid var(--line); }
20
+ .kicker { font: 500 14px/20px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-bottom: 20px; }
21
+ h2 { font-family: var(--display); font-weight: var(--display-weight, 500); letter-spacing: var(--display-tracking, -0.02em); font-size: 80px; line-height: .98; margin: 0 0 56px; }
22
+ h3 { font: 500 15px/20px var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 0 0 14px; }
23
+ p, li { font-size: 20px; line-height: 1.5; max-width: 62ch; }
24
+ .md p:first-child { margin-top: 0; }
25
+ .grid { display: grid; gap: 48px 64px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
26
+ .card { background: var(--surface); border-radius: var(--radius); padding: 32px; }
27
+
28
+ /* cover */
29
+ #cover { position: relative; min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; padding: 56px 0 72px; border: 0; overflow: hidden; }
30
+ #cover .field { position: absolute; inset: 0; opacity: .55; }
31
+ #cover .top, #cover .bottom { position: relative; display: flex; justify-content: space-between; align-items: end; gap: 32px; }
32
+ #cover .top { font: 500 14px/20px var(--mono); letter-spacing: .08em; text-transform: uppercase; }
33
+ #cover .mark { position: relative; }
34
+ #cover .mark img { display: block; width: min(80%, 1100px); height: auto; }
35
+ #cover .mark .wm { font-family: var(--display); font-size: clamp(96px, 14vw, 240px); line-height: .9; letter-spacing: var(--display-tracking, -0.02em); font-weight: var(--display-weight, 500); }
36
+ #cover .line { font-size: 28px; line-height: 1.3; max-width: 28ch; }
37
+ #cover .keys { display: flex; gap: 10px; flex-wrap: wrap; }
38
+ .pill { font: 500 14px/1 var(--mono); padding: 10px 14px; border: 1px solid currentColor; border-radius: var(--pill-radius, 999px); }
39
+
40
+ /* identity */
41
+ .sliders { display: grid; gap: 14px; }
42
+ .slider { display: grid; grid-template-columns: 1fr 220px 1fr; align-items: center; gap: 16px; font-size: 17px; }
43
+ .slider span:last-child { text-align: right; }
44
+ .track { position: relative; height: 2px; background: var(--line); }
45
+ .track i { position: absolute; top: -7px; width: 16px; height: 16px; border-radius: 50%; background: var(--accent); transform: translateX(-50%); }
46
+ .voice { font-family: var(--display); font-size: 44px; line-height: 1.15; letter-spacing: var(--display-tracking, -0.01em); margin: 0; max-width: 24ch; }
47
+
48
+ /* visual */
49
+ .marks { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
50
+ .marks div { border: 1px solid var(--line); border-radius: var(--radius); padding: 64px 48px; display: flex; align-items: center; justify-content: center; min-height: 260px; }
51
+ .marks img { max-width: 80%; max-height: 120px; }
52
+ .swatches { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
53
+ .swatch .chip { height: 180px; border-radius: var(--radius); border: 1px solid var(--line); }
54
+ .swatch b { display: block; font: 500 16px/22px var(--mono); margin-top: 14px; }
55
+ .swatch small { font: 400 14px/20px var(--mono); color: var(--muted); }
56
+ .ramp { display: flex; height: 64px; border-radius: var(--radius); overflow: hidden; margin-top: 24px; border: 1px solid var(--line); }
57
+ .ramp div { flex: 1; }
58
+ .spec { display: grid; grid-template-columns: 180px 1fr; gap: 24px; align-items: baseline; padding: 28px 0; border-bottom: 1px solid var(--line); }
59
+ .spec small { font: 400 14px/20px var(--mono); color: var(--muted); }
60
+
61
+ /* motion, sound, shaders */
62
+ .sample { font-family: var(--sample-font, var(--display)); font-size: 56px; letter-spacing: var(--display-tracking, -0.01em); min-height: 72px; }
63
+ .sample .cur { display: inline-block; width: .5em; height: .9em; background: var(--figure); vertical-align: -0.1em; margin-left: 4px; }
64
+ .clips { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
65
+ .clips figure { margin: 0; }
66
+ .clips video { width: 100%; max-height: 520px; display: block; border-radius: var(--radius); background: #000; object-fit: contain; }
67
+ .clips figcaption { font: 400 14px/20px var(--mono); color: var(--muted); margin-top: 10px; }
68
+ .sounds { display: flex; gap: 12px; flex-wrap: wrap; }
69
+ .sounds button { font: 500 15px/1 var(--mono); color: var(--figure); background: var(--surface); border: 0; border-radius: var(--pill-radius, 999px); padding: 14px 20px; cursor: pointer; }
70
+ footer { padding: 48px 0 64px; border-top: 1px solid var(--line); font: 400 14px/20px var(--mono); color: var(--muted); display: flex; justify-content: space-between; }
71
+ @media print { section, #cover { break-inside: avoid; } }
72
+ @media (max-width: 760px) { .wrap { padding: 0 20px; } h2 { font-size: 48px; } .slider { grid-template-columns: 1fr 120px 1fr; } .marks { grid-template-columns: 1fr; } .spec { grid-template-columns: 1fr; } }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <main id="sheet"></main>
77
+ <script type="module">
78
+ import { live, organicPath } from 'paper-motion';
79
+
80
+ const get = (p, json) => fetch(p).then((r) => (r.ok ? (json ? r.json() : r.text()) : null)).catch(() => null);
81
+ const [tokens, identity, visual, motionMd, soundMd, motion, sounds, shaders] = await Promise.all([
82
+ get('../tokens.json', true), get('../IDENTITY.md'), get('../VISUAL.md'), get('../MOTION.md'), get('../SOUND.md'),
83
+ get('../motion.json', true), get('../sound/sounds.json', true), get('../shader-set/set.json', true),
84
+ ]).then((r) => { const set = r[7]; r[7] = (Array.isArray(set) ? set : set?.entries ?? []).filter((e) => e.video); return r; });
85
+
86
+ // ── markdown: split by "## " into sections, render the small subset brand files use ──
87
+ const sections = (md) => Object.fromEntries((md ?? '').split(/^## /m).slice(1).map((b) => { const i = b.indexOf('\n'); return [b.slice(0, i).trim(), b.slice(i + 1).trim()]; }));
88
+ const inline = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/`(.+?)`/g, '<code>$1</code>').replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
89
+ // Group consecutive bullet lines into one list and other lines into paragraphs, whatever the blank lines.
90
+ const md = (s) => {
91
+ const out = []; let para = [], list = [];
92
+ const flush = () => { if (para.length) out.push(`<p>${inline(para.join(' '))}</p>`); if (list.length) out.push(`<ul>${list.map((l) => `<li>${inline(l)}</li>`).join('')}</ul>`); para = []; list = []; };
93
+ for (const l of (s ?? '').split('\n')) {
94
+ if (!l.trim()) { flush(); continue; }
95
+ const m = l.match(/^\s*[-*] (.*)/);
96
+ if (m) { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } list.push(m[1]); }
97
+ else { if (list.length) { out.push(`<ul>${list.map((x) => `<li>${inline(x)}</li>`).join('')}</ul>`); list = []; } para.push(l.trim()); }
98
+ }
99
+ flush();
100
+ return out.join('');
101
+ };
102
+ const filled = (s) => s && s.trim() && s.trim() !== '—';
103
+ const I = sections(identity), V = sections(visual), M = sections(motionMd), S = sections(soundMd);
104
+
105
+ // ── brand tokens → CSS ──
106
+ const C = tokens?.colors ?? {};
107
+ const root = document.documentElement.style;
108
+ for (const k of ['ground', 'figure', 'muted', 'surface', 'accent']) if (C[k]?.hex) root.setProperty(`--${k}`, C[k].hex);
109
+ const fam = (k, fb) => (tokens?.fonts?.[k]?.family ? `'${tokens.fonts[k].family}', ${fb}` : fb);
110
+ root.setProperty('--display', fam('display', 'system-ui, sans-serif'));
111
+ root.setProperty('--text', fam('text', 'system-ui, sans-serif'));
112
+ root.setProperty('--mono', tokens?.fonts?.mono?.family ? fam('mono', 'ui-monospace, monospace') : fam('text', 'ui-monospace, Menlo, monospace'));
113
+ // shapes follow the brand: hard brands get square pills and buttons
114
+ root.setProperty('--pill-radius', tokens?.shape?.language === 'hard' ? `${tokens.shape.radius ?? 0}px` : tokens?.shape?.language === 'organic' ? `${tokens.shape.radius ?? 12}px` : '999px');
115
+ if (tokens?.type?.display) { root.setProperty('--display-weight', tokens.type.display.weight); root.setProperty('--display-tracking', tokens.type.display.tracking); }
116
+ if (tokens?.shape?.radius !== undefined) root.setProperty('--radius', `${tokens.shape.radius}px`);
117
+ root.setProperty('--line', `color-mix(in oklab, var(--figure) 14%, transparent)`);
118
+ await Promise.all(Object.values(tokens?.fonts ?? {}).filter((f) => f?.css).map((f) => new Promise((res) => document.head.append(Object.assign(document.createElement('link'), { rel: 'stylesheet', href: f.css, onload: res, onerror: res })))));
119
+ // Fonts the brand ships as files (licensed by the user, or bundled) load as @font-face from the brand folder.
120
+ for (const f of Object.values(tokens?.fonts ?? {})) if (f?.file) document.head.append(Object.assign(document.createElement('style'), { textContent: `@font-face { font-family: '${f.family}'; src: url('../${f.file}'); font-weight: 100 900; }` }));
121
+
122
+ const name = tokens?.brand ?? (identity?.match(/^# (.+?)(?: ·|$)/m)?.[1] ?? 'brand');
123
+ document.title = `${name} · brand sheet`;
124
+ const dark = tokens?.mode === 'dark';
125
+ const wmFile = tokens?.wordmark?.files?.[dark ? 'onDark' : 'onLight'];
126
+ const firstOf = (...keys) => keys.map((k) => I[k]).find(filled) ?? '';
127
+ const keywords = (I.Keywords ?? '').split(/[,\n·]/).map((s) => s.replace(/^[-*]\s*/, '').trim()).filter(Boolean);
128
+ // The sheet speaks the brand's language (IDENTITY.md · Voice names it) and follows its casing.
129
+ const L10N = {
130
+ en: { identity: 'identity', visual: 'visual system', motion: 'motion', sound: 'sound', set: 'shader set', who: (n) => `Who ${n} is`, looks: (n) => `How ${n} looks`, moves: (n) => `How ${n} moves`, sounds: (n) => `How ${n} sounds`, materials: (k) => `${k} moving materials`,
131
+ offer: 'offer', audience: 'audience', personality: 'personality', voice: 'voice', not: 'not', references: 'references', wordmark: 'wordmark', palette: 'palette', type: 'type', shape: 'shape', rules: 'rules', movesL: 'moves', sheet: 'brand sheet', made: 'made with paper-motion' },
132
+ es: { identity: 'identidad', visual: 'sistema visual', motion: 'movimiento', sound: 'sonido', set: 'set de shaders', who: (n) => `Quién es ${n}`, looks: (n) => `Cómo se ve ${n}`, moves: (n) => `Cómo se mueve ${n}`, sounds: (n) => `Cómo suena ${n}`, materials: (k) => `${k} materiales en movimiento`,
133
+ offer: 'qué ofrece', audience: 'para quién', personality: 'personalidad', voice: 'voz', not: 'qué no es', references: 'referencias', wordmark: 'wordmark', palette: 'paleta', type: 'tipografía', shape: 'forma', rules: 'reglas', movesL: 'movimientos', sheet: 'página de identidad', made: 'hecho con paper-motion' },
134
+ pt: { identity: 'identidade', visual: 'sistema visual', motion: 'movimento', sound: 'som', set: 'shaders', who: (n) => `Quem é ${n}`, looks: (n) => `Como ${n} se vê`, moves: (n) => `Como ${n} se move`, sounds: (n) => `Como ${n} soa`, materials: (k) => `${k} materiais em movimento`,
135
+ offer: 'oferta', audience: 'público', personality: 'personalidade', voice: 'voz', not: 'o que não é', references: 'referências', wordmark: 'wordmark', palette: 'paleta', type: 'tipografia', shape: 'forma', rules: 'regras', movesL: 'movimentos', sheet: 'página de marca', made: 'feito com paper-motion' },
136
+ };
137
+ const voiceText = I.Voice ?? '';
138
+ const lang = tokens?.language ?? (/\b(spanish|español|castellano)\b/i.test(voiceText) ? 'es' : /portugu/i.test(voiceText) ? 'pt' : 'en');
139
+ const Tx = L10N[lang] ?? L10N.en;
140
+ const lower = (tokens?.wordmark?.text ?? '') && tokens.wordmark.text === tokens.wordmark.text.toLowerCase();
141
+ if (lower) document.head.append(Object.assign(document.createElement('style'), { textContent: 'h2, .kicker, h3, #cover .top { text-transform: lowercase; }' }));
142
+ const html = [];
143
+
144
+ // ── cover ──
145
+ html.push(`<section id="cover"><div class="field"></div><div class="wrap top"><span>${inline(name)} · ${Tx.sheet}</span><span>${new Date().toISOString().slice(0, 10)}</span></div>
146
+ <div class="wrap mark"><div class="wm" style="font-family:var(--${tokens?.wordmark?.font ?? 'display'});font-weight:${tokens?.wordmark?.weight ?? 'var(--display-weight, 500)'};letter-spacing:${tokens?.wordmark?.tracking ?? 'var(--display-tracking, -0.02em)'};margin-bottom:48px">${inline(tokens?.wordmark?.text ?? name)}</div></div>
147
+ <div class="wrap bottom"><div class="line">${inline(firstOf('In one line').split('\n')[0])}</div><div class="keys">${keywords.map((k) => `<span class="pill">${inline(k)}</span>`).join('')}</div></div></section>`);
148
+
149
+ // ── identity ──
150
+ if (identity) {
151
+ const sliders = (I.Personality ?? '').split('\n').map((l) => l.match(/^[-*\s]*(.+?)\s+([●○]{3,})\s+(.+)$/)).filter(Boolean);
152
+ const voiceLine = (I.Voice ?? '').match(/[“"]([^”"]+)[”"]/)?.[1];
153
+ html.push(`<section><div class="wrap"><div class="kicker">01 · ${Tx.identity}</div><h2>${Tx.who(inline(name))}</h2><div class="grid">
154
+ ${filled(firstOf('Offer', 'Purpose')) ? `<div><h3>${Tx.offer}</h3><div class="md">${md(firstOf('Offer', 'Purpose'))}</div></div>` : ''}
155
+ ${filled(I.Audience) ? `<div><h3>${Tx.audience}</h3><div class="md">${md(I.Audience)}</div></div>` : ''}
156
+ ${sliders.length ? `<div><h3>${Tx.personality}</h3><div class="sliders">${sliders.map(([, a, dots, b]) => `<div class="slider"><span>${inline(a)}</span><div class="track"><i style="left:${(((dots.match(/●/g) ?? []).length > 1 ? dots.lastIndexOf('●') : dots.indexOf('●')) / Math.max(1, dots.length - 1)) * 100}%"></i></div><span>${inline(b)}</span></div>`).join('')}</div></div>` : ''}
157
+ ${filled(I.Voice) ? `<div><h3>${Tx.voice}</h3>${voiceLine ? `<p class="voice">“${inline(voiceLine)}”</p>` : `<div class="md">${md(I.Voice)}</div>`}</div>` : ''}
158
+ ${filled(I.Not) ? `<div><h3>${Tx.not}</h3><div class="md">${md(I.Not)}</div></div>` : ''}
159
+ ${filled(I.References) ? `<div><h3>${Tx.references}</h3><div class="md">${md(I.References)}</div></div>` : ''}
160
+ </div></div></section>`);
161
+ }
162
+
163
+ // ── visual system ──
164
+ if (tokens) {
165
+ const sw = Object.entries(C).map(([role, c]) => `<div class="swatch"><div class="chip" style="background:${c.hex}"></div><b>${role} · ${c.hex}</b><small>${inline(c.role ?? '')}</small></div>`).join('');
166
+ const T = tokens.type ?? {};
167
+ const specimen = (role, sample) => T[role] ? `<div class="spec"><small>${role}<br>${tokens.fonts?.[T[role].font]?.family ?? ''} ${T[role].weight} · ${T[role].size}px</small><div style="font-family:var(--${T[role].font === 'mono' ? 'mono' : T[role].font});font-weight:${T[role].weight};font-size:${Math.min(T[role].size, 160)}px;line-height:${T[role].lineHeight};letter-spacing:${T[role].tracking}">${sample}</div></div>` : '';
168
+ const short = (t, n) => (t.length <= n ? t : `${t.slice(0, t.lastIndexOf(' ', n))}…`);
169
+ const line1 = inline(short((firstOf('In one line') || name).split('\n')[0], 34));
170
+ const body = inline(short((firstOf('Offer', 'Purpose', 'Audience') || `${name}, set in its text face at body size, the way most of its words will be read.`).split('\n')[0], 180));
171
+ const caption = inline(keywords.length ? keywords.join(' · ') : Object.keys(C).join(' · '));
172
+ const on = tokens.wordmark?.files ?? {};
173
+ html.push(`<section><div class="wrap"><div class="kicker">02 · ${Tx.visual}</div><h2>${Tx.looks(inline(name))}</h2>
174
+ ${on.onLight || on.onDark ? `<h3>${Tx.wordmark}</h3><div class="marks">${on.onLight ? `<div style="background:${dark ? C.figure?.hex : C.ground?.hex}"><img src="../${on.onLight}"></div>` : ''}${on.onDark ? `<div style="background:${dark ? C.ground?.hex : C.figure?.hex}"><img src="../${on.onDark}"></div>` : ''}</div><br><br>` : ''}
175
+ <h3>${Tx.palette}</h3><div class="swatches">${sw}</div>
176
+ ${tokens.ramp?.length ? `<div class="ramp">${tokens.ramp.map((h) => `<div style="background:${h}"></div>`).join('')}</div>` : ''}
177
+ <br><br><h3>${Tx.type}</h3>${specimen('display', inline(tokens.wordmark?.text ?? name))}${specimen('title', line1)}${specimen('body', body)}${specimen('caption', caption)}
178
+ ${tokens.shape ? `<br><br><h3>${Tx.shape} · ${tokens.shape.language}</h3><svg width="420" height="260" style="display:block"><path id="shape" fill="var(--surface)" stroke="var(--figure)" stroke-width="${tokens.shape.stroke ?? 1}"/></svg>` : ''}
179
+ ${filled(V.Rules) ? `<br><br><h3>${Tx.rules}</h3><div class="md">${md(V.Rules)}</div>` : ''}
180
+ </div></section>`);
181
+ }
182
+
183
+ // ── motion ──
184
+ if (motionMd || motion) {
185
+ html.push(`<section><div class="wrap"><div class="kicker">03 · ${Tx.motion}</div><h2>${Tx.moves(inline(name))}</h2>
186
+ <div class="sample" id="sample"></div><br>
187
+ <div class="grid">${Object.entries(M).filter(([k, v]) => filled(v) && k !== 'Open').map(([k, v]) => `<div><h3>${inline(k.toLowerCase())}</h3><div class="md">${md(v)}</div></div>`).join('')}</div>
188
+ ${motion?.moves?.length ? `<br><br><h3>${Tx.movesL}</h3><div class="clips">${motion.moves.map((m) => `<figure><video src="../${m.video}" muted loop autoplay playsinline></video><figcaption>${inline(m.name)}</figcaption></figure>`).join('')}</div>` : ''}
189
+ </div></section>`);
190
+ }
191
+
192
+ // ── sound ──
193
+ if (soundMd || sounds) {
194
+ html.push(`<section><div class="wrap"><div class="kicker">04 · ${Tx.sound}</div><h2>${Tx.sounds(inline(name))}</h2>
195
+ ${sounds?.length ? `<div class="sounds">${sounds.map((s, i) => `<button data-i="${i}">▶ ${inline(s.name)}</button>`).join('')}</div><br><br>` : ''}
196
+ <div class="grid">${Object.entries(S).filter(([k, v]) => filled(v) && k !== 'Open').map(([k, v]) => `<div><h3>${inline(k.toLowerCase())}</h3><div class="md">${md(v)}</div></div>`).join('')}</div>
197
+ </div></section>`);
198
+ }
199
+
200
+ // ── shader set ──
201
+ if (shaders?.length) {
202
+ html.push(`<section><div class="wrap"><div class="kicker">05 · ${Tx.set}</div><h2>${Tx.materials(shaders.length)}</h2>
203
+ <div class="clips">${shaders.map((s) => `<figure><video src="../shader-set/${s.video}" muted loop autoplay playsinline></video><figcaption>${inline(s.id)}</figcaption></figure>`).join('')}</div></div></section>`);
204
+ }
205
+
206
+ html.push(`<footer class="wrap"><span>${inline(name)}</span><span>${Tx.made}</span></footer>`);
207
+ document.querySelector('#sheet').innerHTML = html.join('');
208
+
209
+ // ── live parts ──
210
+ const shapeEl = document.querySelector('#shape');
211
+ if (shapeEl) {
212
+ const L = tokens.shape.language, r = tokens.shape.radius ?? 0;
213
+ shapeEl.setAttribute('d', L === 'organic' ? organicPath(400, 240, { radius: r, seed: 2 }) : `M${r},0 H${400 - r} Q400,0 400,${r} V${240 - r} Q400,240 ${400 - r},240 H${r} Q0,240 0,${240 - r} V${r} Q0,0 ${r},0 Z`);
214
+ shapeEl.setAttribute('transform', 'translate(10,10)');
215
+ }
216
+ // the cover field stays in the brand's grounds (ground, surface, the ramp's first step): the accent is a moment, never a cover
217
+ const field = [C.ground?.hex, C.surface?.hex ?? tokens?.ramp?.[1], tokens?.ramp?.[1] ?? C.muted?.hex, C.ground?.hex].filter(Boolean);
218
+ if (field.length >= 2) await live(document.querySelector('#cover .field'), 'MeshGradient', { colors: field, distortion: 0.5, swirl: 0.15, speed: 0.12, grainOverlay: 0.06 }).catch(() => {});
219
+ const sample = document.querySelector('#sample');
220
+ if (sample) {
221
+ const voiceQuote = (I.Voice ?? '').match(/[“"]([^”"]{4,90})[”"]/)?.[1];
222
+ const text = (firstOf('In one line').split('\n')[0] || voiceQuote || tokens?.wordmark?.text || name).replace(/[*`]/g, '');
223
+ const mode = motion?.text ?? (/typ/i.test(motionMd ?? '') ? 'typing' : 'reveal');
224
+ if (mode === 'typing') { sample.style.setProperty('--sample-font', tokens?.fonts?.mono ? 'var(--mono)' : 'var(--text)'); sample.style.fontSize = '44px'; }
225
+ let t0 = performance.now();
226
+ const tick = (now) => {
227
+ const t = ((now - t0) / 1000) % (text.length / 18 + 3);
228
+ if (mode === 'typing') { const n = window.__export ? text.length : Math.min(text.length, Math.floor(t * 18)); sample.innerHTML = `${inline(text.slice(0, n))}<span class="cur" style="opacity:${n < text.length || Math.floor(t * 2) % 2 ? 1 : 0}"></span>`; }
229
+ else { const p = Math.min(1, t / 1.2); sample.textContent = text; sample.style.opacity = p; sample.style.transform = `translateY(${(1 - p) * 16}px)`; }
230
+ requestAnimationFrame(tick);
231
+ };
232
+ requestAnimationFrame(tick);
233
+ }
234
+ document.querySelectorAll('.sounds button').forEach((b) => b.addEventListener('click', () => new Audio(`../sound/${sounds[b.dataset.i].file}`).play()));
235
+
236
+ await Promise.all([...new Set(Object.values(tokens?.fonts ?? {}).filter(Boolean).map((f) => f.family))].map((f) => document.fonts.load(`400 32px '${f}'`).catch(() => {})));
237
+ await document.fonts.ready;
238
+ window.__sheetReady = true;
239
+ </script>
240
+ </body>
241
+ </html>
@@ -0,0 +1,4 @@
1
+ # move demo
2
+
3
+ A 5 s demo of one move, at one pace, with one text behavior and one camera, in the brand's look.
4
+ Used for motion option boards (`pamo board … --clip 5`) and as the clip a brand's motion style keeps.
@@ -0,0 +1,158 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>move demo</title>
6
+ <!--
7
+ A 7 s demo of one motion style in the brand's look: frames alternate (the name, then a line in the brand's
8
+ words) at the chosen pace, so the clip shows rhythm, not one move. Reads /brand/tokens.json and
9
+ /brand/IDENTITY.md: render it from the project root. Edit freely: it is a sketch for a board.
10
+ -->
11
+ <style>
12
+ #stage { overflow: hidden; }
13
+ #world { position: absolute; inset: 0; }
14
+ .frame { position: absolute; top: 0; width: 1600px; height: 900px; display: flex; flex-direction: column; justify-content: center; padding: 0 140px; box-sizing: border-box; }
15
+ .name { white-space: nowrap; }
16
+ .cur { display: inline-block; width: 0.5em; height: 0.95em; vertical-align: -0.12em; margin-left: 4px; }
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <main id="stage"><div id="world"></div></main>
21
+ <script type="module">
22
+ import { scene, ease, clamp } from 'paper-motion';
23
+ import * as M from 'paper-motion/moves';
24
+
25
+ // The four choices this demo shows. `pamo new <name> --move … --pace … --text … --camera …` sets them.
26
+ const MOVE = 'crossfade'; // transition between frames: cut crossfade wipe push maskReveal scanReveal zoomThrough whip cells slices iris glitch flip3D flash
27
+ const PACE = 'medium'; // calm | medium | fast
28
+ const TEXT = 'plain'; // how the line appears: typing | rise | blur | track | plain
29
+ const CAMERA = 'still'; // still | drift | travel (travel moves the camera between frames and replaces MOVE)
30
+ const SOUND = 'none'; // sound palette: none | soft | crisp | airy (subtle swells) | ambient (airy over a low bed)
31
+ const DURATION = 7;
32
+
33
+ const tokens = await fetch('/brand/tokens.json').then((r) => (r.ok ? r.json() : null)).catch(() => null);
34
+ const identity = await fetch('/brand/IDENTITY.md').then((r) => (r.ok ? r.text() : '')).catch(() => '');
35
+ const C = tokens?.colors ?? {};
36
+ const col = (role, fb) => C[role]?.hex ?? fb;
37
+ const fam = (k, fb) => (tokens?.fonts?.[k]?.family ? `'${tokens.fonts[k].family}', ${fb}` : fb);
38
+ await Promise.all(Object.values(tokens?.fonts ?? {}).filter((f) => f?.css).map((f) => new Promise((res) => document.head.append(Object.assign(document.createElement('link'), { rel: 'stylesheet', href: f.css, onload: res, onerror: res })))));
39
+ for (const f of Object.values(tokens?.fonts ?? {})) if (f?.file) document.head.append(Object.assign(document.createElement('style'), { textContent: `@font-face { font-family: '${f.family}'; src: url('/brand/${f.file}'); font-weight: 100 900; }` }));
40
+
41
+ // The brand's own words: its one line, else the voice sample, else the first sentence of its offer.
42
+ const section = (h) => (identity.split(new RegExp(`^## ${h}\\s*$`, 'm'))[1] ?? '').split(/^## /m)[0].trim();
43
+ const firstLine = (s) => s.split('\n').map((l) => l.replace(/^[-*]\s*/, '').trim()).find(Boolean) ?? '';
44
+ const quoted = section('Voice').match(/[“"]([^”"]{4,90})[”"]/)?.[1];
45
+ const name = tokens?.wordmark?.text ?? tokens?.brand ?? 'brand';
46
+ // a short line reads in one beat: the voice sample, else the first clause of the one line, at most ~48 characters
47
+ const clip = (t) => { if (!t) return ''; const c = t.split(/[,.;:—]/)[0].trim(); const u = c.length <= 48 ? c : c.slice(0, c.lastIndexOf(' ', 48)); return u; };
48
+ // the brand speaking (its voice sample) comes first: it is in the brand's own language
49
+ const line = (quoted && quoted.length <= 64 ? quoted : '') || clip(firstLine(section('In one line'))) || clip(quoted) || clip(firstLine(section('Offer') || section('Purpose'))) || name;
50
+
51
+ // Two frames that contrast enough for every move to read: ground, and figure (or a surface far from ground).
52
+ const lum = (hex) => { const n = parseInt((hex ?? '#888').slice(1), 16); return ((n >> 16 & 255) * 0.299 + (n >> 8 & 255) * 0.587 + (n & 255) * 0.114) / 255; };
53
+ const ground = col('ground', '#f2f2f2'), figure = col('figure', '#1a1a1a'), surface = C.surface?.hex;
54
+ // the second frame contrasts enough for moves to read, without flashing: a nearby tone of the brand first
55
+ // (surface, the ramp's steps, muted), the figure color only as a last resort
56
+ const near = [surface, tokens?.ramp?.[1], tokens?.ramp?.[2], C.muted?.hex].filter(Boolean).find((c) => Math.abs(lum(c) - lum(ground)) >= 0.07);
57
+ const bBg = near ?? figure;
58
+ const bInk = Math.abs(lum(figure) - lum(bBg)) > Math.abs(lum(ground) - lum(bBg)) ? figure : ground;
59
+
60
+ const T = tokens?.type ?? {};
61
+ const s = scene({ width: 1600, height: 900, fps: 30, duration: DURATION, background: ground });
62
+ const world = document.getElementById('world');
63
+ const mk = (i, kind) => {
64
+ const el = document.createElement('div'); el.className = 'frame';
65
+ el.style.left = CAMERA === 'travel' ? `${i * 1600}px` : '0';
66
+ el.style.background = kind === 'name' ? ground : bBg; el.style.color = kind === 'name' ? figure : bInk;
67
+ if (kind === 'name') {
68
+ el.innerHTML = '<div class="name"></div>';
69
+ Object.assign(el.firstChild.style, { font: `${tokens?.wordmark?.weight ?? T.display?.weight ?? 600} 220px ${fam(tokens?.wordmark?.font ?? 'display', 'system-ui, sans-serif')}`, letterSpacing: tokens?.wordmark?.tracking ?? T.display?.tracking ?? '-0.02em', lineHeight: 0.95 });
70
+ el.firstChild.textContent = name;
71
+ } else {
72
+ el.innerHTML = '<div><span class="ln"></span><span class="cur"></span></div>';
73
+ Object.assign(el.firstChild.style, { font: `${T.title?.weight ?? 500} 80px/1.08 ${fam('display', 'system-ui, sans-serif')}`, letterSpacing: T.title?.tracking ?? '-0.01em', maxWidth: '1300px' });
74
+ el.querySelector('.ln').textContent = line;
75
+ if (TEXT === 'track') el.firstChild.style.whiteSpace = 'nowrap'; // letters settle without reflowing lines
76
+ if (TEXT === 'typing') { el.firstChild.style.fontFamily = fam(tokens?.fonts?.mono ? 'mono' : 'text', 'ui-monospace, monospace'); el.firstChild.style.fontWeight = 400; } // typing reads in the brand's mono (or text face), never the display face
77
+ el.querySelector('.cur').style.background = bInk; el.querySelector('.cur').style.opacity = 0;
78
+ }
79
+ world.append(el);
80
+ return el;
81
+ };
82
+
83
+ // Beats: name, line, name, line… each holds for the pace, then the move carries it to the next.
84
+ const P = M.PACE[PACE], trans = P.enter * 1.6, textDur = P.enter * 1.6;
85
+ const beats = [];
86
+ for (let t = 0.3, i = 0; t < DURATION; i++) {
87
+ const kind = i % 2 ? 'line' : 'name';
88
+ const hold = kind === 'line' ? textDur + P.hold * 0.55 : P.hold * 0.45; // a demo compresses holds so even calm shows its rhythm
89
+ beats.push({ kind, start: t, end: t + hold, el: mk(i, kind) });
90
+ t += hold + trans;
91
+ }
92
+ beats.forEach((b, i) => { b.el.style.opacity = i === 0 ? 1 : 0; });
93
+ const cps = { calm: 14, medium: 22, fast: 32 }[PACE]; // typing speed per pace (characters a second)
94
+
95
+ s.update((t) => {
96
+ // Every frame is derived from t alone: reset, then apply only the transition in progress (or last finished).
97
+ for (const b of beats) Object.assign(b.el.style, { opacity: CAMERA === 'travel' ? 1 : 0, transform: '', maskImage: '', webkitMaskImage: '', clipPath: '' });
98
+ let k = -1;
99
+ beats.forEach((b, i) => { if (beats[i + 1] && t >= b.end) k = i; });
100
+ const p = k < 0 ? 0 : clamp((t - beats[k].end) / trans);
101
+ const drift = CAMERA === 'drift' ? ` scale(${1 + 0.05 * t / DURATION})` : '';
102
+ if (CAMERA === 'travel') world.style.transform = `translateX(${-(Math.max(0, k) + (k < 0 ? 0 : ease.inOutCubic(p))) * 1600}px)`;
103
+ else {
104
+ world.style.transform = drift.trim();
105
+ if (k < 0) beats[0].el.style.opacity = 1;
106
+ else {
107
+ const a = beats[k].el, b = beats[k + 1].el;
108
+ (({ cut: () => M.cut(a, b, p >= 0.5 ? 1 : 0), crossfade: () => M.crossfade(a, b, p), wipe: () => M.wipe(a, b, p),
109
+ push: () => M.push(a, b, p), maskReveal: () => M.maskReveal(a, b, p), scanReveal: () => M.scanReveal(a, b, p),
110
+ zoomThrough: () => M.zoomThrough(a, b, p, { origin: beats[k].kind === 'name' ? '30% 50%' : '50% 50%' }) })[MOVE]
111
+ ?? (() => M[MOVE]?.(a, b, p)))(); // any other transition in the library: whip, cells, slices, iris, glitch, flip3D, flash
112
+ }
113
+ }
114
+ // the line appears as its frame lands
115
+ for (const b of beats.filter((x) => x.kind === 'line')) {
116
+ const el = b.el.querySelector('.ln'), start = b.start - (CAMERA === 'travel' ? 0 : trans * 0.3);
117
+ if (TEXT === 'typing') M.typeOn(el, line, t, { times: M.typingTimes(line, { start, cps, seed: beats.indexOf(b) + 1 }), cursor: t < b.end + trans ? b.el.querySelector('.cur') : null });
118
+ else if (TEXT === 'rise') M.wordRise(el, t, { start, each: P.stagger, duration: textDur });
119
+ else if (TEXT === 'blur') M.blurIn(el, t, { start, duration: textDur });
120
+ else if (TEXT === 'track') M.trackIn(el, t, { start, duration: textDur * 1.2 });
121
+ }
122
+ });
123
+
124
+ // ── sound palette: one cue family per gesture (transition, landing, text), all low and soft ──
125
+ const PAL = {
126
+ soft: { lowpass: 1800, move: (t) => s.sfx(t, 'whoosh', { dur: Math.min(1.4, Math.max(0.4, trans * 1.4)), from: 220, to: 700, peak: 0.6, gain: 0.2, body: 0.4 }), land: (t) => s.sfx(t, 'thud', { freq: 55, gain: 0.22, decay: 0.45 }), text: (t) => s.sfx(t, 'paper', { dur: 0.5, gain: 0.035 }) },
127
+ crisp: { lowpass: 4200, move: (t) => { s.sfx(t, 'flick', { gain: 0.12 }); s.sfx(t + 0.02, 'tick', { freq: 320, gain: 0.07, decay: 0.03 }); }, land: (t) => s.sfx(t, 'knock', { freq: 160, gain: 0.16, decay: 0.08 }), text: (t) => s.sfx(t, 'tick', { freq: 260, gain: 0.05, decay: 0.04 }) },
128
+ airy: { lowpass: 2000, move: (t) => s.sfx(t - 0.2, 'swell', { dur: Math.max(1.4, trans * 2.2), freq: 73.4, gain: 0.045 }), land: () => {}, text: (t) => s.sfx(t, 'paper', { dur: 0.6, gain: 0.025 }) },
129
+ ambient: { lowpass: 2000, bed: () => s.sfx(0, 'drone', { dur: DURATION, freq: 55, gain: 0.04, attack: 1.5, release: 1.5 }), move: (t) => s.sfx(t - 0.2, 'swell', { dur: Math.max(1.4, trans * 2.2), freq: 73.4, gain: 0.05 }), land: () => {}, text: (t) => s.sfx(t, 'paper', { dur: 0.6, gain: 0.03 }) },
130
+ }[SOUND];
131
+ if (PAL) {
132
+ PAL.bed?.();
133
+ const bank = await fetch('/brand/sound/keys/keys.json').then((r) => (r.ok ? 'brand/sound/keys' : null)).catch(() => null);
134
+ beats.forEach((b, i) => {
135
+ if (beats[i + 1]) { PAL.move(b.end); PAL.land(b.end + trans); }
136
+ if (b.kind === 'line') {
137
+ const start = b.start - (CAMERA === 'travel' ? 0 : trans * 0.3);
138
+ if (TEXT === 'typing') M.keySounds(s, line, M.typingTimes(line, { start, cps, seed: i + 1 }), { bank, seed: i + 1 });
139
+ else PAL.text(start);
140
+ }
141
+ });
142
+ s.sound({ lowpass: PAL.lowpass, gain: 1 });
143
+ }
144
+
145
+ // track sets the line on one row: shrink it to fit the frame at its widest (start) spacing
146
+ if (TEXT === 'track') for (const b of beats.filter((x) => x.kind === 'line')) {
147
+ const box = b.el.firstChild, ln = b.el.querySelector('.ln');
148
+ await document.fonts.ready;
149
+ ln.style.letterSpacing = '0.4em';
150
+ const fit = (1600 - 280) / box.scrollWidth;
151
+ if (fit < 1) box.style.fontSize = `${parseFloat(getComputedStyle(box).fontSize) * fit}px`;
152
+ ln.style.letterSpacing = '';
153
+ }
154
+ await Promise.all([...document.querySelectorAll('.name, .frame > div')].map((el) => { const c = getComputedStyle(el); return document.fonts.load(`${c.fontWeight} ${c.fontSize} ${c.fontFamily}`); }));
155
+ await s.start();
156
+ </script>
157
+ </body>
158
+ </html>
@@ -0,0 +1,39 @@
1
+ # Brief — <title>
2
+ Status: draft | approved by <user> on <date>
3
+
4
+ ## Purpose
5
+ Where it lives · who watches · what should happen after watching.
6
+
7
+ ## Feeling
8
+ Three words. One sentence of intent.
9
+
10
+ ## References
11
+ - <link> — what we take from it: …
12
+ - <image> — what we take from it: …
13
+
14
+ ## Anti-references (never)
15
+ - …
16
+
17
+ ## Content
18
+ Exact text / none · must be read? · hero moment.
19
+
20
+ ## Recipe and format
21
+ Recipe · aspect (16:9 / 9:16 / 1:1) · duration · fps · loop · safe areas.
22
+
23
+ ## Structure and pace
24
+ Continuous / cuts / chapters · pace (cut every N s) · sync (music/VO/none) · opening · ending.
25
+
26
+ ## Look
27
+ From the brand (say "brand") · what this piece changes · composition · which shader-set entries.
28
+
29
+ ## Motion
30
+ From the brand's motion.json · what this piece changes · motion blur.
31
+
32
+ ## Sound
33
+ From the brand's sound.json · music (the user's file) or sound design only · loudness.
34
+
35
+ ## Done when
36
+ The user's own words.
37
+
38
+ ## Open questions
39
+ - …
@@ -0,0 +1,62 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>untitled piece</title>
6
+ <!-- A piece wired to the brand folder: look, moves, sound and material come from /brand (serve from the project root). -->
7
+ <style>
8
+ #stage { overflow: hidden; }
9
+ .abs { position: absolute; box-sizing: border-box; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <main id="stage">
14
+ <div id="bg" class="abs" style="inset:0"></div>
15
+ <div id="group" class="abs" style="display:flex;flex-direction:column"><div id="title"></div><div id="line"></div></div>
16
+ </main>
17
+ <script type="module">
18
+ import { scene, progress, ease } from 'paper-motion';
19
+ import { wordRise, blurIn, trackIn, typeOn, typingTimes, keySounds } from 'paper-motion/moves';
20
+ import { loadBrand } from 'paper-motion/brand';
21
+
22
+ const B = await loadBrand();
23
+ const W = 1920, H = 1080, SHORT = Math.min(W, H), M = Math.round(SHORT * 0.08); // `pamo new --format` sets W and H
24
+ const s = scene({ width: W, height: H, fps: 30, duration: 8, background: B.color('ground') });
25
+
26
+ // Material: the brand's first shader-set entry, or a slow field from its ramp.
27
+ const first = B.set.entries.find((e) => e.shader && !e.image);
28
+ if (first) B.shader(s, '#bg', first.id);
29
+ else if (B.ramp.length >= 2) s.shader('#bg', 'MeshGradient', { colors: B.ramp.slice(0, 4), distortion: 0.6, swirl: 0.2, speed: 0.2 });
30
+
31
+ const title = B.style(document.querySelector('#title'), 'display', { short: SHORT });
32
+ const line = B.style(document.querySelector('#line'), 'title', { short: SHORT });
33
+ // title and line stack from the bottom margin; the title shrinks to fit the frame's width in any format
34
+ Object.assign(document.querySelector('#group').style, { left: `${M}px`, right: `${M}px`, bottom: `${M}px`, gap: `${Math.round(SHORT * 0.03)}px` });
35
+ Object.assign(title.style, { whiteSpace: 'nowrap' });
36
+ title.textContent = B.name;
37
+ line.textContent = (B.identity['In one line'] ?? '').split('\n')[0] || B.name;
38
+ await document.fonts.ready;
39
+ const fit = (W - M * 2) / title.scrollWidth;
40
+ if (fit < 1) title.style.fontSize = `${parseFloat(title.style.fontSize) * fit}px`;
41
+ if (H > W) line.style.fontSize = `${parseFloat(line.style.fontSize) * 0.8}px`;
42
+
43
+ // Text behaves the way the brand's motion style says (motion.json: typing | rise | blur | track).
44
+ const P = B.pace, T1 = 0.6, T2 = T1 + P.enter * 2, TEXT = B.motion?.text ?? 'rise';
45
+ // typed text is set in the brand's text face (a terminal types in its mono), a little smaller than a title
46
+ if (TEXT === 'typing') Object.assign(line.style, { fontFamily: B.font('text'), fontSize: `${parseFloat(line.style.fontSize) * 0.55}px`, letterSpacing: '0em', lineHeight: 1.3 });
47
+ const lineText = line.textContent, times = typingTimes(lineText, { start: T2, cps: { calm: 14, medium: 22, fast: 32 }[B.motion?.pace ?? 'medium'], seed: 1 });
48
+ s.update((t) => {
49
+ wordRise(title, t, { start: T1, each: P.stagger, duration: P.enter * 1.6 });
50
+ if (TEXT === 'typing') typeOn(line, lineText, t, { times });
51
+ else if (TEXT === 'blur') blurIn(line, t, { start: T2, duration: P.enter * 1.6 });
52
+ else if (TEXT === 'track') trackIn(line, t, { start: T2, duration: P.enter * 2 });
53
+ else wordRise(line, t, { start: T2, each: P.stagger, duration: P.enter * 1.6 });
54
+ });
55
+ B.cue(s, 'land', T1);
56
+ if (TEXT === 'typing') keySounds(s, lineText, times, B.keys); else B.cue(s, 'text', T2);
57
+ B.sound(s);
58
+
59
+ await s.start();
60
+ </script>
61
+ </body>
62
+ </html>
@@ -0,0 +1,47 @@
1
+ // Mounts entries of a brand's shader set (brand/shader-set/set.json). Copied here by `pamo shader-set build`.
2
+ // One entry → a loop in the set's format; several → a review board (4 columns, numbered).
3
+ import { scene } from 'paper-motion';
4
+
5
+ const BRAND = '/brand';
6
+
7
+ /** Periodic over the loop: a param travels from → to and back, `cycles` times per loop. */
8
+ const wave = (t, D, from, to, cycles = 1) => from + (to - from) * (0.5 - 0.5 * Math.cos((2 * Math.PI * cycles * t) / D));
9
+
10
+ export async function mount(entries, format, { board = false, numbers = [] } = {}) {
11
+ const tokens = await fetch(`${BRAND}/tokens.json`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
12
+ const W = format.width ?? 1920, H = format.height ?? 1080, D = format.duration ?? 6;
13
+ const s = scene({ width: board ? 1920 : W, height: board ? Math.round(1920 / 4 / (W / H)) * Math.ceil(entries.length / 4) : H, fps: format.fps ?? 30, duration: D, background: tokens?.colors?.ground?.hex ?? '#000' });
14
+ const stage = document.querySelector('#stage');
15
+ const cols = board ? 4 : 1, cw = board ? 1920 / 4 : W, ch = board ? Math.round(cw / (W / H)) : H;
16
+ const wm = tokens?.wordmark?.files ?? {};
17
+ const image = (img) => {
18
+ if (!img) return undefined;
19
+ if (img === 'wordmark') return `${BRAND}/${wm.onDark ?? wm.onLight}`; // light ink on transparent (LiquidMetal, GemSmoke)
20
+ if (img === 'wordmark-dark') return `${BRAND}/${wm.onLight ?? wm.onDark}`; // dark ink on transparent (Heatmap)
21
+ return img.startsWith('/') || img.startsWith('http') ? img : `${BRAND}/${img}`;
22
+ };
23
+ for (const [i, e] of entries.entries()) {
24
+ const el = document.createElement('div');
25
+ Object.assign(el.style, { position: 'absolute', left: `${(i % cols) * cw}px`, top: `${Math.floor(i / cols) * ch}px`, width: `${cw}px`, height: `${ch}px`, overflow: 'hidden', background: e.background ?? 'transparent' });
26
+ stage.append(el);
27
+ if (e.source === 'user' && e.file) {
28
+ // the user's own loop: played in sync with the scene, covering the frame
29
+ const v = Object.assign(document.createElement('video'), { src: `${BRAND}/${e.file}`, muted: true, loop: true, playsInline: true });
30
+ v.setAttribute('data-pm-sync', ''); v.setAttribute('loop', '');
31
+ Object.assign(v.style, { width: '100%', height: '100%', objectFit: 'cover' });
32
+ el.append(v);
33
+ } else {
34
+ const props = { ...e.props };
35
+ if (e.image) props.image = image(e.image);
36
+ const layer = s.shader(el, e.shader, props, board || !e.loop ? {} : { loop: e.loop });
37
+ if (e.motion) s.update((t) => layer.set({ [e.motion.param]: wave(t, D, e.motion.from, e.motion.to, e.motion.cycles) }));
38
+ }
39
+ if (board) {
40
+ const lab = document.createElement('div');
41
+ Object.assign(lab.style, { position: 'absolute', left: `${(i % cols) * cw + 10}px`, top: `${Math.floor(i / cols) * ch + 10}px`, font: '500 15px ui-monospace, Menlo, monospace', color: '#fff', background: 'rgba(0,0,0,.62)', padding: '4px 8px', borderRadius: '5px', zIndex: 2 });
42
+ lab.textContent = `${numbers[i] ?? i + 1} · ${e.id}`;
43
+ stage.append(lab);
44
+ }
45
+ }
46
+ await s.start();
47
+ }
@@ -0,0 +1,3 @@
1
+ # untitled still
2
+
3
+ A still frame (1600×900, 3 s so `--t 1` is safe). Built for option boards and style frames.
@@ -0,0 +1,56 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>untitled still</title>
6
+ <!-- A still: an option for a board, a style frame, a sheet section. Fonts come from brand/tokens.json when it exists. -->
7
+ <style>
8
+ #stage { overflow: hidden; }
9
+ .abs { position: absolute; box-sizing: border-box; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <main id="stage">
14
+ <svg id="panel" class="abs" style="left:880px;top:140px" width="600" height="620"><path /></svg>
15
+ <div id="title" class="abs" style="left:120px;top:260px;white-space:pre"></div>
16
+ <div id="line" class="abs" style="left:124px;top:520px;max-width:640px"></div>
17
+ </main>
18
+ <script type="module">
19
+ import { scene, organicPath } from 'paper-motion';
20
+
21
+ // Read the brand if there is one; neutral stand-ins otherwise (system sans, greys).
22
+ const tokens = await fetch('/brand/tokens.json').then((r) => (r.ok ? r.json() : null)).catch(() => null);
23
+ const C = tokens?.colors ?? {};
24
+ const col = (role, fallback) => C[role]?.hex ?? fallback;
25
+ const font = (key, fallback) => (tokens?.fonts?.[key]?.family ? `'${tokens.fonts[key].family}'` : fallback);
26
+ // Stylesheets must finish loading before fonts.load() can find their faces.
27
+ await Promise.all(Object.values(tokens?.fonts ?? {}).filter((f) => f?.css).map((f) => new Promise((res) => {
28
+ const l = Object.assign(document.createElement('link'), { rel: 'stylesheet', href: f.css, onload: res, onerror: res });
29
+ document.head.append(l);
30
+ })));
31
+ // Fonts the brand ships as files load as @font-face from the brand folder.
32
+ for (const f of Object.values(tokens?.fonts ?? {})) if (f?.file) document.head.append(Object.assign(document.createElement('style'), { textContent: `@font-face { font-family: '${f.family}'; src: url('/brand/${f.file}'); font-weight: 100 900; }` }));
33
+
34
+ const s = scene({ width: 1600, height: 900, fps: 30, duration: 3, background: col('ground', '#f2f2f2') });
35
+
36
+ const title = document.querySelector('#title'), line = document.querySelector('#line');
37
+ Object.assign(title.style, { font: `${tokens?.type?.display?.weight ?? 600} 200px ${font('display', 'system-ui, sans-serif')}`, letterSpacing: tokens?.type?.display?.tracking ?? '-0.02em', color: col('figure', '#1a1a1a'), lineHeight: '0.95' });
38
+ Object.assign(line.style, { font: `400 34px/1.35 ${font('text', 'system-ui, sans-serif')}`, color: col('figure', '#1a1a1a') });
39
+ title.textContent = tokens?.wordmark?.text ?? 'brand';
40
+ line.textContent = 'one line in the brand’s own words.';
41
+
42
+ // Shapes follow the brand's shape language: organic → organicPath, soft → rounded, hard → square.
43
+ const shape = tokens?.shape?.language ?? 'soft';
44
+ const path = document.querySelector('#panel path');
45
+ const d = shape === 'organic' ? organicPath(600, 620, { radius: tokens?.shape?.radius ?? 24, seed: 3 })
46
+ : `M0,0 H600 V620 H0 Z`;
47
+ path.setAttribute('d', d);
48
+ path.setAttribute('fill', col('surface', '#d9d9d9'));
49
+ if (shape === 'soft') document.querySelector('#panel').style.borderRadius = `${tokens?.shape?.radius ?? 24}px`;
50
+
51
+ // Wait for every face before the first frame, or the still renders in a fallback font.
52
+ await Promise.all([title, line].map((el) => { const c = getComputedStyle(el); return document.fonts.load(`${c.fontWeight} ${c.fontSize} ${c.fontFamily}`); }));
53
+ await s.start();
54
+ </script>
55
+ </body>
56
+ </html>