@navipartner/demo-video 0.6.0 → 0.7.1

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.
@@ -38,6 +38,13 @@ interface VideoBrand {
38
38
  fontsUrl: string;
39
39
  }
40
40
 
41
+ /**
42
+ * What a story is (look.md): a showcase walks the app's main journey for a
43
+ * buyer (45–90 s); a feature clip shows one feature (15–30 s). The beats are
44
+ * the same; the shape differs.
45
+ */
46
+ type Kind = "showcase" | "feature";
47
+
41
48
  /** A testid (letters, digits, dash, underscore) or a Playwright selector. */
42
49
  interface Target {
43
50
  target: string;
@@ -53,6 +60,21 @@ interface CardSpec {
53
60
  logo?: boolean;
54
61
  style: "pale" | "brand";
55
62
  seconds: number;
63
+ /** A still of what the chapter achieves, beside the words (a URL). */
64
+ still?: string;
65
+ }
66
+ interface CardOptions {
67
+ /** The next card replaces this one directly, with no app in between. */
68
+ keepUp?: boolean;
69
+ /** The video's open card: already up on the first frame, no dissolve in. */
70
+ opening?: boolean;
71
+ /** The story's kind: how long the open card may stay up. */
72
+ kind?: Kind;
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.
76
+ */
77
+ behind?: () => Promise<void>;
56
78
  }
57
79
  interface DirectorApi {
58
80
  /** The app's iframe, for actions that talk to the app directly. */
@@ -75,7 +97,10 @@ interface DirectorApi {
75
97
  click(target: Target, options?: {
76
98
  zoom?: number;
77
99
  }): Promise<void>;
78
- type(target: Target, text: string, perCharMs?: number): Promise<void>;
100
+ /** `replace`: select what the field holds first, so the text replaces it. */
101
+ type(target: Target, text: string, perCharMs?: number, options?: {
102
+ replace?: boolean;
103
+ }): Promise<void>;
79
104
  press(key: string): Promise<void>;
80
105
  park(): Promise<void>;
81
106
  waitFor(target: Target, options: {
@@ -98,12 +123,16 @@ interface DirectorApi {
98
123
  */
99
124
  clockMs(): number;
100
125
  /**
101
- * Dissolves the card in over the app and holds it. It dissolves back out
102
- * unless `keepUp`: then the next card replaces it directly.
126
+ * Dissolves the card in over the app and holds it, doing `behind` while
127
+ * it covers the app. It dissolves back out unless `keepUp`: then the next
128
+ * card replaces it directly.
103
129
  */
104
- card(spec: CardSpec, options?: {
105
- keepUp?: boolean;
106
- }): Promise<void>;
130
+ card(spec: CardSpec, options?: CardOptions): Promise<void>;
131
+ /**
132
+ * Saves a still of the element as it looks now, for a card that shows it.
133
+ * False when it isn't on screen.
134
+ */
135
+ capture(target: Target, file: string): Promise<boolean>;
107
136
  /** Testids currently visible in the app, for failure reports. */
108
137
  visibleTestIds(): Promise<string[]>;
109
138
  }
@@ -166,4 +195,4 @@ interface DemoAdapter {
166
195
  declare const defineDemo: (adapter: DemoAdapter) => DemoAdapter;
167
196
  declare const defineAction: <S extends z.ZodRawShape>(action: DemoAction<S>) => DemoAction<S>;
168
197
 
169
- export { type Brand as B, type CardSpec as C, type DemoAdapter as D, type Target as T, type VideoBrand as V, type DemoAction as a, type DirectorApi as b, defineDemo as c, defineAction as d };
198
+ export { type Brand as B, type CardSpec as C, type DemoAdapter as D, type Kind as K, type Target as T, type VideoBrand as V, type DemoAction as a, type DirectorApi as b, defineDemo as c, defineAction as d };
@@ -4,29 +4,39 @@ import path from "path";
4
4
  import { z } from "zod";
5
5
 
6
6
  // src/timing.ts
7
- var ZOOM_MS = 1200;
7
+ var ZOOM_MS = 900;
8
8
  var CAMERA_EASE = "cubic-bezier(0.65, 0, 0.35, 1)";
9
- var CARD_IN_MS = 800;
10
- var CARD_OUT_MS = 900;
11
- var CURSOR_MIN_MS = 550;
12
- var CURSOR_MAX_MS = 1100;
13
- var CURSOR_MS_PER_PX = 0.9;
14
- var SETTLE_MS = 300;
15
- var PRE_CLICK_MS = 220;
9
+ var CARD_IN_MS = 500;
10
+ var CARD_OUT_MS = 600;
11
+ var CURSOR_MIN_MS = 400;
12
+ var CURSOR_MAX_MS = 800;
13
+ var CURSOR_MS_PER_PX = 0.7;
14
+ var SETTLE_MS = 150;
15
+ var PRE_CLICK_MS = 160;
16
16
  var PRESS_MS = 110;
17
- var POST_CLICK_MS = 650;
18
- var POST_KEY_MS = 300;
19
- var POST_ACTION_MS = 650;
20
- var LINGER_MS = 1400;
17
+ var POST_CLICK_MS = 450;
18
+ var POST_KEY_MS = 250;
19
+ var POST_ACTION_MS = 500;
20
+ var TYPE_CHAR_MS = 45;
21
+ var LINGER_MS = 900;
22
+ var TITLE_MAX_SECONDS = {
23
+ showcase: 3.5,
24
+ feature: 2.5
25
+ };
21
26
  var POLL_MS = 100;
22
27
  var wordCount = (text) => text ? text.replace(/\*/g, "").split(/\s+/).filter(Boolean).length : 0;
23
- var cardSeconds = (spec) => {
24
- const read = 1 + 0.32 * (wordCount(spec.eyebrow) + wordCount(spec.heading) + wordCount(spec.sub));
25
- return Math.max(spec.seconds, Math.round(read * 10) / 10);
28
+ 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);
30
+ return Math.max(
31
+ spec.seconds,
32
+ Math.min(maxSeconds, Math.round(read * 10) / 10)
33
+ );
26
34
  };
35
+ var titleSeconds = (spec, kind = "showcase") => cardSeconds(spec, TITLE_MAX_SECONDS[kind]);
27
36
 
28
37
  // src/stage.ts
29
38
  var STAGE_PATH = "/__demo/stage";
39
+ var STILLS_PATH = "/__demo/stills";
30
40
  var STAGE_WIDTH = 1920;
31
41
  var STAGE_HEIGHT = 1080;
32
42
  var FRAME_WIDTH = 1728;
@@ -65,9 +75,15 @@ var stageScript = (layout, look) => String.raw`
65
75
  // Nothing here waits: time is virtual (frames.ts), so each call starts a
66
76
  // move and returns at once, and the director films its duration.
67
77
 
68
- // A shot from camera.ts: the app's translation and total scale.
69
- const setShot = (shot) => {
78
+ // A shot from camera.ts: the app's translation and total scale. Instant
79
+ // behind a card, so the chapter opens on it.
80
+ const setShot = (shot, instant) => {
81
+ if (instant) { camera.classList.add("instant"); }
70
82
  camera.style.transform = "translate(" + shot.tx + "px, " + shot.ty + "px) scale(" + shot.k + ")";
83
+ if (instant) {
84
+ void camera.offsetWidth;
85
+ camera.classList.remove("instant");
86
+ }
71
87
  };
72
88
 
73
89
  // Behind an opaque card: back to wide with no move, so every chapter opens
@@ -122,14 +138,30 @@ var stageScript = (layout, look) => String.raw`
122
138
  // (payoff into end card) without the app showing in between.
123
139
  let current = null;
124
140
 
141
+ // Card stills, loaded and decoded before filming starts, so a card never
142
+ // shows one half-drawn.
143
+ const stills = new Map();
144
+ const preload = (urls) =>
145
+ Promise.all(urls.map((url) => {
146
+ const img = new Image();
147
+ img.alt = "";
148
+ img.src = url;
149
+ stills.set(url, img);
150
+ return img.decode();
151
+ })).then(() => undefined);
152
+
125
153
  const buildCard = (spec) => {
126
154
  const el = document.createElement("div");
127
- el.className = "card " + (spec.style === "brand" ? "brand" : "pale");
155
+ el.className = "card " + (spec.style === "brand" ? "brand" : "pale") + (spec.still ? " split" : "");
156
+ // The words, in one column; beside them, a still of the result.
157
+ const text = document.createElement("div");
158
+ text.className = "text";
159
+ el.appendChild(text);
128
160
  const add = (tag, cls, html) => {
129
161
  const child = document.createElement(tag);
130
- child.className = cls;
162
+ child.className = "part " + cls;
131
163
  child.innerHTML = html;
132
- el.appendChild(child);
164
+ text.appendChild(child);
133
165
  return child;
134
166
  };
135
167
  if (spec.eyebrow) { add("p", "eyebrow", escapeHtml(spec.eyebrow)); }
@@ -142,6 +174,15 @@ var stageScript = (layout, look) => String.raw`
142
174
  }
143
175
  if (spec.heading) { add("h1", "heading", emphasise(spec.heading)); }
144
176
  if (spec.sub) { add("p", "sub", escapeHtml(spec.sub)); }
177
+ if (spec.still) {
178
+ const img = stills.get(spec.still) || new Image();
179
+ img.src = spec.still;
180
+ img.className = "part still";
181
+ // A tall still (a pass, a kiosk screen) and a wide one (a dialog, a
182
+ // receipt row) are bounded differently.
183
+ img.classList.add(img.naturalHeight > img.naturalWidth ? "tall" : "broad");
184
+ el.appendChild(img);
185
+ }
145
186
  return el;
146
187
  };
147
188
 
@@ -181,7 +222,7 @@ var stageScript = (layout, look) => String.raw`
181
222
  setTimeout(() => el.remove(), CARD_OUT_MS);
182
223
  };
183
224
 
184
- window.__demo = { setShot, resetCamera, cursorTo, parkNow, showCard, hideCard };
225
+ window.__demo = { setShot, resetCamera, cursorTo, parkNow, showCard, hideCard, preload };
185
226
  cursor.style.transform = "translate(" + cursorX + "px, " + cursorY + "px)";
186
227
  })();
187
228
  `;
@@ -238,34 +279,41 @@ var stageHtml = (background, appUrl, look, options = {}) => {
238
279
  through them; the dissolve is the card fading while the app blurs. */
239
280
  .card {
240
281
  position: absolute; inset: 0; z-index: 40; isolation: isolate;
241
- display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 22px;
242
- padding: 0 240px; text-align: center;
282
+ display: flex; align-items: center; justify-content: center; gap: 104px;
283
+ padding: 0 200px; text-align: center;
243
284
  opacity: 0; transition: opacity ${CARD_OUT_MS}ms ease;
244
285
  pointer-events: none;
245
286
  }
287
+ /* Words stay inside the central 1080\xD71080, so a square crop keeps them. */
288
+ .card .text { display: flex; flex-direction: column; align-items: center; gap: 28px; max-width: 1080px; }
289
+ /* Text and UI on one card: the words on the left, the result beside them. */
290
+ .card.split { padding: 0 150px; text-align: left; }
291
+ .card.split .text { align-items: flex-start; flex: 0 1 820px; }
246
292
  .card.show { opacity: 1; transition: opacity ${CARD_IN_MS}ms ease; }
247
293
  .card.pale { background: ${background}; }
248
294
  .card.brand {
249
295
  background: radial-gradient(1200px 800px at 20% 10%, rgba(255,255,255,0.10), transparent 60%), ${look.primary};
250
296
  }
251
297
  .card.instant, .card.instant * { transition: none !important; }
252
- .card > * {
298
+ .card .part {
253
299
  margin: 0; opacity: 0; filter: blur(12px);
254
- transition: opacity 400ms ease, filter 400ms ease;
300
+ transition: opacity 300ms ease, filter 300ms ease;
255
301
  }
256
- .card.show > * { opacity: 1; filter: blur(0); transition: opacity 600ms ease, filter 600ms ease; }
257
- .card.show > .eyebrow, .card.show > .logo { transition-delay: 150ms; }
258
- .card.show > .heading { transition-delay: 260ms; }
259
- .card.show > .sub { transition-delay: 700ms; }
260
- /* Beats every .card.show rule above (same specificity, declared later). */
261
- .card.show.leaving > *, .card.show.leaving > .heading, .card.show.leaving > .sub,
262
- .card.show.leaving > .eyebrow, .card.show.leaving > .logo {
302
+ .card.show .part { opacity: 1; filter: blur(0); transition: opacity 500ms ease, filter 500ms ease; }
303
+ .card.show .eyebrow, .card.show .logo { transition-delay: 80ms; }
304
+ .card.show .heading { transition-delay: 160ms; }
305
+ .card.show .still { transition-delay: 260ms; }
306
+ .card.show .sub { transition-delay: 420ms; }
307
+ /* Beats every .card.show rule above (more specific). */
308
+ .card.show.leaving .part, .card.show.leaving .heading, .card.show.leaving .sub,
309
+ .card.show.leaving .eyebrow, .card.show.leaving .logo, .card.show.leaving .still {
263
310
  opacity: 0; filter: blur(12px);
264
- transition: opacity 280ms ease 0ms, filter 280ms ease 0ms;
311
+ transition: opacity 240ms ease 0ms, filter 240ms ease 0ms;
265
312
  }
266
- .card.show.after > .eyebrow, .card.show.after > .logo { transition-delay: 420ms; }
267
- .card.show.after > .heading { transition-delay: 520ms; }
268
- .card.show.after > .sub { transition-delay: 900ms; }
313
+ .card.show.after .eyebrow, .card.show.after .logo { transition-delay: 300ms; }
314
+ .card.show.after .heading { transition-delay: 380ms; }
315
+ .card.show.after .still { transition-delay: 480ms; }
316
+ .card.show.after .sub { transition-delay: 620ms; }
269
317
  /* Faint grain over the background and cards: it dithers their soft
270
318
  gradients, which in 8-bit video would band and block. */
271
319
  body::before, .card::before {
@@ -275,18 +323,29 @@ var stageHtml = (background, appUrl, look, options = {}) => {
275
323
  opacity: 0.045;
276
324
  }
277
325
  .card::before { z-index: -1; }
278
- .card .eyebrow { font-size: 24px; font-weight: 600; letter-spacing: 0.02em; color: #707070; }
326
+ .card .eyebrow {
327
+ font-size: 28px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase;
328
+ color: ${look.primary};
329
+ }
279
330
  .card .logo.wide { width: 720px; height: auto; }
280
331
  .card .logo.square { height: 240px; width: auto; }
281
332
  .card .heading {
282
333
  font-family: ${font(look.headingFont)}, Georgia, serif;
283
- font-size: 76px; font-weight: 400; line-height: 1.15; letter-spacing: -0.01em;
334
+ font-size: 116px; font-weight: 400; line-height: 1.08; letter-spacing: -0.015em;
284
335
  color: ${look.ink};
285
336
  }
286
337
  .card .heading em { font-style: italic; color: ${look.primary}; }
287
- .card .sub { font-size: 28px; font-weight: 400; color: ${look.text}; opacity: 0.8; }
338
+ .card .sub { font-size: 40px; font-weight: 400; line-height: 1.3; color: ${look.text}; opacity: 0.85; }
339
+ .card.split .heading { font-size: 92px; }
340
+ .card.split .sub { font-size: 36px; }
341
+ .card .still {
342
+ flex: 0 0 auto; border-radius: 18px; background: #fff;
343
+ box-shadow: 0 28px 90px rgba(15, 23, 42, 0.18), 0 2px 8px rgba(15, 23, 42, 0.08);
344
+ }
345
+ .card .still.tall { max-height: 760px; max-width: 640px; }
346
+ .card .still.broad { max-width: 760px; max-height: 620px; }
288
347
  .card.brand .heading, .card.brand .heading em { color: #fff; }
289
- .card.brand .sub, .card.brand .eyebrow { color: rgba(255,255,255,0.82); }
348
+ .card.brand .sub, .card.brand .eyebrow { color: rgba(255,255,255,0.86); }
290
349
  .card.brand .logo { filter: brightness(0) invert(1); }
291
350
  </style>
292
351
  </head>
@@ -385,6 +444,7 @@ var target = {
385
444
  };
386
445
  var scale = z.number().min(1).max(3);
387
446
  var seconds = z.number().min(0.5).max(60);
447
+ var behindCard = { behindCard: z.boolean().optional() };
388
448
  var cardFields = {
389
449
  eyebrow: z.string().min(1).max(40).optional(),
390
450
  heading: z.string().min(1).max(80).optional(),
@@ -393,7 +453,13 @@ var cardFields = {
393
453
  logo: z.boolean().optional(),
394
454
  /** "pale" = brand-tinted white; "brand" = full-bleed primary colour. */
395
455
  style: z.enum(["pale", "brand"]).default("pale"),
396
- seconds: z.number().min(0.5).max(10).default(2.5)
456
+ seconds: z.number().min(0.5).max(10).default(1.8)
457
+ };
458
+ var show = z.union([z.string().min(1), z.object({ ...target }).strict()]).optional();
459
+ var stepCardFields = {
460
+ ...cardFields,
461
+ style: z.enum(["pale", "brand"]).optional(),
462
+ show
397
463
  };
398
464
  var hasContent = (c) => Boolean(c.heading || c.logo);
399
465
  var CARD_EMPTY = "a card needs a heading or the logo";
@@ -401,15 +467,19 @@ var coreStepSchemas = [
401
467
  z.object({
402
468
  do: z.literal("click"),
403
469
  ...target,
404
- zoom: scale.optional()
470
+ zoom: scale.optional(),
471
+ ...behindCard
405
472
  }).strict(),
406
473
  z.object({
407
474
  do: z.literal("type"),
408
475
  ...target,
409
476
  text: z.string(),
410
- perCharMs: z.number().int().min(0).max(500).optional()
477
+ perCharMs: z.number().int().min(0).max(500).optional(),
478
+ // Select what the field holds first, so the text replaces it.
479
+ replace: z.boolean().optional(),
480
+ ...behindCard
411
481
  }).strict(),
412
- z.object({ do: z.literal("press"), key: z.string().min(1) }).strict(),
482
+ z.object({ do: z.literal("press"), key: z.string().min(1), ...behindCard }).strict(),
413
483
  z.object({
414
484
  do: z.literal("zoom"),
415
485
  ...target,
@@ -427,7 +497,8 @@ var coreStepSchemas = [
427
497
  do: z.literal("waitFor"),
428
498
  ...target,
429
499
  hidden: z.boolean().optional(),
430
- timeout: seconds.default(15)
500
+ timeout: seconds.default(15),
501
+ ...behindCard
431
502
  }).strict(),
432
503
  z.object({
433
504
  do: z.literal("expectText"),
@@ -436,7 +507,7 @@ var coreStepSchemas = [
436
507
  contains: z.boolean().optional(),
437
508
  timeout: seconds.default(10)
438
509
  }).strict(),
439
- z.object({ do: z.literal("card"), ...cardFields }).strict(),
510
+ z.object({ do: z.literal("card"), ...stepCardFields }).strict(),
440
511
  z.object({
441
512
  do: z.literal("hold"),
442
513
  seconds: z.number().min(0).max(10)
@@ -447,6 +518,7 @@ var CORE_STEP_NAMES = coreStepSchemas.map(
447
518
  );
448
519
  var RESERVED_FIELDS = ["do"];
449
520
  var cardSchema = z.object(cardFields).strict().refine(hasContent, CARD_EMPTY);
521
+ var titleSchema = z.object({ ...cardFields, show }).strict().refine(hasContent, CARD_EMPTY);
450
522
  var isCoreStep = (step) => CORE_STEP_NAMES.includes(step.do);
451
523
  var buildStoryboardSchema = (adapter) => {
452
524
  const actionSchemas = Object.entries(adapter.actions ?? {}).map(
@@ -471,8 +543,9 @@ var buildStoryboardSchema = (adapter) => {
471
543
  return z.object({
472
544
  app: z.literal(adapter.app),
473
545
  description: z.string().min(1).max(280).optional(),
546
+ kind: z.enum(["showcase", "feature"]).optional(),
474
547
  primary: z.boolean().optional(),
475
- title: cardSchema.optional(),
548
+ title: titleSchema.optional(),
476
549
  closing: cardSchema.optional(),
477
550
  steps: z.array(stepSchema).min(1)
478
551
  }).strict().superRefine((story, ctx) => {
@@ -515,6 +588,7 @@ var loadStoryboard = (file, adapter) => {
515
588
  var MIN_ZOOM = MIN_SCALE;
516
589
  var MAX_ZOOM = MAX_SCALE;
517
590
  var MAX_PUSHES_PER_CHAPTER = 2;
591
+ var MAX_SHOWCASE_CHAPTERS = 4;
518
592
  var MAX_HOLD_SECONDS = 6;
519
593
  var MIN_PER_CHAR_MS = 40;
520
594
  var MAX_WORDS = { eyebrow: 4, heading: 8, sub: 16 };
@@ -534,7 +608,8 @@ var CODE_ON_CARD = [
534
608
  ]
535
609
  ];
536
610
  var INTERACTIONS = /* @__PURE__ */ new Set(["click", "type", "press"]);
537
- var isInteraction = (step) => INTERACTIONS.has(step.do) || !isCoreStep(step);
611
+ var isBehindCard = (step) => step.behindCard === true;
612
+ var isInteraction = (step) => (INTERACTIONS.has(step.do) || !isCoreStep(step)) && !isBehindCard(step);
538
613
  var lintStoryboard = (story) => {
539
614
  const issues = [];
540
615
  const steps = story.steps;
@@ -616,10 +691,32 @@ var lintStoryboard = (story) => {
616
691
  steps.forEach((step, index) => {
617
692
  const where = `steps.${index}`;
618
693
  const s = step;
694
+ if (isBehindCard(step)) {
695
+ let k = index - 1;
696
+ while (k >= 0 && isBehindCard(steps[k])) {
697
+ k--;
698
+ }
699
+ const afterCard = k === -1 ? Boolean(story.title) : steps[k].do === "card";
700
+ if (!afterCard) {
701
+ issues.push(
702
+ `${where}: behindCard only right after a card, which covers the app while it's done`
703
+ );
704
+ }
705
+ if (step.do === "click" && s.zoom !== void 0) {
706
+ issues.push(
707
+ `${where}: a click behind a card has no shot to push in to; drop its zoom`
708
+ );
709
+ }
710
+ }
619
711
  if (step.do === "card") {
620
712
  endChapter(index);
621
713
  chapterStart = index + 1;
622
714
  lintCard(where, s);
715
+ if (s.eyebrow !== void 0) {
716
+ issues.push(
717
+ `${where}.eyebrow: cards between chapters have no eyebrow; the statement and its sub-line say it`
718
+ );
719
+ }
623
720
  const rest = steps.slice(index + 1);
624
721
  const nextCard = rest.findIndex((x) => x.do === "card");
625
722
  const chapter = nextCard === -1 ? rest : rest.slice(0, nextCard);
@@ -667,6 +764,54 @@ var lintStoryboard = (story) => {
667
764
  }
668
765
  });
669
766
  endChapter(steps.length);
767
+ const snapshots = /* @__PURE__ */ new Map();
768
+ const showOf = (where, value) => {
769
+ if (value === void 0) {
770
+ return;
771
+ }
772
+ const t = value;
773
+ const key = typeof t === "string" ? `${t}#0` : `${t.target}#${t.nth ?? 0}`;
774
+ const first = snapshots.get(key);
775
+ if (first) {
776
+ issues.push(
777
+ `${where}.show: ${first} shows the same snapshot; show each result once`
778
+ );
779
+ } else {
780
+ snapshots.set(key, where);
781
+ }
782
+ };
783
+ showOf("title", story.title?.show);
784
+ steps.forEach((step, index) => {
785
+ if (step.do === "card") {
786
+ showOf(`steps.${index}`, step.show);
787
+ }
788
+ });
789
+ const kind = story.kind ?? "showcase";
790
+ if (story.primary && kind !== "showcase") {
791
+ issues.push(
792
+ "kind: the primary story walks the app's main journey, so it is a showcase"
793
+ );
794
+ }
795
+ const chapters = steps.filter((step, i) => {
796
+ if (step.do !== "card") {
797
+ return false;
798
+ }
799
+ const rest = steps.slice(i + 1);
800
+ const next = rest.findIndex((x) => x.do === "card");
801
+ return (next === -1 ? rest : rest.slice(0, next)).some(
802
+ (x) => !isBehindCard(x)
803
+ );
804
+ }).length;
805
+ if (kind === "feature" && chapters !== 1) {
806
+ issues.push(
807
+ `kind: a feature clip shows one feature, in one chapter; this has ${chapters}. Make it one, or a showcase`
808
+ );
809
+ }
810
+ if (kind === "showcase" && chapters > MAX_SHOWCASE_CHAPTERS) {
811
+ issues.push(
812
+ `${chapters} chapters; a showcase has at most ${MAX_SHOWCASE_CHAPTERS}: make a second story`
813
+ );
814
+ }
670
815
  return issues;
671
816
  };
672
817
  var normaliseText = (text) => text.replace(/\s+/g, " ").trim();
@@ -675,16 +820,21 @@ export {
675
820
  ZOOM_MS,
676
821
  CARD_IN_MS,
677
822
  CARD_OUT_MS,
823
+ CURSOR_MIN_MS,
824
+ CURSOR_MAX_MS,
678
825
  SETTLE_MS,
679
826
  PRE_CLICK_MS,
680
827
  PRESS_MS,
681
828
  POST_CLICK_MS,
682
829
  POST_KEY_MS,
683
830
  POST_ACTION_MS,
831
+ TYPE_CHAR_MS,
684
832
  LINGER_MS,
685
833
  POLL_MS,
686
834
  cardSeconds,
835
+ titleSeconds,
687
836
  STAGE_PATH,
837
+ STILLS_PATH,
688
838
  STAGE_WIDTH,
689
839
  STAGE_HEIGHT,
690
840
  DEFAULT_VIEWPORT,
@@ -703,5 +853,6 @@ export {
703
853
  StoryboardError,
704
854
  parseStoryboard,
705
855
  loadStoryboard,
856
+ isBehindCard,
706
857
  lintStoryboard
707
858
  };