@navipartner/demo-video 0.7.4 → 0.7.5

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/bin/args.d.mts CHANGED
@@ -22,6 +22,8 @@ export interface DemoArgs {
22
22
  draft: boolean;
23
23
  }
24
24
  export const COMMANDS: readonly string[];
25
+ /** Creates demo/out/ with a .gitignore that ignores all of it. */
26
+ export function makeOutDir(outDir: string): void;
25
27
  /** The package.json that makes demo.config.ts CommonJS, or null. */
26
28
  export function commonJsDemo(demoDir: string): string | null;
27
29
  export function parseArgs(argv: string[], cwd: string): DemoArgs;
package/bin/args.mjs CHANGED
@@ -14,6 +14,22 @@ const NEEDS_STORYBOARD = new Set(["check", "render", "record"]);
14
14
  // Commands whose Playwright run loads demo.config.ts.
15
15
  const LOADS_ADAPTER = new Set(["check", "render", "record", "brand-needs"]);
16
16
 
17
+ /**
18
+ * Creates demo/out/ with a .gitignore of its own, so the renders, the
19
+ * contact sheet and the stills are never committed, whether or not init
20
+ * added the repo's ignore line.
21
+ */
22
+ export const makeOutDir = (outDir) => {
23
+ fs.mkdirSync(outDir, { recursive: true });
24
+ const ignore = path.join(outDir, ".gitignore");
25
+ if (!fs.existsSync(ignore)) {
26
+ fs.writeFileSync(
27
+ ignore,
28
+ "# Written by @navipartner/demo-video: nothing here is committed.\n*\n",
29
+ );
30
+ }
31
+ };
32
+
17
33
  /**
18
34
  * The package.json that makes demo.config.ts CommonJS, or null. Playwright
19
35
  * compiles a .ts file as CommonJS unless the nearest package.json says
@@ -6,9 +6,9 @@ import { createRequire } from "node:module";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
 
9
- import { listStories, parseArgs } from "./args.mjs";
9
+ import { listStories, makeOutDir, parseArgs } from "./args.mjs";
10
10
  import { encodeRecording, extractStillsFrom } from "./encode.mjs";
11
- import { findApps, initApp, initProject } from "./init.mjs";
11
+ import { findApps, initApp, initProject, skillDrift } from "./init.mjs";
12
12
  import { appMenu, menuMarkdown, pick } from "./pick.mjs";
13
13
  import { buildPitchBrand } from "./pitch.mjs";
14
14
  import { writeRunner } from "./runner.mjs";
@@ -38,6 +38,7 @@ const runPlaywright = (args, mode) => {
38
38
  const require = createRequire(path.join(args.appDir, "package.json"));
39
39
  const cli = require.resolve("@playwright/test/cli");
40
40
  fs.rmSync(args.workDir, { recursive: true, force: true });
41
+ makeOutDir(args.outDir);
41
42
  fs.mkdirSync(args.workDir, { recursive: true });
42
43
  writeRunner(args);
43
44
  const result = spawnSync(
@@ -313,7 +314,7 @@ const runPitch = async (argv) => {
313
314
  finish(render, false);
314
315
  throw new Error("Filming the pitch draft failed (see above)");
315
316
  }
316
- fs.mkdirSync(render.outDir, { recursive: true });
317
+ makeOutDir(render.outDir);
317
318
  const result = encodeRecording(render.workDir, render.outDir);
318
319
  finish(render, true);
319
320
  // The video leaves the repo; its stills and sheet there go.
@@ -363,6 +364,12 @@ const run = () => {
363
364
  console.error(error.message);
364
365
  return 2;
365
366
  }
367
+ if (["check", "render", "record"].includes(args.command)) {
368
+ const drift = skillDrift(args.appDir);
369
+ if (drift) {
370
+ console.error(drift);
371
+ }
372
+ }
366
373
  switch (args.command) {
367
374
  case "check": {
368
375
  const status = runPlaywright(args, "check");
@@ -391,7 +398,7 @@ const run = () => {
391
398
  finish(args, false);
392
399
  return status;
393
400
  }
394
- fs.mkdirSync(args.outDir, { recursive: true });
401
+ makeOutDir(args.outDir);
395
402
  const result = encodeRecording(args.workDir, args.outDir);
396
403
  finish(args, true);
397
404
  if (args.json) {
@@ -404,7 +411,7 @@ const run = () => {
404
411
  return 0;
405
412
  }
406
413
  case "encode": {
407
- fs.mkdirSync(args.outDir, { recursive: true });
414
+ makeOutDir(args.outDir);
408
415
  const { output, duration } = encodeRecording(args.workDir, args.outDir);
409
416
  finish(args, true);
410
417
  console.log(`Wrote ${output} (${duration.toFixed(1)} s)`);
package/bin/init.d.mts CHANGED
@@ -12,6 +12,8 @@ export function initRepo(
12
12
  root: string,
13
13
  options?: { dryRun?: boolean; label?: boolean },
14
14
  ): InitResult;
15
+ /** A line to print when the repo's copy of the skill isn't this engine's. */
16
+ export function skillDrift(appDir: string): string | null;
15
17
  /** The repo's apps (folders with a playwright.config), "." for the root. */
16
18
  export function findApps(root: string): string[];
17
19
  export function initProject(
package/bin/init.mjs CHANGED
@@ -87,6 +87,11 @@ const withKey = (object, key, value) => {
87
87
  return Object.fromEntries(entries);
88
88
  };
89
89
 
90
+ // Line endings aside: git may check the repo's copy out with CRLF.
91
+ const sameText = (x, y) =>
92
+ fs.readFileSync(x, "utf8").replace(/\r\n/g, "\n") ===
93
+ fs.readFileSync(y, "utf8").replace(/\r\n/g, "\n");
94
+
90
95
  const sameTree = (a, b) => {
91
96
  if (!fs.existsSync(b)) {
92
97
  return false;
@@ -95,8 +100,37 @@ const sameTree = (a, b) => {
95
100
  const [x, y] = [path.join(a, entry.name), path.join(b, entry.name)];
96
101
  return entry.isDirectory()
97
102
  ? sameTree(x, y)
98
- : fs.existsSync(y) && fs.readFileSync(x).equals(fs.readFileSync(y));
103
+ : fs.existsSync(y) && sameText(x, y);
104
+ });
105
+ };
106
+
107
+ /**
108
+ * What to tell the developer when the repo's copy of the skill (init puts
109
+ * it in .claude/skills/) isn't this engine's: Claude would follow an older
110
+ * workflow and look than the engine checks. Null when it is, or when the
111
+ * repo has no copy.
112
+ */
113
+ export const skillDrift = (appDir) => {
114
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], {
115
+ cwd: appDir,
116
+ encoding: "utf8",
99
117
  });
118
+ if (top.error || top.status !== 0) {
119
+ return null;
120
+ }
121
+ const copy = path.join(
122
+ top.stdout.trim(),
123
+ ".claude",
124
+ "skills",
125
+ "np-demo-video",
126
+ );
127
+ if (
128
+ !fs.existsSync(copy) ||
129
+ sameTree(path.join(engineDir, "skills", "np-demo-video"), copy)
130
+ ) {
131
+ return null;
132
+ }
133
+ return `.claude/skills/np-demo-video isn't the skill of ${PKG} ${engineVersion()}: run "demo-video init" at the repo root to update it.`;
100
134
  };
101
135
 
102
136
  const gitIgnores = (root, file) =>
@@ -338,9 +338,11 @@ var stageHtml = (background, appUrl, look, options = {}) => {
338
338
  font-family: ${font(look.headingFont)}, Georgia, serif;
339
339
  font-size: 116px; font-weight: 400; line-height: 1.08; letter-spacing: -0.015em;
340
340
  color: ${look.ink};
341
+ /* Lines of even length: no word left alone on the last one. */
342
+ text-wrap: balance;
341
343
  }
342
344
  .card .heading em { font-style: italic; color: ${look.primary}; }
343
- .card .sub { font-size: 40px; font-weight: 400; line-height: 1.3; color: ${look.text}; opacity: 0.85; }
345
+ .card .sub { font-size: 40px; font-weight: 400; line-height: 1.3; color: ${look.text}; opacity: 0.85; text-wrap: balance; }
344
346
  .card.split .heading { font-size: 92px; }
345
347
  .card.split .sub { font-size: 36px; }
346
348
  .card .still {
@@ -685,6 +687,7 @@ var lintStoryboard = (story) => {
685
687
  }
686
688
  let chapterStart = 0;
687
689
  let pushes = [];
690
+ let pushedIn = false;
688
691
  const endChapter = (end) => {
689
692
  if (pushes.length > MAX_PUSHES_PER_CHAPTER) {
690
693
  issues.push(
@@ -716,6 +719,7 @@ var lintStoryboard = (story) => {
716
719
  if (step.do === "card") {
717
720
  endChapter(index);
718
721
  chapterStart = index + 1;
722
+ pushedIn = false;
719
723
  lintCard(where, s);
720
724
  if (s.eyebrow !== void 0) {
721
725
  issues.push(
@@ -742,6 +746,15 @@ var lintStoryboard = (story) => {
742
746
  if (shot && pushes.at(-1) !== shot) {
743
747
  pushes.push(shot);
744
748
  }
749
+ if (shot && !isBehindCard(step)) {
750
+ pushedIn = true;
751
+ }
752
+ if (step.do === "zoomOut" && pushedIn) {
753
+ issues.push(
754
+ `${where}: a pull-out. A push-in stays in until the next card, which takes the camera back to wide: push in on what is read (a dialog, the basket, the confirmation) and stay in, and use what opens something new from the wide shot, before the push-in`
755
+ );
756
+ pushedIn = false;
757
+ }
745
758
  if (step.do === "zoom" || step.do === "frame") {
746
759
  const rest = steps.slice(index + 1);
747
760
  const end = rest.findIndex(
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  StoryboardError,
3
3
  parseStoryboard
4
- } from "./chunk-XMOGUZJU.js";
4
+ } from "./chunk-DBH2CI6K.js";
5
5
 
6
6
  // src/adapter.ts
7
7
  var defineDemo = (adapter) => adapter;
@@ -41,7 +41,7 @@ import {
41
41
  unionRect,
42
42
  visibleRect,
43
43
  wideShot
44
- } from "../chunk-XMOGUZJU.js";
44
+ } from "../chunk-DBH2CI6K.js";
45
45
 
46
46
  // src/runner/config.ts
47
47
  import path from "path";
@@ -266,6 +266,43 @@ import path3 from "path";
266
266
 
267
267
  // src/changes.ts
268
268
  var MAX_RESTYLES = 3;
269
+ var NEW_SCREEN_GONE = 0.5;
270
+ var markScreen = (app) => app.evaluate(() => {
271
+ const w = window;
272
+ const read = () => {
273
+ const texts = [];
274
+ for (const node of Array.from(document.body.querySelectorAll("*"))) {
275
+ const own = Array.from(node.childNodes).filter((c) => c.nodeType === Node.TEXT_NODE).map((c) => c.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
276
+ if (!own) {
277
+ continue;
278
+ }
279
+ const r = node.getBoundingClientRect();
280
+ const x = r.x + r.width / 2;
281
+ const y = r.y + r.height / 2;
282
+ if (r.width === 0 || r.height === 0 || x < 0 || y < 0 || x > innerWidth || y > innerHeight || !node.checkVisibility({
283
+ opacityProperty: true,
284
+ visibilityProperty: true
285
+ })) {
286
+ continue;
287
+ }
288
+ const top = document.elementFromPoint(x, y);
289
+ if (top && (node.contains(top) || top.contains(node))) {
290
+ texts.push({
291
+ text: own,
292
+ x: r.x,
293
+ y: r.y,
294
+ width: r.width,
295
+ height: r.height
296
+ });
297
+ }
298
+ if (texts.length >= 300) {
299
+ break;
300
+ }
301
+ }
302
+ return texts;
303
+ };
304
+ w.__demoScreen = { read, before: read() };
305
+ });
269
306
  var watchChanges = (target) => target.evaluate((el) => {
270
307
  const w = window;
271
308
  w.__demoChanges?.observer.disconnect();
@@ -304,6 +341,28 @@ var watchChanges = (target) => target.evaluate((el) => {
304
341
  watch.observer = observer;
305
342
  w.__demoChanges = watch;
306
343
  });
344
+ var newScreen = (app) => app.evaluate((share) => {
345
+ const screen = window.__demoScreen;
346
+ if (!screen) {
347
+ return { x: 0, y: 0, width: innerWidth, height: innerHeight };
348
+ }
349
+ if (screen.before.length === 0) {
350
+ return null;
351
+ }
352
+ const now = screen.read();
353
+ const nowTexts = new Set(now.map((r) => r.text));
354
+ const beforeTexts = new Set(screen.before.map((r) => r.text));
355
+ const gone = screen.before.filter((r) => !nowTexts.has(r.text));
356
+ if (gone.length / screen.before.length < share) {
357
+ return null;
358
+ }
359
+ const moved = [...gone, ...now.filter((r) => !beforeTexts.has(r.text))];
360
+ const x0 = Math.min(...moved.map((r) => r.x));
361
+ const y0 = Math.min(...moved.map((r) => r.y));
362
+ const x1 = Math.max(...moved.map((r) => r.x + r.width));
363
+ const y1 = Math.max(...moved.map((r) => r.y + r.height));
364
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
365
+ }, NEW_SCREEN_GONE);
307
366
  var watching = (app) => app.evaluate(
308
367
  () => Boolean(window.__demoChanges)
309
368
  );
@@ -348,7 +407,12 @@ var biggestChange = (app) => app.evaluate((maxRestyles) => {
348
407
  const hasText = Array.from(node.childNodes).some(
349
408
  (c) => c.nodeType === Node.TEXT_NODE && c.textContent?.trim()
350
409
  );
351
- if (hasText) {
410
+ const r = node.getBoundingClientRect();
411
+ const shown = r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth && node.checkVisibility({
412
+ opacityProperty: true,
413
+ visibilityProperty: true
414
+ });
415
+ if (hasText && shown) {
352
416
  fontPx = Math.min(fontPx, parseFloat(getComputedStyle(node).fontSize));
353
417
  }
354
418
  }
@@ -361,6 +425,7 @@ var SHOWN = 0.5;
361
425
  var FRAME_MAX_SCALE = 1.8;
362
426
  var PUSH_WORTHY = 1.4;
363
427
  var READABLE_PX = 16;
428
+ var MOVES_ON_MS = POST_CLICK_MS + REACTION_MS + ZOOM_MS + SETTLE_MS;
364
429
  var STILL_MAX_PX = 1600;
365
430
  var testIdSelector = (selector) => /^[\w-]+$/.test(selector) ? `[data-testid="${selector}"]` : selector;
366
431
  var normalise = (text) => text.replace(/\s+/g, " ").trim();
@@ -397,6 +462,11 @@ var Director = class {
397
462
  names: [],
398
463
  textPx: Number.POSITIVE_INFINITY
399
464
  };
465
+ /**
466
+ * The chapter so far: whether it pushed in, and the smallest text its
467
+ * clicks showed wide. A chapter that stays wide must read wide.
468
+ */
469
+ chapter = { pushed: false, textPx: Number.POSITIVE_INFINITY };
400
470
  /** When the whole window came into view (virtual ms), for ESTABLISH_MS. */
401
471
  wideSince = 0;
402
472
  cdp;
@@ -493,8 +563,14 @@ var Director = class {
493
563
  this.wasWide = this.isWide();
494
564
  this.shot = next;
495
565
  this.shotLabel = `the shot on "${t.target}" at ${scale}`;
566
+ await this.pushedIn();
496
567
  await this.moveCamera(next);
497
568
  }
569
+ // The screen a push-in is on is the one it stays on, until the card.
570
+ async pushedIn() {
571
+ this.chapter.pushed = true;
572
+ await markScreen(this.app);
573
+ }
498
574
  // Films a camera move. A push-in from the wide shot waits until the whole
499
575
  // window has been seen for ESTABLISH_MS: a page never opens zoomed.
500
576
  async moveCamera(shot) {
@@ -553,6 +629,7 @@ var Director = class {
553
629
  this.wasWide = this.isWide();
554
630
  this.shot = next;
555
631
  this.shotLabel = `the shot framing ${names}`;
632
+ await this.pushedIn();
556
633
  await this.moveCamera(next);
557
634
  }
558
635
  async zoomOut(options = {}) {
@@ -606,7 +683,7 @@ var Director = class {
606
683
  const margin = 8;
607
684
  if (cx < v.x + margin || cx > v.x + v.width - margin || cy < v.y + margin || cy > v.y + v.height - margin) {
608
685
  throw new Error(
609
- `"${t.target}" is outside ${this.shotLabel}: zoomOut first, or frame an area that holds everything this shot uses.`
686
+ `"${t.target}" is outside ${this.shotLabel}: frame an area that holds everything this shot uses, or use it from the wide shot, before the push-in.`
610
687
  );
611
688
  }
612
689
  return { point: this.toStagePoint(rect), rect };
@@ -638,6 +715,7 @@ var Director = class {
638
715
  }
639
716
  ).catch(() => Number.NaN);
640
717
  if (!inner) {
718
+ await markScreen(this.app);
641
719
  await watchChanges(this.locator(t));
642
720
  }
643
721
  if (filmed) {
@@ -659,6 +737,7 @@ var Director = class {
659
737
  await this.page.mouse.click(point.x, point.y);
660
738
  }
661
739
  await options.then?.();
740
+ const pressedAt = Date.now();
662
741
  if (this.covered) {
663
742
  await this.settle(COVERED_SETTLED_MS, COVERED_MAX_MS);
664
743
  if (!inner) {
@@ -675,23 +754,60 @@ var Director = class {
675
754
  if (inner) {
676
755
  return;
677
756
  }
757
+ const biggest = await biggestChange(this.app);
758
+ const opened = await newScreen(this.app);
678
759
  if (zoomed) {
679
- await this.assertChangeInShot(t, options.verb);
760
+ if (this.cropped(opened) || await this.movesOn(pressedAt)) {
761
+ throw new Error(
762
+ `${options.verb} "${t.target}" opens a new screen or dialog under the push-in, so it would show cropped: use it from the wide shot (no push-in before it), and push in on the new screen once it has been seen whole.`
763
+ );
764
+ }
765
+ this.assertChangeInShot(t, options.verb, biggest);
680
766
  return;
681
767
  }
682
- const biggest = await biggestChange(this.app);
683
- this.wideUse.rects.push(rect, ...biggest ? [biggest.rect] : []);
684
- this.wideUse.names.push(t.target);
685
768
  const smallest = Math.min(
686
769
  Number.isFinite(fontPx) ? fontPx : Number.POSITIVE_INFINITY,
687
770
  biggest?.fontPx ?? Number.POSITIVE_INFINITY
688
- );
689
- if (Number.isFinite(smallest)) {
690
- this.wideUse.textPx = Math.min(
691
- this.wideUse.textPx,
692
- smallest * this.layout.fit
693
- );
771
+ ) * this.layout.fit;
772
+ this.chapter.textPx = Math.min(this.chapter.textPx, smallest);
773
+ if (opened) {
774
+ return;
775
+ }
776
+ this.wideUse.rects.push(rect, ...biggest ? [biggest.rect] : []);
777
+ this.wideUse.names.push(t.target);
778
+ this.wideUse.textPx = Math.min(this.wideUse.textPx, smallest);
779
+ }
780
+ // After a press under a push-in, in check: the time render's beat and a
781
+ // camera move would take, watching for a screen that moves on by itself.
782
+ // The screen as the reaction left it is what the push-in stays on.
783
+ async movesOn(pressedAt) {
784
+ await markScreen(this.app);
785
+ if (!this.fast) {
786
+ return false;
694
787
  }
788
+ while (Date.now() - pressedAt < MOVES_ON_MS) {
789
+ await this.page.waitForTimeout(POLL_MS);
790
+ if (this.cropped(await newScreen(this.app))) {
791
+ return true;
792
+ }
793
+ }
794
+ return false;
795
+ }
796
+ // A new screen (where its texts left and arrived) that the shot doesn't
797
+ // hold whole. A list filtered inside the shot is not one.
798
+ cropped(screen) {
799
+ return screen !== null && !shows(this.layout, this.shot, screen);
800
+ }
801
+ // While pushed in, the screen stays the one the push-in is on: a screen
802
+ // the app moves on to (after a wait, a hold, by an action) would show
803
+ // cropped. Check skips holds, so there only render catches a timer.
804
+ async assertSameScreen() {
805
+ if (this.isWide() || this.covered || !this.cropped(await newScreen(this.app))) {
806
+ return;
807
+ }
808
+ throw new Error(
809
+ "the app moves on to a new screen while the camera is pushed in, so it would show cropped: push in only on a screen the chapter then stays on, and do what leads to the next one from the wide shot."
810
+ );
695
811
  }
696
812
  // Until nothing in the app has changed for `quiet` ms, at most `max` ms:
697
813
  // filmed on the virtual clock, in real time in check.
@@ -728,15 +844,14 @@ var Director = class {
728
844
  // click changed lands outside the shot (an item added to a basket
729
845
  // off-screen), the zoom is on the wrong area: the viewer would see a
730
846
  // button pressed and nothing happen.
731
- async assertChangeInShot(t, verb) {
732
- const biggest = await biggestChange(this.app);
847
+ assertChangeInShot(t, verb, biggest) {
733
848
  if (!biggest) {
734
849
  return;
735
850
  }
736
851
  if (coverage(biggest.rect, visibleRect(this.layout, this.shot)) < SHOWN) {
737
852
  this.unseen = {
738
853
  rect: biggest.rect,
739
- message: `${verb} "${t.target}" changes ${biggest.label}, which is outside ${this.shotLabel}, and the next step doesn't show it: zoomOut right after, or frame the control with what it changes.`
854
+ message: `${verb} "${t.target}" changes ${biggest.label}, which is outside ${this.shotLabel}, and the next step doesn't show it: frame the control with what it changes, or use it from the wide shot.`
740
855
  };
741
856
  }
742
857
  }
@@ -797,6 +912,19 @@ var Director = class {
797
912
  end() {
798
913
  this.requireSeen("other");
799
914
  this.closeWide();
915
+ this.closeChapter();
916
+ }
917
+ // A chapter ends (a card, the end). One that never pushed in must have
918
+ // read wide: a portrait kiosk scaled into the frame, or small text, gives
919
+ // the viewer nothing to read on a phone.
920
+ closeChapter() {
921
+ const { pushed, textPx } = this.chapter;
922
+ this.chapter = { pushed: false, textPx: Number.POSITIVE_INFINITY };
923
+ if (!pushed && textPx < READABLE_PX) {
924
+ throw new Error(
925
+ `the chapter before this stays wide, where its text is ${Math.round(textPx)} px on the frame, too small to read: push in on what the viewer should read (the result it ends on), and stay in until the card.`
926
+ );
927
+ }
800
928
  }
801
929
  async during(work, options = {}) {
802
930
  const recorder = this.options.recorder;
@@ -815,6 +943,7 @@ var Director = class {
815
943
  this.requireSeen("other");
816
944
  const result = await run();
817
945
  await this.wait(POST_ACTION_MS);
946
+ await this.assertSameScreen();
818
947
  return result;
819
948
  }
820
949
  // --- waits and checks ------------------------------------------------------------
@@ -826,6 +955,7 @@ var Director = class {
826
955
  options.timeoutMs,
827
956
  () => `"${t.target}" did not ${options.hidden ? "disappear" : "appear"} within ${options.timeoutMs / 1e3} s`
828
957
  );
958
+ await this.assertSameScreen();
829
959
  }
830
960
  async expectText(t, text, options) {
831
961
  this.requireSeen("other");
@@ -846,15 +976,19 @@ var Director = class {
846
976
  options.timeoutMs,
847
977
  () => `expected "${want}"${options.contains ? " (contains)" : ""}, saw "${last}"`
848
978
  );
979
+ await this.assertSameScreen();
849
980
  }
850
981
  async hold(ms) {
851
982
  this.requireSeen("other");
852
983
  await this.wait(ms);
984
+ await this.assertSameScreen();
853
985
  }
854
986
  // --- overlays --------------------------------------------------------------------
855
987
  async card(spec, options = {}) {
856
988
  this.requireSeen("other");
989
+ await this.assertSameScreen();
857
990
  this.closeWide();
991
+ this.closeChapter();
858
992
  this.shot = wideShot(this.layout);
859
993
  this.shotLabel = "the wide shot";
860
994
  if (this.fast) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navipartner/demo-video",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Marketing demo videos of a React app, filmed by Playwright against the app's own e2e mocks.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -89,9 +89,11 @@ Before any JSON, write the plan. For each chapter, in this order:
89
89
  3. **Actions** (2–4), each with **what it changes and where** (a row at the
90
90
  top, the total at the bottom, a new screen).
91
91
  4. **Shots:** the chapter opens wide, on the whole window. Push in only
92
- where the text is too small to read wide, framing the control *and* what
93
- it changes (a live preview included); the engine shows the whole window
94
- first. End on the result, framed if it needs reading.
92
+ where the text is too small to read wide, framing what is read: the
93
+ control *and* what it changes (a live preview included), or the result
94
+ (a dialog, the basket). The engine shows the whole window first, and
95
+ the camera stays in until the card, so taps that open a new screen come
96
+ before the push-in. End on the result, framed if it needs reading.
95
97
  5. **The card:** statement + benefit, written last (below), and the
96
98
  snapshot (`show`) of the result it ends on, if that result is visual.
97
99
  6. **Proof:** the value the app shows, checked with `expectText`.
@@ -112,8 +114,8 @@ ch1 sees: a guest scans two waters and they land in the basket
112
114
  show: basket
113
115
  ch2 sees: the guest pays by card and the payment goes through
114
116
  behind: —
115
- shots: frame "Go to payment" + total; pull out when payment opens;
116
- end framing the receipt
117
+ shots: wide: "Go to payment" opens a new screen; pay wide too;
118
+ end framing the receipt, held until the card
117
119
  card: "Pay *by card*." / "Tap, insert or phone: the terminal takes it."
118
120
  show: receipt
119
121
  payoff "Paid *in seconds*." · sub "64,50 kr. by card." (expectText on the total)
@@ -184,21 +186,23 @@ A chapter in steps:
184
186
  { "do": "card", "heading": "Pay *by card*.",
185
187
  "sub": "Tap, insert or phone: the terminal takes it.", "show": "receipt" },
186
188
  { "do": "click", "target": "accept-terms", "behindCard": true },
187
- { "do": "frame", "targets": ["pay-button", "order-total"] },
188
189
  { "do": "expectText", "target": "order-total", "text": "64,50 kr." },
189
190
  { "do": "click", "target": "pay-button" },
190
- { "do": "zoomOut" },
191
191
  { "do": "waitFor", "target": "receipt" },
192
192
  { "do": "frame", "targets": ["receipt", "receipt-total"] },
193
193
  { "do": "hold", "seconds": 2 }
194
194
 
195
- - **Shots:** stay wide while the shot reads. When a push-in is needed,
196
- prefer `frame` (2–4 targets: the control and what it changes) to guessing
197
- one `zoom` target, and keep the live preview in it; use `maxScale` to
198
- stay looser. When an action changes something outside the shot,
199
- `zoomOut` right after it. When `check` asks for a shot, question the shot
200
- before adding a frame: staying wide, or framing the result, is often the
201
- better fix.
195
+ - **Shots:** stay wide while the shot reads. When it doesn't, push in on
196
+ what the viewer reads (a dialog, the basket, the receipt) and stay in until the next
197
+ card, which takes the camera back to wide; never pull out (`check`
198
+ refuses a `zoomOut` after a push-in). A tap that opens a new screen or
199
+ dialog happens wide, so what it opens is seen whole first: `check`
200
+ refuses it under a push-in, also when the screen moves on by itself a
201
+ moment later (a payment going on to "processing"). Prefer `frame` (2–4
202
+ targets: the control and what it changes) to guessing one `zoom` target,
203
+ and keep the live preview in it; use `maxScale` to stay looser. When
204
+ `check` asks for a shot, question the shot before adding a frame:
205
+ staying wide, or framing the result, is often the better fix.
202
206
  - **Typing:** `"replace": true` clears what the field holds first. Type
203
207
  short, believable values that change something on screen.
204
208
  - **Leave the beats to the engine.** It pauses around every click, settles
@@ -266,8 +270,9 @@ report. Then film a draft:
266
270
 
267
271
  pnpm --dir <app> exec demo-video render --draft [--story <name>]
268
272
 
269
- A draft films at 30 fps, in about half the time of the final (which is
270
- about 15× the video's length). It runs a check first when cards show
273
+ A draft films at 30 fps, in about half the time of the final (which takes
274
+ 6–15× the video's length, depending on the machine; a developer laptop is
275
+ at the low end). It runs a check first when cards show
271
276
  snapshots, to capture them. Playwright starts the app's dev server from the
272
277
  adapter's `webServer`; if it can't on this machine, start it yourself with
273
278
  the same command and env, and it is reused.
@@ -15,7 +15,8 @@ importance:
15
15
  something. Never rushed: results and cards stay up long enough to read.
16
16
  3. **Purposeful camera.** Every page opens wide, on the whole window. The
17
17
  camera pushes in only where the action would be too small to read, and
18
- then only after the viewer has seen where they are. It moves between
18
+ then only after the viewer has seen where they are; once in, it stays
19
+ in until the next card. It moves between
19
20
  shots, never during the action, never chases the cursor, and there are no
20
21
  hard cuts inside a chapter.
21
22
  4. **Soft, light, branded.** A pale brand-tinted background, a floating app
@@ -128,22 +129,29 @@ text only, centred.
128
129
  chrome. A wide shot whose text reads (16 px or more on the 1080p frame)
129
130
  needs no push-in.
130
131
  - **A page never opens zoomed.** Every chapter opens wide when its card
131
- dissolves, and so does every new screen. When a push-in is needed, the
132
+ dissolves, and so does every new screen or dialog, the first time it is
133
+ seen: a tap that opens one is made from the wide shot, also when the
134
+ screen moves on by itself a moment later. When a push-in is needed, the
132
135
  whole window is seen first (the engine holds it ~0.8 s), then the camera
133
136
  moves in.
134
- - **Push in only where it's needed:** where the text is too small to read
135
- wide (`check` asks for it), or to read a result. Frame the control *and*
136
- what it changes, and no tighter than it takes to read (scale 1.3–1.8,
137
- never past 2: tighter loses the context and softens the picture). A live
138
- preview that the action changes belongs in the shot.
137
+ - **Push in only to read:** where the text is too small to read wide
138
+ (`check` asks for it), and then on what the viewer reads (a dialog, the
139
+ basket, the confirmation), not on the control that leads there. A
140
+ portrait app scaled into the frame almost always needs it. Frame the
141
+ control *and* what it changes, and no tighter than it takes to read
142
+ (scale 1.3–1.8, never past 2: tighter loses the context and softens the
143
+ picture). A live preview that the action changes belongs in the shot.
144
+ - **Never pull out.** A push-in stays in until the next card, which takes
145
+ the camera back to wide behind it. In and out on the same screen is
146
+ wasted motion: do the taps that open something new wide, before the
147
+ push-in.
139
148
  - **Hold steady through the action.** The clicks happen inside the shot; the
140
149
  camera doesn't move with them. At most two push-ins a chapter.
141
- - **Show the result.** When an action changes something outside the shot (a
142
- row added at the top, a new screen), pull out right after it. A push-in
150
+ - **Show the result.** What an action changes is in its shot. A push-in
143
151
  that shows a button pressed and nothing happening is the worst shot.
144
152
  - **End on the result.** The last shot of a chapter frames what it achieved
145
153
  (the pass with its wallet badge, the receipt), held long enough to read,
146
- then the card covers it.
154
+ then the card covers it, still pushed in.
147
155
  - **Each move eases in and out** (engine: 900 ms,
148
156
  `cubic-bezier(0.65, 0, 0.35, 1)`), then settles 150 ms before anything
149
157
  happens.
@@ -58,7 +58,7 @@ export default defineDemo({
58
58
  | `press` | `key`, `behindCard` |
59
59
  | `zoom` | `target`, `nth`, `scale` (1.3–2) |
60
60
  | `frame` | `targets` (2–4 testids or `{ target, nth }`), `maxScale` (1.3–2, default 1.8) |
61
- | `zoomOut` | `park` (rest the cursor too) |
61
+ | `zoomOut` | `park` (rest the cursor too). Never after a push-in: a card takes the camera back to wide |
62
62
  | `park` | rests the cursor in empty space |
63
63
  | `waitFor` | `target`, `nth`, `hidden`, `timeout` (s), `behindCard` |
64
64
  | `expectText` | `target`, `nth`, `text`, `contains`, `timeout` (s) |
@@ -91,10 +91,19 @@ push-in from the wide shot comes after the whole window has been seen. At
91
91
  most two push-ins a chapter, and a push-in needs something done or read in
92
92
  its shot. A control a step uses must be inside the current shot. What it
93
93
  changes (watched from the press to the app settling: typing's preview, a
94
- toast, a restyle) must be in the shot or shown by the next step (a
95
- `zoomOut`, or a zoom that frames it). A wide shot whose text is under 16 px
96
- on the frame, where the controls and what they change would fit a push-in
97
- of 1.4 or more, needs one.
94
+ toast, a restyle) must be in the shot or framed by the next step. A
95
+ push-in stays in until the next card: no `zoomOut` after one. A tap that
96
+ opens a new screen or dialog (most of what was read is gone or covered,
97
+ and the shot doesn't hold all that changed), or one after which the app
98
+ moves on by itself within about 2.5 s, happens wide; so does whatever the
99
+ app moves on to while pushed in (a wait, a hold, an action; check skips
100
+ holds, so a timer there only fails render). A list filtered inside the
101
+ shot is not a new screen. A wide shot
102
+ whose text is under 16 px on the frame, where the controls and what they
103
+ change would fit a push-in of 1.4 or more, needs one (taps that open a new
104
+ screen don't count: they stay wide). A chapter that never pushes in needs
105
+ text of 16 px or more on the frame throughout; a portrait kiosk scaled
106
+ into the frame rarely has it.
98
107
 
99
108
  **Adapter actions** may name the control they use (`target(params)`); the
100
109
  cursor then goes there and rests, and the camera rules apply as for a
@@ -110,8 +119,12 @@ length against its kind's range (`seconds` and `kind` in `--json`).
110
119
 
111
120
  | It says | Fix |
112
121
  |---|---|
113
- | `"x" is outside the shot …` | `zoomOut` first, or frame an area that holds everything the shot uses |
114
- | `clicking / typing in / using "x" changes y, which is outside the shot …` | `zoomOut` right after, or frame the control with what it changes |
122
+ | `"x" is outside the shot …` | Frame an area that holds everything the shot uses, or use `x` wide, before the push-in |
123
+ | `clicking / typing in / using "x" changes y, which is outside the shot …` | Frame the control with what it changes, or use it wide |
124
+ | `clicking "x" opens a new screen or dialog under the push-in …` | Drop the push-in before it: tap wide, then push in on the new screen if it needs reading |
125
+ | `the app moves on to a new screen while the camera is pushed in …` | Don't push in on a screen that moves on by itself; push in on the one it moves to |
126
+ | `a pull-out` | Remove the `zoomOut`: end the chapter pushed in on its result, or do the wide taps before the push-in |
127
+ | `the chapter before this stays wide, where its text is N px …` | Push in on the result the chapter ends on (`frame` it) and hold until the card |
115
128
  | `"x" is used in the wide shot, where its text is N px, too small to read …` | `frame` the controls and what they change (the engine pushes in after a wide beat); or, if it's setup, `behindCard` |
116
129
  | `N camera push-ins in one chapter` | Push in once on the chapter's area and hold steady |
117
130
  | `… only fit in the wide shot` | Stay wide, or frame things that sit together |