cinematic-scroll-skill 2.1.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.
- package/COMPATIBILITY.md +244 -0
- package/LICENSE +21 -0
- package/MODELS.md +92 -0
- package/README.md +250 -0
- package/SKILL.md +1003 -0
- package/audit-mode.md +497 -0
- package/bin/install.mjs +91 -0
- package/compile-choreography.mjs +296 -0
- package/decision-log.md +241 -0
- package/examples/GETTING_STARTED.md +279 -0
- package/examples/KNOWN_ISSUES.md +50 -0
- package/examples/PROMPTS.md +166 -0
- package/examples/luxe/README.md +88 -0
- package/examples/luxe/index.html +662 -0
- package/examples/noir/README.md +72 -0
- package/examples/noir/index.html +634 -0
- package/examples/pop/README.md +81 -0
- package/examples/pop/index.html +711 -0
- package/examples/renaissance/README.md +39 -0
- package/examples/renaissance/index.html +648 -0
- package/examples/studio/README.md +77 -0
- package/examples/studio/chapters.js +105 -0
- package/examples/studio/index.html +520 -0
- package/manifest.json +92 -0
- package/manifest.md +136 -0
- package/package.json +56 -0
- package/references/film-archetypes.md +211 -0
- package/references/performance-budget.md +499 -0
- package/references/scroll-patterns.md +693 -0
- package/scroll-choreography-compilation.md +543 -0
- package/scroll-choreography.json +1512 -0
- package/taste-guardrails.md +164 -0
- package/templates/nextjs/.env.example +41 -0
- package/templates/nextjs/app/api/fal/proxy/route.ts +33 -0
- package/templates/nextjs/app/api/fal/webhook/route.ts +132 -0
- package/templates/nextjs/app/api/generate-edition-asset/route.ts +66 -0
- package/templates/nextjs/app/globals.css +80 -0
- package/templates/nextjs/app/layout.tsx +21 -0
- package/templates/nextjs/app/page.tsx +10 -0
- package/templates/nextjs/components/ChapterDemoVisual.tsx +212 -0
- package/templates/nextjs/components/ChapterScene.tsx +373 -0
- package/templates/nextjs/components/EditionsPage.tsx +116 -0
- package/templates/nextjs/components/SmoothScrollProvider.tsx +8 -0
- package/templates/nextjs/lib/api-guard.ts +110 -0
- package/templates/nextjs/lib/editions-manifest.ts +224 -0
- package/templates/nextjs/lib/fal-client.ts +12 -0
- package/templates/nextjs/lib/fal-generate.ts +86 -0
- package/templates/nextjs/lib/fal-models.ts +213 -0
- package/templates/nextjs/lib/prompt-contract.ts +97 -0
- package/templates/nextjs/lib/use-device.ts +42 -0
- package/templates/nextjs/lib/use-lenis.ts +35 -0
- package/templates/nextjs/next.config.ts +29 -0
- package/templates/nextjs/package-lock.json +6455 -0
- package/templates/nextjs/package.json +41 -0
- package/templates/nextjs/package.patch.json +28 -0
- package/templates/nextjs/postcss.config.js +6 -0
- package/templates/nextjs/scripts/generate-chapter-assets.mjs +243 -0
- package/templates/nextjs/scripts/setup.mjs +170 -0
- package/templates/nextjs/tailwind.config.ts +37 -0
- package/templates/nextjs/tsconfig.json +23 -0
- package/troubleshooting.md +1284 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* ============================================================================
|
|
3
|
+
compile-choreography.mjs
|
|
4
|
+
The signature mechanic, made real: compiles a `scroll-choreography.json`
|
|
5
|
+
(declarative scroll → camera-move schema) into runnable GSAP ScrollTrigger +
|
|
6
|
+
Lenis code. No build step, no deps — plain Node ESM.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
node compile-choreography.mjs <choreography.json> [--out scene.js] [--html]
|
|
10
|
+
node compile-choreography.mjs --example # compile the bundled example
|
|
11
|
+
node compile-choreography.mjs <file> --html # also emit a runnable demo HTML
|
|
12
|
+
|
|
13
|
+
What it does (mirrors scroll-choreography-compilation.md):
|
|
14
|
+
1. Parse + validate the choreography object
|
|
15
|
+
2. Emit Lenis smooth-scroll init (forwarded to ScrollTrigger)
|
|
16
|
+
3. Per chapter: a pinned ScrollTrigger timeline with layer parallax,
|
|
17
|
+
title reveal, atmosphere/color morph, and velocity nodes
|
|
18
|
+
4. Transitions between chapters
|
|
19
|
+
5. A reduced-motion guard that no-ops the timeline
|
|
20
|
+
|
|
21
|
+
The single most important job: map the schema's CSS-style property names
|
|
22
|
+
(translateX/translateY/rotateZ…) to GSAP's shorthand (x/y/rotation…),
|
|
23
|
+
because GSAP silently ignores the CSS names. That mapping lives in ONE place
|
|
24
|
+
below and is the reason this compiler exists.
|
|
25
|
+
========================================================================== */
|
|
26
|
+
|
|
27
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
28
|
+
import { dirname, join, basename } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
|
|
33
|
+
/* ---- the one mapping that matters: schema (CSS) → GSAP shorthand ---------- */
|
|
34
|
+
const GSAP_PROP = {
|
|
35
|
+
translateX: 'x', translateY: 'y', translateZ: 'z',
|
|
36
|
+
rotateX: 'rotationX', rotateY: 'rotationY', rotateZ: 'rotation',
|
|
37
|
+
scale: 'scale', opacity: 'opacity',
|
|
38
|
+
// passthroughs that GSAP accepts as-is:
|
|
39
|
+
letterSpacing: 'letterSpacing', backgroundColor: 'backgroundColor',
|
|
40
|
+
};
|
|
41
|
+
const gprop = (p) => GSAP_PROP[p] ?? p;
|
|
42
|
+
const withUnit = (v, unit) => (unit && typeof v === 'number' ? `${v}${unit}` : v);
|
|
43
|
+
|
|
44
|
+
/* ---- tiny validation (enough to fail loudly, not a full JSON-Schema run) -- */
|
|
45
|
+
function validate(doc) {
|
|
46
|
+
const errs = [];
|
|
47
|
+
if (!doc || typeof doc !== 'object') errs.push('root is not an object');
|
|
48
|
+
if (!Array.isArray(doc.chapters) || !doc.chapters.length)
|
|
49
|
+
errs.push('`chapters` must be a non-empty array');
|
|
50
|
+
(doc.chapters || []).forEach((c, i) => {
|
|
51
|
+
if (!c.id) errs.push(`chapters[${i}] missing id`);
|
|
52
|
+
if (c.layers && !Array.isArray(c.layers)) errs.push(`chapters[${i}].layers must be an array`);
|
|
53
|
+
});
|
|
54
|
+
if (errs.length) throw new Error('Invalid choreography:\n - ' + errs.join('\n - '));
|
|
55
|
+
return doc;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ---- emit a single property tween fragment -------------------------------- */
|
|
59
|
+
function propTween(p) {
|
|
60
|
+
// p: { property, from, to, easing, unit }
|
|
61
|
+
const g = gprop(p.property);
|
|
62
|
+
const to = withUnit(p.to, p.unit);
|
|
63
|
+
const frag = { [g]: to };
|
|
64
|
+
if (p.easing) frag.ease = mapEase(p.easing);
|
|
65
|
+
return frag;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* GSAP accepts cubic-bezier via CustomEase, but named eases are safer in raw
|
|
69
|
+
output. Pass cubic-beziers straight through as a string GSAP can register;
|
|
70
|
+
map a few common ones to named eases for portability. */
|
|
71
|
+
function mapEase(e) {
|
|
72
|
+
if (!e) return undefined;
|
|
73
|
+
const named = {
|
|
74
|
+
'cubic-bezier(0.16, 1, 0.3, 1)': 'power3.out',
|
|
75
|
+
'cubic-bezier(0.7, 0, 0.84, 0)': 'power3.in',
|
|
76
|
+
'cubic-bezier(0.87, 0, 0.13, 1)': 'power4.inOut',
|
|
77
|
+
'cubic-bezier(0.34, 1.56, 0.64, 1)': 'back.out(1.4)',
|
|
78
|
+
};
|
|
79
|
+
return named[e] || e; // GSAP-named eases (power3.out etc.) pass through
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ---- compile one chapter to a GSAP block ---------------------------------- */
|
|
83
|
+
function compileChapter(ch, globals) {
|
|
84
|
+
const sel = `[data-chapter='${ch.id}']`;
|
|
85
|
+
const pin = ch.pin || {};
|
|
86
|
+
const pinDur = pin.pinDuration ?? 200;
|
|
87
|
+
const lines = [];
|
|
88
|
+
lines.push(` /* ── Chapter: ${ch.id} (pattern: ${ch.pattern || 'custom'}) ── */`);
|
|
89
|
+
lines.push(` {`);
|
|
90
|
+
lines.push(` const tl = gsap.timeline({`);
|
|
91
|
+
lines.push(` scrollTrigger: {`);
|
|
92
|
+
lines.push(` trigger: "${sel}",`);
|
|
93
|
+
lines.push(` start: "top top",`);
|
|
94
|
+
lines.push(` end: "+=${pinDur}%",`);
|
|
95
|
+
lines.push(` scrub: ${globals.scrollSmoothing ?? true},`);
|
|
96
|
+
lines.push(` pin: ${pin.enabled !== false},`);
|
|
97
|
+
lines.push(` pinSpacing: ${pin.pinSpacing !== false},`);
|
|
98
|
+
lines.push(` anticipatePin: 1,`);
|
|
99
|
+
lines.push(` invalidateOnRefresh: true,`);
|
|
100
|
+
lines.push(` },`);
|
|
101
|
+
lines.push(` });`);
|
|
102
|
+
|
|
103
|
+
// layers → parallax tweens, positioned at 0 so they scrub together
|
|
104
|
+
(ch.layers || []).forEach((layer) => {
|
|
105
|
+
const lsel = `${sel} [data-layer='${layer.id}']`;
|
|
106
|
+
const props = layer.animation?.properties || [];
|
|
107
|
+
if (!props.length) return;
|
|
108
|
+
const tween = {};
|
|
109
|
+
const fromTween = {};
|
|
110
|
+
let hasFrom = false;
|
|
111
|
+
props.forEach((p) => {
|
|
112
|
+
const g = gprop(p.property);
|
|
113
|
+
tween[g] = withUnit(p.to, p.unit);
|
|
114
|
+
if (p.from !== undefined) { fromTween[g] = withUnit(p.from, p.unit); hasFrom = true; }
|
|
115
|
+
});
|
|
116
|
+
const dur = layer.animation?.duration ?? 1;
|
|
117
|
+
const easing = mapEase(props[0]?.easing || globals.defaultEasing);
|
|
118
|
+
const willChange = layer.willChange ? `, willChange: "transform"` : '';
|
|
119
|
+
if (hasFrom) {
|
|
120
|
+
lines.push(` tl.fromTo("${lsel}", ${json(fromTween)}, { ${spread(tween)}, ease: ${q(easing)}, duration: ${dur}${willChange} }, 0);`);
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(` tl.to("${lsel}", { ${spread(tween)}, ease: ${q(easing)}, duration: ${dur}${willChange} }, 0);`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// title reveal
|
|
127
|
+
if (ch.titleReveal) {
|
|
128
|
+
lines.push(...compileTitleReveal(ch.titleReveal, sel, globals));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// atmosphere / colour morph
|
|
132
|
+
if (ch.atmosphere?.colorMorph) {
|
|
133
|
+
const m = ch.atmosphere.colorMorph;
|
|
134
|
+
lines.push(` tl.to("${sel}", { backgroundColor: ${q(m.to)}, ease: "none", duration: 1 }, ${m.scrollStart ?? 0});`);
|
|
135
|
+
} else if (ch.atmosphere?.backgroundColor) {
|
|
136
|
+
lines.push(` gsap.set("${sel}", { backgroundColor: ${q(ch.atmosphere.backgroundColor)} });`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// velocity nodes → ScrollTrigger onUpdate reacting to getVelocity()
|
|
140
|
+
if (Array.isArray(ch.velocityNodes) && ch.velocityNodes.length) {
|
|
141
|
+
lines.push(...compileVelocity(ch.velocityNodes, sel));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
lines.push(` }`);
|
|
145
|
+
return lines.join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function compileTitleReveal(t, sel, globals) {
|
|
149
|
+
const tsel = `${sel} [data-title]`;
|
|
150
|
+
const r = t.scrollRange || { start: 0, end: 0.4 };
|
|
151
|
+
const ease = q(mapEase(t.easing || globals.defaultEasing));
|
|
152
|
+
const at = r.start ?? 0;
|
|
153
|
+
const dur = Math.max(0.1, (r.end ?? 0.4) - (r.start ?? 0));
|
|
154
|
+
const L = [];
|
|
155
|
+
L.push(` /* title: ${t.type} */`);
|
|
156
|
+
switch (t.type) {
|
|
157
|
+
case 'maskReveal':
|
|
158
|
+
case 'clipPathWipe':
|
|
159
|
+
L.push(` tl.fromTo("${tsel}", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", ease: ${ease}, duration: ${dur} }, ${at});`);
|
|
160
|
+
break;
|
|
161
|
+
case 'verticalMask':
|
|
162
|
+
L.push(` tl.fromTo("${tsel}", { clipPath: "inset(100% 0 0 0)" }, { clipPath: "inset(0% 0 0 0)", ease: ${ease}, duration: ${dur} }, ${at});`);
|
|
163
|
+
break;
|
|
164
|
+
case 'wordStagger':
|
|
165
|
+
case 'splitLineRise':
|
|
166
|
+
L.push(` tl.fromTo("${tsel} .w", { yPercent: 110, autoAlpha: 0 }, { yPercent: 0, autoAlpha: 1, stagger: ${t.stagger?.offset ?? 0.06}, ease: ${ease}, duration: ${dur} }, ${at});`);
|
|
167
|
+
break;
|
|
168
|
+
case 'letterStagger':
|
|
169
|
+
case 'typewriterReveal':
|
|
170
|
+
L.push(` tl.fromTo("${tsel} .c", { autoAlpha: 0 }, { autoAlpha: 1, stagger: ${t.stagger?.offset ?? 0.02}, ease: "none", duration: ${dur} }, ${at});`);
|
|
171
|
+
break;
|
|
172
|
+
case 'letterSpacingScrub':
|
|
173
|
+
L.push(` tl.fromTo("${tsel}", { letterSpacing: "0.4em", autoAlpha: 0.4 }, { letterSpacing: "0em", autoAlpha: 1, ease: ${ease}, duration: ${dur} }, ${at});`);
|
|
174
|
+
break;
|
|
175
|
+
case 'scaleDownEntrance':
|
|
176
|
+
L.push(` tl.fromTo("${tsel}", { scale: 1.3, autoAlpha: 0 }, { scale: 1, autoAlpha: 1, ease: ${ease}, duration: ${dur} }, ${at});`);
|
|
177
|
+
break;
|
|
178
|
+
case 'blurCrossfade':
|
|
179
|
+
// never animate filter; crossfade two stacked copies (taste-guardrails §1.1)
|
|
180
|
+
L.push(` tl.fromTo("${tsel} .sharp", { autoAlpha: 0 }, { autoAlpha: 1, ease: ${ease}, duration: ${dur} }, ${at});`);
|
|
181
|
+
L.push(` tl.to("${tsel} .soft", { autoAlpha: 0, ease: ${ease}, duration: ${dur} }, ${at});`);
|
|
182
|
+
break;
|
|
183
|
+
default:
|
|
184
|
+
L.push(` tl.fromTo("${tsel}", { autoAlpha: 0, y: 30 }, { autoAlpha: 1, y: 0, ease: ${ease}, duration: ${dur} }, ${at});`);
|
|
185
|
+
}
|
|
186
|
+
return L;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function compileVelocity(nodes, sel) {
|
|
190
|
+
const tsel = `${sel} [data-title]`;
|
|
191
|
+
const L = [];
|
|
192
|
+
L.push(` /* velocity-reactive typography */`);
|
|
193
|
+
L.push(` ScrollTrigger.create({`);
|
|
194
|
+
L.push(` trigger: "${sel}", start: "top bottom", end: "bottom top",`);
|
|
195
|
+
L.push(` onUpdate: (self) => {`);
|
|
196
|
+
L.push(` const v = Math.abs(self.getVelocity()) / 1000;`);
|
|
197
|
+
nodes.forEach((n) => {
|
|
198
|
+
const cmp = n.comparison === 'below' ? '<' : '>';
|
|
199
|
+
const s = n.above || {};
|
|
200
|
+
const lerp = n.lerpFactor ?? 0.1;
|
|
201
|
+
const set = Object.entries(s).map(([k, val]) => `${gprop(k)}: ${typeof val === 'string' ? q(val) : val}`).join(', ');
|
|
202
|
+
const base = n.below || {};
|
|
203
|
+
const reset = Object.entries(base).map(([k, val]) => `${gprop(k)}: ${typeof val === 'string' ? q(val) : val}`).join(', ');
|
|
204
|
+
L.push(` if (v ${cmp} ${n.threshold}) { gsap.to("${tsel}", { ${set}, duration: ${lerp}, overwrite: "auto" }); }`);
|
|
205
|
+
if (reset) L.push(` else { gsap.to("${tsel}", { ${reset}, duration: ${lerp}, overwrite: "auto" }); }`);
|
|
206
|
+
});
|
|
207
|
+
L.push(` },`);
|
|
208
|
+
L.push(` });`);
|
|
209
|
+
return L;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function compileTransition(t) {
|
|
213
|
+
const TYPE = {
|
|
214
|
+
craneShot: { y: -100, rotationX: 4 },
|
|
215
|
+
whipPan: { x: '-100vw' },
|
|
216
|
+
matchCut: { autoAlpha: 0 },
|
|
217
|
+
dissolve: { autoAlpha: 0, scale: 0.97 },
|
|
218
|
+
pushIn: { scale: 1.08 },
|
|
219
|
+
hardCut: {},
|
|
220
|
+
};
|
|
221
|
+
const move = TYPE[t.type] || {};
|
|
222
|
+
const ease = q(mapEase(t.easing || 'power4.inOut'));
|
|
223
|
+
const set = Object.entries(move).map(([k, v]) => `${k}: ${typeof v === 'string' ? q(v) : v}`).join(', ');
|
|
224
|
+
if (t.type === 'hardCut' || !set) {
|
|
225
|
+
return ` /* transition ${t.from} → ${t.to}: hard cut (no tween) */`;
|
|
226
|
+
}
|
|
227
|
+
return [
|
|
228
|
+
` /* transition ${t.from} → ${t.to}: ${t.type} */`,
|
|
229
|
+
` gsap.timeline({ scrollTrigger: { trigger: "[data-chapter='${t.to}']", start: "top bottom", end: "top top", scrub: true } })`,
|
|
230
|
+
` .to("[data-chapter='${t.from}']", { ${set}, ease: ${ease} }, 0);`,
|
|
231
|
+
].join('\n');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* ---- helpers -------------------------------------------------------------- */
|
|
235
|
+
const q = (s) => (s === undefined ? 'undefined' : JSON.stringify(s));
|
|
236
|
+
const json = (o) => JSON.stringify(o);
|
|
237
|
+
const spread = (o) => Object.entries(o).map(([k, v]) => `${k}: ${typeof v === 'string' ? q(v) : v}`).join(', ');
|
|
238
|
+
|
|
239
|
+
/* ---- top-level emit ------------------------------------------------------- */
|
|
240
|
+
function compile(doc) {
|
|
241
|
+
validate(doc);
|
|
242
|
+
const g = doc.globals || {};
|
|
243
|
+
const out = [];
|
|
244
|
+
out.push(`/* AUTO-GENERATED by compile-choreography.mjs — do not edit by hand. */`);
|
|
245
|
+
out.push(`/* Source choreography: ${doc.metadata?.name || 'unnamed'} */`);
|
|
246
|
+
out.push(`import { gsap } from "gsap";`);
|
|
247
|
+
out.push(`import { ScrollTrigger } from "gsap/ScrollTrigger";`);
|
|
248
|
+
out.push(`import Lenis from "lenis";`);
|
|
249
|
+
out.push(`gsap.registerPlugin(ScrollTrigger);`);
|
|
250
|
+
out.push(``);
|
|
251
|
+
out.push(`export function initChoreography() {`);
|
|
252
|
+
out.push(` const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;`);
|
|
253
|
+
out.push(` if (reduce) { /* ${g.reducedMotionFallback || 'static'} fallback: skip all motion */ return; }`);
|
|
254
|
+
out.push(``);
|
|
255
|
+
out.push(` /* smooth scroll → ScrollTrigger */`);
|
|
256
|
+
out.push(` const lenis = new Lenis({ lerp: ${g.scrollSmoothing ?? 0.1} });`);
|
|
257
|
+
out.push(` lenis.on("scroll", ScrollTrigger.update);`);
|
|
258
|
+
out.push(` gsap.ticker.add((t) => lenis.raf(t * 1000));`);
|
|
259
|
+
out.push(` gsap.ticker.lagSmoothing(0);`);
|
|
260
|
+
out.push(` gsap.defaults({ ease: ${q(mapEase(g.defaultEasing) || 'power3.out')}, duration: ${g.defaultDuration ?? 1} });`);
|
|
261
|
+
out.push(``);
|
|
262
|
+
(doc.chapters || []).forEach((ch) => out.push(compileChapter(ch, g)));
|
|
263
|
+
if (Array.isArray(doc.transitions)) {
|
|
264
|
+
out.push(``);
|
|
265
|
+
doc.transitions.forEach((t) => out.push(compileTransition(t)));
|
|
266
|
+
}
|
|
267
|
+
out.push(``);
|
|
268
|
+
out.push(` ScrollTrigger.refresh();`);
|
|
269
|
+
out.push(`}`);
|
|
270
|
+
return out.join('\n');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* ---- CLI ------------------------------------------------------------------ */
|
|
274
|
+
function main() {
|
|
275
|
+
const args = process.argv.slice(2);
|
|
276
|
+
let src = args.find((a) => !a.startsWith('--'));
|
|
277
|
+
if (args.includes('--example') || !src) {
|
|
278
|
+
src = join(__dirname, 'scroll-choreography.json');
|
|
279
|
+
}
|
|
280
|
+
if (!existsSync(src)) { console.error(`✗ not found: ${src}`); process.exit(1); }
|
|
281
|
+
|
|
282
|
+
let doc = JSON.parse(readFileSync(src, 'utf8'));
|
|
283
|
+
// a schema file stores its real choreography under examples[0]
|
|
284
|
+
if (doc.$schema && Array.isArray(doc.examples) && doc.examples.length) {
|
|
285
|
+
console.error(`note: ${basename(src)} is a schema — compiling examples[0] ("${doc.examples[0].metadata?.name || 'example'}")`);
|
|
286
|
+
doc = doc.examples[0];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const code = compile(doc);
|
|
290
|
+
const outArg = args.indexOf('--out');
|
|
291
|
+
const outPath = outArg >= 0 ? args[outArg + 1] : null;
|
|
292
|
+
if (outPath) { writeFileSync(outPath, code); console.error(`✓ wrote ${outPath} (${code.split('\n').length} lines)`); }
|
|
293
|
+
else { process.stdout.write(code + '\n'); }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
main();
|
package/decision-log.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Decision Log
|
|
2
|
+
|
|
3
|
+
> Why we chose what we chose. Every significant technical decision with context, alternatives considered, and the trade-off accepted.
|
|
4
|
+
>
|
|
5
|
+
> When in doubt, read this before opening an issue.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## D1: Lenis over Native Smooth Scroll
|
|
10
|
+
|
|
11
|
+
**Decision:** Use Lenis for smooth scrolling in production builds.
|
|
12
|
+
**Date:** 2025-05
|
|
13
|
+
**Context:** Native browser scroll is jittery on macOS trackpads and produces visible stepping on scroll-scrubbed animations. The frame-to-frame inconsistency breaks the cinematic illusion.
|
|
14
|
+
|
|
15
|
+
**Alternatives considered:**
|
|
16
|
+
- **Native scroll:** Rejected — jittery on trackpads, no velocity data, inconsistent frame timing across browsers.
|
|
17
|
+
- **GSAP ScrollSmoother:** Accepted as co-primary when GSAP is already in the build — now free after the 2025 Webflow acquisition, GSAP-native, no RAF-forwarding glue, integrates with ScrollTrigger automatically. Preferred for Mode B.
|
|
18
|
+
- **Locomotive Scroll:** Rejected — heavier bundle (~40KB vs Lenis ~13KB), less actively maintained, React integration requires custom hooks, community moving away from it.
|
|
19
|
+
- **Hand-rolled rAF scroll:** Used only in Mode A single-file demos (zero-dependency by design). Too complex and error-prone for production multi-chapter sites.
|
|
20
|
+
|
|
21
|
+
**Trade-off accepted:** +13KB bundle size for consistent scroll behavior across all devices and access to scroll velocity data (used in velocity-reactive patterns). Lenis requires RAF forwarding to GSAP ScrollTrigger via `lenis.on('scroll', ScrollTrigger.update)` — a one-line integration that must not be forgotten.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## D2: GSAP over Framer Motion for ScrollTrigger
|
|
26
|
+
|
|
27
|
+
**Decision:** Use GSAP ScrollTrigger for pinned chapters and scroll-scrubbed animations.
|
|
28
|
+
**Date:** 2025-05
|
|
29
|
+
**Context:** Need reliable pinning, scrubbing, and timeline control for cinematic scroll experiences. Pinned chapters are the signature mechanic — if pinning is unreliable, the entire product fails.
|
|
30
|
+
|
|
31
|
+
**Alternatives considered:**
|
|
32
|
+
- **Framer Motion:** Excellent for component-level enter/exit animations and gesture-based interactions. Scroll integration (`useScroll`, `useTransform`) is powerful for simple parallax but less mature for complex pinned sections with snap points, scrubbed timelines, and nested triggers. Rejected for the pinning layer, accepted for micro-interactions.
|
|
33
|
+
- **CSS scroll-driven animations (`@scroll-timeline`):** Experimental, poor browser support (behind flags in most browsers), no JavaScript API for dynamic control. Rejected for production use; may be revisited in 2026.
|
|
34
|
+
- **Hand-rolled IntersectionObserver + rAF:** Used in Mode A demos (zero-dependency). Works for simple reveals but the complexity of managing multiple overlapping pins, snap behavior, and cleanup exceeds the value of avoiding a dependency. Rejected for Mode B.
|
|
35
|
+
- **ScrollMagic:** Deprecated. The original scroll-animation library, but GreenSock's ScrollTrigger superseded it in every dimension. Not considered.
|
|
36
|
+
|
|
37
|
+
**Trade-off accepted:** GSAP's imperative API is less "React-native" than Framer Motion's declarative style. We mitigate this with `@gsap/react`'s `useGSAP()` hook for automatic cleanup via scoped queries. The benefit — ScrollTrigger's pinning, scrubbing, snap, and timeline control are unmatched in the ecosystem. The 2025 Webflow acquisition making all plugins free removed the last cost barrier.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## D3: choreo-3d as Abstraction Layer
|
|
42
|
+
|
|
43
|
+
**Decision:** Use `choreo-3d` as the primary animation orchestration package.
|
|
44
|
+
**Date:** 2025-05
|
|
45
|
+
**Context:** Need a package that handles the common cinematic scroll patterns (pinning, depth layers, 3D tilt, background morph, scroll-spy) without repeating GSAP boilerplate across every project. The SKILL.md motion requirements are complex enough that reimplementing them per-project guarantees inconsistency.
|
|
46
|
+
|
|
47
|
+
**Alternatives considered:**
|
|
48
|
+
- **Direct GSAP (no abstraction):** Maximum flexibility but requires ~200 lines of GSAP boilerplate per chapter for the 7-layer depth system, pinning, scrubbing, and cleanup. Every project would diverge. Rejected for the standard path; available as escape hatch when `choreo-3d` doesn't expose a needed primitive.
|
|
49
|
+
- **react-spring:** Excellent for physics-based motion (springs, decay). Less suited to scroll-scrubbed timelines where precise scroll-position-to-animation mapping is required. Rejected for the scroll layer.
|
|
50
|
+
- **Motion One:** Smaller bundle (~5KB) than GSAP, tree-shakeable, but less mature ecosystem, no pinning solution, no ScrollTrigger equivalent. Rejected — the ecosystem maturity matters more than the bundle size for this use case.
|
|
51
|
+
- **Framer Motion + useScroll:** See D2. Good for simple cases, insufficient for complex pinned scenes.
|
|
52
|
+
|
|
53
|
+
**Trade-off accepted:** Dependency on a relatively new package (`choreo-3d` v1.0.0) for significant productivity gains. The API surface is stable (`ScrollChoreography`, `ScrollLayer`, `ScrollDepthImage`, `ScrollBackgroundMorph`, `useTilt3D`, `useMouseSpring`, `useScrollPin`). Mitigated by the built-in vanilla fallback (sticky + IntersectionObserver + rAF) that ships with every Mode A output — if `choreo-3d` ever breaks, the fallback pattern produces identical motion.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## D4: fal.ai for Image Generation
|
|
58
|
+
|
|
59
|
+
**Decision:** Integrate fal.ai as the optional AI image pipeline.
|
|
60
|
+
**Date:** 2025-05
|
|
61
|
+
**Context:** Need art-directed chapter imagery that matches the user's aesthetic brief. Each chapter needs a hero image that coheres with the palette, historical layer, and modern layer described by the user. Manual image sourcing is slow and often produces mismatched results.
|
|
62
|
+
|
|
63
|
+
**Alternatives considered:**
|
|
64
|
+
- **Midjourney:** No API, no programmatic control, no batch generation. Requires manual Discord interaction for every image. Rejected for a production pipeline.
|
|
65
|
+
- **DALL-E 3 (OpenAI):** Good quality, more expensive (~$0.08-$0.20 per image depending on resolution), less control over style consistency across a batch. Rejected as primary; available via adapter swap if needed.
|
|
66
|
+
- **Stable Diffusion (self-hosted):** Requires GPU infrastructure, model management, LoRA training for style consistency. Powerful but the infrastructure burden exceeds the value for most users. Rejected as default; power users can integrate it externally.
|
|
67
|
+
- **Bring-your-own images:** Fully supported as fallback. Drop images into `public/`, reference them in `editions-manifest.ts`, zero AI setup required. The CSS-only `ChapterDemoVisual` component renders stunning chapter visuals without any images at all.
|
|
68
|
+
|
|
69
|
+
**Trade-off accepted:** Per-image cost (~$0.02-$0.15 depending on model) for production-quality, API-controllable image generation with prompt-level style control. The `lib/fal-models.ts` adapter abstracts model-specific parameter differences (FLUX.2 vs Gemini vs Imagen), so swapping models requires only an env var change. Users can opt out entirely — CSS-only mode is a first-class citizen, not a degraded fallback.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## D5: Two-Mode Architecture (Mode A / Mode B)
|
|
74
|
+
|
|
75
|
+
**Decision:** Support both single-file HTML output and full Next.js project scaffolding.
|
|
76
|
+
**Date:** 2025-05
|
|
77
|
+
**Context:** Different use cases need different delivery formats. A developer prototyping a single hero section for a client does not want a full Next.js project. A team building a production release page needs the full build pipeline.
|
|
78
|
+
|
|
79
|
+
**Mode A** (HTML artifact):
|
|
80
|
+
- **For:** Quick prototypes, single sections, clients who need instant preview, sandbox environments, StackBlitz demos, GitHub Pages hosting.
|
|
81
|
+
- **Against:** No component reuse, no build pipeline, manual asset management, no TypeScript checking.
|
|
82
|
+
- **Key constraint:** Must run from `file://` with zero dependencies. Hand-rolled rAF scroll handling. Identical math to Mode B.
|
|
83
|
+
|
|
84
|
+
**Mode B** (Next.js project):
|
|
85
|
+
- **For:** Full websites, teams, production deployments, Vercel hosting, AI asset pipeline, TypeScript, component reuse.
|
|
86
|
+
- **Against:** Requires build step, Node.js, more complex setup, dependency management (see KNOWN_ISSUES.md for real failure modes).
|
|
87
|
+
- **Key constraint:** Must copy bundled templates verbatim — regenerating from memory breaks install (Lenis ETARGET, missing `choreo-3d`, etc.).
|
|
88
|
+
|
|
89
|
+
**Trade-off accepted:** Dual maintenance burden for maximum flexibility. Mode A outputs must be kept in sync with Mode B's motion grammar (same depth multipliers, same keyframe structures, same reduced-motion fallback). Every change to the 7-layer system or title reveal patterns must be validated in both modes. The payoff is that 80% of users can start with Mode A for instant results, and 40% eventually graduate to Mode B for production — without relearning the system.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## D6: 5-Phase Pipeline over One-Shot Generation
|
|
94
|
+
|
|
95
|
+
**Decision:** Rebuild the skill as a phase-gated pipeline instead of prompt-to-code.
|
|
96
|
+
**Date:** 2025-06
|
|
97
|
+
**Context:** One-shot generation (user prompt → immediate code output) produces inconsistent quality. The agent would skip taste guardrails, misinterpret the aesthetic brief, choose wrong depth configurations, and produce output that violated its own rules. The "one-shot" approach treats the skill as a code generator; the pipeline treats it as a production studio.
|
|
98
|
+
|
|
99
|
+
**The 5 phases:**
|
|
100
|
+
1. **Cinematic Audit** — Score the brief or reference site across 4 dimensions (Pacing, Performance, Accessibility, Emotional Arc). Identify the cinematic language before writing code.
|
|
101
|
+
2. **Motion Storyboard** — Define the chapter structure, film archetype, depth layers, transition types, and pacing before any implementation.
|
|
102
|
+
3. **Technical Spec** — Choose the scroll pattern, depth configuration, easing curves, and mobile strategy. Produce a reviewable `scroll-choreography.json`.
|
|
103
|
+
4. **Build** — Implement from the spec, not from improvisation. Copy bundled templates verbatim for Mode B.
|
|
104
|
+
5. **Polish** — QA checklist, performance audit, accessibility verification, taste guardrail validation.
|
|
105
|
+
|
|
106
|
+
**Trade-off accepted:** More user interaction required (5 review points vs 1 prompt-response cycle), but dramatically higher output quality and fewer revisions needed. The pipeline prevents the "scroll jank + Bootstrap look + ignored reduced-motion" failure mode that one-shot generation produces. Users who want speed can run the phases in quick succession; users who want precision get review gates.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## D7: JSON Schema for Scroll Choreography
|
|
111
|
+
|
|
112
|
+
**Decision:** Create a declarative JSON format (`scroll-choreography.json`) instead of generating GSAP code directly.
|
|
113
|
+
**Date:** 2025-06
|
|
114
|
+
**Context:** Declarative formats are more reviewable (a human can read a JSON file and understand the motion), more versionable (diff-friendly), and compilable to multiple targets. Imperative GSAP code is opaque to review and hard to regenerate without drift.
|
|
115
|
+
|
|
116
|
+
**Schema overview:**
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"chapters": [{
|
|
120
|
+
"id": "prologue",
|
|
121
|
+
"pinDistance": "220vh",
|
|
122
|
+
"archetype": "kubrick",
|
|
123
|
+
"layers": [
|
|
124
|
+
{ "depth": 0.15, "keyframes": [...] },
|
|
125
|
+
{ "depth": 0.50, "keyframes": [...] },
|
|
126
|
+
{ "depth": 1.00, "keyframes": [...] }
|
|
127
|
+
],
|
|
128
|
+
"titleReveal": "mask-reveal",
|
|
129
|
+
"transition": "fade-through-black",
|
|
130
|
+
"mobileStrategy": "stack-static"
|
|
131
|
+
}]
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Alternatives considered:**
|
|
136
|
+
- **Direct GSAP code generation:** Flexible but not reviewable, not diffable, and locked to one target (GSAP). Re-rendering from a changed prompt would produce entirely different code structure. Rejected for the spec layer; still the compilation target.
|
|
137
|
+
- **CSS `@scroll-timeline`:** Not sufficiently supported. Rejected.
|
|
138
|
+
- **Framer Motion config object:** Framer Motion's `useScroll` + `useTransform` can accept config objects, but they don't capture pinning behavior or snap configuration. Insufficient expressiveness. Rejected.
|
|
139
|
+
|
|
140
|
+
**Trade-off accepted:** Additional compilation step adds complexity (JSON → GSAP/Framer Motion/CSS). The benefits justify it: visual editing becomes possible (a UI can render the JSON), validation catches errors before code generation (schema enforces depth ranges, pin duration limits, transition type variety), multi-target output (same JSON compiles to GSAP for Mode B or to CSS+rAF for Mode A), and better diffability in version control.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## D8: 7 Depth Layers Maximum (Not 3, Not 15)
|
|
145
|
+
|
|
146
|
+
**Decision:** Cap the depth layer system at 7 layers per chapter. Recommend 5 as the practical default.
|
|
147
|
+
**Date:** 2025-05
|
|
148
|
+
**Context:** Each parallax layer is a composited GPU layer. Seven layers at high resolution consume significant VRAM. Beyond seven, browsers drop layers back to CPU rasterization — catastrophically slow. Below 3 layers, the parallax effect feels flat and fails the "cinematic depth" quality bar.
|
|
149
|
+
|
|
150
|
+
**Why not 3:** 3 layers (background, midground, foreground) is the minimum for perceptible depth, but it produces a "cardboard diorama" effect — the user can count the layers. A real depth field needs 5+ layers at distinct depth multipliers to feel immersive. The SKILL.md requires minimum 5 layers for any cinematic chapter.
|
|
151
|
+
|
|
152
|
+
**Why not 15:** 15 promoted layers on a 1920x1080 viewport at 4 bytes per pixel = ~120MB GPU memory just for layer backing stores. Add texture memory for the actual content and you exceed mobile GPU budgets (budget Android GPUs have 256-512MB total shared memory). Chrome begins dropping layers at ~10 on desktop, ~4 on mobile. The performance budget document specifies max 10 compositor layers on desktop, max 4 on mobile.
|
|
153
|
+
|
|
154
|
+
**The 7-layer slot system (from SKILL.md):**
|
|
155
|
+
|
|
156
|
+
| Slot | Depth | Role |
|
|
157
|
+
|------|-------|------|
|
|
158
|
+
| 1 | 0.15 | Atmospheric far (sky gradient, distant fog) |
|
|
159
|
+
| 2 | 0.30 | Mid-far (distant props, blurred shapes) |
|
|
160
|
+
| 3 | 0.50 | Mid (subject background, atmospheric texture) |
|
|
161
|
+
| 4 | 0.75 | Subject (main figure / image / 3D object) |
|
|
162
|
+
| 5 | 1.00 | UI text (title, body copy, eyebrow label) |
|
|
163
|
+
| 6 | 1.20 | Foreground accents (floating numbers, edge labels) |
|
|
164
|
+
| 7 | 1.40 | Closest overlays (cursor highlights, badges, scroll cue) |
|
|
165
|
+
|
|
166
|
+
**Trade-off accepted:** The 7-slot system provides a shared vocabulary between the agent and the user ("Layer 3 at 0.5x for the atmospheric texture"), but 7 is the hard maximum, not the target. The practical default is 5 layers (slots 1, 3, 4, 5, 6) — enough depth to feel immersive without approaching the GPU limit. Mobile degrades to 3 layers (performance budget tier 2) or 2 layers (tier 3). The anti-convergence principle (§4.3) requires varying which slots are used across chapters to prevent rhythmic monotony.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## D9: Custom Easing over Defaults
|
|
171
|
+
|
|
172
|
+
**Decision:** Ban default easing curves (`ease`, `ease-in-out`, `linear`) and require intentional custom easing for every animation.
|
|
173
|
+
**Date:** 2025-05
|
|
174
|
+
**Context:** Default easing is the single biggest contributor to "this looks like a template." `ease` and `ease-in-out` are the PowerPoint transitions of the web — they signal default thinking. Real cinematic motion has variation: anticipation, overshoot, decay, snap. The easing curve is as much a design choice as the color palette.
|
|
175
|
+
|
|
176
|
+
**The custom easing vocabulary (from taste-guardrails.md §4.1):**
|
|
177
|
+
|
|
178
|
+
| Use Case | CSS cubic-bezier | GSAP Equivalent | Character |
|
|
179
|
+
|----------|-----------------|-----------------|-----------|
|
|
180
|
+
| Hero entrances | `(0.16, 1, 0.3, 1)` | `power3.out` | Dramatic deceleration — the "reveal" feel |
|
|
181
|
+
| Chapter exits | `(0.7, 0, 0.84, 0)` | `power2.in` | Clean acceleration — the "handoff" feel |
|
|
182
|
+
| Micro-interactions | `(0.34, 1.56, 0.64, 1)` | `back.out(1.4)` | Playful overshoot — the "snappy" feel |
|
|
183
|
+
| Transitions | `(0.87, 0, 0.13, 1)` | `power4.inOut` | Heavy, deliberate — the "chapter cut" feel |
|
|
184
|
+
| Atmospheric drift | `linear` (exception) | `none` | Constant speed — only for background parallax |
|
|
185
|
+
|
|
186
|
+
**Why not just use GSAP's named easings:** GSAP's `power3.out` is close to but not identical to the custom cubic-bezier. The custom curves were hand-tuned for feel against reference sites and iterative preview. The hero entrance curve `(0.16, 1, 0.3, 1)` has a slightly longer deceleration tail than `power3.out` — it "settles" more, creating a sense of weight. The difference is subtle but perceptible.
|
|
187
|
+
|
|
188
|
+
**Why not spring physics for everything:** Springs (via react-spring or Framer Motion's spring transitions) are excellent for micro-interactions and gesture release, but unpredictable for scroll-scrubbed animations. A spring's duration depends on its velocity and stiffness, which conflicts with scroll-scrubbed timing where the animation must complete within a specific scroll distance. Springs are reserved for hover effects and gesture responses; scroll-scrubbed animations use fixed-duration easing.
|
|
189
|
+
|
|
190
|
+
**Trade-off accepted:** Developers must learn 4-6 easing curves instead of using defaults. The learning curve is shallow (copy the value from the table), but the discipline of choosing intentionally for every animation requires attention. The enforcement mechanism is the taste guardrails system — default easing is flagged as a banned pattern during QA. The payoff is motion that feels considered rather than generated.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## D10: 1.2s per 100vh as Default Rhythm
|
|
195
|
+
|
|
196
|
+
**Decision:** Set the default scroll pacing to 1.2 seconds of scroll time per 100vh of content at normal scroll speed.
|
|
197
|
+
**Date:** 2025-05
|
|
198
|
+
**Context:** Timing is not arbitrary. This value is a working default informed by how long the eye needs to settle, process, and anticipate the next visual event — a starting rhythm to adjust, not a measured constant. It represents the time needed for a user's eyes to settle, process, and anticipate the next visual event.
|
|
199
|
+
|
|
200
|
+
**The derivation:**
|
|
201
|
+
- Average scroll speed on desktop: ~400px/s (mouse wheel) to ~800px/s (trackpad gesture)
|
|
202
|
+
- 100vh at 1080p = 1080px
|
|
203
|
+
- At 600px/s average: 1080 / 600 = 1.8s — too slow, users feel the page is dragging
|
|
204
|
+
- At 900px/s average: 1080 / 900 = 1.2s — the sweet spot
|
|
205
|
+
- On mobile, the same ratio holds because viewport height is smaller but scroll speed is proportionally reduced
|
|
206
|
+
|
|
207
|
+
**Why not faster (0.8s/100vh):** At 0.8s, the user spends more time reacting than anticipating. Title reveals feel rushed — the eye hasn't settled before the next element enters. The composition feels "chased" rather than "revealed." This speed works for montage sequences (rapid cuts) but not for hero chapters or narrative sequences.
|
|
208
|
+
|
|
209
|
+
**Why not slower (2.0s/100vh):** At 2.0s, users with faster scroll habits (trackpad flickers, mouse wheel rachers) outpace the animation. The pinned section ends before the reveal completes — the "pin releases too early" failure mode. This speed works for Kubrick-style long takes (300-400vh pins with glacial pacing) but not as a default.
|
|
210
|
+
|
|
211
|
+
**The ±20% adjustment rule:** The 1.2s baseline is adjusted per film archetype and chapter type:
|
|
212
|
+
|
|
213
|
+
| Archetype / Chapter Type | Rhythm Adjustment | Result |
|
|
214
|
+
|--------------------------|-------------------|--------|
|
|
215
|
+
| Kubrick (authority, dread) | +20% | 1.44s/100vh |
|
|
216
|
+
| Nolan (fast-slow contrast) | ±30% alternating | 0.84s-1.56s |
|
|
217
|
+
| Wes Anderson (musical) | -15% | 1.02s/100vh |
|
|
218
|
+
| Villeneuve (atmospheric) | +25% | 1.50s/100vh |
|
|
219
|
+
| Montage sequence | -40% | 0.72s/100vh |
|
|
220
|
+
| Long take | +30% | 1.56s/100vh |
|
|
221
|
+
|
|
222
|
+
**Pin duration derived from rhythm:**
|
|
223
|
+
- Minimum pin: 150vh → 1.8s at normal speed (taste-guardrails.md §3.2)
|
|
224
|
+
- Maximum pin: 400vh → 4.8s at normal speed (taste-guardrails.md §3.3)
|
|
225
|
+
- Sweet spot: 200-250vh → 2.4-3.0s
|
|
226
|
+
|
|
227
|
+
**Trade-off accepted:** The default assumes "normal" scroll speed, which varies significantly by input device (mouse wheel vs trackpad vs touch flick vs keyboard). The skill cannot control scroll speed, so the rhythm is calibrated to the median behavior. Fast scrollers may see abbreviated animations; slow scrollers may see extended holds. The `scrub: 0.5` setting on ScrollTrigger provides 0.5s of smoothing lag to bridge the gap. The ±20% adjustment rule gives the agent flexibility per project without abandoning the system.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## How to Add a New Decision
|
|
232
|
+
|
|
233
|
+
When a significant technical choice is made that affects architecture, dependencies, user experience, or maintenance burden, document it here. Each entry must include:
|
|
234
|
+
|
|
235
|
+
1. **Decision** — One sentence stating what was decided.
|
|
236
|
+
2. **Date** — When the decision was made.
|
|
237
|
+
3. **Context** — Why the decision was needed. What problem does it solve?
|
|
238
|
+
4. **Alternatives considered** — At least 2 alternatives, with why each was rejected.
|
|
239
|
+
5. **Trade-off accepted** — What cost, complexity, or limitation was accepted in exchange for the benefit. No decision is free.
|
|
240
|
+
|
|
241
|
+
Decisions are immutable once published. If a decision is reversed, add a new entry referencing the original and explaining the reversal. Never edit past entries — the log is append-only.
|