@navipartner/demo-video 0.7.1 → 0.7.2

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/README.md CHANGED
@@ -33,7 +33,7 @@ video of the app: it writes the adapter and the primary story with you.
33
33
  | Linear | @Linear "… for Northwind (northwind.example)" | the same, in a customer's researched brand |
34
34
  | A PR | the `demo video` label | reviewing the stories the PR adds or changes |
35
35
  | A PR | `/demo-video checkout` (`help` for the menu) | filming any story from the PR's branch |
36
- | Your machine | `pnpm exec demo-video render` | writing and tuning a story |
36
+ | Your machine | Claude with the skill | writing and tuning a story: 30 fps drafts, then the 60 fps video once you say the whole flow is right |
37
37
 
38
38
  Requests only pick existing stories; new stories come through a developer's
39
39
  PR. In a PR comment, the first line is the request, a translation is found
@@ -44,8 +44,9 @@ the menu.
44
44
 
45
45
  Ask @Linear in plain words, naming the repo, the story and the customer. In
46
46
  a coding session it follows the skill's `linear.md`, attaches the MP4 and
47
- its contact sheet to the issue, and commits nothing. It runs on Linear's AI
48
- credits; its sandbox is slow, about 15× the video's length at 60 fps.
47
+ its contact sheet to the issue, and commits nothing. It films the agreed
48
+ story at 60 fps. It runs on Linear's AI credits; its sandbox is slow, about
49
+ 15× the video's length at 60 fps.
49
50
 
50
51
  For a customer, the video is a **pitch draft**. @Linear researches their
51
52
  brand from their own website, the engine checks it and films at 30 fps, and
@@ -92,8 +93,8 @@ output is kept in `demo/out/failed/`.
92
93
  pnpm test && pnpm typecheck && pnpm lint && pnpm build
93
94
 
94
95
  CI runs the tests, actionlint and a smoke render of the fixture app. To
95
- release, bump `version` in `package.json` on main, then
96
- `git tag vX.Y.Z && git push origin vX.Y.Z`. CI publishes to npm as `latest`
96
+ release, tag main: `git tag vX.Y.Z && git push origin vX.Y.Z`. The tag
97
+ sets the version; `package.json` isn't bumped by hand. CI publishes to npm as `latest`
97
98
  through trusted publishing (no npm token) and moves the `v0` tag. Running
98
99
  `release.yml` by hand publishes a `next` prerelease.
99
100
 
@@ -135,6 +135,12 @@ const printReport = (report) => {
135
135
  ` about ${report.seconds} s; a ${report.kind === "feature" ? "feature clip" : "showcase"} runs ${low}–${high} s${fits ? `, so this is ${fits}` : ""}`,
136
136
  );
137
137
  }
138
+ // A card held up by its setup lingers: worth moving some of it.
139
+ for (const card of report.slowCards ?? []) {
140
+ console.log(
141
+ ` ${card.where}: the setup behind it takes about ${card.setupSeconds} s, so it stays up longer than its ${card.readSeconds} s to read; move some setup behind an earlier card`,
142
+ );
143
+ }
138
144
  return;
139
145
  }
140
146
  const where = report.step
@@ -71,8 +71,8 @@ interface CardOptions {
71
71
  /** The story's kind: how long the open card may stay up. */
72
72
  kind?: Kind;
73
73
  /**
74
- * Done while the card covers the app (setup, the chapter's first shot):
75
- * no cursor, no camera moves, and the card stays up until it's done.
74
+ * Done while the card covers the app (setup): no cursor, no camera
75
+ * moves, and the card stays up until it's done.
76
76
  */
77
77
  behind?: () => Promise<void>;
78
78
  }
@@ -116,7 +116,10 @@ interface DirectorApi {
116
116
  * Runs an adapter action. While filming, time is stopped, so an action that
117
117
  * waits on the app (a scan that fetches) needs frames filmed meanwhile.
118
118
  */
119
- during<T>(work: () => Promise<T>): Promise<T>;
119
+ during<T>(work: () => Promise<T>, options?: {
120
+ /** The control the work uses: the cursor goes there, as for a click. */
121
+ target?: Target;
122
+ }): Promise<T>;
120
123
  /**
121
124
  * The app's clock, in ms: real time in check mode, the filmed (virtual)
122
125
  * time while filming. The idle accounting measures waits with it.
@@ -140,6 +143,13 @@ interface DirectorApi {
140
143
  interface DemoAction<S extends z.ZodRawShape = z.ZodRawShape> {
141
144
  params: z.ZodObject<S>;
142
145
  run(d: DirectorApi, params: z.infer<z.ZodObject<S>>): Promise<void>;
146
+ /**
147
+ * The control the action uses (a colour field, an upload button), as a
148
+ * testid, a selector or `{ target, nth }`. With one, the cursor glides to
149
+ * it and rests before the action, the camera rules apply as for a click,
150
+ * and the app's reaction plays out after it.
151
+ */
152
+ target?(params: z.infer<z.ZodObject<S>>): string | Target | undefined;
143
153
  }
144
154
  /** One thing a brand gives the app: what it is and where it shows. */
145
155
  type BrandNeed = string | {
@@ -12,6 +12,11 @@ var CURSOR_MIN_MS = 400;
12
12
  var CURSOR_MAX_MS = 800;
13
13
  var CURSOR_MS_PER_PX = 0.7;
14
14
  var SETTLE_MS = 150;
15
+ var ESTABLISH_MS = 800;
16
+ var SETTLED_MS = 250;
17
+ var REACTION_MS = 1e3;
18
+ var COVERED_SETTLED_MS = 150;
19
+ var COVERED_MAX_MS = 600;
15
20
  var PRE_CLICK_MS = 160;
16
21
  var PRESS_MS = 110;
17
22
  var POST_CLICK_MS = 450;
@@ -26,7 +31,7 @@ var TITLE_MAX_SECONDS = {
26
31
  var POLL_MS = 100;
27
32
  var wordCount = (text) => text ? text.replace(/\*/g, "").split(/\s+/).filter(Boolean).length : 0;
28
33
  var cardSeconds = (spec, maxSeconds = Number.POSITIVE_INFINITY) => {
29
- const read = 0.6 + 0.28 * (wordCount(spec.heading) + wordCount(spec.sub)) + 0.14 * wordCount(spec.eyebrow);
34
+ const read = 0.5 + 0.25 * wordCount(spec.heading) + 0.2 * wordCount(spec.sub) + 0.12 * wordCount(spec.eyebrow);
30
35
  return Math.max(
31
36
  spec.seconds,
32
37
  Math.min(maxSeconds, Math.round(read * 10) / 10)
@@ -453,7 +458,7 @@ var cardFields = {
453
458
  logo: z.boolean().optional(),
454
459
  /** "pale" = brand-tinted white; "brand" = full-bleed primary colour. */
455
460
  style: z.enum(["pale", "brand"]).default("pale"),
456
- seconds: z.number().min(0.5).max(10).default(1.8)
461
+ seconds: z.number().min(0.5).max(10).default(1.6)
457
462
  };
458
463
  var show = z.union([z.string().min(1), z.object({ ...target }).strict()]).optional();
459
464
  var stepCardFields = {
@@ -516,7 +521,7 @@ var coreStepSchemas = [
516
521
  var CORE_STEP_NAMES = coreStepSchemas.map(
517
522
  (s) => s.shape.do.value
518
523
  );
519
- var RESERVED_FIELDS = ["do"];
524
+ var RESERVED_FIELDS = ["do", "behindCard"];
520
525
  var cardSchema = z.object(cardFields).strict().refine(hasContent, CARD_EMPTY);
521
526
  var titleSchema = z.object({ ...cardFields, show }).strict().refine(hasContent, CARD_EMPTY);
522
527
  var isCoreStep = (step) => CORE_STEP_NAMES.includes(step.do);
@@ -533,7 +538,7 @@ var buildStoryboardSchema = (adapter) => {
533
538
  );
534
539
  }
535
540
  }
536
- return z.object({ do: z.literal(name) }).merge(action.params).strict();
541
+ return z.object({ do: z.literal(name), ...behindCard }).merge(action.params).strict();
537
542
  }
538
543
  );
539
544
  const stepSchema = z.discriminatedUnion("do", [
@@ -823,6 +828,11 @@ export {
823
828
  CURSOR_MIN_MS,
824
829
  CURSOR_MAX_MS,
825
830
  SETTLE_MS,
831
+ ESTABLISH_MS,
832
+ SETTLED_MS,
833
+ REACTION_MS,
834
+ COVERED_SETTLED_MS,
835
+ COVERED_MAX_MS,
826
836
  PRE_CLICK_MS,
827
837
  PRESS_MS,
828
838
  POST_CLICK_MS,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { K as Kind, D as DemoAdapter } from './adapter-bOi1ZBRa.js';
2
- export { B as Brand, C as CardSpec, a as DemoAction, b as DirectorApi, T as Target, V as VideoBrand, d as defineAction, c as defineDemo } from './adapter-bOi1ZBRa.js';
1
+ import { K as Kind, D as DemoAdapter } from './adapter-DxZmb-QU.js';
2
+ export { B as Brand, C as CardSpec, a as DemoAction, b as DirectorApi, T as Target, V as VideoBrand, d as defineAction, c as defineDemo } from './adapter-DxZmb-QU.js';
3
3
  import { Page } from '@playwright/test';
4
4
  import { z } from 'zod';
5
5
  export { z } from 'zod';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  StoryboardError,
3
3
  parseStoryboard
4
- } from "./chunk-RMBVQL2J.js";
4
+ } from "./chunk-XMOGUZJU.js";
5
5
 
6
6
  // src/adapter.ts
7
7
  var defineDemo = (adapter) => adapter;
@@ -1,5 +1,5 @@
1
1
  import { PlaywrightTestConfig, Page } from '@playwright/test';
2
- import { D as DemoAdapter } from '../adapter-bOi1ZBRa.js';
2
+ import { D as DemoAdapter } from '../adapter-DxZmb-QU.js';
3
3
  import 'zod';
4
4
 
5
5
  /**
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  CARD_IN_MS,
3
3
  CARD_OUT_MS,
4
+ COVERED_MAX_MS,
5
+ COVERED_SETTLED_MS,
4
6
  CURSOR_MAX_MS,
5
7
  CURSOR_MIN_MS,
6
8
  DEFAULT_VIEWPORT,
9
+ ESTABLISH_MS,
7
10
  LINGER_MS,
8
11
  MIN_SCALE,
9
12
  PARK,
@@ -13,6 +16,8 @@ import {
13
16
  POST_KEY_MS,
14
17
  PRESS_MS,
15
18
  PRE_CLICK_MS,
19
+ REACTION_MS,
20
+ SETTLED_MS,
16
21
  SETTLE_MS,
17
22
  STAGE_HEIGHT,
18
23
  STAGE_PATH,
@@ -36,7 +41,7 @@ import {
36
41
  unionRect,
37
42
  visibleRect,
38
43
  wideShot
39
- } from "../chunk-RMBVQL2J.js";
44
+ } from "../chunk-XMOGUZJU.js";
40
45
 
41
46
  // src/runner/config.ts
42
47
  import path from "path";
@@ -260,15 +265,31 @@ import fs2 from "fs";
260
265
  import path3 from "path";
261
266
 
262
267
  // src/changes.ts
268
+ var MAX_RESTYLES = 3;
263
269
  var watchChanges = (target) => target.evaluate((el) => {
264
- const changed = /* @__PURE__ */ new Set();
270
+ const w = window;
271
+ w.__demoChanges?.observer.disconnect();
272
+ const changed = /* @__PURE__ */ new Map();
273
+ const watch = { changed, lastAt: Date.now() };
265
274
  const observer = new MutationObserver((records) => {
266
275
  for (const record of records) {
276
+ if (record.type !== "attributes") {
277
+ watch.lastAt = Date.now();
278
+ }
267
279
  const nodes = record.type === "childList" ? Array.from(record.addedNodes) : [record.target];
268
280
  for (const node of nodes) {
269
281
  const changedEl = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
270
282
  if (changedEl && !el.contains(changedEl) && !changedEl.contains(el)) {
271
- changed.add(changedEl);
283
+ const seen = changed.get(changedEl) ?? {
284
+ content: false,
285
+ restyles: 0
286
+ };
287
+ if (record.type === "attributes") {
288
+ seen.restyles++;
289
+ } else {
290
+ seen.content = true;
291
+ }
292
+ changed.set(changedEl, seen);
272
293
  }
273
294
  }
274
295
  }
@@ -276,14 +297,21 @@ var watchChanges = (target) => target.evaluate((el) => {
276
297
  observer.observe(document.body, {
277
298
  subtree: true,
278
299
  childList: true,
279
- characterData: true
300
+ characterData: true,
301
+ attributes: true,
302
+ attributeFilter: ["style", "class", "src"]
280
303
  });
281
- window.__demoChanges = {
282
- observer,
283
- changed
284
- };
304
+ watch.observer = observer;
305
+ w.__demoChanges = watch;
306
+ });
307
+ var watching = (app) => app.evaluate(
308
+ () => Boolean(window.__demoChanges)
309
+ );
310
+ var quietMs = (app) => app.evaluate(() => {
311
+ const watch = window.__demoChanges;
312
+ return watch ? Date.now() - watch.lastAt : Number.POSITIVE_INFINITY;
285
313
  });
286
- var biggestChange = (app) => app.evaluate(() => {
314
+ var biggestChange = (app) => app.evaluate((maxRestyles) => {
287
315
  const w = window;
288
316
  const watch = w.__demoChanges;
289
317
  delete w.__demoChanges;
@@ -292,9 +320,10 @@ var biggestChange = (app) => app.evaluate(() => {
292
320
  }
293
321
  watch.observer.disconnect();
294
322
  let best = null;
323
+ let bestEl = null;
295
324
  let label = "";
296
- for (const el of watch.changed) {
297
- if (!el.isConnected) {
325
+ for (const [el, seen] of watch.changed) {
326
+ if (!el.isConnected || !seen.content && seen.restyles > maxRestyles) {
298
327
  continue;
299
328
  }
300
329
  const r = el.getBoundingClientRect();
@@ -303,17 +332,35 @@ var biggestChange = (app) => app.evaluate(() => {
303
332
  }
304
333
  if (!best || r.width * r.height > best.width * best.height) {
305
334
  best = { x: r.x, y: r.y, width: r.width, height: r.height };
335
+ bestEl = el;
306
336
  label = el.getAttribute("data-testid") ?? el.tagName.toLowerCase() + (el.textContent ? ` "${el.textContent.trim().slice(0, 30)}"` : "");
307
337
  }
308
338
  }
309
- return best ? { rect: best, label } : null;
310
- });
339
+ if (!best || !bestEl) {
340
+ return null;
341
+ }
342
+ let fontPx = Number.POSITIVE_INFINITY;
343
+ const texts = [
344
+ bestEl,
345
+ ...Array.from(bestEl.querySelectorAll("*")).slice(0, 200)
346
+ ];
347
+ for (const node of texts) {
348
+ const hasText = Array.from(node.childNodes).some(
349
+ (c) => c.nodeType === Node.TEXT_NODE && c.textContent?.trim()
350
+ );
351
+ if (hasText) {
352
+ fontPx = Math.min(fontPx, parseFloat(getComputedStyle(node).fontSize));
353
+ }
354
+ }
355
+ return { rect: best, label, fontPx };
356
+ }, MAX_RESTYLES);
311
357
 
312
358
  // src/director.ts
313
359
  var TARGET_TIMEOUT_MS = 15e3;
314
360
  var SHOWN = 0.5;
315
361
  var FRAME_MAX_SCALE = 1.8;
316
362
  var PUSH_WORTHY = 1.4;
363
+ var READABLE_PX = 16;
317
364
  var STILL_MAX_PX = 1600;
318
365
  var testIdSelector = (selector) => /^[\w-]+$/.test(selector) ? `[data-testid="${selector}"]` : selector;
319
366
  var normalise = (text) => text.replace(/\s+/g, " ").trim();
@@ -347,8 +394,11 @@ var Director = class {
347
394
  */
348
395
  wideUse = {
349
396
  rects: [],
350
- names: []
397
+ names: [],
398
+ textPx: Number.POSITIVE_INFINITY
351
399
  };
400
+ /** When the whole window came into view (virtual ms), for ESTABLISH_MS. */
401
+ wideSince = 0;
352
402
  cdp;
353
403
  get fast() {
354
404
  return this.options.fast;
@@ -440,16 +490,21 @@ var Director = class {
440
490
  if (sameShot(next, this.shot)) {
441
491
  return;
442
492
  }
493
+ this.wasWide = this.isWide();
443
494
  this.shot = next;
444
495
  this.shotLabel = `the shot on "${t.target}" at ${scale}`;
445
496
  await this.moveCamera(next);
446
497
  }
447
- // Films a camera move. Behind a card the shot is simply there when the
448
- // card goes, so the chapter opens on it.
498
+ // Films a camera move. A push-in from the wide shot waits until the whole
499
+ // window has been seen for ESTABLISH_MS: a page never opens zoomed.
449
500
  async moveCamera(shot) {
450
501
  if (this.fast) {
451
502
  return;
452
503
  }
504
+ const fromWide = this.wasWide;
505
+ if (fromWide && !this.covered) {
506
+ await this.wait(ESTABLISH_MS - (this.clockMs() - this.wideSince));
507
+ }
453
508
  const instant = this.covered;
454
509
  await this.page.evaluate(([s, i]) => window.__demo.setShot(s, i), [
455
510
  shot,
@@ -484,7 +539,7 @@ var Director = class {
484
539
  );
485
540
  if (scale < MIN_SCALE) {
486
541
  throw new Error(
487
- `${names} only fit in the wide shot (at ${scale.toFixed(2)}); frame things that sit together, or stay wide`
542
+ `${names} only fit in the wide shot (at ${scale.toFixed(2)}): stay wide, or frame things that sit together`
488
543
  );
489
544
  }
490
545
  const next = shotFor(this.layout, all, scale);
@@ -495,6 +550,7 @@ var Director = class {
495
550
  if (sameShot(next, this.shot)) {
496
551
  return;
497
552
  }
553
+ this.wasWide = this.isWide();
498
554
  this.shot = next;
499
555
  this.shotLabel = `the shot framing ${names}`;
500
556
  await this.moveCamera(next);
@@ -520,7 +576,15 @@ var Director = class {
520
576
  await this.page.evaluate((s) => window.__demo.setShot(s), wide);
521
577
  }
522
578
  await this.wait(Math.max(moving ? ZOOM_MS + SETTLE_MS : 0, parkMs));
579
+ if (moving) {
580
+ this.wideSince = this.clockMs();
581
+ }
523
582
  }
583
+ // Whether the camera shows the whole window now.
584
+ isWide() {
585
+ return this.shot.k <= this.layout.fit * 1.01;
586
+ }
587
+ wasWide = false;
524
588
  // --- cursor + input -----------------------------------------------------------
525
589
  /** Rests the cursor in empty space, off the content. */
526
590
  async park() {
@@ -542,32 +606,41 @@ var Director = class {
542
606
  const margin = 8;
543
607
  if (cx < v.x + margin || cx > v.x + v.width - margin || cy < v.y + margin || cy > v.y + v.height - margin) {
544
608
  throw new Error(
545
- `"${t.target}" is outside ${this.shotLabel}. Zoom on an area that holds everything this shot uses, or zoomOut first.`
609
+ `"${t.target}" is outside ${this.shotLabel}: zoomOut first, or frame an area that holds everything this shot uses.`
546
610
  );
547
611
  }
548
612
  return { point: this.toStagePoint(rect), rect };
549
613
  }
550
614
  async click(t, options = {}) {
615
+ await this.interact(t, { zoom: options.zoom, verb: "clicking" });
616
+ }
617
+ // One interaction with a control, as a person makes it: the cursor glides
618
+ // there and rests, presses (a real click, or only the look of one when an
619
+ // adapter action does the work), `then` runs (typing, the action), and the
620
+ // app's reaction plays out. What it changes is watched from before the
621
+ // press to the end, so typing's live preview, a toast after a save and a
622
+ // restyle all count as the result. Behind a card none of it is filmed.
623
+ async interact(t, options) {
551
624
  if (options.zoom !== void 0) {
552
625
  await this.zoomTo(t, options.zoom);
553
626
  } else {
554
627
  this.requireSeen("other");
555
628
  }
556
629
  const { point, rect } = await this.pointAt(t);
557
- const zoomed = this.shot.k > this.layout.fit * 1.01;
558
- const wide = !zoomed && !this.covered;
559
- if (zoomed || wide) {
630
+ const filmed = !this.fast && !this.covered;
631
+ const zoomed = !this.isWide();
632
+ const inner = await watching(this.app);
633
+ const fontPx = inner ? Number.NaN : await this.locator(t).evaluate(
634
+ (el) => parseFloat(getComputedStyle(el).fontSize),
635
+ void 0,
636
+ {
637
+ timeout: 1e3
638
+ }
639
+ ).catch(() => Number.NaN);
640
+ if (!inner) {
560
641
  await watchChanges(this.locator(t));
561
642
  }
562
- if (this.fast) {
563
- await this.page.mouse.click(point.x, point.y);
564
- if (zoomed || wide) {
565
- await this.page.waitForTimeout(POST_CLICK_MS);
566
- }
567
- } else if (this.covered) {
568
- await this.page.mouse.click(point.x, point.y);
569
- await this.wait(POST_CLICK_MS);
570
- } else {
643
+ if (filmed) {
571
644
  const ms = await this.page.evaluate(
572
645
  ([x, y]) => window.__demo.cursorTo(x, y),
573
646
  [point.x, point.y]
@@ -575,33 +648,79 @@ var Director = class {
575
648
  await this.wait(ms);
576
649
  await this.page.mouse.move(point.x, point.y);
577
650
  await this.wait(PRE_CLICK_MS);
651
+ }
652
+ if (options.press === "look") {
653
+ await this.wait(PRESS_MS);
654
+ } else if (filmed) {
578
655
  await this.page.mouse.down();
579
656
  await this.wait(PRESS_MS);
580
657
  await this.page.mouse.up();
658
+ } else {
659
+ await this.page.mouse.click(point.x, point.y);
660
+ }
661
+ await options.then?.();
662
+ if (this.covered) {
663
+ await this.settle(COVERED_SETTLED_MS, COVERED_MAX_MS);
664
+ if (!inner) {
665
+ await biggestChange(this.app);
666
+ }
667
+ return;
668
+ }
669
+ if (filmed) {
581
670
  await this.wait(POST_CLICK_MS);
671
+ } else {
672
+ await this.page.waitForTimeout(POST_CLICK_MS);
673
+ }
674
+ await this.settle(SETTLED_MS, REACTION_MS);
675
+ if (inner) {
676
+ return;
582
677
  }
583
678
  if (zoomed) {
584
- await this.assertChangeInShot(t);
585
- } else if (wide) {
586
- const biggest = await biggestChange(this.app);
587
- this.wideUse.rects.push(rect, ...biggest ? [biggest.rect] : []);
588
- this.wideUse.names.push(t.target);
679
+ await this.assertChangeInShot(t, options.verb);
680
+ return;
681
+ }
682
+ const biggest = await biggestChange(this.app);
683
+ this.wideUse.rects.push(rect, ...biggest ? [biggest.rect] : []);
684
+ this.wideUse.names.push(t.target);
685
+ const smallest = Math.min(
686
+ Number.isFinite(fontPx) ? fontPx : Number.POSITIVE_INFINITY,
687
+ 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
+ );
694
+ }
695
+ }
696
+ // Until nothing in the app has changed for `quiet` ms, at most `max` ms:
697
+ // filmed on the virtual clock, in real time in check.
698
+ async settle(quiet, max) {
699
+ for (let waited = 0; waited < max; waited += POLL_MS) {
700
+ if (await quietMs(this.app) >= quiet) {
701
+ return;
702
+ }
703
+ if (this.fast) {
704
+ await this.page.waitForTimeout(POLL_MS);
705
+ } else {
706
+ await this.wait(POLL_MS);
707
+ }
589
708
  }
590
709
  }
591
710
  // The wide shot ends (a push-in, a card, the end). Its clicks must have
592
711
  // needed it: if their controls and results all fit a push-in, the wide
593
712
  // shot showed them too small to read on a phone.
594
713
  closeWide() {
595
- const { rects, names } = this.wideUse;
596
- this.wideUse = { rects: [], names: [] };
597
- if (names.length === 0) {
714
+ const { rects, names, textPx } = this.wideUse;
715
+ this.wideUse = { rects: [], names: [], textPx: Number.POSITIVE_INFINITY };
716
+ if (names.length === 0 || textPx >= READABLE_PX) {
598
717
  return;
599
718
  }
600
719
  const scale = fittingScale(this.layout, unionRect(rects));
601
720
  if (scale >= PUSH_WORTHY) {
602
- const clicked = [...new Set(names)].map((n) => `"${n}"`).join(", ");
721
+ const used = [...new Set(names)].map((n) => `"${n}"`).join(", ");
603
722
  throw new Error(
604
- `${clicked} ${names.length > 1 ? "are" : "is"} clicked in the wide shot, but the controls and what they change fit a push-in at ${Math.min(scale, FRAME_MAX_SCALE).toFixed(1)}: frame them so the shot reads on a phone, or do it behindCard if it's setup`
723
+ `${used} ${names.length > 1 ? "are" : "is"} used in the wide shot, where its text is ${Math.round(textPx)} px, too small to read, and the controls with what they change fit a push-in at ${Math.min(scale, FRAME_MAX_SCALE).toFixed(1)}: frame them (the engine pushes in after a wide beat), or do it behindCard if it's setup`
605
724
  );
606
725
  }
607
726
  }
@@ -609,7 +728,7 @@ var Director = class {
609
728
  // click changed lands outside the shot (an item added to a basket
610
729
  // off-screen), the zoom is on the wrong area: the viewer would see a
611
730
  // button pressed and nothing happen.
612
- async assertChangeInShot(t) {
731
+ async assertChangeInShot(t, verb) {
613
732
  const biggest = await biggestChange(this.app);
614
733
  if (!biggest) {
615
734
  return;
@@ -617,7 +736,7 @@ var Director = class {
617
736
  if (coverage(biggest.rect, visibleRect(this.layout, this.shot)) < SHOWN) {
618
737
  this.unseen = {
619
738
  rect: biggest.rect,
620
- message: `clicking "${t.target}" changes ${biggest.label}, which is outside ${this.shotLabel}, and the next step doesn't show it. Frame what the click changes, or pull out right after.`
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.`
621
740
  };
622
741
  }
623
742
  }
@@ -637,20 +756,33 @@ var Director = class {
637
756
  throw new Error(unseen.message);
638
757
  }
639
758
  async type(t, text, perCharMs = TYPE_CHAR_MS, options = {}) {
640
- await this.click(t);
641
- if (options.replace) {
642
- await this.page.keyboard.press("ControlOrMeta+A");
643
- }
644
- if (this.fast || this.covered) {
645
- await this.page.keyboard.type(text);
646
- await this.wait(POST_KEY_MS);
647
- return;
648
- }
649
- for (const char of text) {
650
- await this.page.keyboard.type(char);
651
- await this.wait(perCharMs);
652
- }
653
- await this.wait(POST_KEY_MS);
759
+ await this.interact(t, {
760
+ verb: "typing in",
761
+ then: async () => {
762
+ if (options.replace) {
763
+ await this.page.keyboard.press("ControlOrMeta+A");
764
+ } else {
765
+ await this.app.evaluate(() => {
766
+ const el = document.activeElement;
767
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
768
+ try {
769
+ el.setSelectionRange(el.value.length, el.value.length);
770
+ } catch {
771
+ }
772
+ }
773
+ });
774
+ }
775
+ if (this.fast || this.covered) {
776
+ await this.page.keyboard.type(text);
777
+ return;
778
+ }
779
+ await this.wait(POST_KEY_MS);
780
+ for (const char of text) {
781
+ await this.page.keyboard.type(char);
782
+ await this.wait(perCharMs);
783
+ }
784
+ }
785
+ });
654
786
  }
655
787
  async press(key) {
656
788
  this.requireSeen("other");
@@ -666,13 +798,22 @@ var Director = class {
666
798
  this.requireSeen("other");
667
799
  this.closeWide();
668
800
  }
669
- async during(work) {
670
- this.requireSeen("other");
801
+ async during(work, options = {}) {
671
802
  const recorder = this.options.recorder;
672
- if (this.fast || !recorder) {
673
- return work();
803
+ const run = () => this.fast || !recorder ? work() : recorder.during(work());
804
+ if (options.target) {
805
+ let result2;
806
+ await this.interact(options.target, {
807
+ verb: "using",
808
+ press: "look",
809
+ then: async () => {
810
+ result2 = await run();
811
+ }
812
+ });
813
+ return result2;
674
814
  }
675
- const result = await recorder.during(work());
815
+ this.requireSeen("other");
816
+ const result = await run();
676
817
  await this.wait(POST_ACTION_MS);
677
818
  return result;
678
819
  }
@@ -737,6 +878,7 @@ var Director = class {
737
878
  if (!options.keepUp) {
738
879
  await this.hideCard();
739
880
  await this.wait(CARD_OUT_MS);
881
+ this.wideSince = this.clockMs();
740
882
  }
741
883
  }
742
884
  async behind(work) {
@@ -897,10 +1039,16 @@ var runStep = async (d, step, adapter) => {
897
1039
  throw new Error(`unknown step "${step.do}"`);
898
1040
  }
899
1041
  const params = Object.fromEntries(
900
- Object.entries(step).filter(([key]) => key !== "do")
1042
+ Object.entries(step).filter(
1043
+ ([key]) => key !== "do" && key !== "behindCard"
1044
+ )
901
1045
  );
902
1046
  const parsed = action.params.parse(params);
903
- await d.during(() => action.run(d, parsed));
1047
+ const target = action.target?.(parsed);
1048
+ await d.during(
1049
+ () => action.run(d, parsed),
1050
+ target === void 0 ? void 0 : { target: toTarget(target) }
1051
+ );
904
1052
  };
905
1053
  var needsLinger = (previous) => previous !== void 0 && previous.do !== "hold" && previous.do !== "card" && !isBehindCard(previous);
906
1054
  var TITLE = -1;
@@ -916,9 +1064,6 @@ var planCards = (steps, hasTitle) => {
916
1064
  while (j < steps.length && isBehindCard(steps[j])) {
917
1065
  behind.push(j++);
918
1066
  }
919
- if (j < steps.length && (steps[j].do === "zoom" || steps[j].do === "frame")) {
920
- behind.push(j++);
921
- }
922
1067
  plans.set(start, {
923
1068
  behind,
924
1069
  opens: j < steps.length && steps[j].do !== "card"
@@ -948,9 +1093,9 @@ var stillCards = (steps, title) => [
948
1093
  var WAITS = /* @__PURE__ */ new Set(["waitFor", "expectText"]);
949
1094
  var runSteps = async (d, steps, adapter, options = {}) => {
950
1095
  const idleLimitMs = adapter.idleTimeoutSeconds ? adapter.idleTimeoutSeconds * 1e3 - IDLE_MARGIN_MS : Number.POSITIVE_INFINITY;
951
- let quietMs = options.initialQuietMs ?? 0;
1096
+ let quietMs2 = options.initialQuietMs ?? 0;
952
1097
  const tooQuiet = () => new Error(
953
- `the app would sit untouched for ${Math.round(quietMs / 1e3)} s; its idle timeout is ${adapter.idleTimeoutSeconds} s. Add an interaction or shorten the holds.`
1098
+ `the app would sit untouched for ${Math.round(quietMs2 / 1e3)} s; its idle timeout is ${adapter.idleTimeoutSeconds} s. Add an interaction or shorten the holds.`
954
1099
  );
955
1100
  const plans = planCards(steps, Boolean(options.title));
956
1101
  const covered = new Set([...plans.values()].flatMap((p) => p.behind));
@@ -1017,8 +1162,8 @@ var runSteps = async (d, steps, adapter, options = {}) => {
1017
1162
  try {
1018
1163
  const linger = step.do === "card" && needsLinger(steps[index - 1]);
1019
1164
  if (step.do === "hold" || step.do === "card" || CAMERA_MOVES.has(step.do) && !covered.has(index)) {
1020
- quietMs += step.do === "hold" ? Number(step.seconds) * 1e3 : step.do === "card" ? cardSeconds(step) * 1e3 + CARD_DISSOLVE_MS + (linger ? LINGER_MS : 0) : CAMERA_MOVE_MS;
1021
- if (quietMs > idleLimitMs) {
1165
+ quietMs2 += step.do === "hold" ? Number(step.seconds) * 1e3 : step.do === "card" ? cardSeconds(step) * 1e3 + CARD_DISSOLVE_MS + (linger ? LINGER_MS : 0) : CAMERA_MOVE_MS;
1166
+ if (quietMs2 > idleLimitMs) {
1022
1167
  throw tooQuiet();
1023
1168
  }
1024
1169
  }
@@ -1032,13 +1177,13 @@ var runSteps = async (d, steps, adapter, options = {}) => {
1032
1177
  await runStep(d, step, adapter);
1033
1178
  }
1034
1179
  if (WAITS.has(step.do)) {
1035
- quietMs += d.clockMs() - started;
1036
- if (quietMs > idleLimitMs) {
1180
+ quietMs2 += d.clockMs() - started;
1181
+ if (quietMs2 > idleLimitMs) {
1037
1182
  throw tooQuiet();
1038
1183
  }
1039
1184
  }
1040
1185
  if (INTERACTIONS.has(step.do) || adapter.actions?.[step.do]) {
1041
- quietMs = 0;
1186
+ quietMs2 = 0;
1042
1187
  }
1043
1188
  } catch (cause) {
1044
1189
  await fail(index, cause);
@@ -1055,7 +1200,7 @@ var runSteps = async (d, steps, adapter, options = {}) => {
1055
1200
  }
1056
1201
  };
1057
1202
  if (options.title) {
1058
- quietMs += titleSeconds(options.title, options.kind) * 1e3 + CARD_OUT_MS;
1203
+ quietMs2 += titleSeconds(options.title, options.kind) * 1e3 + CARD_OUT_MS;
1059
1204
  await runCard(TITLE);
1060
1205
  }
1061
1206
  for (const index of steps.keys()) {
@@ -1099,22 +1244,33 @@ var stepMs = (step) => {
1099
1244
  return POST_ACTION_MS + CURSOR_MS;
1100
1245
  }
1101
1246
  };
1102
- var estimateSeconds = (story) => {
1247
+ var estimateSeconds = (story) => Math.round(estimateMs(story) / 1e3);
1248
+ var estimateMs = (story) => {
1103
1249
  const steps = story.steps;
1104
1250
  const plans = planCards(steps, Boolean(story.title));
1105
1251
  const covered = new Set([...plans.values()].flatMap((p) => p.behind));
1106
1252
  const after = (at) => steps[(plans.get(at)?.behind.at(-1) ?? at) + 1];
1107
1253
  let ms = 0;
1254
+ let wideAt = 0;
1108
1255
  if (story.title) {
1109
1256
  ms += titleSeconds(story.title, story.kind) * 1e3;
1110
1257
  ms += after(TITLE)?.do === "card" ? 0 : CARD_OUT_MS;
1258
+ wideAt = ms;
1111
1259
  }
1112
1260
  steps.forEach((step, i) => {
1113
1261
  if (covered.has(i)) {
1114
1262
  return;
1115
1263
  }
1116
1264
  if (step.do !== "card") {
1265
+ const pushes = step.do === "zoom" || step.do === "frame" || step.do === "click" && step.zoom !== void 0;
1266
+ if (pushes && wideAt !== null) {
1267
+ ms += Math.max(0, ESTABLISH_MS - (ms - wideAt));
1268
+ wideAt = null;
1269
+ }
1117
1270
  ms += stepMs(step);
1271
+ if (step.do === "zoomOut") {
1272
+ wideAt = ms;
1273
+ }
1118
1274
  return;
1119
1275
  }
1120
1276
  const previous = steps[i - 1];
@@ -1122,13 +1278,45 @@ var estimateSeconds = (story) => {
1122
1278
  ms += (linger ? LINGER_MS : 0) + CARD_IN_MS;
1123
1279
  ms += cardSeconds(step) * 1e3;
1124
1280
  ms += after(i)?.do === "card" || !after(i) && story.closing ? 0 : CARD_OUT_MS;
1281
+ wideAt = ms;
1125
1282
  });
1126
1283
  if (story.closing) {
1127
1284
  const last = steps.at(-1)?.do;
1128
1285
  ms += last !== "card" && last !== "hold" ? LINGER_MS : 0;
1129
1286
  ms += CARD_IN_MS + titleSeconds(story.closing, story.kind) * 1e3;
1130
1287
  }
1131
- return Math.round(ms / 1e3);
1288
+ return ms;
1289
+ };
1290
+ var COVERED_STEP_MS = {
1291
+ click: 300,
1292
+ type: 350,
1293
+ press: 100,
1294
+ waitFor: 200
1295
+ };
1296
+ var COVERED_ACTION_MS = 500;
1297
+ var slowCards = (story) => {
1298
+ const steps = story.steps;
1299
+ const plans = planCards(steps, Boolean(story.title));
1300
+ const slow = [];
1301
+ for (const [at, plan] of plans) {
1302
+ const card = at === TITLE ? story.title : steps[at];
1303
+ if (!card || plan.behind.length === 0) {
1304
+ continue;
1305
+ }
1306
+ const setupMs = plan.behind.reduce(
1307
+ (sum, i) => sum + (COVERED_STEP_MS[steps[i].do] ?? COVERED_ACTION_MS),
1308
+ 0
1309
+ );
1310
+ const readSeconds = at === TITLE ? titleSeconds(card, story.kind) : cardSeconds(card);
1311
+ if (setupMs / 1e3 > readSeconds + 0.3) {
1312
+ slow.push({
1313
+ where: at === TITLE ? "title" : `steps.${at}`,
1314
+ readSeconds,
1315
+ setupSeconds: Math.round(setupMs / 100) / 10
1316
+ });
1317
+ }
1318
+ }
1319
+ return slow;
1132
1320
  };
1133
1321
 
1134
1322
  // src/frames.ts
@@ -1497,7 +1685,8 @@ var registerDemoTests = (test, adapter, demoDir) => {
1497
1685
  ok: true,
1498
1686
  steps: story.steps.length,
1499
1687
  kind: story.kind ?? "showcase",
1500
- seconds: estimateSeconds(story)
1688
+ seconds: estimateSeconds(story),
1689
+ slowCards: slowCards(story)
1501
1690
  });
1502
1691
  } catch (error) {
1503
1692
  writeJson(reportPath, failureReport(error));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navipartner/demo-video",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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": {
@@ -71,7 +71,7 @@ step of the spec, decide:
71
71
 
72
72
  | The step is… | In the video |
73
73
  |---|---|
74
- | The idea itself (scan, pay, the preview changing) | On screen, pushed in |
74
+ | The idea itself (scan, pay, the preview changing) | On screen: wide when it reads, pushed in only when it's too small |
75
75
  | Needed, but not the idea (a language dialog, a cookie banner, a tab, a login) | `"behindCard": true` right after the chapter's card: done while the card covers the app |
76
76
  | Filling a form | One or two short values, in fields whose effect shows on screen (the preview, the total). The rest stays as the mocks have it |
77
77
  | Proof (a total, a confirmation) | `expectText`, framed and held |
@@ -88,8 +88,10 @@ Before any JSON, write the plan. For each chapter, in this order:
88
88
  2. **Behind the card:** the setup it needs, if any.
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
- 4. **Shots:** the chapter opens on its first push-in: one shot on the area
92
- that holds a control *and* what it changes. It ends framing the result.
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.
93
95
  5. **The card:** statement + benefit, written last (below), and the
94
96
  snapshot (`show`) of the result it ends on, if that result is visual.
95
97
  6. **Proof:** the value the app shows, checked with `expectText`.
@@ -156,7 +158,10 @@ end NaviPartner logo · "Now in NP Restaurant."
156
158
  - `brandNeeds`: every `config` key and asset slot `setup` uses, each with
157
159
  what it is and where it shows. Be specific: a pitch's research fills
158
160
  exactly these.
159
- - `actions` for what a test does through code (a scanner bridge), and
161
+ - `actions` for what a test does through code (a scanner bridge, a
162
+ colour picker, a file upload). An action the viewer sees names its
163
+ control (`target`), so the cursor goes there and the camera rules
164
+ apply; don't add holds to make up for a missing one. Also
160
165
  `idleTimeoutSeconds` if the app resets when idle.
161
166
  3. Write the primary story, then `check` it.
162
167
 
@@ -187,10 +192,13 @@ A chapter in steps:
187
192
  { "do": "frame", "targets": ["receipt", "receipt-total"] },
188
193
  { "do": "hold", "seconds": 2 }
189
194
 
190
- - **Shots:** prefer `frame` (2–4 targets, pushed in as far as they fit) to
191
- guessing one `zoom` target. The chapter's first shot is set behind its
192
- card. When a click changes something outside the shot, `zoomOut` right
193
- after it. End the chapter framing its result.
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.
194
202
  - **Typing:** `"replace": true` clears what the field holds first. Type
195
203
  short, believable values that change something on screen.
196
204
  - **Leave the beats to the engine.** It pauses around every click, settles
@@ -244,7 +252,7 @@ the base story.
244
252
 
245
253
  Videos are silent: they must work on mute.
246
254
 
247
- ## 6. Check, then render
255
+ ## 6. Check, then film a draft
248
256
 
249
257
  pnpm --dir <app> exec demo-video check [--story <name>] --json
250
258
 
@@ -254,15 +262,15 @@ how long the video will run against its kind's range. Its error names
254
262
  the fix; reference.md lists them all. Its `visible` list shows what is on
255
263
  screen at the failing step. Repeat until it passes, at most 3 rounds; a
256
264
  story that still fails is reported as "couldn't show this", with the last
257
- report. Then:
265
+ report. Then film a draft:
258
266
 
259
- pnpm --dir <app> exec demo-video render [--story <name>]
267
+ pnpm --dir <app> exec demo-video render --draft [--story <name>]
260
268
 
261
- Rendering films every frame (60 fps), about 15× the video's length, and
262
- runs a check first when cards show snapshots, to capture them. While tuning,
263
- `render --draft` films at 30 fps in about half the time. Playwright starts
264
- the app's dev server from the adapter's `webServer`; if it can't on this
265
- machine, start it yourself with the same command and env, and it is reused.
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
271
+ snapshots, to capture them. Playwright starts the app's dev server from the
272
+ adapter's `webServer`; if it can't on this machine, start it yourself with
273
+ the same command and env, and it is reused.
266
274
 
267
275
  ## 7. Review like a viewer
268
276
 
@@ -277,11 +285,31 @@ stills beside it. Judge against look.md:
277
285
  screen; chapters end on their result.
278
286
  - **Truth:** every word is true of what's on screen.
279
287
 
280
- Adjust the JSON and re-render. Two rounds of tuning is normal; more means
281
- the plan from step 3 is the problem.
288
+ Adjust the JSON and film another draft. Two rounds of tuning is normal;
289
+ more means the plan from step 3 is the problem.
290
+
291
+ ## 8. The final, only on the developer's go-ahead
292
+
293
+ Give the developer the draft (its MP4 and contact sheet) and **stop**. Film
294
+ the final 60 fps video only when the developer has watched the latest draft
295
+ and says, specifically, that they are satisfied with the whole flow and want
296
+ the final: "the flow is right, render the final", "go ahead at 60".
297
+
298
+ These are not a go-ahead, so ask instead ("Happy with the whole flow? Say
299
+ so and I'll render the final at 60 fps."):
300
+ - "looks good" or praise of one part, without asking for the final;
301
+ - approval of an earlier draft;
302
+ - a request for changes, however small;
303
+ - no answer.
304
+
305
+ A go-ahead covers the draft it was given for. After any change to the
306
+ story, film a new draft and wait for a new go-ahead. Then:
307
+
308
+ pnpm --dir <app> exec demo-video render [--story <name>]
282
309
 
283
310
  ## Report
284
311
 
285
- Give the MP4 path and duration, the chapter plan, the contact sheet, and
286
- anything the video could not show truthfully (it shows demo fixture data,
287
- not a real tenant's).
312
+ Give the MP4 path and duration (the draft's, or the final's), the chapter
313
+ plan, the contact sheet, and anything the video could not show truthfully
314
+ (it shows demo fixture data, not a real tenant's). After a draft, say that
315
+ the final waits for their go-ahead.
@@ -13,10 +13,11 @@ importance:
13
13
  2. **No dead time.** Setup happens behind cards, only values that show
14
14
  something are typed, and every beat is there so the viewer sees
15
15
  something. Never rushed: results and cards stay up long enough to read.
16
- 3. **Purposeful camera.** It moves between shots, never during the action:
17
- the chapter opens on the shot that holds the action and its result,
18
- holds steady through the clicks, and ends on the result. It never chases
19
- the cursor, and there are no hard cuts inside a chapter.
16
+ 3. **Purposeful camera.** Every page opens wide, on the whole window. The
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
19
+ shots, never during the action, never chases the cursor, and there are no
20
+ hard cuts inside a chapter.
20
21
  4. **Soft, light, branded.** A pale brand-tinted background, a floating app
21
22
  window, big serif statements, and the brand colour used boldly.
22
23
  5. **Believable data.** The most real-looking records the e2e mocks have.
@@ -104,9 +105,11 @@ text only, centred.
104
105
  the receipt that appears), checked by an `expectText`. No invented
105
106
  durations or customer statistics.
106
107
  - **Never on a card:** testids, barcodes, internal names or any other code.
107
- - **Reading time:** a card stays up 0.6 s plus ~0.28 s a word (an eyebrow's
108
- words count half), at least 1.8 s. The open and end cards stay at most
109
- 3.5 s in a showcase and 2.5 s in a clip, unless the story asks for more.
108
+ - **Reading time:** a card stays up 0.5 s plus ~0.25 s a word of its
109
+ statement and ~0.2 s a word of its sub-line (an eyebrow's words 0.12 s),
110
+ at least 1.6 s. The open and end cards stay at most 3.5 s in a showcase
111
+ and 2.5 s in a clip, unless the story asks for more. Setup behind a card
112
+ that takes longer holds it up: `check` says so.
110
113
 
111
114
  ## Transitions
112
115
 
@@ -120,17 +123,19 @@ text only, centred.
120
123
 
121
124
  ## Camera
122
125
 
123
- - **Wide shot:** the app floats in the middle of the frame at ~90 % width.
124
- Small corner radius, soft long shadow, no browser chrome. Wide is for
125
- moments that need the whole window (a new screen arriving). A desktop app
126
- shown wide is too small to read on a phone, so clicks whose controls and
127
- results would fit a push-in get one (`check` asks for it).
128
- - **A chapter opens on its first shot.** When a chapter starts with a
129
- push-in, the camera is already there when its card dissolves: no push-in
130
- after every card.
131
- - **Push in once per shot** on the area that holds the control *and* what it
132
- changes, to scale **1.4–1.8** (never past 2: tighter loses the context and
133
- softens the picture). The UI should fill the frame and read on a phone.
126
+ - **Wide is the default.** The app floats in the middle of the frame at
127
+ ~90 % width, with a small corner radius, a soft long shadow and no browser
128
+ chrome. A wide shot whose text reads (16 px or more on the 1080p frame)
129
+ needs no push-in.
130
+ - **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
+ whole window is seen first (the engine holds it ~0.8 s), then the camera
133
+ 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.
134
139
  - **Hold steady through the action.** The clicks happen inside the shot; the
135
140
  camera doesn't move with them. At most two push-ins a chapter.
136
141
  - **Show the result.** When an action changes something outside the shot (a
@@ -150,7 +155,11 @@ text only, centred.
150
155
  and doesn't pulse, ripple or jerk.
151
156
  - **A beat around every click:** it rests on the control (160 ms, its hover
152
157
  state shows), presses (110 ms, the pressed state shows), and the app's
153
- reaction plays (450 ms) before anything else happens.
158
+ reaction plays: 450 ms, then until the app has settled (a toast after a
159
+ save, a preview redrawn), up to a second more.
160
+ - **Every control the viewer sees used gets the cursor**, a colour picker or
161
+ an upload as much as a button: an adapter action names its control, and
162
+ the cursor goes there and rests before it acts.
154
163
  - Typing runs at ~45 ms a character.
155
164
  - Between actions it rests where it doesn't cover what the viewer should
156
165
  read.
@@ -238,8 +247,9 @@ the product as the hero), not their technique.
238
247
  ([Nemo](https://www.nemovideo.com/blog/kinetic-typography-product-ads),
239
248
  [Arcade, 14 million demo sessions](https://www.arcade.software/post/2026-arcade-benchmarks))
240
249
  - **Reading time.** Netflix allows at most 20 characters a second, text up
241
- 0.83–7 s; the BBC works to about 0.3 s a word. Our 0.6 s + 0.28 s a word
242
- sits between them.
250
+ 0.83–7 s; the BBC works to about 0.3 s a word. Our cards are read faster
251
+ than subtitles (0.25 s a statement word, 0.2 s a sub-line word): the viewer
252
+ isn't also following speech.
243
253
  ([Netflix](https://backlothelp.netflix.com/hc/en-us/articles/219375728))
244
254
  - **Camera.** Screen Studio's "Focused" motion settles quickly so UI can be
245
255
  read; that is the model: quick moves, then stillness.
@@ -49,7 +49,7 @@ export default defineDemo({
49
49
  | Step | Fields |
50
50
  |---|---|
51
51
  | `click` | `target`, `nth`, `zoom` (push in on it first, 1.3–2), `behindCard` |
52
- | `type` | `target`, `nth`, `text`, `perCharMs` (≥ 40, default 45), `replace` (clear the field first; without it the text is added), `behindCard` |
52
+ | `type` | `target`, `nth`, `text`, `perCharMs` (≥ 40, default 45), `replace` (clear the field first; without it the text goes at its end), `behindCard` |
53
53
  | `press` | `key`, `behindCard` |
54
54
  | `zoom` | `target`, `nth`, `scale` (1.3–2) |
55
55
  | `frame` | `targets` (2–4 testids or `{ target, nth }`), `maxScale` (1.3–2, default 1.8) |
@@ -77,16 +77,24 @@ checked by an `expectText` in the story.
77
77
 
78
78
  **Behind a card:** `behindCard` steps right after a card (or at the very
79
79
  start, behind the title card) run while the card covers the app, with no
80
- cursor and no camera. The chapter's first `zoom` or `frame` is set behind the
81
- card too, so the chapter opens on it. A card stays up until what's behind it
82
- is done.
83
-
84
- **Camera rules** (`check` enforces them): at most two push-ins a chapter,
85
- and a push-in needs something done or read in its shot. A click target must
86
- be inside the current shot. What a zoomed click changes must be in the shot
87
- or shown by the next step (a `zoomOut`, or a zoom that frames it). Clicks in
88
- the wide shot whose controls and results would fit a push-in of 1.4 or more
89
- need one.
80
+ cursor and no camera. `click`, `type`, `press`, `waitFor` and adapter
81
+ actions take it; never a push-in, since every chapter opens wide. A card stays up until what's behind
82
+ it is done: `check` names a card whose setup outlasts its reading time.
83
+
84
+ **Camera rules** (`check` enforces them): every page opens wide, and a
85
+ push-in from the wide shot comes after the whole window has been seen. At
86
+ most two push-ins a chapter, and a push-in needs something done or read in
87
+ its shot. A control a step uses must be inside the current shot. What it
88
+ changes (watched from the press to the app settling: typing's preview, a
89
+ toast, a restyle) must be in the shot or shown by the next step (a
90
+ `zoomOut`, or a zoom that frames it). A wide shot whose text is under 16 px
91
+ on the frame, where the controls and what they change would fit a push-in
92
+ of 1.4 or more, needs one.
93
+
94
+ **Adapter actions** may name the control they use (`target(params)`); the
95
+ cursor then goes there and rests, and the camera rules apply as for a
96
+ click. Without one, an action has no cursor and only a short beat after
97
+ it.
90
98
 
91
99
  **Timing:** the engine adds the beats around clicks, the camera's settle and
92
100
  a linger before each card (look.md has the numbers). Holds may not add up
@@ -97,11 +105,11 @@ length against its kind's range (`seconds` and `kind` in `--json`).
97
105
 
98
106
  | It says | Fix |
99
107
  |---|---|
100
- | `"x" is outside the shot …` | The shot frames the wrong area: frame what this shot clicks, or `zoomOut` first |
101
- | `clicking "x" changes y, which is outside the shot …` | Frame what the click changes, or `zoomOut` right after the click |
102
- | `"x" is clicked in the wide shot, but … fit a push-in` | `frame` the controls and what they change; or, if it's setup, `behindCard` |
108
+ | `"x" is outside the shot …` | `zoomOut` first, or frame an area that holds everything the shot uses |
109
+ | `clicking / typing in / using "x" changes y, which is outside the shot …` | `zoomOut` right after, or frame the control with what it changes |
110
+ | `"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` |
103
111
  | `N camera push-ins in one chapter` | Push in once on the chapter's area and hold steady |
104
- | `… only fit in the wide shot` | `frame` things that sit together, or stay wide |
112
+ | `… only fit in the wide shot` | Stay wide, or frame things that sit together |
105
113
  | `a zoom with nothing in its shot` | Remove it, or act or read something in it |
106
114
  | `zoom 2.4; use 1.3–2` | Zoom less: context reads better and stays sharp |
107
115
  | `behindCard only right after a card` | Move the step to just after a card, or drop `behindCard` |