@hraness/slopcamera 3.5.0 → 3.7.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 (52) hide show
  1. package/README.md +42 -26
  2. package/apps/desktop/README.md +1 -1
  3. package/apps/desktop/application/html-overlay-renderer.ts +6 -0
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/cli/args.ts +63 -1
  6. package/apps/desktop/cli/command-host-resources.ts +5 -0
  7. package/apps/desktop/cli/commands.ts +27 -1
  8. package/apps/desktop/cli/help.ts +43 -21
  9. package/apps/desktop/cli/html-film-init.ts +158 -0
  10. package/apps/desktop/cli/html-film-names.ts +17 -0
  11. package/apps/desktop/cli/html-film.ts +471 -0
  12. package/apps/desktop/cli/html-overlay-renderer.ts +29 -2
  13. package/apps/desktop/cli/html-scene.ts +3 -3
  14. package/apps/desktop/cli/main.ts +1 -0
  15. package/apps/desktop/cli/menubar-status.ts +156 -0
  16. package/apps/desktop/cli/menubar.ts +88 -136
  17. package/apps/desktop/cli/portable-surface.ts +55 -1
  18. package/apps/desktop/dist/cli/main.js +353 -328
  19. package/apps/desktop/html-overlay/film.ts +418 -0
  20. package/dist/cli.js +19 -9
  21. package/dist/generate.js +1 -1
  22. package/dist/{index-8bhp7cj2.js → index-842t02pp.js} +1 -1
  23. package/dist/{index-jab2mg0s.js → index-nv3byqa1.js} +20 -4
  24. package/dist/index-p74xy76d.js +3 -0
  25. package/dist/{index-24w75s6a.js → index-y8spxs77.js} +10 -7
  26. package/dist/index.js +1 -1
  27. package/dist/operations.js +1 -1
  28. package/dist/workflow.js +1 -1
  29. package/docs/README.md +5 -5
  30. package/examples/html/launch-film/README.md +51 -0
  31. package/examples/html/launch-film/build.ts +158 -0
  32. package/examples/html/launch-film/film.css +593 -0
  33. package/examples/html/launch-film/film.html +85 -0
  34. package/examples/html/launch-film/film.js +410 -0
  35. package/examples/html/launch-film/film.json +63 -0
  36. package/examples/html/launch-film/mockups.tsx +74 -0
  37. package/examples/html/launch-film/timeline.ts +57 -0
  38. package/package.json +14 -8
  39. package/skills/slopcamera/SKILL.md +3 -2
  40. package/skills/slopcamera/references/install.md +3 -3
  41. package/skills/slopcamera/references/launch-films.md +154 -0
  42. package/skills/slopcamera/references/visual-communication.md +1 -1
  43. package/src/cli.ts +138 -19
  44. package/src/generate.ts +44 -1
  45. package/src/generation-pricing.ts +237 -0
  46. package/src/icon-set.ts +1086 -0
  47. package/src/icon.ts +396 -59
  48. package/src/index.ts +1 -0
  49. package/src/operations.ts +52 -0
  50. package/src/support.ts +6 -0
  51. package/src/version.ts +1 -1
  52. package/dist/index-77fjfg9f.js +0 -3
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Launch film choreography.
3
+ *
4
+ * Every visible value is a pure function of the frame time: each frame resets
5
+ * what it touches, measures layout, then writes styles. A frame renders the
6
+ * same whether it is requested first, last, twice, or alone, which is what
7
+ * `slopcamera html render` and `slopcamera html still` rely on.
8
+ *
9
+ * build.ts bundles this file with the helpers into film.html.
10
+ */
11
+ import {
12
+ camera, cameraBetween, cameraTransform, clamp, crossfade, cursor, drawMark, easings, kin, lerp,
13
+ placeCursor, prog, show, split, spring,
14
+ } from "@hraness/slopcamera/local/html-film";
15
+ import { filmTimeline, formatValue } from "./timeline.ts";
16
+
17
+ const cfg = window.__FILM__;
18
+ const overlay = window.SlopcameraOverlay;
19
+ const copy = cfg.copy;
20
+ const T = filmTimeline(copy);
21
+ const $ = id => document.getElementById(id);
22
+ const W = cfg.width;
23
+ const H = cfg.height;
24
+ const { inOutCubic, inOutQuart, outCubic, outQuint, outExpo } = easings;
25
+
26
+ // ------------------------------------------------------------------ setup
27
+
28
+ /** Fills every [data-copy] element from film.json, then splits it for kinetic type. */
29
+ function fillCopy() {
30
+ for (const el of document.querySelectorAll("[data-copy]")) {
31
+ const value = el.dataset.copy.split(".").reduce((node, key) => node?.[key], copy);
32
+ if (typeof value !== "string") throw new Error(`film.json has no copy for ${el.dataset.copy}`);
33
+ el.textContent = value;
34
+ split(el);
35
+ }
36
+ }
37
+
38
+ /** The cold-open collage: cloned cards on a tilted plane. */
39
+ const tiles = [];
40
+ function buildCollage() {
41
+ const cards = [...$("open-src").children];
42
+ const columns = 4;
43
+ const rows = 4;
44
+ for (let i = 0; i < columns * rows; i++) {
45
+ const tile = document.createElement("div");
46
+ tile.className = "open-tile";
47
+ tile.append(cards[i % cards.length].cloneNode(true));
48
+ $("open-grid").append(tile);
49
+ const column = i % columns;
50
+ const row = Math.floor(i / columns);
51
+ tiles.push({ el: tile, column, row, delay: ((i * 7) % 11) * 0.06 });
52
+ }
53
+ }
54
+
55
+ /** Product steps: one copy block and one progress dot each. */
56
+ const steps = [];
57
+ function buildSteps() {
58
+ copy.steps.forEach((step, index) => {
59
+ const block = document.createElement("div");
60
+ block.className = "walk-step";
61
+ const num = document.createElement("span");
62
+ num.className = "kin walk-num";
63
+ num.textContent = `${String(index + 1).padStart(2, "0")} / ${String(copy.steps.length).padStart(2, "0")}`;
64
+ const heading = document.createElement("h2");
65
+ heading.className = "kin walk-h";
66
+ heading.textContent = step.heading;
67
+ const body = document.createElement("p");
68
+ body.className = "kin walk-p";
69
+ body.textContent = step.body;
70
+ block.append(num, heading, body);
71
+ $("walk-copy").append(block);
72
+ [num, heading, body].forEach(el => split(el));
73
+ const dot = document.createElement("span");
74
+ dot.className = "walk-dot";
75
+ const fill = document.createElement("i");
76
+ dot.append(fill);
77
+ $("walk-dots").append(dot);
78
+ steps.push({ ...step, id: `step-${index + 1}`, block, num, heading, body, fill });
79
+ });
80
+ }
81
+
82
+ /** Proof: one counter per fact. */
83
+ const proofs = [];
84
+ function buildProof() {
85
+ for (const item of copy.proof.items) {
86
+ const box = document.createElement("div");
87
+ box.className = "proof-item";
88
+ const value = document.createElement("span");
89
+ value.className = "proof-value";
90
+ const label = document.createElement("span");
91
+ label.className = "proof-label";
92
+ label.textContent = item.label;
93
+ box.append(value, label);
94
+ $("proof-row").append(box);
95
+ proofs.push({ ...item, box, valueEl: value });
96
+ }
97
+ }
98
+
99
+ // ----------------------------------------------------------------- layout
100
+
101
+ /** Where the copy, dots and product window sit for this aspect. */
102
+ function layout() {
103
+ if (W / H > 1.3) {
104
+ return {
105
+ copy: { x: W * 0.07, y: H * 0.34, w: W * 0.29 },
106
+ dots: { x: W * 0.07, y: H * 0.72 },
107
+ win: { x: W * 0.41, y: H * 0.11, w: W * 0.53, h: H * 0.78 },
108
+ };
109
+ }
110
+ const tall = H / W > 1.3;
111
+ return {
112
+ copy: { x: W * 0.08, y: H * (tall ? 0.07 : 0.06), w: W * 0.84 },
113
+ dots: { x: W * 0.08, y: H * (tall ? 0.255 : 0.3) },
114
+ win: { x: W * 0.06, y: H * (tall ? 0.3 : 0.36), w: W * 0.88, h: H * (tall ? 0.63 : 0.58) },
115
+ };
116
+ }
117
+ const L = layout();
118
+
119
+ function place(el, box) {
120
+ el.style.left = `${box.x}px`;
121
+ el.style.top = `${box.y}px`;
122
+ if (box.w !== undefined) el.style.width = `${box.w}px`;
123
+ if (box.h !== undefined) el.style.height = `${box.h}px`;
124
+ }
125
+
126
+ /** An element's rectangle in surface coordinates, measured with the camera reset. */
127
+ function surfaceRect(name) {
128
+ const el = $("win-surface").querySelector(`[data-film="${name}"]`);
129
+ if (el === null) throw new Error(`The product mockup has no [data-film="${name}"] element.`);
130
+ const surface = $("win-surface");
131
+ const origin = surface.getBoundingClientRect();
132
+ // Undo any scale on the window or surface so the result is in layout pixels.
133
+ const k = surface.offsetWidth > 0 ? surface.offsetWidth / origin.width : 1;
134
+ const r = el.getBoundingClientRect();
135
+ return { x: (r.left - origin.left) * k, y: (r.top - origin.top) * k, w: r.width * k, h: r.height * k, el };
136
+ }
137
+
138
+ // ------------------------------------------------------------------ scenes
139
+
140
+ function background(t) {
141
+ const walkStart = T.act("step-1").start;
142
+ const toPaper = crossfade(t, walkStart - 0.2, 0.9);
143
+ // Back to night while the proof leaves, so the limits card lands on a settled ground.
144
+ const toNight = crossfade(t, T.act("limits").start - 0.55, 0.6);
145
+ const paper = toPaper.in * toNight.out;
146
+ $("bg-paper").style.opacity = String(paper);
147
+ show($("bg-paper"), paper > 0);
148
+ }
149
+
150
+ function coldOpen(t) {
151
+ const s = $("s-open");
152
+ const on = T.active(t, "open");
153
+ show(s, on);
154
+ if (!on) return;
155
+ const local = T.local(t, "open");
156
+ const end = T.act("open").end;
157
+ const tileW = 520 * cfg.unit;
158
+ const gapX = tileW * 1.08;
159
+ const gapY = tileW * 0.46;
160
+ const drift = local * 34 * cfg.unit;
161
+ $("open-grid").style.transform = `rotateX(${lerp(24, 16, outCubic(prog(t, 0, end)))}deg) rotateZ(-8deg) scale(${lerp(1.18, 1.3, prog(t, 0, end)).toFixed(4)})`;
162
+ for (const tile of tiles) {
163
+ const x = W / 2 + (tile.column - 1.5) * gapX - tileW / 2 + (tile.row % 2) * gapX * 0.5;
164
+ const y = H / 2 + (tile.row - 1.5) * gapY - gapY / 2 - drift;
165
+ const rise = spring(local - 0.1 - tile.delay, 1.1, 0.8);
166
+ tile.el.style.opacity = String(clamp(rise) * (1 - prog(t, end - 0.6, end)));
167
+ tile.el.style.transform = `translate3d(${x.toFixed(2)}px, ${(y + (1 - rise) * 80 * cfg.unit).toFixed(2)}px, 0)`;
168
+ }
169
+ $("open-scrim").style.opacity = String(inOutCubic(prog(local, 0.7, 1.5)));
170
+ const [first, second] = s.querySelectorAll(".open-line");
171
+ // The second line waits for the first to leave, so words never stack.
172
+ kin(first, local, 0.9, 2.45);
173
+ kin(second, local, 3.05, 4.55);
174
+ }
175
+
176
+ function title(t) {
177
+ const s = $("s-title");
178
+ const on = T.active(t, "title");
179
+ show(s, on);
180
+ if (!on) return;
181
+ const local = T.local(t, "title");
182
+ const outAt = T.act("title").duration - 0.75;
183
+ kin($("title-word"), local, 0.15, outAt, { stagger: 0.07, duration: 1.0 });
184
+ kin($("title-promise"), local, 0.75, outAt + 0.05, { stagger: 0.035 });
185
+ }
186
+
187
+ /** The mark draws around the title, then opens into the product window. */
188
+ function mark(t) {
189
+ const el = $("mark");
190
+ const titleStart = T.act("title").start;
191
+ const walkStart = T.act("step-1").start;
192
+ const drawn = outQuint(prog(t, titleStart + 0.9, titleStart + 2.1));
193
+ const morph = inOutQuart(prog(t, walkStart - 0.15, walkStart + 0.85));
194
+ const fade = 1 - prog(t, walkStart + 0.8, walkStart + 1.2);
195
+ if (drawn <= 0 || fade <= 0) {
196
+ show(el, false);
197
+ return;
198
+ }
199
+ show(el, true);
200
+ el.style.transform = "";
201
+ // Measure the title even after its scene has closed, then restore it.
202
+ const scene = $("s-title");
203
+ const display = scene.style.display;
204
+ scene.style.display = "";
205
+ const word = $("title-word");
206
+ const wordBox = { x: word.offsetLeft, y: word.offsetTop, w: word.offsetWidth, h: word.offsetHeight };
207
+ scene.style.display = display;
208
+ const pad = 42 * cfg.unit;
209
+ const from = { x: wordBox.x - pad, y: wordBox.y - pad * 0.6, w: wordBox.w + pad * 2, h: wordBox.h + pad * 1.2 };
210
+ const to = L.win;
211
+ drawMark({ box: el, svg: $("mark-svg"), rect: $("mark-rect") }, {
212
+ x: lerp(from.x, to.x, morph),
213
+ y: lerp(from.y, to.y, morph),
214
+ w: lerp(from.w, to.w, morph),
215
+ h: lerp(from.h, to.h, morph),
216
+ radius: lerp(20, 18, morph) * cfg.unit,
217
+ stroke: lerp(5, 2, morph) * cfg.unit,
218
+ drawn,
219
+ opacity: fade,
220
+ });
221
+ }
222
+
223
+ /** Camera target for a step, in window coordinates. */
224
+ function stepCamera(step, baseZoom, content, rects) {
225
+ return camera({
226
+ viewport: { w: L.win.w, h: L.win.h },
227
+ content,
228
+ focus: rects.get(step.focus),
229
+ zoom: Math.max(baseZoom, Math.min(baseZoom * (step.zoom ?? 1.45), 1.4)),
230
+ anchor: 0.45,
231
+ });
232
+ }
233
+
234
+ function walk(t) {
235
+ const s = $("s-walk");
236
+ const first = T.act("step-1");
237
+ const last = T.act(steps.at(-1).id);
238
+ const on = t >= first.start && t < last.end;
239
+ show(s, on);
240
+ if (!on) return;
241
+
242
+ place($("walk-copy"), L.copy);
243
+ place($("walk-dots"), L.dots);
244
+ place($("win"), L.win);
245
+
246
+ // Window entrance: grows out of the mark, then settles with a spring.
247
+ const enter = spring(t - first.start - 0.45, 1.1, 0.78);
248
+ const leave = inOutCubic(prog(t, last.end - 0.7, last.end));
249
+ $("win").style.opacity = String(clamp(enter * 1.4) * (1 - leave));
250
+ $("win").style.transform = `translate3d(0, ${((1 - enter) * 40 - leave * 30) * cfg.unit}px, 0) scale(${lerp(0.94, 1, enter).toFixed(4)})`;
251
+
252
+ // Camera: measure with the transform reset, then glide between steps.
253
+ const surface = $("win-surface");
254
+ surface.style.transform = "";
255
+ for (const el of surface.querySelectorAll("[data-film-state]")) el.removeAttribute("data-film-state");
256
+ surface.style.minHeight = "";
257
+ const content = { w: surface.offsetWidth, h: surface.offsetHeight };
258
+ const baseZoom = L.win.w / content.w;
259
+ // Measure every target while the camera is reset; the transform below would skew the rects.
260
+ const rects = new Map();
261
+ for (const step of steps) {
262
+ for (const name of [step.focus, step.target, step.highlight]) {
263
+ if (name !== undefined && !rects.has(name)) rects.set(name, surfaceRect(name));
264
+ }
265
+ }
266
+ const rest = camera({ viewport: { w: L.win.w, h: L.win.h }, content, focus: { x: 0, y: 0, w: content.w, h: 0 }, zoom: baseZoom, anchor: 0 });
267
+ let cam = rest;
268
+ let previous = rest;
269
+ for (const step of steps) {
270
+ const act = T.act(step.id);
271
+ const target = stepCamera(step, baseZoom, content, rects);
272
+ cam = cameraBetween(previous, target, inOutQuart(prog(t, act.start + 0.2, act.start + 1.4)));
273
+ if (t < act.end) break;
274
+ previous = target;
275
+ }
276
+ cam = cameraBetween(cam, rest, inOutCubic(prog(t, last.end - 1.4, last.end - 0.4)));
277
+ surface.style.transform = cameraTransform(cam);
278
+ // Paint the mockup's own page below its natural height, so a short mockup in a tall
279
+ // window never leaves a blank band. The camera above still frames the measured content.
280
+ surface.style.minHeight = `${Math.ceil(Math.max(content.h, (L.win.h - cam.ty) / cam.z))}px`;
281
+
282
+ // Copy, dots, cursor and highlight for each step.
283
+ const toWindow = (x, y) => ({ x: cam.tx + x * cam.z, y: cam.ty + y * cam.z });
284
+ const aims = steps.map(step => {
285
+ const target = rects.get(step.target);
286
+ return { target, ...toWindow(target.x + target.w * 0.5, target.y + target.h * 0.55) };
287
+ });
288
+ let cursorState = { x: 0, y: 0, opacity: 0, press: 0 };
289
+ let highlight = null;
290
+ steps.forEach((step, index) => {
291
+ const act = T.act(step.id);
292
+ const local = t - act.start;
293
+ const isLast = index === steps.length - 1;
294
+ const outAt = isLast ? act.duration - 0.9 : act.duration - 0.45;
295
+ const visible = t >= act.start - 0.1 && t < act.end + 0.8;
296
+ show(step.block, visible);
297
+ if (visible) {
298
+ kin(step.num, local, 0.35, outAt, { stagger: 0.03 });
299
+ kin(step.heading, local, 0.45, outAt + 0.03, { stagger: 0.06 });
300
+ kin(step.body, local, 0.7, outAt + 0.06, { stagger: 0.025 });
301
+ }
302
+ step.fill.style.transform = `scaleX(${prog(t, act.start, act.end).toFixed(4)})`;
303
+
304
+ const { target } = aims[index];
305
+ const clickAt = act.start + 1.95;
306
+ if (step.after !== undefined && t >= clickAt + 0.12) target.el.setAttribute("data-film-state", step.after);
307
+ if (t >= act.start && t < act.end) {
308
+ const aim = { x: aims[index].x, y: aims[index].y };
309
+ const from = index === 0
310
+ ? { at: act.start + 0.6, x: L.win.w * 0.82, y: L.win.h * 0.95 }
311
+ : { at: act.start + 0.3, x: aims[index - 1].x, y: aims[index - 1].y };
312
+ const keys = [from, { at: act.start + 1.5, ...aim }, { at: clickAt, ...aim, click: true }];
313
+ // The cursor fades in on the first step and out after the last click.
314
+ cursorState = cursor(t, keys, { hold: isLast ? 1.2 : 99, fade: 0.3 });
315
+ if (index > 0) cursorState = { ...cursorState, opacity: isLast ? 1 - prog(t, clickAt + 1.2, clickAt + 1.5) : 1 };
316
+ if (step.highlight !== undefined) {
317
+ const hl = rects.get(step.highlight);
318
+ const a = toWindow(hl.x, hl.y);
319
+ const pad = 8;
320
+ highlight = {
321
+ x: a.x - pad, y: a.y - pad, w: hl.w * cam.z + pad * 2, h: hl.h * cam.z + pad * 2,
322
+ radius: 12, stroke: 3,
323
+ drawn: outQuint(prog(t, clickAt + 0.05, clickAt + 0.85)),
324
+ opacity: 1 - prog(t, act.end - 0.5, act.end - 0.2),
325
+ };
326
+ }
327
+ }
328
+ });
329
+ placeCursor($("win-cursor"), cursorState);
330
+ $("win-ripple").style.opacity = String(cursorState.press * 0.9);
331
+ $("win-ripple").style.transform = `scale(${lerp(0.4, 1.6, cursorState.press).toFixed(4)})`;
332
+ const hl = { box: $("hl"), svg: $("hl-svg"), rect: $("hl-rect") };
333
+ if (highlight === null) show(hl.box, false);
334
+ else {
335
+ show(hl.box, true);
336
+ drawMark(hl, highlight);
337
+ }
338
+ }
339
+
340
+ function proof(t) {
341
+ const s = $("s-proof");
342
+ const on = T.active(t, "proof");
343
+ show(s, on);
344
+ if (!on) return;
345
+ const local = T.local(t, "proof");
346
+ const outAt = T.act("proof").duration - 0.6;
347
+ proofs.forEach((item, index) => {
348
+ const start = 0.35 + index * 0.18;
349
+ const rise = spring(local - start, 1.2, 0.75);
350
+ const leave = inOutCubic(prog(local, outAt, outAt + 0.5));
351
+ item.box.style.opacity = String(clamp(rise * 1.5) * (1 - leave));
352
+ item.box.style.transform = `translate3d(0, ${((1 - rise) * 60 - leave * 40) * cfg.unit}px, 0)`;
353
+ const count = outExpo(prog(local, start, start + 1.5));
354
+ item.valueEl.textContent = `${formatValue(item.value * count, item.value)}${item.suffix ?? ""}`;
355
+ });
356
+ kin(s.querySelector(".proof-caption"), local, 1.2, outAt, { stagger: 0.03 });
357
+ }
358
+
359
+ function limits(t) {
360
+ const s = $("s-limits");
361
+ const on = T.active(t, "limits");
362
+ show(s, on);
363
+ if (!on) return;
364
+ const local = T.local(t, "limits");
365
+ const outAt = T.act("limits").duration - 0.55;
366
+ const rise = spring(local - 0.05, 1.0, 0.85);
367
+ const leave = inOutCubic(prog(local, outAt, outAt + 0.5));
368
+ const card = $("limits-card");
369
+ card.style.opacity = String(clamp(rise * 1.3) * (1 - leave));
370
+ card.style.transform = `scale(${lerp(0.96, 1, rise).toFixed(4)})`;
371
+ kin(card.querySelector(".limits-h"), local, 0.2, outAt, { stagger: 0.03 });
372
+ kin(card.querySelector(".limits-p"), local, 0.4, outAt, { stagger: 0.045 });
373
+ }
374
+
375
+ function endCard(t) {
376
+ const s = $("s-end");
377
+ const on = t >= T.act("end").start;
378
+ show(s, on);
379
+ if (!on) return;
380
+ const local = T.local(t, "end");
381
+ kin($("end-word"), local, 0.2, Infinity, { stagger: 0.07, duration: 1.0 });
382
+ kin(s.querySelector(".end-url"), local, 0.8);
383
+ kin(s.querySelector(".end-line"), local, 1.05, Infinity, { stagger: 0.03 });
384
+ }
385
+
386
+ // ------------------------------------------------------------------ frame
387
+
388
+ function render(t) {
389
+ background(t);
390
+ coldOpen(t);
391
+ title(t);
392
+ walk(t);
393
+ mark(t);
394
+ proof(t);
395
+ limits(t);
396
+ endCard(t);
397
+ }
398
+
399
+ fillCopy();
400
+ buildCollage();
401
+ buildSteps();
402
+ buildProof();
403
+
404
+ overlay.ready(Promise.all(
405
+ cfg.fonts.map(font => new FontFace(font.family, `url("${overlay.asset(font.name)}") format("woff2")`, { weight: font.weight, style: "normal" })
406
+ .load()
407
+ .then(face => document.fonts.add(face))),
408
+ ).then(() => document.fonts.ready));
409
+
410
+ overlay.onFrame(({ timeMs }) => render(timeMs / 1000));
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "Introducing Example",
3
+ "aspect": "16:9",
4
+ "fps": 30,
5
+ "seed": 20260101,
6
+ "background": "#111214",
7
+ "accent": "#5b8def",
8
+ "copy": {
9
+ "name": "Example",
10
+ "promise": "Keeps your team's requests in one list.",
11
+ "url": "example.com",
12
+ "open": [
13
+ "Requests arrive from everywhere.",
14
+ "Most of them wait for someone to notice."
15
+ ],
16
+ "steps": [
17
+ {
18
+ "heading": "Collect",
19
+ "body": "New requests land in one list, newest first.",
20
+ "focus": "list",
21
+ "target": "row-2",
22
+ "highlight": "row-2"
23
+ },
24
+ {
25
+ "heading": "Review",
26
+ "body": "Open one to see who asked and what they need.",
27
+ "focus": "detail-body",
28
+ "target": "action",
29
+ "highlight": "detail-body"
30
+ },
31
+ {
32
+ "heading": "Close",
33
+ "body": "Mark it done and the requester sees it.",
34
+ "focus": "list",
35
+ "target": "row-2",
36
+ "highlight": "row-2",
37
+ "after": "done"
38
+ }
39
+ ],
40
+ "proof": {
41
+ "caption": "Numbers from the facts file.",
42
+ "items": [
43
+ {
44
+ "value": 3,
45
+ "suffix": "",
46
+ "label": "Replace with a number from your facts file"
47
+ },
48
+ {
49
+ "value": 12,
50
+ "suffix": " s",
51
+ "label": "Replace with a number from your facts file"
52
+ }
53
+ ]
54
+ },
55
+ "limits": {
56
+ "heading": "What it does not do",
57
+ "body": "It does not assign work for you. It shows what is waiting."
58
+ },
59
+ "end": {
60
+ "line": "Free to try."
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Placeholder product surfaces for the film. Replace these with your site's
3
+ * real mockup components (for example from @hraness/design-kit/mockups) and
4
+ * keep the `data-film` names that film.json steps point at.
5
+ *
6
+ * Illustration only: names and text are made up.
7
+ */
8
+ import type { ReactNode } from "react";
9
+
10
+ const people = ["Ada", "Ben", "Chen", "Dara", "Eli", "Fay"];
11
+ const requests = [
12
+ "Update the pricing page footnote",
13
+ "Export last month's report as CSV",
14
+ "Add Chen to the design review",
15
+ "Fix the broken link in onboarding",
16
+ "Move the launch call to Thursday",
17
+ "Check the new signup form on mobile",
18
+ ];
19
+
20
+ function Row({ index, children }: { index: number; children: ReactNode }) {
21
+ return (
22
+ <li className="fm-row" data-film={`row-${index + 1}`}>
23
+ <span className="fm-avatar" aria-hidden="true">{people[index % people.length]![0]}</span>
24
+ <span className="fm-row-text">{children}</span>
25
+ <span className="fm-row-meta">{people[index % people.length]}</span>
26
+ </li>
27
+ );
28
+ }
29
+
30
+ /** A browser frame around a simple requests app. */
31
+ export function ProductMockup({ url = "app.example.com" }: { url?: string }) {
32
+ return (
33
+ <div className="fm-browser" role="img" aria-label="Illustration of a requests list with one request open">
34
+ <div className="fm-chrome">
35
+ <span className="fm-dots" aria-hidden="true"><i /><i /><i /></span>
36
+ <span className="fm-url">{url}</span>
37
+ </div>
38
+ <div className="fm-page" data-film-page="">
39
+ <aside className="fm-side">
40
+ <b>Requests</b>
41
+ <span className="fm-side-on">Open</span>
42
+ <span>Done</span>
43
+ <span>Archived</span>
44
+ </aside>
45
+ <section className="fm-list" data-film="list">
46
+ <h3 className="fm-h">Open requests</h3>
47
+ <ol>{requests.map((text, index) => <Row index={index} key={text}>{text}</Row>)}</ol>
48
+ </section>
49
+ <section className="fm-detail" data-film="detail">
50
+ {/* Focus a camera on this block, not the full-height panel, so the push-in keeps the heading. */}
51
+ <div data-film="detail-body">
52
+ <p className="fm-eyebrow">Request from Ben</p>
53
+ <h3 className="fm-h">{requests[1]}</h3>
54
+ <p className="fm-body">The finance team needs last month's numbers by Friday. A CSV is fine.</p>
55
+ <button className="fm-button" data-film="action" type="button">Mark done</button>
56
+ </div>
57
+ </section>
58
+ </div>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ /** A small card used in the cold open collage. */
64
+ export function OpenCard({ index }: { index: number }) {
65
+ return (
66
+ <div className="fm-card">
67
+ <span className="fm-avatar" aria-hidden="true">{people[index % people.length]![0]}</span>
68
+ <div>
69
+ <b>{people[index % people.length]}</b>
70
+ <p>{requests[index % requests.length]}</p>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The film's acts, built from film.json. build.ts uses this for the scene
3
+ * duration, captions.vtt and beats.json; film.js uses it for every frame. One
4
+ * source, so captions and per-beat clips never drift from the picture.
5
+ */
6
+ import { defineTimeline, type HtmlFilmTimeline } from "@hraness/slopcamera/local/html-film";
7
+
8
+ export interface FilmStep {
9
+ readonly heading: string;
10
+ readonly body: string;
11
+ /** `data-film` name the camera frames. */
12
+ readonly focus: string;
13
+ /** `data-film` name the cursor clicks. */
14
+ readonly target: string;
15
+ /** `data-film` name the drawn box outlines after the click. */
16
+ readonly highlight?: string;
17
+ /** Sets `data-film-state` on the target after the click, such as `done`. */
18
+ readonly after?: string;
19
+ /** Camera push-in over the fitted view. Defaults to 1.45. */
20
+ readonly zoom?: number;
21
+ }
22
+ export interface FilmProofItem { readonly value: number; readonly suffix?: string; readonly label: string }
23
+ export interface FilmCopy {
24
+ readonly name: string;
25
+ readonly promise: string;
26
+ readonly url: string;
27
+ readonly open: readonly string[];
28
+ readonly steps: readonly FilmStep[];
29
+ readonly proof: { readonly caption: string; readonly items: readonly FilmProofItem[] };
30
+ readonly limits: { readonly heading: string; readonly body: string };
31
+ readonly end: { readonly line: string };
32
+ }
33
+
34
+ /** Seconds each product step stays on screen. */
35
+ export const STEP_SECONDS = 4.4;
36
+
37
+ /** Formats `value` with as many decimals as `like` has. */
38
+ export function formatValue(value: number, like: number): string {
39
+ const decimals = (String(like).split(".")[1] ?? "").length;
40
+ return value.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
41
+ }
42
+
43
+ export function filmTimeline(copy: FilmCopy): HtmlFilmTimeline {
44
+ return defineTimeline([
45
+ { id: "open", duration: 5, caption: copy.open.join(" ") },
46
+ { id: "title", duration: 4, overlap: 0.6, caption: `${copy.name}. ${copy.promise}` },
47
+ ...copy.steps.map((step, index) => ({
48
+ id: `step-${String(index + 1)}`,
49
+ duration: STEP_SECONDS,
50
+ overlap: index === 0 ? 0.8 : 0,
51
+ caption: `${step.heading}. ${step.body}`,
52
+ })),
53
+ { id: "proof", duration: 4.5, overlap: 0.5, caption: copy.proof.items.map(item => `${formatValue(item.value, item.value)}${item.suffix ?? ""} ${item.label}`).join(". ") },
54
+ { id: "limits", duration: 4.5, caption: `${copy.limits.heading}. ${copy.limits.body}` },
55
+ { id: "end", duration: 4.5, overlap: 0.5, caption: `${copy.name}. ${copy.url}. ${copy.end.line}` },
56
+ ]);
57
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hraness/slopcamera",
3
- "version": "3.5.0",
4
- "description": "Slopcamera lets your coding agent make images, diagrams, animation, 3D scenes, and video from source files it can keep revising.",
3
+ "version": "3.7.0",
4
+ "description": "SlopCamera lets your coding agent make images, diagrams, animation, 3D scenes, and video from source files it can keep revising.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -131,6 +131,10 @@
131
131
  "./local/html-overlay": {
132
132
  "types": "./apps/desktop/html-overlay/index.ts",
133
133
  "import": "./apps/desktop/html-overlay/index.ts"
134
+ },
135
+ "./local/html-film": {
136
+ "types": "./apps/desktop/html-overlay/film.ts",
137
+ "import": "./apps/desktop/html-overlay/film.ts"
134
138
  }
135
139
  },
136
140
  "bin": {
@@ -161,6 +165,8 @@
161
165
  "examples/studio/hybrid-scene.ts",
162
166
  "examples/html/*.html",
163
167
  "examples/html/*.json",
168
+ "examples/html/launch-film/*",
169
+ "!examples/html/launch-film/out",
164
170
  "examples/style-portfolio/**/*.html",
165
171
  "examples/style-portfolio/**/*.ts",
166
172
  "examples/style-portfolio/**/*.mjs",
@@ -243,10 +249,11 @@
243
249
  "check:sdk": "bun run typecheck:sdk && bun run lint:sdk && bun run build:sdk && bun run test:sdk && bun run check:release-workflows && bun run check:schema && bun run check:skill",
244
250
  "check:desktop": "bun run check:effect && bun run typecheck:desktop && bun run lint:desktop && bun run test:desktop && bun run build:desktop",
245
251
  "check:web": "bun apps/web/scripts/verify-example-sources.ts && bun run --cwd apps/web check",
246
- "kb:refresh": "bunx --bun github:hraness/kb#v0.15.2 refresh --root kb",
247
- "kb:check:lane": "bunx --bun github:hraness/kb#v0.15.2 check --root kb --no-catalog",
248
- "kb:check": "bunx --bun github:hraness/kb#v0.15.2 check --root kb && bunx --bun github:hraness/kb#v0.15.2 agents check --root kb --repo .",
249
- "kb:catalog": "bunx --bun github:hraness/kb#v0.15.2 catalog --root kb",
252
+ "kb:refresh": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.24.0/hraness-wordcell-0.24.0.tgz wordcell refresh --root kb",
253
+ "kb:check:lane": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.24.0/hraness-wordcell-0.24.0.tgz wordcell check --root kb --no-catalog",
254
+ "kb:check": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.24.0/hraness-wordcell-0.24.0.tgz wordcell check --root kb && bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.24.0/hraness-wordcell-0.24.0.tgz wordcell agents check --root kb --repo .",
255
+ "kb:catalog": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.24.0/hraness-wordcell-0.24.0.tgz wordcell catalog --root kb",
256
+ "kb:search": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.24.0/hraness-wordcell-0.24.0.tgz wordcell search --root kb --mode exact --selected-passage",
250
257
  "check:cost-surfaces": "bun ./scripts/check-cost-surfaces.mjs",
251
258
  "check": "bun run check:cost-surfaces && bun run check:standalone && bun run check:copy && bun run check:sdk && bun run check:desktop && bun run check:api && bun run check:web && bun run check:standalone && bun run test:package",
252
259
  "prepack": "bun run check",
@@ -260,7 +267,7 @@
260
267
  "dependencies": {
261
268
  "@ai-sdk/gateway-v4": "npm:@ai-sdk/gateway@^4.0.27",
262
269
  "@hraness/algal": "github:hraness/algal#c46264a",
263
- "@hraness/support-foundation": "github:hraness/support-foundation#b32c1c81bb2444f50509ed54388758ecfab1f1c0",
270
+ "@hraness/support-foundation": "github:hraness/support-foundation#8bb514d24b79dc3f305390700ae312cab88e7ad2",
264
271
  "@noble/hashes": "2.2.0",
265
272
  "@paper-design/shaders": "0.0.77",
266
273
  "@resvg/resvg-js": "2.6.2",
@@ -284,7 +291,6 @@
284
291
  "devDependencies": {
285
292
  "@antithesishq/bombadil": "0.7.2",
286
293
  "@eslint/js": "^9.39.2",
287
- "@hraness/design-kit": "github:hraness/design-kit#v0.3.0",
288
294
  "@hraness/direct": "github:hraness/direct#v0.7.20",
289
295
  "@native-sdk/cli": "0.5.3",
290
296
  "@types/bun": "^1.3.14",