@navipartner/demo-video 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,8 @@ Marketing demo videos of React apps.
4
4
 
5
5
  - Filmed on the app's own e2e mocks.
6
6
  - NaviPartner look: camera pushes, serif cards.
7
- - Any customer's brand, chosen per video.
7
+ - NaviPartner's brand, the only one kept.
8
+ - Customer brands researched per video, never saved.
8
9
  - Smooth 60 fps, frame by frame.
9
10
  - pnpm repos, GitHub Actions, `@playwright/test` 1.59+.
10
11
 
@@ -24,11 +25,12 @@ Marketing demo videos of React apps.
24
25
 
25
26
  | Ask | Who | You get |
26
27
  |---|---|---|
27
- | @Linear "video of checkout from np-pos for Acme" | Linear users | that story in Acme's brand, attached |
28
- | @Linear "pitch draft of checkout for Northwind (northwind.example)" | Linear users | a researched brand, attached |
28
+ | @Linear "video of checkout from np-pos for Northwind (northwind.example)" | Linear users | that story in Northwind's researched brand, attached |
29
+ | @Linear "video of checkout from np-pos" | Linear users | that story, NaviPartner brand, attached |
29
30
  | `/demo-video` on a PR | members | primary story, from the PR branch |
30
- | `/demo-video checkout in acme branding` | members | that story, that brand |
31
- | `/demo-video help` | members | menu of stories and brands |
31
+ | `/demo-video checkout` | members | that story |
32
+ | `/demo-video checkout in danish` | members | its translation |
33
+ | `/demo-video help` | members | menu of stories |
32
34
  | `demo video` label on a PR | whoever labels | every changed story, each push |
33
35
  | Actions → Demo video → Run workflow | repo writers | any story, any branch |
34
36
  | `pnpm exec demo-video render` | developers | any story, locally |
@@ -37,14 +39,15 @@ Marketing demo videos of React apps.
37
39
  - New stories come via PRs.
38
40
  - PR comments: first line is the request.
39
41
  - Unknown names get the menu.
42
+ - Customer branding only through @Linear.
40
43
 
41
44
  ### In Linear
42
45
 
43
46
  - Ask @Linear in plain words.
44
47
  - Name the repo, story and customer.
45
48
  - Follows the skill's `linear.md`.
46
- - Brand in the repo: 60 fps video.
47
- - No brand yet: a pitch draft.
49
+ - NaviPartner itself: 60 fps video.
50
+ - A customer: a pitch draft.
48
51
  - MP4 and contact sheet attached.
49
52
  - Nothing committed, nothing kept.
50
53
  - Linear AI credits, no API key.
@@ -52,22 +55,24 @@ Marketing demo videos of React apps.
52
55
 
53
56
  ### Pitch drafts
54
57
 
55
- - @Linear researches the customer's own website.
56
- - Brand checked, then filmed at 30 fps.
58
+ - Researched from the customer's own website.
59
+ - Checked, then filmed at 30 fps.
57
60
  - Google Fonts stand-ins only.
58
61
  - That customer only; check before sending.
62
+ - Other use needs their written permission.
59
63
 
60
64
  ## Stories and brands
61
65
 
62
66
  - **Story:** `demo/stories/<name>.json`, scenes as JSON.
63
67
  - Built only from the mocks.
64
68
  - One story per app is primary.
65
- - **Variant:** `checkout.acme.json`, customer's own words.
66
- - **Brand:** `demo/brands/<name>.json` plus assets.
67
- - `navipartner` ships as the default.
68
- - **Brand needs:** the adapter lists what brands need.
69
- - Missing needs fail `check`.
70
- - Customer brands need customer permission.
69
+ - **Translation:** its own story, e.g. `checkout-da.json`.
70
+ - Same flow, cards in that language.
71
+ - **Brands:** only `navipartner`, shipped with the engine.
72
+ - No `demo/brands/` in repos: `check` refuses it.
73
+ - Stories never name a brand.
74
+ - **Brand needs:** the adapter lists what a brand gives.
75
+ - Pitch research fills exactly those.
71
76
  - The skill has fields and the look.
72
77
 
73
78
  ## Commands
@@ -88,11 +93,10 @@ Run in the app's folder: `pnpm exec demo-video <command>`.
88
93
  | Flag | Means |
89
94
  |---|---|
90
95
  | `--story <name>` | default: primary story |
91
- | `--brand <name>` | default: `navipartner` |
92
96
  | `--draft` | 30 fps, twice as fast |
93
- | `--brands <dir>` | extra brands folder, searched first |
94
97
  | `--app <dir>` | app folder, default `.` |
95
98
  | `--json` | machine-readable output |
99
+ | `--brand`, `--brands <dir>` | used by `pitch` only |
96
100
 
97
101
  Output lands in `demo/out/`. Failures keep Playwright's output in `demo/out/failed/`.
98
102
 
package/bin/args.d.mts CHANGED
@@ -27,9 +27,6 @@ export interface StoryEntry {
27
27
  name: string;
28
28
  description: string | null;
29
29
  primary: boolean;
30
- /** A variant: the story it extends, and the brand it is for. */
31
- extends?: string;
32
- brand?: string | null;
33
30
  }
34
31
  export function listStories(demoDir: string): StoryEntry[];
35
32
  export function resolveStoryboard(
package/bin/args.mjs CHANGED
@@ -62,10 +62,6 @@ export const listStories = (demoDir) => {
62
62
  name: f.slice(0, -".json".length),
63
63
  description: story.description ?? null,
64
64
  primary: story.primary === true,
65
- // A variant: the base story's flow, for one brand.
66
- ...(story.extends
67
- ? { extends: story.extends, brand: story.brand ?? null }
68
- : {}),
69
65
  };
70
66
  })
71
67
  .sort(
@@ -136,7 +136,7 @@ const runPick = (argv) => {
136
136
  const arg = argv[i];
137
137
  if (arg === "--json") {
138
138
  json = true;
139
- } else if (["--request", "--app", "--story", "--brand"].includes(arg)) {
139
+ } else if (["--request", "--app", "--story"].includes(arg)) {
140
140
  flags[arg.slice(2)] = argv[++i] ?? "";
141
141
  } else {
142
142
  dirs.push(arg);
@@ -144,7 +144,7 @@ const runPick = (argv) => {
144
144
  }
145
145
  if (dirs.length === 0) {
146
146
  console.error(
147
- 'Usage: demo-video pick [--request "<text>" | --app <a> --story <s> --brand <b>] [--json] <app dir>...',
147
+ 'Usage: demo-video pick [--request "<text>" | --app <a> --story <s>] [--json] <app dir>...',
148
148
  );
149
149
  return 2;
150
150
  }
@@ -165,9 +165,7 @@ const runPick = (argv) => {
165
165
  } else if (out.menu) {
166
166
  console.log(out.markdown);
167
167
  } else {
168
- console.log(
169
- `${out.app}: story ${out.story}, brand ${out.brand ?? "(the app's default)"}`,
170
- );
168
+ console.log(`${out.app}: story ${out.story}`);
171
169
  }
172
170
  return 0;
173
171
  };
@@ -345,7 +343,7 @@ const run = () => {
345
343
  } else {
346
344
  for (const s of stories) {
347
345
  console.log(
348
- `${s.primary ? "* " : " "}${s.name}${s.description ? `: ${s.description}` : ""}${s.extends ? ` (${s.extends}, for ${s.brand})` : ""}`,
346
+ `${s.primary ? "* " : " "}${s.name}${s.description ? `: ${s.description}` : ""}`,
349
347
  );
350
348
  }
351
349
  }
package/bin/init.mjs CHANGED
@@ -22,15 +22,15 @@ const engineVersion = () =>
22
22
 
23
23
  export const CALLER_WORKFLOW = `name: Demo video
24
24
  # The "${LABEL}" label films the stories a PR adds or changes. A comment
25
- # "/demo-video [<story>] [in <brand> branding]" on a PR, or Actions → Demo
26
- # video → Run workflow, films an existing story with a brand. In Linear, ask
27
- # @Linear (the skill's linear.md), pitch drafts included.
25
+ # "/demo-video [<story>]" on a PR, or Actions → Demo video → Run workflow,
26
+ # films an existing story in NaviPartner's brand. A customer's branding: ask
27
+ # @Linear (the skill's linear.md); repos keep no brands.
28
28
  on:
29
29
  pull_request:
30
30
  types: [labeled, synchronize]
31
31
  issue_comment:
32
32
  types: [created]
33
- # Actions → Demo video → Run workflow: film a story with a brand.
33
+ # Actions → Demo video → Run workflow: film a story.
34
34
  workflow_dispatch:
35
35
  inputs:
36
36
  app:
@@ -39,9 +39,6 @@ on:
39
39
  story:
40
40
  description: "Story name (empty: the app's primary story)"
41
41
  default: ""
42
- brand:
43
- description: "Brand (empty: the app's default)"
44
- default: ""
45
42
  permissions: {}
46
43
  jobs:
47
44
  render:
@@ -60,7 +57,6 @@ jobs:
60
57
  with:
61
58
  app: \${{ inputs.app }}
62
59
  story: \${{ inputs.story }}
63
- brand: \${{ inputs.brand }}
64
60
  `;
65
61
 
66
62
  const read = (file) =>
package/bin/pick.d.mts CHANGED
@@ -4,13 +4,11 @@ export interface AppMenu {
4
4
  dir: string;
5
5
  name: string;
6
6
  stories: StoryEntry[];
7
- brands: string[];
8
7
  }
9
8
  export type Pick =
10
9
  | {
11
10
  app: string;
12
11
  story: string;
13
- brand: string | null;
14
12
  menu?: undefined;
15
13
  }
16
14
  | { menu: true; reason: string; apps?: AppMenu[] };
@@ -19,5 +17,5 @@ export function pick(request: string, apps: AppMenu[]): Pick;
19
17
  export function menuMarkdown(apps: AppMenu[], reason: string): string;
20
18
  export function pickExact(
21
19
  apps: AppMenu[],
22
- names: { app?: string; story?: string; brand?: string },
20
+ names: { app?: string; story?: string },
23
21
  ): Pick;
package/bin/pick.mjs CHANGED
@@ -1,27 +1,23 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
2
 
5
3
  import { listStories } from "./args.mjs";
6
4
 
7
5
  // `demo-video pick`: turns a `/demo-video …` request into one of the
8
- // already-made stories and a brand. No AI and nothing written: stories are a
9
- // developer's to make (locally, with the skill); a request only chooses.
6
+ // already-made stories. No AI and nothing written: stories are a developer's
7
+ // to make (locally, with the skill); a request only chooses. Every video is
8
+ // in NaviPartner's brand: a customer's is researched by @Linear for one video
9
+ // (the skill's linear.md), never kept in a repo.
10
10
  //
11
- // /demo-video → the primary story (one app)
12
- // /demo-video help → the menu
13
- // /demo-video checkout in acme branding → story "checkout", brand "acme"
14
- // /demo-video retail checkout → the app whose folder says retail
11
+ // /demo-video → the primary story (one app)
12
+ // /demo-video help → the menu
13
+ // /demo-video checkout → story "checkout"
14
+ // /demo-video checkout in danish → its translation "checkout-da"
15
+ // /demo-video retail checkout → the app whose folder says retail
15
16
  //
16
17
  // Anything it can't settle on one story is answered with the menu.
17
18
 
18
- const engineDir = path.resolve(
19
- path.dirname(fileURLToPath(import.meta.url)),
20
- "..",
21
- );
22
-
23
19
  const STOP = new Set(
24
- "a an and as at by for from in into make me of on or our please show story the this to video with branding brand marketing demo film render".split(
20
+ "a an and as at by for from in into make me of on or our please show story the this to video with branding brand marketing demo film render navipartner på".split(
25
21
  " ",
26
22
  ),
27
23
  );
@@ -31,44 +27,46 @@ const words = (text) =>
31
27
  (w) => [w, ...(w.includes("-") ? w.split("-") : [])],
32
28
  );
33
29
 
34
- const brandNames = (dir) =>
35
- fs.existsSync(dir)
36
- ? fs
37
- .readdirSync(dir)
38
- .filter((f) => f.endsWith(".json"))
39
- .map((f) => f.slice(0, -".json".length))
40
- : [];
41
-
42
- // The adapter may keep its brands elsewhere (`brands: "../brands"`). Read
43
- // from demo.config.ts's text: a literal path, as the field is documented.
44
- const adapterBrandsDir = (appDir) => {
45
- const config = path.join(appDir, "demo", "demo.config.ts");
46
- const text = fs.existsSync(config) ? fs.readFileSync(config, "utf8") : "";
47
- const dir = /\bbrands:\s*["'`]([^"'`]+)["'`]/.exec(text)?.[1] ?? "./brands";
48
- return path.resolve(appDir, "demo", dir);
30
+ // A translation is a story named for its language (checkout-da): "danish"
31
+ // and "dansk" say its "da" too.
32
+ const languageNames = (part) => {
33
+ if (!/^[a-z]{2}$/.test(part)) {
34
+ return [];
35
+ }
36
+ try {
37
+ return [
38
+ new Intl.DisplayNames(["en"], { type: "language" }).of(part),
39
+ new Intl.DisplayNames([part], { type: "language" }).of(part),
40
+ ]
41
+ .filter((name) => name && name.toLowerCase() !== part)
42
+ .map((name) => name.toLowerCase());
43
+ } catch {
44
+ return [];
45
+ }
49
46
  };
50
47
 
51
- /** What an app offers: its stories, and its brands plus the engine's. */
48
+ /** What an app offers: its stories. */
52
49
  export const appMenu = (appDir) => ({
53
50
  dir: appDir,
54
51
  name: path.basename(appDir),
55
52
  stories: listStories(path.join(appDir, "demo")),
56
- brands: [
57
- ...new Set([
58
- ...brandNames(adapterBrandsDir(appDir)),
59
- ...brandNames(path.join(engineDir, "brands")),
60
- ]),
61
- ],
62
53
  });
63
54
 
55
+ const LINEAR =
56
+ "A customer's branding isn't kept in a repo: in Linear, ask @Linear for the video, and it researches their brand for that one video.";
57
+
64
58
  /**
65
- * Picks from `apps` (appMenu results). Returns { app, story, brand } (brand
66
- * null = the adapter's default) or { menu: true, reason }.
59
+ * Picks from `apps` (appMenu results). Returns { app, story } or
60
+ * { menu: true, reason }.
67
61
  */
68
62
  export const pick = (request, apps) => {
69
63
  // A bare /demo-video films the primary story; this asks what there is.
70
64
  if (/^\s*(help|menu|list)\s*$/i.test(request)) {
71
- return { menu: true, reason: "The stories and brands:" };
65
+ return { menu: true, reason: "The stories:" };
66
+ }
67
+ const branded = /(?:^|\s)in\s+([\w-]+)\s+brand(?:ing)?\b/i.exec(request);
68
+ if (branded && branded[1].toLowerCase() !== "navipartner") {
69
+ return { menu: true, reason: LINEAR };
72
70
  }
73
71
  return pickStory(request, apps);
74
72
  };
@@ -76,22 +74,10 @@ export const pick = (request, apps) => {
76
74
  const pickStory = (request, apps) => {
77
75
  const said = words(request);
78
76
  const has = (word) => said.includes(word.toLowerCase());
77
+ const saysPart = (part) => has(part) || languageNames(part).some(has);
79
78
 
80
- // The brand: any offered brand named in the request.
81
- const allBrands = [...new Set(apps.flatMap((a) => a.brands))];
82
- const brands = allBrands.filter(has);
83
- if (brands.length > 1) {
84
- return {
85
- menu: true,
86
- reason: `Name one brand, not ${brands.join(" and ")}.`,
87
- };
88
- }
89
- const brand = brands[0] ?? null;
90
-
91
- // What's left of the request once the brand and filler are gone.
92
- const content = said.filter(
93
- (w) => !STOP.has(w) && !brands.includes(w) && !/^\d+$/.test(w),
94
- );
79
+ // What's left of the request once the filler is gone.
80
+ const content = said.filter((w) => !STOP.has(w) && !/^\d+$/.test(w));
95
81
 
96
82
  // The app: named by its folder, else by a part of it no other app's
97
83
  // folder has ("retail", not "self"), else the only one.
@@ -114,23 +100,18 @@ const pickStory = (request, apps) => {
114
100
  }
115
101
  }
116
102
 
117
- // Score every story: its name (or a part of it) said outright counts most,
103
+ // Score every story: its name (or its parts) said outright counts most,
118
104
  // then words shared with its description.
119
- // Variants aren't scored: the base story plus the brand finds them.
120
105
  const scored = candidates.flatMap((app) =>
121
- app.stories
122
- .filter((story) => !story.extends)
123
- .map((story) => {
124
- const nameParts = story.name.split("-");
125
- // "checkout express" says the story checkout-express in full.
126
- let score = has(story.name) || nameParts.every((p) => has(p)) ? 10 : 0;
127
- score += nameParts.filter((p) => p.length > 2 && has(p)).length * 3;
128
- const description = new Set(words(story.description ?? ""));
129
- score += content.filter(
130
- (w) => w.length > 2 && description.has(w),
131
- ).length;
132
- return { app, story, score };
133
- }),
106
+ app.stories.map((story) => {
107
+ const nameParts = story.name.split("-");
108
+ // "checkout express" says the story checkout-express in full.
109
+ let score = has(story.name) || nameParts.every(saysPart) ? 10 : 0;
110
+ score += nameParts.filter(saysPart).length * 3;
111
+ const description = new Set(words(story.description ?? ""));
112
+ score += content.filter((w) => w.length > 2 && description.has(w)).length;
113
+ return { app, story, score };
114
+ }),
134
115
  );
135
116
  const best = Math.max(0, ...scored.map((s) => s.score));
136
117
  const top = scored.filter((s) => s.score === best);
@@ -147,7 +128,7 @@ const pickStory = (request, apps) => {
147
128
  .map((app) => ({ app, story: app.stories.find((s) => s.primary) }))
148
129
  .filter((p) => p.story);
149
130
  if (noFeature && candidates.length === 1 && primaries.length === 1) {
150
- return withBrand(primaries[0].app, primaries[0].story.name, brand);
131
+ return { app: primaries[0].app.dir, story: primaries[0].story.name };
151
132
  }
152
133
  return {
153
134
  menu: true,
@@ -163,24 +144,7 @@ const pickStory = (request, apps) => {
163
144
  reason: `That matches ${top.map((t) => `\`${t.story.name}\``).join(", ")}. Pick one by name:`,
164
145
  };
165
146
  }
166
- return withBrand(top[0].app, top[0].story.name, brand);
167
- };
168
-
169
- // The brand must be one the chosen app has: another app's brand would fail
170
- // at render time with a worse message. A story with a variant for that brand
171
- // (checkout + acme → checkout.acme) films the variant: its words are the
172
- // customer's.
173
- const withBrand = (app, story, brand) => {
174
- if (brand && !app.brands.includes(brand)) {
175
- return {
176
- menu: true,
177
- reason: `No brand \`${brand}\` in ${app.name}. Pick one:`,
178
- apps: [app],
179
- };
180
- }
181
- const variant =
182
- brand && app.stories.find((s) => s.extends === story && s.brand === brand);
183
- return { app: app.dir, story: variant ? variant.name : story, brand };
147
+ return { app: top[0].app.dir, story: top[0].story.name };
184
148
  };
185
149
 
186
150
  /** The menu as Markdown, for the PR reply. */
@@ -191,36 +155,30 @@ export const menuMarkdown = (apps, reason) => {
191
155
  if (app.stories.length === 0) {
192
156
  lines.push("- no stories yet");
193
157
  }
194
- for (const s of app.stories.filter((x) => !x.extends)) {
158
+ for (const s of app.stories) {
195
159
  lines.push(
196
160
  `- \`${s.name}\`${s.primary ? " (primary)" : ""}${s.description ? `: ${s.description}` : ""}`,
197
161
  );
198
- for (const v of app.stories.filter((x) => x.extends === s.name)) {
199
- lines.push(` - in \`${v.brand}\` branding: \`${v.name}\``);
200
- }
201
162
  }
202
- lines.push(`- brands: ${app.brands.map((b) => `\`${b}\``).join(", ")}`, "");
163
+ lines.push("");
203
164
  }
204
- const example = apps.find((a) => a.stories.some((s) => !s.extends));
165
+ const example = apps.find((a) => a.stories.length > 0);
205
166
  if (example) {
206
- lines.push(
207
- `For example: \`/demo-video ${example.stories.find((s) => !s.extends).name}${example.brands.length > 1 ? ` in ${example.brands.find((b) => b !== "navipartner") ?? example.brands[0]} branding` : ""}\``,
208
- );
209
- lines.push(
210
- "",
211
- "In Linear, ask @Linear instead, also for a customer without a brand here (a pitch draft).",
212
- );
167
+ lines.push(`For example: \`/demo-video ${example.stories[0].name}\``);
168
+ }
169
+ if (reason !== LINEAR) {
170
+ lines.push("", LINEAR);
213
171
  }
214
172
  return lines.join("\n");
215
173
  };
216
174
 
217
175
  /**
218
- * The form's version (Actions → Run workflow): app, story and brand by name,
219
- * no guessing. Empty story = the primary story, empty brand = the adapter's
220
- * default, empty app = the only app. A name that doesn't exist gets the menu,
221
- * so a typo never films something else. `apps` in a menu result narrows it.
176
+ * The form's version (Actions → Run workflow): app and story by name, no
177
+ * guessing. Empty story = the primary story, empty app = the only app. A
178
+ * name that doesn't exist gets the menu, so a typo never films something
179
+ * else. `apps` in a menu result narrows it.
222
180
  */
223
- export const pickExact = (apps, { app = "", story = "", brand = "" }) => {
181
+ export const pickExact = (apps, { app = "", story = "" }) => {
224
182
  const slashes = (p) => p.split("\\").join("/");
225
183
  const wanted = slashes(app).replace(/\/+$/, "");
226
184
  const candidates = wanted
@@ -250,16 +208,5 @@ export const pickExact = (apps, { app = "", story = "", brand = "" }) => {
250
208
  apps: [chosen],
251
209
  };
252
210
  }
253
- if (found.extends) {
254
- // A variant is made for one brand.
255
- if (brand && brand !== found.brand) {
256
- return {
257
- menu: true,
258
- reason: `\`${found.name}\` is made for \`${found.brand}\`. Leave Brand empty, or pick \`${found.extends}\`:`,
259
- apps: [chosen],
260
- };
261
- }
262
- return { app: chosen.dir, story: found.name, brand: found.brand };
263
- }
264
- return withBrand(chosen, found.name, brand || null);
211
+ return { app: chosen.dir, story: found.name };
265
212
  };
@@ -152,13 +152,6 @@ interface DemoAdapter {
152
152
  setup(page: Page, ctx: {
153
153
  brand: Brand;
154
154
  }): Promise<void>;
155
- /**
156
- * The app's own brands folder, relative to demo/ (default "./brands").
157
- * Searched before the brands shipped with the engine (navipartner, ...).
158
- */
159
- brands?: string;
160
- /** Default "navipartner". */
161
- defaultBrand?: string;
162
155
  /**
163
156
  * What this app takes from a brand, each with what it is and where it
164
157
  * shows ("full-screen photo behind the menu, landscape"). Apps differ: one
@@ -502,76 +502,15 @@ var parseStoryboard = (raw, adapter, source = "storyboard") => {
502
502
  throw new StoryboardError(`Invalid ${source}:
503
503
  ${lines.join("\n")}`);
504
504
  };
505
- var cardChange = z.object({
506
- eyebrow: cardFields.eyebrow,
507
- heading: cardFields.heading,
508
- sub: cardFields.sub,
509
- logo: cardFields.logo,
510
- style: z.enum(["pale", "brand"]).optional(),
511
- seconds: z.number().min(0.5).max(10).optional()
512
- }).strict();
513
- var variantSchema = z.object({
514
- extends: z.string().regex(/^[\w-]+$/, "the base story's name"),
515
- brand: z.string().regex(/^[\w-]+$/, "a brand name"),
516
- description: z.string().min(1).max(280).optional(),
517
- title: cardChange.optional(),
518
- closing: cardChange.optional(),
519
- cards: z.array(cardChange).optional()
520
- }).strict();
521
505
  var readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
522
- var isVariant = (raw) => typeof raw === "object" && raw !== null && "extends" in raw;
523
- var loadVariant = (raw, file, adapter) => {
524
- const name = path.basename(file);
525
- const result = variantSchema.safeParse(raw);
526
- if (!result.success) {
527
- const lines = result.error.issues.map(
528
- (i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`
529
- );
530
- throw new StoryboardError(`Invalid ${name}:
531
- ${lines.join("\n")}`);
532
- }
533
- const variant = result.data;
534
- const baseFile = path.join(path.dirname(file), `${variant.extends}.json`);
535
- if (!fs.existsSync(baseFile)) {
536
- throw new StoryboardError(
537
- `${name} extends "${variant.extends}", but there is no ${variant.extends}.json beside it`
538
- );
539
- }
540
- const base = readJson(baseFile);
541
- if (isVariant(base)) {
542
- throw new StoryboardError(
543
- `${name} extends ${variant.extends}.json, which is a variant itself; extend the story it extends`
544
- );
545
- }
546
- const steps = Array.isArray(base.steps) ? base.steps : [];
547
- const cardAt = steps.flatMap((s, i) => s.do === "card" ? [i] : []);
548
- if (variant.cards && variant.cards.length !== cardAt.length) {
506
+ var loadStoryboard = (file, adapter) => {
507
+ const raw = readJson(file);
508
+ if (typeof raw === "object" && raw !== null && ("extends" in raw || "brand" in raw)) {
549
509
  throw new StoryboardError(
550
- `${name} gives ${variant.cards.length} card${variant.cards.length === 1 ? "" : "s"}; ${variant.extends}.json has ${cardAt.length}. A variant gives every card of its story, in order.`
510
+ `${path.basename(file)} names a brand or extends a story: stories hold neither. A customer's brand is researched by @Linear for one video; a translation is a story of its own (checkout-da.json).`
551
511
  );
552
512
  }
553
- const change = (card, over) => over ? { ...card, ...over } : card;
554
- const merged = steps.map(
555
- (step, i) => variant.cards ? change(step, variant.cards[cardAt.indexOf(i)]) : step
556
- );
557
- const rest = { ...base };
558
- delete rest.primary;
559
- const story = parseStoryboard(
560
- {
561
- ...rest,
562
- description: variant.description ?? base.description,
563
- title: change(base.title, variant.title),
564
- closing: change(base.closing, variant.closing),
565
- steps: merged
566
- },
567
- adapter,
568
- `${name} (on ${variant.extends}.json)`
569
- );
570
- return { ...story, brand: variant.brand, variantOf: variant.extends };
571
- };
572
- var loadStoryboard = (file, adapter) => {
573
- const raw = readJson(file);
574
- return isVariant(raw) ? loadVariant(raw, file, adapter) : parseStoryboard(raw, adapter, path.basename(file));
513
+ return parseStoryboard(raw, adapter, path.basename(file));
575
514
  };
576
515
  var MIN_ZOOM = MIN_SCALE;
577
516
  var MAX_ZOOM = MAX_SCALE;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { D as DemoAdapter } from './adapter-D_iU2dh2.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-D_iU2dh2.js';
1
+ import { D as DemoAdapter } from './adapter-CDOXdUZI.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-CDOXdUZI.js';
3
3
  import { Page } from '@playwright/test';
4
4
  import { z } from 'zod';
5
5
  export { z } from 'zod';
@@ -236,10 +236,6 @@ declare const cardSchema: z.ZodEffects<z.ZodObject<{
236
236
  type Card = z.infer<typeof cardSchema>;
237
237
  interface Storyboard {
238
238
  app: string;
239
- /** A variant's brand (see loadStoryboard): it is filmed with this one only. */
240
- brand?: string;
241
- /** The story a variant extends. */
242
- variantOf?: string;
243
239
  /** What the story shows, for people choosing one (and the menu). */
244
240
  description?: string;
245
241
  /** The app's primary journey: the story filmed when none is named. One per app. */
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  StoryboardError,
3
3
  parseStoryboard
4
- } from "./chunk-BYAFDWPA.js";
4
+ } from "./chunk-D7UYGNSF.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-D_iU2dh2.js';
2
+ import { D as DemoAdapter } from '../adapter-CDOXdUZI.js';
3
3
  import 'zod';
4
4
 
5
5
  /**
@@ -30,7 +30,7 @@ import {
30
30
  unionRect,
31
31
  visibleRect,
32
32
  wideShot
33
- } from "../chunk-BYAFDWPA.js";
33
+ } from "../chunk-D7UYGNSF.js";
34
34
 
35
35
  // src/runner/config.ts
36
36
  import path from "path";
@@ -135,10 +135,17 @@ var engineRoot = () => {
135
135
  }
136
136
  };
137
137
  var sharedBrandsDir = () => path2.join(engineRoot(), "brands");
138
- var brandDirs = (demoDir, brands = "./brands") => [
139
- path2.resolve(demoDir, brands),
138
+ var brandDirs = (pitchDir) => [
139
+ ...pitchDir ? [pitchDir] : [],
140
140
  sharedBrandsDir()
141
141
  ];
142
+ var refuseRepoBrands = (demoDir) => {
143
+ if (fs.existsSync(path2.join(demoDir, "brands"))) {
144
+ throw new Error(
145
+ `${path2.join(demoDir, "brands")} isn't used: repos keep no brands. NaviPartner's ships with the engine; a customer's is researched by @Linear for one video and never saved. Delete the folder.`
146
+ );
147
+ }
148
+ };
142
149
  var brandNames = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -".json".length)) : [];
143
150
  var loadBrand = (dirs, name) => {
144
151
  if (!NAME_RE.test(name)) {
@@ -1196,17 +1203,10 @@ var registerDemoTests = (test, adapter, demoDir) => {
1196
1203
  ].join("\n")
1197
1204
  );
1198
1205
  }
1199
- if (storyboard.brand && env.brand && env.brand !== storyboard.brand) {
1200
- throw new StoryboardError(
1201
- `${path4.basename(env.storyboardPath, ".json")} is ${storyboard.variantOf} for the brand ${storyboard.brand}. Render it without --brand, or render ${storyboard.variantOf} with --brand ${env.brand}.`
1202
- );
1203
- }
1206
+ refuseRepoBrands(demoDir);
1204
1207
  const brand = loadBrand(
1205
- [
1206
- ...env.brandsDir ? [env.brandsDir] : [],
1207
- ...brandDirs(demoDir, adapter.brands)
1208
- ],
1209
- env.brand ?? storyboard.brand ?? adapter.defaultBrand ?? "navipartner"
1208
+ brandDirs(env.brandsDir),
1209
+ env.brand ?? "navipartner"
1210
1210
  );
1211
1211
  const missing = missingNeeds(brand, adapter.brandNeeds);
1212
1212
  if (missing.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navipartner/demo-video",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
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": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: np-demo-video
3
- description: Produce or change a marketing demo video of a NaviPartner React app (retail self-checkout, food-order kiosk, or any app with a demo/ adapter) in the house look, which is calm camera pushes, serif statement cards and customer branding, recorded by the engine @navipartner/demo-video. Use when asked for a product demo video, a showcase of a flow, a marketing or SoMe clip, a customer-branded video (e.g. "two waters, in a customer's branding"), a video asked for in Linear, including a pitch draft for a customer without a brand (e.g. "video of checkout for Acme", "pitch draft of checkout for Northwind"; follow linear.md), or to re-record a demo after UI changes.
3
+ description: Produce or change a marketing demo video of a NaviPartner React app (retail self-checkout, food-order kiosk, or any app with a demo/ adapter) in the house look, which is calm camera pushes, serif statement cards and customer branding, recorded by the engine @navipartner/demo-video. Use when asked for a product demo video, a showcase of a flow, a marketing or SoMe clip, a translation of a story into another language, a video in a customer's branding asked for in Linear (e.g. "video of checkout for Northwind (northwind.example)"; follow linear.md), or to re-record a demo after UI changes.
4
4
  ---
5
5
 
6
6
  # NaviPartner demo video
@@ -18,7 +18,8 @@ workflow.
18
18
  From the request, take:
19
19
  - **Product:** which app.
20
20
  - **Chapters:** the 1–4 ideas to show.
21
- - **Brand:** default NaviPartner.
21
+ - **Brand:** NaviPartner's, always, in a repo. A customer's branding is
22
+ @Linear's: it researches their brand for one video (linear.md).
22
23
  - **Data:** the products, names and amounts the request mentions ("two
23
24
  waters").
24
25
 
@@ -28,8 +29,7 @@ Card text defaults to English. Ask only for what you cannot infer.
28
29
 
29
30
  Every app has one **primary story**: the journey the app exists for, from
30
31
  start to the moment of value. It is the app's first story, and the one filmed
31
- when a request names no feature ("make a video of the QR app", "a video in
32
- the customer's branding").
32
+ when a request names no feature ("make a video of the QR app").
33
33
 
34
34
  | App | Primary story | Ends on |
35
35
  |---|---|---|
@@ -51,8 +51,8 @@ two. In the PR, say which spec the story follows, so the developer can confirm
51
51
  it is the right journey.
52
52
 
53
53
  Run `pnpm --dir <app> exec demo-video list` first. If a primary story exists and the
54
- request names no feature, render it (`pnpm --dir <app> exec demo-video render` without `--story`,
55
- with `--brand` if one is asked for) instead of writing a new one. For a
54
+ request names no feature, render it (`pnpm --dir <app> exec demo-video render` without `--story`)
55
+ instead of writing a new one. For a
56
56
  repo with several apps and no app named, ask which app, or offer each app's
57
57
  primary story. A PR never films it by default; only stories a PR adds or
58
58
  changes are filmed there.
@@ -78,10 +78,10 @@ Check it against look.md:
78
78
  - Does every card describe exactly what the chapter after it shows?
79
79
  - Does the hook land by second 6?
80
80
 
81
- Example, retail self-checkout for a customer (here the made-up brand Acme):
81
+ Example, retail self-checkout:
82
82
 
83
83
  ```
84
- open Introducing · Acme logo · sub "Self-checkout"
84
+ open Introducing · NaviPartner logo · sub "Self-checkout"
85
85
  ch1 sees: a guest scans two waters and they land in the basket
86
86
  shots: wide for the scans (the rows appear at the top);
87
87
  push in 1.5 on the basket rows to read them
@@ -91,7 +91,7 @@ ch2 sees: the guest pays by card and the payment goes through
91
91
  pull out when the payment screen opens
92
92
  card: "Pay *by card*."
93
93
  payoff "Paid *in seconds*." · sub "64,50 kr. by card." (expectText on the total)
94
- end Acme logo · "Self-checkout by NaviPartner"
94
+ end NaviPartner logo · "Self-checkout by NaviPartner"
95
95
  ```
96
96
 
97
97
  ## 3. Learn the app
@@ -110,9 +110,9 @@ end Acme logo · "Self-checkout by NaviPartner"
110
110
  with an absurdly long name or an error item.
111
111
  - If the request needs data the e2e tests don't have, say so instead of
112
112
  inventing it.
113
- - One exception: a customer brand may rename the mock's products in its
114
- `config.demoData` (see "A new brand"). It gives them names only, never
115
- new barcodes, prices or flows.
113
+ - One exception: a pitch draft's brand may rename the mock's products
114
+ in `config.demoData`, when the adapter takes it (a `brandNeeds` entry).
115
+ It gives them names only, never new barcodes, prices or flows.
116
116
 
117
117
  ### A new app
118
118
 
@@ -147,8 +147,9 @@ confirms them. A `/demo-video` PR comment, the Run workflow form and a
147
147
  request to @Linear only pick an existing story and a brand: they never
148
148
  write one. A story is
149
149
  `<app>/demo/stories/<name>.json`, named for what it shows (`checkout`,
150
- `settings`), and holds no brand: the same story renders for any brand with
151
- `--brand <name>`. A customer's own words go in a variant (below).
150
+ `settings`), and holds no brand: it films in NaviPartner's brand, or in a
151
+ customer's that @Linear researches for one video. A translation is a story
152
+ of its own (below).
152
153
 
153
154
  { "app": "<adapter app>",
154
155
  "description": "One sentence: what the story shows. Follows e2e/<spec>.",
@@ -187,11 +188,9 @@ How the look maps onto steps:
187
188
  press, holds the press, waits for the app to react, settles the camera
188
189
  before acting, lingers on a chapter's result before its card, and opens
189
190
  every chapter wide with the cursor at rest. Don't add holds for those.
190
- - **Works with every brand:** any story can be filmed with any brand (the
191
- label uses the app's default). Never target text a brand's `demoData`
192
- sets (renamed products, an order number): pick products by testid and
193
- `nth`, the mock's order. Run `check` without `--brand` as well as with the
194
- brand you're writing for.
191
+ - **Works with any brand:** a pitch draft films the same story in a
192
+ customer's brand. Never target text a brand can change (renamed products,
193
+ an order number): pick products by testid and `nth`, the mock's order.
195
194
  - **Safety:** after each navigation, `waitFor` the next screen's testid. Put
196
195
  `expectText` on prices and confirmations so the video fails if the copy is
197
196
  wrong, and so a card may quote them.
@@ -216,87 +215,29 @@ The words are what the viewer remembers; write them with care.
216
215
  - Statements ≤ 7 words, subs ≤ 16 (`check` stops at 8 and 16), sentence
217
216
  case, ending with a full stop.
218
217
 
219
- ### A new brand
220
-
221
- A customer brand is `<app>/demo/brands/<name>.json` with its assets in
222
- `<app>/demo/brands/<name>/`. Fields: [reference.md](reference.md).
223
-
224
- A customer's name, logo and look are their trademarks. A video in them is
225
- made with their permission, from what they gave us, and stays internal until
226
- they approve it.
227
-
228
- - **Only with permission.** Make a customer brand only when the request says
229
- the customer has agreed to a video in their branding (an account manager or
230
- the customer asked for it). If that's unclear, ask before you start.
231
- - **Only their own material, never invented or recreated:** the logo file and
232
- brand guide the customer supplied, or that a NaviPartner colleague received
233
- from them. Don't download, trace, redraw or recolour a logo from their
234
- website, and don't guess a colour or font. If a value is missing, ask the
235
- customer contact for it; colours from their public site are a stand-in to
236
- confirm with them, never final.
237
- - **Say where it came from.** The PR that adds the brand names the source of
238
- the logo, colours and fonts, and who gave permission. Without that, the
239
- brand doesn't merge.
240
- - **Logo:** an SVG or a PNG with a transparent background, readable on a
241
- light background (`logoColor`, a top-level key, recolours a white SVG; use
242
- it only when their brand guide allows that colour version).
243
- - **Fonts:** only a font whose licence covers use in a video. Google Fonts are
244
- open-licensed and fine. A customer's own or commercial font is used only if
245
- they confirm their licence covers it; otherwise take the closest Google
246
- Font and say so in the report. Never copy font files from their website.
247
- - **No claims for them:** cards state what the video shows. No customer
248
- quotes, statistics or "trusted by" lines, and a closing card says what the
249
- product is, not that the customer endorses it.
250
- - **Publishing:** a customer-branded video goes to the customer for approval
251
- before it is published anywhere. Say so in the report.
252
- - **`config`:** only keys the app's branding code accepts. Override every
253
- value the e2e mock sets that would show through, such as a mock
254
- `backgroundColor`.
255
- - **`config.demoData`** (when the adapter supports it): the customer's names
256
- for the mock's products, keyed by the mock's barcodes. Mock data such as
257
- "Toothpaste" or "TEST CARD" reads as a test run in a customer video. Use
258
- plain product names ("Lemonade"), or ones the customer gave you, never a
259
- third party's brand ("Coca-Cola"). Keep the mock's prices, so pick
260
- products that are believable at those prices, and put the totals on the
261
- cards from the render, not from your own sums.
262
- - **App limits:** an app may only offer presets (retail has two font pairs).
263
- Pick the closest and say in the report what the kiosk can't show.
264
- - **`video` fonts** load from Google Fonts: the heading in 400, 700 and
265
- italic 400, the body in 400 and 600. If a font lacks one, give `fontsUrl`
266
- yourself.
267
- - Render with `--brand <name>` and check the stills before calling it done.
268
-
269
- ### Videos asked for in Linear
270
-
271
- Asked for a video in a Linear session (an existing story in a brand the repo
272
- has, or a **pitch draft** for a customer without one): follow
273
- [linear.md](linear.md) and nothing else here. A pitch writes nothing to the repo, and its researched
274
- brand is not a brand: never commit it or copy its values into one. When the
275
- customer agrees to more, make a real brand with the rules above.
276
-
277
- ### A variant: the story in a customer's words
278
-
279
- When a customer video needs its own words (their product, their place), write
280
- a variant beside the story instead of a copy: `checkout.acme.json`.
281
-
282
- { "extends": "checkout",
283
- "brand": "acme",
284
- "description": "The checkout story, for Acme.",
285
- "title": { "sub": "Self-service at Acme" },
286
- "closing": { "sub": "…" },
287
- "cards": [ { "heading": "A burger *and a lemonade*." }, { … }, { … } ] }
288
-
289
- - The steps are always the base story's: a variant changes words and brand,
290
- never the flow. Fix the flow in the base, once, for every variant.
291
- - `cards` gives every card step of the base, in order; each entry, like
292
- `title` and `closing`, changes only the fields it names. When the base
293
- gains or loses a card, the variant stops loading until it is updated.
294
- - It films with its own brand only. `/demo-video checkout in acme branding`
295
- and the form (story `checkout`, brand `acme`) pick the variant.
296
- - The same rules as any card: numbers proven by the base's `expectText`s,
297
- and the customer's permission for their name (see "A new brand").
298
- - `check --story checkout.acme` before the PR, and re-check the variants
299
- when you change their base.
218
+ ### Brands
219
+
220
+ Repos keep no brands. NaviPartner's ships with the engine, and every video
221
+ in a repo is in it. Never create `demo/brands/`: `check` refuses it.
222
+
223
+ A video in a customer's branding is asked for in Linear: @Linear researches
224
+ their brand for that one video and keeps nothing (follow
225
+ [linear.md](linear.md), and nothing else here, when that's the request).
226
+
227
+ ### A translation
228
+
229
+ A story in another language is a story of its own, named for its language:
230
+ `checkout-da.json` beside `checkout.json`.
231
+
232
+ - The same flow as the base, plus whatever switches the app to that
233
+ language (its language button, as the e2e tests do it).
234
+ - Its title, cards and closing in that language, the same rules as any card.
235
+ - `description` says the language ("The checkout, in Danish."). It is never
236
+ `primary`.
237
+ - `/demo-video checkout in danish` (or `checkout da`, `checkout på dansk`)
238
+ picks it.
239
+ - `check --story checkout-da` before the PR, and again when you change the
240
+ base story.
300
241
 
301
242
  ### Not supported yet
302
243
 
@@ -315,7 +256,8 @@ step for real and fails on anything that would film badly:
315
256
  | `clicking "x" changes y, which is outside the shot …` | Frame what the click changes, or `zoomOut` right after the click |
316
257
  | `N camera push-ins in one chapter` | Push in once on the chapter's area and hold steady |
317
258
  | `… only fit in the wide shot` | `frame` things that sit together, or stay wide |
318
- | `… gives N cards; … has M` | Give the variant one entry per card of its story, in order |
259
+ | `… names a brand or extends a story` | Remove it: a customer's branding is @Linear's, a translation is a story of its own |
260
+ | `…/demo/brands isn't used` | Delete the folder: repos keep no brands |
319
261
  | `a zoom with nothing in its shot` | Remove it, or act or read something in it |
320
262
  | `zoom 2.4; use 1.3–2` | Zoom less: context reads better and stays sharp |
321
263
  | `"…" looks like a testid-like name / barcode / code` | Cards are for people: say it in words |
@@ -326,7 +268,7 @@ Fix what the report names. Its `visible` list shows what is on screen at the
326
268
  failing step. Repeat until it passes, at most 3 rounds; a story that still
327
269
  fails is reported as "couldn't show this", with the last report. Then:
328
270
 
329
- pnpm --dir <app> exec demo-video render [--story <name>] [--brand <name>]
271
+ pnpm --dir <app> exec demo-video render [--story <name>]
330
272
 
331
273
  Rendering films every frame (60 fps, frame by frame), so it takes about
332
274
  five times the video's length. While tuning, `render --draft` films at
@@ -358,7 +300,5 @@ Give:
358
300
  - the MP4 path and duration
359
301
  - the chapter plan
360
302
  - the contact sheet and stills
361
- - which brand it was rendered with, and for a customer brand, that the
362
- video needs the customer's approval before it is published
363
303
  - anything the video could not show truthfully (it shows demo fixture data,
364
304
  not a real tenant's)
@@ -1,23 +1,22 @@
1
1
  # A video asked for in Linear
2
2
 
3
3
  Someone asks @Linear for a video, in their own words: "make a video of the
4
- checkout story for Acme", "pitch draft of checkout for Northwind
5
- (northwind.example)". You film one of the app's **existing** stories, in a brand:
4
+ checkout story from np-pos for Northwind (northwind.example)". You film one
5
+ of the app's **existing** stories:
6
6
 
7
- - **A brand the repo has** (`acme` in `demo/brands/`): a normal video at
8
- 60 fps. That brand was added with the customer's permission.
9
- - **A customer without a brand here:** a **pitch draft**. You research their
10
- brand, the engine checks it and films at 30 fps, for a sales pitch to that
11
- customer only.
7
+ - **For NaviPartner itself:** a normal video at 60 fps.
8
+ - **For a customer:** a **pitch draft**. Repos keep no brands, so you
9
+ research theirs, the engine checks it and films at 30 fps, for a sales
10
+ pitch to that customer only. Nothing about them is saved.
12
11
 
13
12
  ## Hard rules
14
13
 
15
14
  - **Nothing is saved.** Never commit, push or open a pull request. Never
16
- change a story, the app, its mocks or a brand in the repo. Everything you
15
+ change a story, the app or its mocks, and never add a brand to the repo. Everything you
17
16
  make lives in `/tmp/pitch` or `demo/out/` and is deleted at the end.
18
17
  - **An existing story, unchanged.** If no story fits the request, say so and
19
18
  list the stories. Don't write one.
20
- - **A pitch takes the customer's own material only.** Find their official
19
+ - **The customer's own material only.** Find their official
21
20
  website (use the one named, if any). Take the logo, colours and images
22
21
  from it: the site itself, its brand or press page, or the CDN the site
23
22
  loads its own images from. No logo databases, no other companies' sites,
@@ -38,19 +37,17 @@ repo root when the app is the repo, otherwise the folder with
38
37
  (without `--with-deps`: a sandbox has the system packages and no root),
39
38
  then `mkdir -p /tmp/pitch`.
40
39
  2. **See what there is:** `pnpm exec demo-video pick --request help .` lists
41
- the stories and the brands.
42
- 3. **Pick the story and the brand:**
43
- `pnpm exec demo-video pick --json --request "<story> in <brand> branding" .`
44
- with the story and the customer the request names (no story named: leave
45
- it out, for the primary story). It answers `{ "story", "brand" }`, and
46
- takes a customer's variant of the story when there is one. `"brand":
47
- null` means the customer has no brand here: it's a pitch draft, go to
48
- step 5.
49
- 4. **A brand the repo has:**
50
- `pnpm exec demo-video render --story <story> --brand <brand> --json`. At
51
- 60 fps this takes about 15× the video's length and prints nothing
52
- meanwhile. It prints the MP4 (`output`) and the contact sheet (`sheet`).
53
- Go to step 7.
40
+ the stories.
41
+ 3. **Pick the story:**
42
+ `pnpm exec demo-video pick --json --request "<story>" .` with the story
43
+ the request names, and its language if it asks for one ("checkout in
44
+ danish" picks the translation `checkout-da`). No story named: leave it
45
+ out, for the primary story. It answers `{ "story" }`, or a menu if the
46
+ request matches none.
47
+ 4. **For NaviPartner itself:**
48
+ `pnpm exec demo-video render --story <story> --json`. At 60 fps this
49
+ takes about 15× the video's length and prints nothing meanwhile. It
50
+ prints the MP4 (`output`) and the contact sheet (`sheet`). Go to step 7.
54
51
  5. **A pitch draft: research the brand.**
55
52
  - Read what the app needs:
56
53
  `pnpm exec demo-video brand-needs --json > /tmp/pitch/needs.json`.
@@ -68,6 +65,9 @@ repo root when the app is the repo, otherwise the folder with
68
65
  image is a direct https URL from the customer's own site that fits
69
66
  its `what`. Other keys take the same kind of value as in `example`.
70
67
  Give optional ones only when their material has them.
68
+ - **`config.demoData`** (only if the app's needs list it): plain names
69
+ for the mock's products, never a third party's brand ("Coca-Cola"),
70
+ believable at the mock's prices.
71
71
  6. **A pitch draft: film it.**
72
72
  `pnpm exec demo-video pitch /tmp/pitch/proposal.json --story <story> --json`.
73
73
  It checks the proposal, downloads the images, checks the story with the
@@ -80,12 +80,11 @@ repo root when the app is the repo, otherwise the folder with
80
80
  well, the app looks branded. Film again only if something is clearly
81
81
  wrong.
82
82
  8. **Deliver:** attach the MP4 and the contact sheet to the issue, with a
83
- line saying which story, which brand and how long. For a pitch draft,
84
- also:
83
+ line saying which story and how long. For a pitch draft, also:
85
84
  > Pitch draft for <customer> only. The brand was researched from their own
86
85
  > material (<sources>) and isn't kept anywhere. Fonts are Google Fonts
87
86
  > stand-ins. Check the video before sending it: any other use needs
88
- > <customer>'s permission and a brand added in a PR.
87
+ > <customer>'s written permission.
89
88
 
90
89
  Add your notes: what you couldn't find and what you used instead.
91
90
  9. **Clean up:** `rm -rf /tmp/pitch demo/out`. Then `git status` must show
@@ -112,10 +112,8 @@ end card 3–5 s logo + tagline, still
112
112
  colour. The brand's logo and wordmark are on the open and end cards. The
113
113
  app itself is shown in the customer's branding (their kiosk looks like
114
114
  their kiosk).
115
- - **Default brand:** NaviPartner (below). A customer video, made with the
116
- customer's permission and their own brand material (see "A new brand" in
117
- SKILL.md), swaps the logo, colours, fonts, tagline and data (their
118
- products, their language), following the same roles.
115
+ - **Brand:** NaviPartner's (below). A pitch draft (linear.md) swaps in a
116
+ customer's researched logo, colours and fonts, following the same roles.
119
117
 
120
118
  ### NaviPartner brand
121
119
 
@@ -7,8 +7,7 @@ the mocks booted the app), `viewport` (default 1728×972; a portrait kiosk is
7
7
  scaled to fit) and `setup(page, { brand })`, which runs the app's e2e mocks
8
8
  **unchanged** and lays the brand over the response that carries it.
9
9
  Optional: `actions` (app steps such as a barcode scan), `idleTimeoutSeconds`,
10
- `defaultBrand`, `brands` (folder, default `./brands`), `locale`, `timezone`,
11
- and `brandNeeds`: the `config` keys and asset slots `setup` uses, each a
10
+ `locale`, `timezone`, and `brandNeeds`: the `config` keys and asset slots `setup` uses, each a
12
11
  description (where it shows, what shape) or `{ what, optional: true }`. A
13
12
  brand without a need that isn't optional fails `check`.
14
13
 
@@ -68,25 +67,19 @@ export default defineDemo({
68
67
  beats around clicks, the camera's settle and a linger before each card.
69
68
  Holds may not add up past the app's idle timeout.
70
69
 
71
- ## Variant (`<app>/demo/stories/<story>.<brand>.json`)
72
-
73
- `extends` (the base story's name), `brand`, optional `description`, `title`,
74
- `closing` and `cards` (one entry per card step of the base, in order). Each
75
- card entry takes the card fields and changes only those it names. No
76
- `steps`, no `primary`: the flow is the base's. Filmed with its brand only.
77
-
78
- ## Brand (`<app>/demo/brands/<name>.json`, assets in `<name>/`)
70
+ ## Brand (the engine's `navipartner`, or one a pitch draft builds)
79
71
 
80
72
  - `tenantName`
81
73
  - `config`: opaque to the engine, the keys the app's branding API reads
82
- (`primaryColor`, …). An adapter may also read `config.demoData`: the
83
- customer's names for the mock's products, keyed by the mock's barcodes
84
- (prices and flows stay the mock's). It takes `demoData` out before it
74
+ (`primaryColor`, …). An adapter may also read `config.demoData`: names
75
+ for the mock's products, keyed by the mock's barcodes (prices and flows
76
+ stay the mock's). It takes `demoData` out before it
85
77
  overlays the branding.
86
- - `assets`: slot → file, e.g. `{ "logo": "<name>/logo.svg" }`
78
+ - `assets`: slot → file, e.g. `{ "logo": "navipartner/logo.svg" }`
87
79
  - `video`: `primary`, `ink`, `accent`, `text`, `headingFont`, `bodyFont`,
88
80
  optional `fontsUrl`, `logo` (asset slot)
89
81
  - optional `logoColor` (recolours white fills in an SVG logo) and
90
82
  `stage.background`
91
83
 
92
- The engine ships `navipartner`; an app's own brand of the same name wins.
84
+ Repos keep no brands: `check` refuses a `demo/brands/` folder. A pitch
85
+ draft's brand is built by `demo-video pitch` from its proposal (linear.md).