@plaintake/scenario 1.5.0 → 1.7.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/dist/index.js CHANGED
@@ -42,7 +42,16 @@ var DemoEventTypeSchema = z.enum([
42
42
  * Appended once, when the wait ends, so `tEndNs` is a real reading rather than a
43
43
  * placeholder: the `assertion.pass` shape, not the `step.start`/`step.finish` pair.
44
44
  */
45
- "human.wait"
45
+ "human.wait",
46
+ /*
47
+ * An actor hand-off: `demo.turn(actor, ...)` parking the outgoing actor's `BrowserContext`
48
+ * and activating the incoming one. A member of its own rather than folded into
49
+ * `step.start`/`step.finish`, because a hand-off is not a step — it has no `target` and
50
+ * narrates no action of its own. Verb steps taken during a turn keep using
51
+ * `step.start`/`step.finish` exactly as before; the `actor` field below records which
52
+ * actor performed the step.
53
+ */
54
+ "turn"
46
55
  ]);
47
56
  var TargetSchema = z.object({
48
57
  role: z.string().optional(),
@@ -60,7 +69,15 @@ var DemoEventSchema = z.object({
60
69
  title: z.string().optional(),
61
70
  subtitle: z.string().optional(),
62
71
  target: TargetSchema.optional(),
63
- payload: z.record(z.string(), z.unknown()).optional()
72
+ payload: z.record(z.string(), z.unknown()).optional(),
73
+ /**
74
+ * Which actor performed this event, for a `turn` hand-off or a verb step taken during one.
75
+ * Absent means the scenario's default actor — a single-actor event log never sets this, so
76
+ * it stays byte-identical to every log recorded before actors existed. No `.default()`
77
+ * here: the default actor's id is an application-layer notion the recorder resolves, not a
78
+ * value this schema should invent and materialise on parse.
79
+ */
80
+ actor: z.string().min(1).optional()
64
81
  });
65
82
 
66
83
  // ../schema/src/json-schema.ts
@@ -349,6 +366,25 @@ var CameraSchema = z5.object({
349
366
  path: ["shots"]
350
367
  }
351
368
  );
369
+ var ActorSchema = z5.object({
370
+ id: z5.string().min(1),
371
+ label: z5.string().min(1)
372
+ });
373
+ var SegmentSchema = z5.object({
374
+ id: z5.string().min(1),
375
+ actorId: z5.string().min(1),
376
+ startMs: z5.number().int().nonnegative(),
377
+ endMs: z5.number().int().positive()
378
+ });
379
+ var TransitionCardSchema = z5.object({
380
+ fromActorId: z5.string().min(1),
381
+ toActorId: z5.string().min(1),
382
+ durationMs: z5.number().int().positive().max(15e3),
383
+ lines: z5.array(z5.string().min(1)).min(1).max(2),
384
+ backgroundColor: HEX_COLOUR,
385
+ textColor: HEX_COLOUR,
386
+ assPath: z5.string().regex(/^captions\/turn-[0-9]+\.ass$/)
387
+ });
352
388
  var HighlightRectSchema = z5.object({
353
389
  id: z5.string().min(1),
354
390
  x: z5.number().int().min(0),
@@ -625,8 +661,54 @@ var RenderPlanSchema = z5.object({
625
661
  cursor: CursorSchema.optional(),
626
662
  camera: CameraSchema.optional(),
627
663
  highlight: HighlightSchema.optional(),
628
- speech: SpeechSchema.optional()
629
- });
664
+ speech: SpeechSchema.optional(),
665
+ /**
666
+ * The multi-actor demo's cast, its capture timeline cut into per-actor windows, the
667
+ * cards drawn at each hand-off, and the badge track naming the active actor throughout —
668
+ * four fields that carry one capability and so, like every other optional block above,
669
+ * are present or absent together (enforced by the first refinement below). Absent means
670
+ * a single-actor demo, which is every plan before this and the default after it.
671
+ */
672
+ actors: z5.array(ActorSchema).min(2).optional(),
673
+ segments: z5.array(SegmentSchema).min(2).optional(),
674
+ transitionCards: z5.array(TransitionCardSchema).min(1).optional(),
675
+ badgesAssPath: z5.literal("captions/badges.ass").optional()
676
+ }).refine(
677
+ (plan) => {
678
+ const capabilityFields = [plan.actors, plan.segments, plan.transitionCards, plan.badgesAssPath];
679
+ return capabilityFields.every((field) => field !== void 0) || capabilityFields.every((field) => field === void 0);
680
+ },
681
+ {
682
+ // The same "carries its capability" discipline every optional block above already
683
+ // follows: a plan naming actors with nowhere to draw their badges (or vice versa)
684
+ // cannot render, so it is refused at the parse rather than discovered mid-encode.
685
+ message: "actors, segments, transitionCards and badgesAssPath must be present together or absent together",
686
+ path: ["actors"]
687
+ }
688
+ ).refine(
689
+ (plan) => {
690
+ if (plan.actors === void 0 || plan.segments === void 0 || plan.transitionCards === void 0 || plan.badgesAssPath === void 0) {
691
+ return true;
692
+ }
693
+ const { actors, segments, transitionCards } = plan;
694
+ const namesDeclaredActor = (actorId) => actors.some((actor) => actor.id === actorId);
695
+ return transitionCards.length === segments.length - 1 && segments[0]?.startMs === 0 && segments.every(
696
+ (segment, index) => segment.endMs > segment.startMs && (index === 0 || segments[index - 1]?.endMs === segment.startMs) && namesDeclaredActor(segment.actorId)
697
+ ) && transitionCards.every(
698
+ (card) => namesDeclaredActor(card.fromActorId) && namesDeclaredActor(card.toActorId)
699
+ );
700
+ },
701
+ {
702
+ // Mirrors `ChaptersSchema`'s tiling refine: segments must start at 0 and tile the
703
+ // source contiguously, the same reason a gap or overlap in chapter marks is refused
704
+ // rather than tolerated. `transitionCards.length === segments.length - 1` is the
705
+ // count a hand-off between each pair of adjacent segments implies, and every
706
+ // `segment.actorId`/`transitionCard.fromActorId`/`transitionCard.toActorId` must name
707
+ // a declared actor or there is nobody for the recorder to have handed the context to.
708
+ message: "segments must tile raw/session.webm contiguously from 0, transitionCards.length must equal segments.length - 1, and every segment.actorId, transitionCard.fromActorId and transitionCard.toActorId must name a declared actor",
709
+ path: ["segments"]
710
+ }
711
+ );
630
712
 
631
713
  // ../schema/src/results.ts
632
714
  import { z as z6 } from "zod";
@@ -760,7 +842,7 @@ var VerificationReportSchema = z6.object({
760
842
  bundleDir: DISPLAY_PATH,
761
843
  artifactCount: z6.number().int().nonnegative()
762
844
  });
763
- var DifferenceCategorySchema = z6.enum(["step", "assertion", "target", "timing", "caption"]);
845
+ var DifferenceCategorySchema = z6.enum(["step", "assertion", "target", "timing", "caption", "actor"]);
764
846
  var DiffCommandResultSchema = z6.object({
765
847
  ...envelope("diff"),
766
848
  bundleA: DISPLAY_PATH,
@@ -846,6 +928,35 @@ var InspectResultSchema = z6.object({
846
928
  suppliedCount: z6.number().int().nonnegative(),
847
929
  voice: z6.string().optional()
848
930
  }).optional(),
931
+ /**
932
+ * The multi-actor demo's cast, or absent for a single-actor bundle — every bundle
933
+ * recorded before actors existed, and still the default for most scenarios after, so
934
+ * this stays absent rather than a zero-cast object, the same distinction `narration`
935
+ * above draws between "silent" and "nothing to report".
936
+ *
937
+ * Sourced from the frozen plan's `actors`/`segments` fields, never the scenario file:
938
+ * `inspectCommand` (`apps/cli/src/commands.ts`) reads only the render plan and manifest,
939
+ * so a bundle whose scenario has since been edited or deleted still inspects correctly.
940
+ * Not sourced from `plan.transitionCards`, even though `transitionCards.length` and
941
+ * `segments.length - 1` are provably equal by the render plan's own refinement: `segments`
942
+ * is the more primal fact — the capture's own cut points — so `turnCount` is derived from
943
+ * the field a reader of this JSON would already trust as the timeline, not from a second
944
+ * field that only agrees with it by a schema-level invariant this result does not surface.
945
+ */
946
+ actors: z6.object({
947
+ /** `plan.actors.length`. Reported alongside `labels` rather than left for a caller to
948
+ * compute, matching `narration.clipCount` sitting beside `narration` above for the
949
+ * same reason: the count is the fact most callers want first, before the list. */
950
+ count: z6.number().int().min(2),
951
+ /** In `plan.actors` order — the cast list a badge or transition card would draw, not
952
+ * deduplicated or sorted. */
953
+ labels: z6.array(z6.string().min(1)).min(2),
954
+ /** Hand-offs, not participants: one fewer than `plan.segments.length`, since a
955
+ * segment is a window one actor already holds and a turn is the cut between two of
956
+ * them — the same count `plan.transitionCards.length` carries, arrived at
957
+ * independently from `segments` for the reason above. */
958
+ turnCount: z6.number().int().nonnegative()
959
+ }).refine((value) => value.count === value.labels.length, "actors.count must equal actors.labels.length").optional(),
849
960
  /** The rendered MP4s, from the manifest, so hashes are not recomputed. */
850
961
  outputs: z6.array(ArtifactRefSchema),
851
962
  assertions: z6.array(AssertionSchema),
@@ -9,6 +9,7 @@ export declare const DemoEventTypeSchema: z.ZodEnum<{
9
9
  "assertion.fail": "assertion.fail";
10
10
  "privacy.mask": "privacy.mask";
11
11
  "human.wait": "human.wait";
12
+ turn: "turn";
12
13
  }>;
13
14
  export declare const TargetSchema: z.ZodObject<{
14
15
  role: z.ZodOptional<z.ZodString>;
@@ -36,6 +37,7 @@ export declare const DemoEventSchema: z.ZodObject<{
36
37
  "assertion.fail": "assertion.fail";
37
38
  "privacy.mask": "privacy.mask";
38
39
  "human.wait": "human.wait";
40
+ turn: "turn";
39
41
  }>;
40
42
  stableId: z.ZodOptional<z.ZodString>;
41
43
  title: z.ZodOptional<z.ZodString>;
@@ -52,6 +54,7 @@ export declare const DemoEventSchema: z.ZodObject<{
52
54
  }, z.core.$strip>>;
53
55
  }, z.core.$strip>>;
54
56
  payload: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
57
+ actor: z.ZodOptional<z.ZodString>;
55
58
  }, z.core.$strip>;
56
59
  export type DemoEvent = z.infer<typeof DemoEventSchema>;
57
60
  export type DemoEventType = z.infer<typeof DemoEventTypeSchema>;
@@ -65,9 +65,9 @@ export declare const ChapterMarkSchema: z.ZodObject<{
65
65
  title: z.ZodString;
66
66
  }, z.core.$strip>;
67
67
  /**
68
- * Chapter markers. Only ever produced on the Pro Tier — but nothing here knows that,
69
- * and that is the point: the tier decision is taken once when the bundle is created, and the
70
- * renderer executes what it finds.
68
+ * Chapter markers. Produced whenever the scenario calls `demo.chapter()` — free on every
69
+ * tier — and nothing here knows or decides that, which is the point: the marks freeze into
70
+ * the plan when the bundle is created, and the renderer executes what it finds.
71
71
  *
72
72
  * Unlike the outro's colours, chapter titles are arbitrary scenario text and cannot be
73
73
  * restricted to a safe character set. They never reach a filtergraph — they go into
@@ -190,6 +190,77 @@ export declare const CameraSchema: z.ZodObject<{
190
190
  }, z.core.$strip>;
191
191
  export type Camera = z.infer<typeof CameraSchema>;
192
192
  export type CameraShot = z.infer<typeof CameraShotSchema>;
193
+ /**
194
+ * One participant in a multi-actor demo, each with its own Playwright `BrowserContext` —
195
+ * `AGENTS.md` §1 constraint 3 guarantees at most one is ever active at a single instant.
196
+ * `id` is what `SegmentSchema.actorId` and `TransitionCardSchema.fromActorId`/`toActorId`
197
+ * reference; `label` is the human-readable name a badge or a transition card draws.
198
+ */
199
+ export declare const ActorSchema: z.ZodObject<{
200
+ id: z.ZodString;
201
+ label: z.ZodString;
202
+ }, z.core.$strip>;
203
+ export type Actor = z.infer<typeof ActorSchema>;
204
+ /**
205
+ * One contiguous window of `raw/session.webm` during which a single actor held the (one,
206
+ * at most) active `BrowserContext`. `startMs`/`endMs` are source-relative — the same space
207
+ * `source.durationMs` and the `trim=duration=...` FFmpeg argument already operate in — not
208
+ * a file path: the concatenation of raw capture segments into the one `raw/session.webm`
209
+ * this plan's `source` names has already happened by the time this schema is populated, so
210
+ * there is deliberately no `rawFile`/path field here, unlike what an earlier design draft
211
+ * suggested. A segment describes how that single file is internally structured, never a
212
+ * second file to read.
213
+ */
214
+ export declare const SegmentSchema: z.ZodObject<{
215
+ id: z.ZodString;
216
+ actorId: z.ZodString;
217
+ startMs: z.ZodNumber;
218
+ endMs: z.ZodNumber;
219
+ }, z.core.$strip>;
220
+ export type Segment = z.infer<typeof SegmentSchema>;
221
+ /**
222
+ * A full-frame card drawn between two actors' segments on a hand-off — structurally
223
+ * `Intro`/`Outro`'s sibling, down to the same `HEX_COLOUR` injection guard on its colours,
224
+ * plus which actor is handing off to which.
225
+ *
226
+ * Unlike the single well-known `captions/intro.ass`/`captions/outro.ass`, a demo can have
227
+ * many hand-offs, so `assPath` cannot be one literal. It is a regex-constrained family
228
+ * instead — `SpeechClipSchema.path`'s discipline applied to its own N-member family:
229
+ * constrained enough that no filtergraph metacharacter or path traversal is representable.
230
+ */
231
+ export declare const TransitionCardSchema: z.ZodObject<{
232
+ fromActorId: z.ZodString;
233
+ toActorId: z.ZodString;
234
+ durationMs: z.ZodNumber;
235
+ lines: z.ZodArray<z.ZodString>;
236
+ backgroundColor: z.ZodString;
237
+ textColor: z.ZodString;
238
+ assPath: z.ZodString;
239
+ }, z.core.$strip>;
240
+ export type TransitionCard = z.infer<typeof TransitionCardSchema>;
241
+ /**
242
+ * How long a `demo.turn(actor, { card })` transition card is on screen when its own
243
+ * `durationMs` was omitted.
244
+ *
245
+ * Lives here, beside `TransitionCardSchema`, rather than in either package that resolves it:
246
+ * `packages/recorder-playwright/src/pipeline.ts`'s `transitionCardDurationMs` reads it at
247
+ * derivation time, before `transitionCards` exists, to offset later segments; the renderer
248
+ * that eventually draws a frozen `RenderPlanSchema.transitionCards` entry with no
249
+ * `durationMs` of its own needs the same number. `AGENTS.md` §2 forbids `renderer-ffmpeg`
250
+ * from importing `recorder-playwright` (the reverse edge already exists, so importing back
251
+ * would be the cycle `no-circular` refuses), so a bare constant defined in either package
252
+ * would be unreachable from the other — both already depend on `@plaintake/schema`, which is
253
+ * why it is defined here instead.
254
+ *
255
+ * Matches `DEFAULT_INTRO_MS` and `FREE_TIER_OUTRO.durationMs`
256
+ * (`packages/license/src/branding.ts`), both already 3000ms for the same shape of decision —
257
+ * a full-frame card with a line or two to read, defaulted absent an author- or
258
+ * configuration-supplied length. Not imported from `@plaintake/license`: that package
259
+ * resolves *licensed* branding, and a transition card is never licence-gated (the camera is
260
+ * the one capability still Pro-gated; see `AGENTS.md`), so a shared import would be the wrong
261
+ * fix for a coincidence, not a rule the two packages share.
262
+ */
263
+ export declare const TRANSITION_CARD_DEFAULT_MS = 3000;
193
264
  /**
194
265
  * One dimmed-frame-with-a-hole window: a step's measured target rect, frozen exactly as
195
266
  * `CursorPointSchema` freezes a target's centre, and for the same reason. `renderer-ffmpeg`'s
@@ -339,6 +410,14 @@ export type HighlightInput = Omit<Highlight, 'assPath'>;
339
410
  export type OutroInput = Omit<Outro, 'assPath'>;
340
411
  /** The opening card as the application layer supplies it, exactly as `OutroInput` works. */
341
412
  export type IntroInput = Omit<Intro, 'assPath'>;
413
+ /**
414
+ * The transition card decision as the application layer supplies it — `pipeline.ts`, in
415
+ * practice, which resolves a hand-off's concrete `durationMs` via `transitionCardDurationMs`
416
+ * before this is ever built, exactly as `intro`/`outro` durations are resolved before
417
+ * `buildRenderPlan` is called. `assPath` is filled in by `buildRenderPlan`, indexed by
418
+ * position in the array against the schema's own `captions/turn-[0-9]+\.ass` family.
419
+ */
420
+ export type TransitionCardInput = Omit<TransitionCard, 'assPath'>;
342
421
  /**
343
422
  * The one value a plan's `schema` field ever holds.
344
423
  *
@@ -543,6 +622,26 @@ export declare const RenderPlanSchema: z.ZodObject<{
543
622
  voice: z.ZodString;
544
623
  }, z.core.$strip>>;
545
624
  }, z.core.$strip>>;
625
+ actors: z.ZodOptional<z.ZodArray<z.ZodObject<{
626
+ id: z.ZodString;
627
+ label: z.ZodString;
628
+ }, z.core.$strip>>>;
629
+ segments: z.ZodOptional<z.ZodArray<z.ZodObject<{
630
+ id: z.ZodString;
631
+ actorId: z.ZodString;
632
+ startMs: z.ZodNumber;
633
+ endMs: z.ZodNumber;
634
+ }, z.core.$strip>>>;
635
+ transitionCards: z.ZodOptional<z.ZodArray<z.ZodObject<{
636
+ fromActorId: z.ZodString;
637
+ toActorId: z.ZodString;
638
+ durationMs: z.ZodNumber;
639
+ lines: z.ZodArray<z.ZodString>;
640
+ backgroundColor: z.ZodString;
641
+ textColor: z.ZodString;
642
+ assPath: z.ZodString;
643
+ }, z.core.$strip>>>;
644
+ badgesAssPath: z.ZodOptional<z.ZodLiteral<"captions/badges.ass">>;
546
645
  }, z.core.$strip>;
547
646
  export type RenderPlan = z.infer<typeof RenderPlanSchema>;
548
647
  export type Cue = z.infer<typeof CueSchema>;
@@ -228,6 +228,7 @@ export declare const DiffCommandResultSchema: z.ZodObject<{
228
228
  category: z.ZodEnum<{
229
229
  assertion: "assertion";
230
230
  target: "target";
231
+ actor: "actor";
231
232
  step: "step";
232
233
  timing: "timing";
233
234
  caption: "caption";
@@ -326,6 +327,11 @@ export declare const InspectResultSchema: z.ZodObject<{
326
327
  suppliedCount: z.ZodNumber;
327
328
  voice: z.ZodOptional<z.ZodString>;
328
329
  }, z.core.$strip>>;
330
+ actors: z.ZodOptional<z.ZodObject<{
331
+ count: z.ZodNumber;
332
+ labels: z.ZodArray<z.ZodString>;
333
+ turnCount: z.ZodNumber;
334
+ }, z.core.$strip>>;
329
335
  outputs: z.ZodArray<z.ZodObject<{
330
336
  path: z.ZodString;
331
337
  sha256: z.ZodString;
package/dist/types.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { ScenarioMeta } from './schema/index.js';
2
- import type { Locator, Page } from 'playwright-core';
2
+ import type { BrowserContextOptions, Locator, Page } from 'playwright-core';
3
3
  /**
4
4
  * What kind of interaction `run()` performs, declared rather than observed. Like
5
5
  * `target`, this is metadata the recorder records and never performs — it tells the
6
- * cursor track what to draw at the target (`click` a ripple, `type`/`point` a settle).
7
- * Absent means `point`: the cursor still moves to the target, nothing pulses.
6
+ * cursor track what to draw at the target (`click` a ripple, `type`/`point`/`scroll` a
7
+ * settle — none of the three pulse, only `click` does). Absent means `point`: the cursor
8
+ * still moves to the target, nothing pulses.
8
9
  */
9
- export type DemoAction = 'click' | 'type' | 'point';
10
+ export type DemoAction = 'click' | 'type' | 'point' | 'scroll';
10
11
  export type DemoStep = {
11
12
  id: string;
12
13
  title: string;
@@ -124,6 +125,124 @@ export type DemoHandoff = PreflightHandoff & {
124
125
  */
125
126
  mask?: string;
126
127
  };
128
+ /**
129
+ * Presentation fields shared by every `Actor` verb below — the same subset of `DemoStep`
130
+ * that is still the caller's to supply once `target` and `action` no longer are: a verb's
131
+ * argument list already gives the target (when it has one), and the verb's own name already
132
+ * gives the action. Only how the step presents is left to ask for.
133
+ *
134
+ * `title` is optional here where `DemoStep.title` is required, on purpose: a verb call that
135
+ * narrates nothing — a background click with no caption — is meant to stay a one-liner,
136
+ * not force every single interaction through the same captioning `demo.step()` requires.
137
+ */
138
+ export type ActorStepMeta = {
139
+ /**
140
+ * Stable id for the step this call records, the same role `DemoStep.id` plays and for the
141
+ * same reasons: it becomes the `stableId` on this step's `step.start`/`step.finish` events,
142
+ * the key a target-miss or an abort diagnostic names, and — via `clipFileName(id)` — the
143
+ * exact filename an author drops into `narrationDir` for a `--speech file` WAV. Absent
144
+ * means the runtime synthesizes one; supply your own when you need that identity to stay
145
+ * stable across runs — a hand-chosen id does not shift when an earlier call in the same
146
+ * turn is added, removed or reordered the way a synthesized one might.
147
+ */
148
+ id?: string;
149
+ title?: string;
150
+ subtitle?: string;
151
+ /** See `DemoStep.holdMs`. */
152
+ holdMs?: number;
153
+ /** See `DemoStep.highlight`, including the "requires a measurable target" rule. */
154
+ highlight?: boolean | {
155
+ label?: string;
156
+ };
157
+ };
158
+ /**
159
+ * A named participant in a multi-actor demo: its own Playwright `BrowserContext`, its own
160
+ * page, and a handle of verbs that each perform a Playwright action *and* record the
161
+ * render-track annotation that action needs — the pairing `demo.step()` otherwise leaves an
162
+ * author to assemble by hand (a `target`, a declared `action`, a `run`). `admin.click(button)`
163
+ * does both in one call; there is no separate bookkeeping step the way there is today.
164
+ *
165
+ * `page` is the same escape hatch `DemoRunArgs.page` already is: anything the named verbs
166
+ * below don't model — a Playwright call with no render-track equivalent — is still reachable
167
+ * through it, at the same cost reaching past `demo.step()` into raw Playwright already has
168
+ * today: nothing about the call is recorded automatically.
169
+ *
170
+ * Every verb that takes a `Locator` records that locator's measured rect on `step.start`, the
171
+ * same rect `DemoStep.target` records; a verb with nothing to point at (`goto`, and `scroll`'s
172
+ * delta form) records none, exactly as a `DemoStep` with no `target` does.
173
+ *
174
+ * Same name, different thing from `@plaintake/schema`'s `Actor`: that one is the frozen
175
+ * `{ id, label }` render-plan record a *finished* run leaves behind; this one is the live,
176
+ * verb-bearing handle a scenario's `run()` calls while a session is still recording.
177
+ */
178
+ export type Actor = {
179
+ /** Raw Playwright escape hatch. See this type's own doc comment. */
180
+ page: Page;
181
+ goto(url: string, meta?: ActorStepMeta): Promise<void>;
182
+ click(target: Locator, meta?: ActorStepMeta): Promise<void>;
183
+ /**
184
+ * Fills `target` with `text`. Masking a field's *value* once it exists is `mask`'s job,
185
+ * not this verb's — `type` only ever records that typing happened, never what was typed.
186
+ */
187
+ type(target: Locator, text: string, meta?: ActorStepMeta): Promise<void>;
188
+ /**
189
+ * Presses `key` while `target` holds focus — Playwright's `locator.press`, not the
190
+ * page-global `page.keyboard.press`. A demo step points at something; a key press with
191
+ * nothing to point at is exactly what `actor.page.keyboard.press(key)` remains for, at
192
+ * the same recorded-nothing cost as any other reach past a named verb.
193
+ */
194
+ press(target: Locator, key: string, meta?: ActorStepMeta): Promise<void>;
195
+ /**
196
+ * Scrolls the page or a specific element into view — two different Playwright calls,
197
+ * `locator.scrollIntoViewIfNeeded()` and `page.mouse.wheel(deltaX, deltaY)`, behind one
198
+ * verb, chosen by which shape `where` is. Named `where` rather than `target` because the
199
+ * delta form is not a target — nothing measurable sits behind a pixel offset — where every
200
+ * other verb's `target` names a `Locator` and nothing else. A `Locator` scrolls that element
201
+ * into view and records its rect exactly as `click`/`hover` do; a `{ deltaX?, deltaY? }`
202
+ * delta scrolls the viewport by that many pixels (either defaulting to 0) and records no
203
+ * target, exactly as `goto` does.
204
+ */
205
+ scroll(where: Locator | {
206
+ deltaX?: number;
207
+ deltaY?: number;
208
+ }, meta?: ActorStepMeta): Promise<void>;
209
+ hover(target: Locator, meta?: ActorStepMeta): Promise<void>;
210
+ /**
211
+ * Mirrors `locator.selectOption`'s common case: one value or several. Anything its fuller
212
+ * overload set covers that a plain value or value list cannot is `step`'s or `page`'s to
213
+ * reach instead.
214
+ */
215
+ select(target: Locator, value: string | string[], meta?: ActorStepMeta): Promise<void>;
216
+ /**
217
+ * Reuses `DemoAssertion` verbatim. The only thing this adds over `DemoContext.assert` is
218
+ * which actor the recorded event is attributed to.
219
+ */
220
+ assert(assertion: DemoAssertion): Promise<void>;
221
+ /**
222
+ * Escape hatch for an interaction none of the named verbs above model, bound to this actor
223
+ * exactly as `DemoContext.step` is bound to the default one.
224
+ */
225
+ step(step: DemoStep): Promise<void>;
226
+ /**
227
+ * Reuses `DemoMask` verbatim. Actor-scoped: applies to this actor's own `BrowserContext`
228
+ * only. Each actor's context is its own page, its own DOM and its own selectors, so a mask
229
+ * registered here has nothing to do in a context it was never registered against.
230
+ */
231
+ mask(mask: DemoMask): Promise<void>;
232
+ /**
233
+ * Another `Page` on this actor's own `BrowserContext` — the multi-tab counterpart to
234
+ * `demo.actor`'s multi-context one, still governed by the same one-active-page-at-a-time
235
+ * rule within this actor's own turn.
236
+ */
237
+ newTab(): Promise<Page>;
238
+ /**
239
+ * Hands the real browser window to a person during this actor's turn. Reuses `DemoHandoff`
240
+ * verbatim; the only difference from `DemoContext.handoff` is that a handoff can originate
241
+ * from any actor's turn, not only the default one a scenario would fall back to if none
242
+ * were ever named — a person can be handed the browser whoever currently holds it.
243
+ */
244
+ handoff(request: DemoHandoff): Promise<void>;
245
+ };
127
246
  /**
128
247
  * What a scenario may do before recording starts.
129
248
  *
@@ -153,6 +272,49 @@ export interface DemoContext extends PreflightContext {
153
272
  assert(assertion: DemoAssertion): Promise<void>;
154
273
  mask(mask: DemoMask): Promise<void>;
155
274
  pause(ms: number): Promise<void>;
275
+ /**
276
+ * Returns a handle for a named actor, standing up its `BrowserContext` and `Page` the first
277
+ * time `id` is seen. Async, not a synchronous handle: `browser.newContext()` and
278
+ * `context.newPage()` are themselves async Playwright calls, and there is no way to hand
279
+ * back an `Actor` whose `page` is already a real, working page without awaiting them first
280
+ * — so authors write `const admin = await demo.actor('admin')`. See `turn` for what makes
281
+ * an actor's context the active one.
282
+ *
283
+ * The first `id` a scenario ever calls this with reuses the implicit default context and
284
+ * page every scenario already runs on, rather than opening a second one nobody asked for,
285
+ * so in practice that call resolves immediately — there is no new `BrowserContext` to wait
286
+ * on. The signature stays `Promise<Actor>` for every actor regardless, rather than
287
+ * special-casing the first one: a single-actor scenario written before actors existed keeps
288
+ * working unchanged, and turning it into a two-actor one only costs the second
289
+ * `demo.actor()` call, not a rewrite of the first actor's steps.
290
+ */
291
+ actor(id: string, opts?: {
292
+ label?: string;
293
+ contextOptions?: BrowserContextOptions;
294
+ }): Promise<Actor>;
295
+ /**
296
+ * Marks a hand-off from whichever actor currently holds the browser to `actor`, parking the
297
+ * outgoing `BrowserContext` and activating the incoming one. Async because, like `chapter`
298
+ * and `pause`, it is itself an event on the render timeline, not just a bookkeeping call.
299
+ *
300
+ * `opts.card`, when given, plays a full-frame transition card between the two actors'
301
+ * segments — the `lines` it names, an optional spoken `narration`, held for `durationMs`.
302
+ *
303
+ * Omitting `opts.card` is allowed, but still draws a card: `RenderPlanSchema.transitionCards`
304
+ * requires exactly one entry per hand-off with non-empty `lines`, so a card-less turn's card
305
+ * is synthesised at render time as `Now: <label>`, naming the incoming actor by the `label`
306
+ * it was given to `demo.actor()`, held for the same default duration an explicit card with no
307
+ * `durationMs` of its own gets. The persistent corner badge names the active actor throughout
308
+ * either way; `opts.card` only ever changes whether that hand-off also gets a full-frame beat
309
+ * of its own, and what it says.
310
+ */
311
+ turn(actor: Actor, opts?: {
312
+ card?: {
313
+ lines: string[];
314
+ narration?: string;
315
+ durationMs?: number;
316
+ };
317
+ }): Promise<void>;
156
318
  }
157
319
  export type DemoRunArgs = {
158
320
  page: Page;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plaintake/scenario",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Authoring SDK for PlainTake demo scenarios: defineDemo and the scenario DSL types. Install for editor autocomplete; the PlainTake binary ships a runtime fallback.",
5
5
  "license": "MIT",
6
6
  "type": "module",