@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.
package/README.md CHANGED
@@ -1,132 +1,107 @@
1
1
  # @navipartner/demo-video
2
2
 
3
- Marketing demo videos of React apps.
3
+ Marketing demo videos of React apps, in the NaviPartner look: brisk camera
4
+ pushes and big serif cards that can show a snapshot of the result beside
5
+ the words. A story is a showcase of an app's main journey (45–90 s) or a
6
+ clip of one feature (15–30 s). The engine films an app on its own
7
+ Playwright e2e mocks, frame by frame at 60 fps, so pans stay smooth. It
8
+ works in pnpm repos on GitHub Actions, with `@playwright/test` 1.59 or later.
4
9
 
5
- - Filmed on the app's own e2e mocks.
6
- - NaviPartner look: camera pushes, serif cards.
7
- - NaviPartner's brand, the only one kept.
8
- - Customer brands researched per video, never saved.
9
- - Smooth 60 fps, frame by frame.
10
- - pnpm repos, GitHub Actions, `@playwright/test` 1.59+.
10
+ NaviPartner's brand is the only one kept. A video in a customer's branding
11
+ is made in Linear, where @Linear researches their brand for that one video
12
+ and saves nothing.
11
13
 
12
14
  ## Setup
13
15
 
16
+ From the repo root:
17
+
14
18
  npx --ignore-scripts @navipartner/demo-video init
15
19
  pnpm install
16
20
 
17
- - Adds the skill, label and workflow.
18
- - Finds apps with a `playwright.config`.
19
- - One app: set up directly.
20
- - Several apps: `init --app <folder>` each.
21
- - Safe to re-run; `--dry-run` previews.
22
- - Then ask Claude for a video.
21
+ `init` adds the skill, the `demo video` label and the workflow, and finds
22
+ the apps: folders with a `playwright.config`. A repo with one app is set up
23
+ in one go; with several, it prints the `init --app <folder>` line to run
24
+ for each.
25
+ It's safe to re-run, and `--dry-run` previews. Then ask Claude for a demo
26
+ video of the app: it writes the adapter and the primary story with you.
23
27
 
24
28
  ## Get a video
25
29
 
26
- | Ask | Who | You get |
30
+ | Where | Ask | For |
27
31
  |---|---|---|
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 |
30
- | `/demo-video` on a PR | members | primary story, from the PR branch |
31
- | `/demo-video checkout` | members | that story |
32
- | `/demo-video checkout in danish` | members | its translation |
33
- | `/demo-video help` | members | menu of stories |
34
- | `demo video` label on a PR | whoever labels | every changed story, each push |
35
- | Actions → Demo video → Run workflow | repo writers | any story, any branch |
36
- | `pnpm exec demo-video render` | developers | any story, locally |
37
-
38
- - Requests only pick existing stories.
39
- - New stories come via PRs.
40
- - PR comments: first line is the request.
41
- - Unknown names get the menu.
42
- - Customer branding only through @Linear.
32
+ | Linear | @Linear "video of checkout from np-pos" | anyone who wants a video of an existing story |
33
+ | Linear | @Linear "… for Northwind (northwind.example)" | the same, in a customer's researched brand |
34
+ | A PR | the `demo video` label | reviewing the stories the PR adds or changes |
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 |
43
37
 
44
- ### In Linear
38
+ Requests only pick existing stories; new stories come through a developer's
39
+ PR. In a PR comment, the first line is the request, a translation is found
40
+ by its language (`checkout in danish`), and a name that doesn't match gets
41
+ the menu.
45
42
 
46
- - Ask @Linear in plain words.
47
- - Name the repo, story and customer.
48
- - Follows the skill's `linear.md`.
49
- - NaviPartner itself: 60 fps video.
50
- - A customer: a pitch draft.
51
- - MP4 and contact sheet attached.
52
- - Nothing committed, nothing kept.
53
- - Linear AI credits, no API key.
54
- - Slow sandbox: ~15× video length.
43
+ ### In Linear
55
44
 
56
- ### Pitch drafts
45
+ Ask @Linear in plain words, naming the repo, the story and the customer. In
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.
57
49
 
58
- - Researched from the customer's own website.
59
- - Checked, then filmed at 30 fps.
60
- - Google Fonts stand-ins only.
61
- - That customer only; check before sending.
62
- - Other use needs their written permission.
50
+ For a customer, the video is a **pitch draft**. @Linear researches their
51
+ brand from their own website, the engine checks it and films at 30 fps, and
52
+ the brand is deleted afterwards. Fonts are Google Fonts stand-ins. A pitch
53
+ draft is for that customer only: check it before sending it, and any other
54
+ use needs their written permission.
63
55
 
64
56
  ## Stories and brands
65
57
 
66
- - **Story:** `demo/stories/<name>.json`, scenes as JSON.
67
- - Built only from the mocks.
68
- - One story per app is primary.
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.
76
- - The skill has fields and the look.
58
+ A story is `demo/stories/<name>.json`: the scenes of one journey, built only
59
+ from what the e2e mocks return. One story per app is its primary story. A
60
+ translation is a story of its own, such as `checkout-da.json`, with the same
61
+ flow and its cards in that language.
62
+
63
+ Repos keep no brands. `navipartner` ships with the engine, `check` refuses a
64
+ `demo/brands/` folder, and stories never name a brand. The adapter's
65
+ `brandNeeds` lists what a brand gives the app (a background image, a
66
+ secondary colour), which is exactly what a pitch draft researches. The skill
67
+ (`skills/np-demo-video/`) has every field and the look.
77
68
 
78
69
  ## Commands
79
70
 
80
- Run in the app's folder: `pnpm exec demo-video <command>`.
71
+ Run them in the app's folder: `pnpm exec demo-video <command>`.
81
72
 
82
73
  | Command | Does |
83
74
  |---|---|
84
- | `init` | sets up a repo or app |
85
- | `list` | stories, primary first |
86
- | `check` | dry run in seconds, names problems |
87
- | `render` | MP4, contact sheet, ten stills |
88
- | `record` / `encode` / `stills` | render in parts |
89
- | `brand-needs` | what a brand must give |
90
- | `pitch <proposal.json>` | pitch draft from a researched brand |
91
- | `pick` | what a request means (workflows) |
92
-
93
- | Flag | Means |
94
- |---|---|
95
- | `--story <name>` | default: primary story |
96
- | `--draft` | 30 fps, twice as fast |
97
- | `--app <dir>` | app folder, default `.` |
98
- | `--json` | machine-readable output |
99
- | `--brand`, `--brands <dir>` | used by `pitch` only |
100
-
101
- Output lands in `demo/out/`. Failures keep Playwright's output in `demo/out/failed/`.
102
-
103
- ## Develop
75
+ | `init` | sets up a repo or an app |
76
+ | `list` | the stories, primary first |
77
+ | `check` | a dry run in seconds that names what would film badly |
78
+ | `render` | the MP4, a contact sheet and ten stills, in `demo/out/` (it checks first when cards show snapshots) |
79
+ | `record`, `encode`, `stills` | `render` in parts |
80
+ | `brand-needs` | what a brand must give this app |
81
+ | `pitch <proposal.json>` | a pitch draft from a researched brand |
82
+ | `pick` | what a request means (used by the workflows) |
83
+
84
+ `--story <name>` picks a story (default: the primary one), `--draft` films
85
+ at 30 fps in about half the time, `--app <dir>` names the app folder and
86
+ `--json` gives machine-readable output. When something fails, Playwright's
87
+ output is kept in `demo/out/failed/`.
88
+
89
+ ## Develop and release
104
90
 
105
91
  pnpm install
106
92
  pnpm test && pnpm typecheck && pnpm lint && pnpm build
107
93
 
108
- - CI runs tests, actionlint, smoke render.
109
-
110
- ## Release
111
-
112
- 1. Bump `version` on main.
113
- 2. `git tag vX.Y.Z && git push origin vX.Y.Z`.
114
-
115
- - CI publishes to npm (`latest`).
116
- - Trusted publishing, no npm token.
117
- - `v0` tag moves along.
118
- - Manual `release.yml` run: `next` prerelease.
119
-
120
- ## Public package, private repo
121
-
122
- - `check-public.mjs` checks every tarball.
123
- - Allowed files only, no source maps.
124
- - No secrets, emails or local paths.
125
- - Names aren't caught: keep customers out.
94
+ 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`
97
+ through trusted publishing (no npm token) and moves the `v0` tag. Running
98
+ `release.yml` by hand publishes a `next` prerelease.
126
99
 
127
- ## Compatibility
100
+ The package is public; this repo is not. `scripts/check-public.mjs` checks
101
+ every tarball for files outside the allowlist, source maps, secrets, emails
102
+ and local paths. It can't catch names, so keep customers and people out of
103
+ everything that ships.
128
104
 
129
- - Projects pin the CLI version.
130
- - Their workflows follow `v0` immediately.
131
- - Workflows must support CLI 0.4+.
132
- - Breaking that means `v1`.
105
+ Projects pin the CLI in their lockfile, but their workflows follow `v0` at
106
+ once. So `render.yml` and `request.yml` must work with every CLI from 0.4;
107
+ anything that can't is `v1`.
@@ -8,8 +8,8 @@ import path from "node:path";
8
8
 
9
9
  import { listStories, parseArgs } from "./args.mjs";
10
10
  import { encodeRecording, extractStillsFrom } from "./encode.mjs";
11
- import { initApp, initProject } from "./init.mjs";
12
- import { appMenu, menuMarkdown, pick, pickExact } from "./pick.mjs";
11
+ import { findApps, initApp, initProject } from "./init.mjs";
12
+ import { appMenu, menuMarkdown, pick } from "./pick.mjs";
13
13
  import { buildPitchBrand } from "./pitch.mjs";
14
14
  import { writeRunner } from "./runner.mjs";
15
15
 
@@ -56,15 +56,85 @@ const runPlaywright = (args, mode) => {
56
56
  DEMO_OUT: args.outDir,
57
57
  DEMO_WORK: args.workDir,
58
58
  DEMO_DRAFT: args.draft ? "1" : "",
59
+ DEMO_STILLS: args.stillsDir ?? "",
59
60
  },
60
61
  },
61
62
  );
62
63
  return result.status ?? 1;
63
64
  };
64
65
 
66
+ const readReport = (args) => {
67
+ const reportPath = path.join(args.workDir, "check.json");
68
+ return fs.existsSync(reportPath)
69
+ ? JSON.parse(fs.readFileSync(reportPath, "utf8"))
70
+ : {
71
+ ok: false,
72
+ error:
73
+ "the dry run produced no report (Playwright did not run the demo test — see its output above)",
74
+ };
75
+ };
76
+
77
+ // A card that shows a still of its chapter's result (`show`) needs the
78
+ // still before filming: a check captures it, as the element last looks in
79
+ // that chapter. Returns the check's run, whose folder holds the stills
80
+ // until the take is filmed, or null when no card shows one.
81
+ const captureStills = (args) => {
82
+ const story = JSON.parse(fs.readFileSync(args.storyboardPath, "utf8"));
83
+ const shows =
84
+ story.title?.show !== undefined ||
85
+ (story.steps ?? []).some((s) => s?.do === "card" && s.show !== undefined);
86
+ if (!shows) {
87
+ return null;
88
+ }
89
+ const workDir = path.join(path.dirname(args.workDir), "check");
90
+ const runnerDir = path.join(workDir, "runner");
91
+ const check = {
92
+ ...args,
93
+ command: "check",
94
+ workDir,
95
+ runnerDir,
96
+ configPath: path.join(runnerDir, "playwright.config.mjs"),
97
+ stillsDir: path.join(workDir, "stills"),
98
+ };
99
+ const status = runPlaywright(check, "check");
100
+ const report = readReport(check);
101
+ if (!report.ok || status !== 0) {
102
+ finish(check, false);
103
+ throw new Error(
104
+ `The story doesn't pass check, so its card stills can't be captured: ${report.error}`,
105
+ );
106
+ }
107
+ return check;
108
+ };
109
+
110
+ // Films the take, with the card stills when the story has any.
111
+ const film = (args) => {
112
+ const check = captureStills(args);
113
+ const status = runPlaywright(
114
+ { ...args, stillsDir: check?.stillsDir },
115
+ "record",
116
+ );
117
+ if (check) {
118
+ finish(check, true);
119
+ }
120
+ return status;
121
+ };
122
+
123
+ // How long each kind runs (look.md, src/timing.ts KIND_SECONDS).
124
+ const KIND_SECONDS = { showcase: [45, 90], feature: [15, 30] };
125
+
65
126
  const printReport = (report) => {
66
127
  if (report.ok) {
67
128
  console.log(`✓ story passes (${report.steps} steps)`);
129
+ const range = KIND_SECONDS[report.kind];
130
+ if (report.seconds !== undefined && range) {
131
+ const [low, high] = range;
132
+ const fits =
133
+ report.seconds < low ? "short" : report.seconds > high ? "long" : null;
134
+ console.log(
135
+ ` about ${report.seconds} s; a ${report.kind === "feature" ? "feature clip" : "showcase"} runs ${low}–${high} s${fits ? `, so this is ${fits}` : ""}`,
136
+ );
137
+ }
68
138
  return;
69
139
  }
70
140
  const where = report.step
@@ -100,6 +170,14 @@ const runInit = (argv) => {
100
170
  return 2;
101
171
  }
102
172
  const root = process.cwd();
173
+ if (app && !fs.existsSync(path.join(root, app, "package.json"))) {
174
+ // Name the folders that are apps, so the fix is a copy away.
175
+ const apps = findApps(root);
176
+ console.error(
177
+ `${app} isn't an app: it has no package.json.${apps.length ? `\nApps here: ${apps.join(", ")}` : ""}`,
178
+ );
179
+ return 2;
180
+ }
103
181
  const result = app
104
182
  ? initApp(path.resolve(root, app), { dryRun })
105
183
  : initProject(root, { dryRun });
@@ -124,35 +202,31 @@ const runInit = (argv) => {
124
202
  return 0;
125
203
  };
126
204
 
127
- // demo-video pick [--request "<text>" | --app <a> --story <s> --brand <b>]
128
- // [--json] <app dir>...: which existing story and brand a request means
129
- // (see pick.mjs). --request reads a sentence (a /demo-video comment); the
130
- // flags are exact names (the Run workflow form).
205
+ // demo-video pick [--request "<text>"] [--json] <app dir>...: which existing
206
+ // story a request means (see pick.mjs): a /demo-video comment, or a request
207
+ // to @Linear. No request: the primary story.
131
208
  const runPick = (argv) => {
132
- const flags = {};
209
+ let request = "";
133
210
  const dirs = [];
134
211
  let json = false;
135
212
  for (let i = 0; i < argv.length; i++) {
136
213
  const arg = argv[i];
137
214
  if (arg === "--json") {
138
215
  json = true;
139
- } else if (["--request", "--app", "--story"].includes(arg)) {
140
- flags[arg.slice(2)] = argv[++i] ?? "";
216
+ } else if (arg === "--request") {
217
+ request = argv[++i] ?? "";
141
218
  } else {
142
219
  dirs.push(arg);
143
220
  }
144
221
  }
145
222
  if (dirs.length === 0) {
146
223
  console.error(
147
- 'Usage: demo-video pick [--request "<text>" | --app <a> --story <s>] [--json] <app dir>...',
224
+ 'Usage: demo-video pick [--request "<text>"] [--json] <app dir>...',
148
225
  );
149
226
  return 2;
150
227
  }
151
228
  const apps = dirs.map((d) => appMenu(path.resolve(d)));
152
- const result =
153
- flags.request !== undefined
154
- ? pick(flags.request, apps)
155
- : pickExact(apps, flags);
229
+ const result = pick(request, apps);
156
230
  const out = result.menu
157
231
  ? {
158
232
  menu: true,
@@ -211,21 +285,25 @@ const runPitch = async (argv) => {
211
285
  brandsDir,
212
286
  "--json",
213
287
  ];
288
+ // The check also captures the card stills, in the customer's brand.
214
289
  const check = parseArgs(["check", ...common], process.cwd());
290
+ check.stillsDir = path.join(check.workDir, "stills");
215
291
  const checked = runPlaywright(check, "check");
216
- const reportPath = path.join(check.workDir, "check.json");
217
- const report = fs.existsSync(reportPath)
218
- ? JSON.parse(fs.readFileSync(reportPath, "utf8"))
219
- : { ok: false, error: "the dry run produced no report" };
220
- finish(check, report.ok && checked === 0);
292
+ const report = readReport(check);
221
293
  if (!report.ok || checked !== 0) {
294
+ finish(check, false);
222
295
  throw new Error(
223
296
  `The researched brand doesn't film with this story: ${report.error}`,
224
297
  );
225
298
  }
226
299
  // A pitch is a draft for one customer: 30 fps, about half the time.
227
300
  const render = parseArgs(["render", ...common, "--draft"], process.cwd());
228
- if (runPlaywright(render, "record") !== 0) {
301
+ const filmed = runPlaywright(
302
+ { ...render, stillsDir: check.stillsDir },
303
+ "record",
304
+ );
305
+ finish(check, true);
306
+ if (filmed !== 0) {
229
307
  finish(render, false);
230
308
  throw new Error("Filming the pitch draft failed (see above)");
231
309
  }
@@ -283,14 +361,7 @@ const run = () => {
283
361
  case "check": {
284
362
  const status = runPlaywright(args, "check");
285
363
  // The report comes back through the work folder and goes with it.
286
- const reportPath = path.join(args.workDir, "check.json");
287
- const report = fs.existsSync(reportPath)
288
- ? JSON.parse(fs.readFileSync(reportPath, "utf8"))
289
- : {
290
- ok: false,
291
- error:
292
- "the dry run produced no report (Playwright did not run the demo test — see its output above)",
293
- };
364
+ const report = readReport(args);
294
365
  const ok = report.ok && status === 0;
295
366
  finish(args, ok);
296
367
  if (args.json) {
@@ -302,14 +373,14 @@ const run = () => {
302
373
  }
303
374
  case "record": {
304
375
  // The recording stays in the work folder for `encode`.
305
- const status = runPlaywright(args, "record");
376
+ const status = film(args);
306
377
  if (status !== 0) {
307
378
  finish(args, false);
308
379
  }
309
380
  return status;
310
381
  }
311
382
  case "render": {
312
- const status = runPlaywright(args, "record");
383
+ const status = film(args);
313
384
  if (status !== 0) {
314
385
  finish(args, false);
315
386
  return status;
package/bin/init.d.mts CHANGED
@@ -5,6 +5,8 @@ export interface InitResult {
5
5
  }
6
6
  export const IGNORE_LINE: string;
7
7
  export const LABEL: string;
8
+ /** How init is run before the package is installed. */
9
+ export const INIT: string;
8
10
  export const CALLER_WORKFLOW: string;
9
11
  export function initRepo(
10
12
  root: string,
package/bin/init.mjs CHANGED
@@ -9,6 +9,8 @@ import { fileURLToPath } from "node:url";
9
9
  // workflow. Safe to re-run; with dryRun it reports what it would change.
10
10
 
11
11
  const PKG = "@navipartner/demo-video";
12
+ /** How init is run before the package is installed (the README's way). */
13
+ export const INIT = `npx --ignore-scripts ${PKG} init`;
12
14
  export const IGNORE_LINE = "**/demo/out/";
13
15
  export const LABEL = "demo video";
14
16
 
@@ -22,23 +24,14 @@ const engineVersion = () =>
22
24
 
23
25
  export const CALLER_WORKFLOW = `name: Demo video
24
26
  # The "${LABEL}" label films the stories a PR adds or changes. A comment
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
+ # "/demo-video [<story>]" on a PR films an existing story in NaviPartner's
28
+ # brand. A customer's branding: ask
27
29
  # @Linear (the skill's linear.md); repos keep no brands.
28
30
  on:
29
31
  pull_request:
30
32
  types: [labeled, synchronize]
31
33
  issue_comment:
32
34
  types: [created]
33
- # Actions → Demo video → Run workflow: film a story.
34
- workflow_dispatch:
35
- inputs:
36
- app:
37
- description: "App folder, e.g. apps/retail-self-service (empty: the only app)"
38
- default: ""
39
- story:
40
- description: "Story name (empty: the app's primary story)"
41
- default: ""
42
35
  permissions: {}
43
36
  jobs:
44
37
  render:
@@ -48,15 +41,12 @@ jobs:
48
41
  pull-requests: write
49
42
  uses: navipartner/demo-video/.github/workflows/render.yml@v0
50
43
  request:
51
- if: github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/demo-video'))
44
+ if: github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/demo-video')
52
45
  permissions:
53
46
  contents: read
54
47
  pull-requests: write
55
48
  issues: write
56
49
  uses: navipartner/demo-video/.github/workflows/request.yml@v0
57
- with:
58
- app: \${{ inputs.app }}
59
- story: \${{ inputs.story }}
60
50
  `;
61
51
 
62
52
  const read = (file) =>
@@ -257,7 +247,7 @@ export const initRepo = (root, { dryRun = false, label = true } = {}) => {
257
247
  .find(Boolean);
258
248
  if (existing && !existing.includes("request.yml@")) {
259
249
  todo.push(
260
- '.github/workflows/demo-video.yml predates the "request" job (/demo-video comments, the Run workflow form): delete it and re-run init to get the current one.',
250
+ '.github/workflows/demo-video.yml predates the "request" job (/demo-video comments): delete it and re-run init to get the current one.',
261
251
  );
262
252
  }
263
253
  } else {
@@ -378,7 +368,7 @@ export const initApp = (appDir, { dryRun = false } = {}) => {
378
368
  const todo = [];
379
369
  const pkgFile = path.join(appDir, "package.json");
380
370
  if (!fs.existsSync(pkgFile)) {
381
- throw new Error(`No package.json in ${appDir}`);
371
+ throw new Error(`${appDir} has no package.json, so it isn't an app.`);
382
372
  }
383
373
  const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
384
374
  const version = engineVersion();
@@ -408,14 +398,19 @@ export const initApp = (appDir, { dryRun = false } = {}) => {
408
398
  );
409
399
  }
410
400
 
401
+ const configFile = ["ts", "mts", "js", "mjs", "cts", "cjs"]
402
+ .map((ext) => path.join(appDir, `playwright.config.${ext}`))
403
+ .find((f) => fs.existsSync(f));
404
+ if (!configFile) {
405
+ todo.push(
406
+ "Add a playwright.config to this folder: videos run on the app's e2e setup.",
407
+ );
408
+ }
411
409
  const demoDir = path.join(appDir, "demo");
412
410
  const adapter = path.join(demoDir, "demo.config.ts");
413
411
  if (fs.existsSync(adapter)) {
414
412
  changes.push(["ok", "demo/demo.config.ts", "exists"]);
415
413
  } else {
416
- const configFile = ["ts", "mts", "js", "mjs"]
417
- .map((ext) => path.join(appDir, `playwright.config.${ext}`))
418
- .find((f) => fs.existsSync(f));
419
414
  const webServer = configFile ? webServerOf(read(configFile)) : {};
420
415
  const skeleton = adapterSkeleton({
421
416
  app: (pkg.name ?? path.basename(appDir)).replace(/^@[^/]+\//, ""),
@@ -504,12 +499,16 @@ export const initProject = (root, options = {}) => {
504
499
  }
505
500
  const todo = [...repo.todo];
506
501
  if (apps.length > 1) {
502
+ // Exactly what to run: one line per app, copied as is.
507
503
  todo.push(
508
- `This repo has ${apps.length} apps with Playwright e2e tests: ${apps.join(", ")}. Set up each one you want videos of with init --app <folder>.`,
504
+ [
505
+ `${apps.length} apps found and none set up yet. Run this for each app you want videos of:`,
506
+ ...apps.map((app) => ` ${INIT} --app ${app}`),
507
+ ].join("\n"),
509
508
  );
510
509
  } else {
511
510
  todo.push(
512
- "No app with a playwright.config found: the demo films an app on its Playwright e2e tests. Add them, then run init again (or init --app <folder>).",
511
+ `No app found (a folder with package.json and playwright.config). Run ${INIT} --app <folder> once it has both.`,
513
512
  );
514
513
  }
515
514
  return { apps, changes: repo.changes, todo };
package/bin/pick.d.mts CHANGED
@@ -15,7 +15,3 @@ export type Pick =
15
15
  export function appMenu(appDir: string): AppMenu;
16
16
  export function pick(request: string, apps: AppMenu[]): Pick;
17
17
  export function menuMarkdown(apps: AppMenu[], reason: string): string;
18
- export function pickExact(
19
- apps: AppMenu[],
20
- names: { app?: string; story?: string },
21
- ): Pick;
package/bin/pick.mjs CHANGED
@@ -171,42 +171,3 @@ export const menuMarkdown = (apps, reason) => {
171
171
  }
172
172
  return lines.join("\n");
173
173
  };
174
-
175
- /**
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.
180
- */
181
- export const pickExact = (apps, { app = "", story = "" }) => {
182
- const slashes = (p) => p.split("\\").join("/");
183
- const wanted = slashes(app).replace(/\/+$/, "");
184
- const candidates = wanted
185
- ? apps.filter((a) => {
186
- const dir = slashes(a.dir);
187
- return (
188
- a.name === wanted || dir === wanted || dir.endsWith(`/${wanted}`)
189
- );
190
- })
191
- : apps;
192
- if (candidates.length === 0) {
193
- return { menu: true, reason: `No app \`${wanted}\`. The apps:` };
194
- }
195
- if (candidates.length > 1) {
196
- return { menu: true, reason: "Which app? Fill in App with one of:" };
197
- }
198
- const chosen = candidates[0];
199
- const found = story
200
- ? chosen.stories.find((s) => s.name === story)
201
- : chosen.stories.find((s) => s.primary);
202
- if (!found) {
203
- return {
204
- menu: true,
205
- reason: story
206
- ? `No story \`${story}\` in ${chosen.name}. Pick one:`
207
- : `${chosen.name} has no primary story. Pick one:`,
208
- apps: [chosen],
209
- };
210
- }
211
- return { app: chosen.dir, story: found.name };
212
- };