@erclx/canon 5.68.0 → 5.70.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +33 -159
  3. package/assets/brand/mark.svg +17 -9
  4. package/claude/.claude-plugin/plugin.json +1 -1
  5. package/claude/skills/app-drive/REQUIREMENT.md +5 -5
  6. package/claude/skills/app-drive/SKILL.md +17 -6
  7. package/claude/skills/app-drive/references/drive-record.md +61 -8
  8. package/claude/skills/app-drive/references/isolate.md +1 -1
  9. package/claude/skills/canon-cli/SKILL.md +2 -2
  10. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
  11. package/claude/skills/canon-feedback/SKILL.md +1 -1
  12. package/claude/skills/design-identity/REQUIREMENT.md +4 -4
  13. package/claude/skills/design-identity/SKILL.md +7 -7
  14. package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
  15. package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +19 -10
  16. package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
  17. package/claude/skills/design-pick/references/design-config.md +33 -0
  18. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  19. package/claude/skills/design-record/SKILL.md +1 -1
  20. package/claude/skills/design-taste/REQUIREMENT.md +2 -2
  21. package/claude/skills/design-taste/SKILL.md +3 -3
  22. package/claude/skills/design-taste/references/preflight.md +1 -1
  23. package/claude/skills/git-issue/REQUIREMENT.md +1 -1
  24. package/claude/skills/git-issue/references/issue.md +1 -1
  25. package/claude/skills/plan-feature/SKILL.md +1 -1
  26. package/claude/skills/plan-feature/references/plan.md +21 -6
  27. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  28. package/claude/skills/pr-evidence/references/evidence.md +2 -2
  29. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
  30. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  31. package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
  32. package/claude/skills/role-planner/SKILL.md +4 -2
  33. package/claude/skills/role-worker/SKILL.md +5 -6
  34. package/claude/skills/task-board/references/tasks.md +1 -1
  35. package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
  36. package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
  37. package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
  38. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  39. package/claude/skills/ui-craft/SKILL.md +1 -1
  40. package/claude/skills/ui-craft/references/copy.md +4 -1
  41. package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +6 -4
  42. package/claude/skills/{ui-record → ui-film}/SKILL.md +26 -13
  43. package/claude/skills/ui-film/references/compose-craft.md +81 -0
  44. package/claude/skills/{ui-record → ui-film}/references/config.md +9 -4
  45. package/claude/skills/{ui-record → ui-film}/references/handoff.md +17 -7
  46. package/claude/skills/{ui-record → ui-film}/references/pr-clip.md +5 -5
  47. package/claude/skills/{ui-record → ui-film}/references/take-craft.md +28 -2
  48. package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
  49. package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
  50. package/docs/agents/audits.md +1 -1
  51. package/docs/agents/migrate.md +13 -13
  52. package/docs/index.md +1 -0
  53. package/docs/migrations/by-hand.md +107 -0
  54. package/docs/migrations/by-verb.md +118 -0
  55. package/docs/migrations/index.md +14 -0
  56. package/docs/target/index.md +1 -2
  57. package/docs/target/projects.md +3 -3
  58. package/docs/target/sync.md +23 -3
  59. package/docs/workflow/ai-workflow.md +1 -1
  60. package/docs/workflow/skill-map-on-request.md +1 -2
  61. package/docs/workflow/skill-map.md +9 -10
  62. package/docs/workflow/visual-design-workflow.md +6 -1
  63. package/governance/ledger/claude/background.md +26 -0
  64. package/governance/ledger/skills/ui-craft/copy.md +8 -4
  65. package/governance/rules/claude/background.md +19 -0
  66. package/governance/stacks/base.toml +1 -0
  67. package/package.json +6 -4
  68. package/scripts/ci/install-playwright-deps.sh +52 -0
  69. package/scripts/core/design-sync.ts +204 -0
  70. package/scripts/core/regen-web-favicon.ts +27 -12
  71. package/scripts/core/regen-web-previews.ts +8 -1
  72. package/scripts/core/render-film.ts +179 -0
  73. package/src/capture/frame-values.ts +12 -12
  74. package/src/claude/cases/authoring.ts +2 -2
  75. package/src/claude/cases/misc.ts +1 -1
  76. package/src/claude/cases/setup.ts +0 -5
  77. package/src/claude/cases/workflow.ts +6 -6
  78. package/src/commands/fixtures/gh.sh +64 -0
  79. package/src/commands/gov.ts +33 -3
  80. package/src/commands/migrate.ts +12 -2
  81. package/src/commands/records.ts +3 -2
  82. package/src/commands/sandbox.ts +9 -2
  83. package/src/commands/take.ts +159 -11
  84. package/src/commands/tasks.ts +183 -0
  85. package/src/design/favicon.ts +85 -13
  86. package/src/design/reference.ts +98 -0
  87. package/src/design/render.ts +16 -14
  88. package/src/design/tokens.ts +4 -3
  89. package/src/gate/stages.ts +8 -2
  90. package/src/gov/audit.ts +48 -11
  91. package/src/gov/detect.ts +1 -1
  92. package/src/init/steps.ts +2 -2
  93. package/src/markdown/scope.ts +9 -14
  94. package/src/migrate/record-layout.ts +5 -3
  95. package/src/migrate/skill-names.ts +21 -4
  96. package/src/migrate/status.ts +3 -3
  97. package/src/records/validate.ts +15 -2
  98. package/src/sandbox/expect.ts +32 -0
  99. package/src/sandbox/headless/dispatch-shim.sh +15 -0
  100. package/src/sandbox/headless/dispatch.ts +12 -14
  101. package/src/sandbox/headless/run.ts +6 -3
  102. package/src/sandbox/stub-gh.sh +26 -0
  103. package/src/sandbox/stub-remote.ts +8 -29
  104. package/src/sync/engine.ts +1 -1
  105. package/src/take/camera.ts +100 -11
  106. package/src/take/film.ts +55 -4
  107. package/src/take/fixtures/input.html +138 -0
  108. package/src/take/keys.ts +193 -0
  109. package/src/take/log.ts +94 -1
  110. package/src/take/perform.ts +165 -10
  111. package/src/take/plan.ts +101 -6
  112. package/src/take/pointer.ts +2 -1
  113. package/src/take/screencast.ts +28 -3
  114. package/src/take/stepper.ts +141 -23
  115. package/src/take/zoom/NOTICE.md +1 -1
  116. package/src/tasks/launch-wait.ts +160 -0
  117. package/tooling/claude/reference.md +1 -1
  118. package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
  119. package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
  120. package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
  121. package/claude/skills/plan-ready/SKILL.md +0 -136
  122. package/claude/skills/plan-ready/references/assembly.md +0 -75
  123. package/claude/skills/plan-ready/references/ready.md +0 -128
  124. package/docs/target/migrations.md +0 -203
  125. /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
@@ -250,6 +250,7 @@ const PLAN_SECTIONS = [
250
250
  'Constraints',
251
251
  'Files to touch',
252
252
  'Design references',
253
+ 'Reference images',
253
254
  'Verification',
254
255
  'Risks',
255
256
  'Review focus',
@@ -806,6 +807,13 @@ const STRUCTURE_FILES: Readonly<
806
807
  intake: (file) => file !== INTAKE_INDEX && file !== INTAKE_HANDOFF,
807
808
  }
808
809
 
810
+ /**
811
+ * An app drive's log, which sits under an intake folder's `evidence/` and
812
+ * holds findings as headings and fields rather than items, so it reads on the
813
+ * plain criteria rather than the cluster exemptions.
814
+ */
815
+ const DRIVE_LOG = 'evidence/drive-log.md'
816
+
809
817
  async function folderStructure(
810
818
  dir: string,
811
819
  kind: RecordKind,
@@ -816,9 +824,14 @@ async function folderStructure(
816
824
 
817
825
  const report = kind === 'intake' ? intakeStructure : structureFindings
818
826
  const files = (await listMarkdown(dir)).filter(reads)
827
+ const reports = files.map((file) => ({ file, read: report }))
828
+ if (kind === 'intake' && existsSync(join(dir, DRIVE_LOG))) {
829
+ reports.push({ file: DRIVE_LOG, read: structureFindings })
830
+ }
831
+
819
832
  const perFile = await Promise.all(
820
- files.map(async (file) =>
821
- report(await readFile(join(dir, file), 'utf8')).map((entry) => ({
833
+ reports.map(async ({ file, read }) =>
834
+ read(await readFile(join(dir, file), 'utf8')).map((entry) => ({
822
835
  ...entry,
823
836
  record: slug,
824
837
  subject: file,
@@ -44,7 +44,15 @@ export interface AssertionResult {
44
44
  */
45
45
  export interface RunEnvelope {
46
46
  readonly isError: boolean
47
+ /** The runner cap ended the run, read from `subtype` rather than `turns`. */
48
+ readonly isTruncated: boolean
47
49
  readonly turns: number
50
+ /**
51
+ * The cap the run ran under. `num_turns` counts more than the cap does, so
52
+ * an untruncated run can report past it, and a ceiling at or above the cap
53
+ * is held by `isTruncated` instead. A non-finite value reads as absent.
54
+ */
55
+ readonly turnCap?: number
48
56
  readonly denials: number
49
57
  readonly reply?: string
50
58
  }
@@ -487,6 +495,23 @@ function checkReply(
487
495
  }
488
496
  }
489
497
 
498
+ function isFiniteCap(turnCap: number | undefined): turnCap is number {
499
+ return turnCap !== undefined && Number.isFinite(turnCap)
500
+ }
501
+
502
+ function capped(turnCap: number | undefined): string {
503
+ return isFiniteCap(turnCap) ? ` of ${turnCap} turns` : ''
504
+ }
505
+
506
+ /**
507
+ * A ceiling below the cap was declared in `num_turns` and is compared in it. One
508
+ * at or above the cap skips with no result, since emitting a pass would let the
509
+ * envelope pass a run.
510
+ */
511
+ function isBelowCap(maxTurns: number, turnCap: number | undefined): boolean {
512
+ return !isFiniteCap(turnCap) || maxTurns < turnCap
513
+ }
514
+
490
515
  /**
491
516
  * The envelope never determines a pass. It can only fail a run the expectations
492
517
  * would otherwise have passed. Under `bypassPermissions` the denial count is
@@ -516,8 +541,15 @@ function checkEnvelope(
516
541
  message: `envelope: ${envelope.denials} permission denials`,
517
542
  })
518
543
  }
544
+ if (envelope.isTruncated) {
545
+ results.push({
546
+ ok: false,
547
+ message: `envelope: truncated by the runner cap${capped(envelope.turnCap)}`,
548
+ })
549
+ }
519
550
  if (
520
551
  expectation.maxTurns !== undefined &&
552
+ isBelowCap(expectation.maxTurns, envelope.turnCap) &&
521
553
  envelope.turns > expectation.maxTurns
522
554
  ) {
523
555
  results.push({
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ # Run by the claude wrapper installDispatchShim writes in src/sandbox/headless/dispatch.ts.
3
+ # $1 is the real claude binary, and everything after it is the caller's arguments.
4
+ real="$1"
5
+ shift
6
+
7
+ for arg in "$@"; do
8
+ case "$arg" in
9
+ --bg | --background)
10
+ echo "canon sandbox harness: refusing $arg. A sandbox run may not dispatch a background session, so this shim stands first on PATH in place of the real claude binary. Drive the refusal the fixture asks for, or run this outside the harness." >&2
11
+ exit 64
12
+ ;;
13
+ esac
14
+ done
15
+ exec "$real" "$@"
@@ -8,6 +8,11 @@ import {
8
8
  } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const DISPATCH_SHIM = fileURLToPath(
14
+ new URL('dispatch-shim.sh', import.meta.url),
15
+ )
11
16
 
12
17
  /*
13
18
  * The bound on a nested background dispatch, and the recording behind it.
@@ -31,9 +36,11 @@ import { join } from 'node:path'
31
36
  * The prevention. Writes a `claude` that refuses a background dispatch by flag
32
37
  * name and delegates everything else, for the runner to place first on the PATH
33
38
  * of the session it spawns. It stays a bash file, since it stands in for a
34
- * binary any shell in the session may exec.
39
+ * binary any shell in the session may exec. The refusal lives in
40
+ * `dispatch-shim.sh`, so the shell gate lints it, and the `claude` written here
41
+ * is a two-line wrapper that runs it with the real binary as its first argument.
35
42
  *
36
- * The real binary is baked into the file rather than passed through the
43
+ * The real binary is baked into the wrapper rather than passed through the
37
44
  * environment. An env var carrying it would sit in the spawned session's own
38
45
  * environment, which hands any arm the exact string that walks around the shim.
39
46
  *
@@ -46,22 +53,13 @@ import { join } from 'node:path'
46
53
  * backstops.
47
54
  */
48
55
  export function installDispatchShim(dir: string, real: string): void {
49
- const shim = [
56
+ const wrapper = [
50
57
  '#!/usr/bin/env bash',
51
- '# Generated by installDispatchShim in src/sandbox/headless/dispatch.ts.',
52
- 'for arg in "$@"; do',
53
- ' case "$arg" in',
54
- ' --bg | --background)',
55
- ' echo "canon sandbox harness: refusing $arg. A sandbox run may not dispatch a background session, so this shim stands first on PATH in place of the real claude binary. Drive the refusal the fixture asks for, or run this outside the harness." >&2',
56
- ' exit 64',
57
- ' ;;',
58
- ' esac',
59
- 'done',
60
- `exec ${shellQuote(real)} "$@"`,
58
+ `exec bash ${shellQuote(DISPATCH_SHIM)} ${shellQuote(real)} "$@"`,
61
59
  '',
62
60
  ].join('\n')
63
61
 
64
- writeFileSync(join(dir, 'claude'), shim, 'utf8')
62
+ writeFileSync(join(dir, 'claude'), wrapper, 'utf8')
65
63
  chmodSync(join(dir, 'claude'), 0o755)
66
64
  }
67
65
 
@@ -45,9 +45,12 @@ import {
45
45
  *
46
46
  * `maxTurns` is the only budget. `max_turns` in an arm's `expect.toml` is a
47
47
  * ceiling asserted after the run, not a cap enforced during it, so a
48
- * declaration cannot raise what it runs under. An arm needing more than this
49
- * truncates, and a truncated run fails the same assertions a reasoning miss
50
- * does with nothing to separate them.
48
+ * declaration cannot raise what it runs under. A truncated run exits 1 with
49
+ * `subtype` `error_max_turns`, which `canon sandbox check` fails by name.
50
+ * `num_turns` counts more than this cap does, so a ceiling at or above it is
51
+ * held by the cap and compares nothing. The check reads the cap from its own
52
+ * environment, so a run under a raised cap, checked later by hand without the
53
+ * variable, reads the default.
51
54
  *
52
55
  * `bypassPermissions` is the only mode that lets a run write under `.claude/`,
53
56
  * which most arms need. The scoping the permission layer would have given
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # stub gh
3
+ dir="$STUB_GH_DIR"
4
+ line="$(printf '%q ' "$@")"
5
+ line="${line% }"
6
+ printf '%s\n' "$line" >>"$dir/gh.log"
7
+
8
+ case "$1" in
9
+ api) exit 0 ;;
10
+ pr)
11
+ case "$2" in
12
+ create)
13
+ count="$(cat "$dir/pr-count" 2>/dev/null || echo 0)"
14
+ count=$((count + 1))
15
+ echo "$count" >"$dir/pr-count"
16
+ echo "https://github.com/$GITHUB_ORG/$STUB_GH_REPO/pull/$count"
17
+ exit 0
18
+ ;;
19
+ close | list) exit 0 ;;
20
+ esac
21
+ ;;
22
+ esac
23
+
24
+ printf '%s\n' "$line" >>"$dir/gh-unknown.log"
25
+ echo "stub gh: unknown call: $line" >&2
26
+ exit 1
@@ -1,14 +1,15 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import {
3
3
  chmodSync,
4
+ copyFileSync,
4
5
  existsSync,
5
6
  mkdirSync,
6
7
  readdirSync,
7
8
  readFileSync,
8
9
  rmSync,
9
- writeFileSync,
10
10
  } from 'node:fs'
11
11
  import { join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
12
13
 
13
14
  const STUB_ORG = 'stub-org'
14
15
  const ANCHOR_REPO = 'canon-sandbox'
@@ -26,34 +27,11 @@ const PINNED_DATE = '2026-01-01T00:00:00Z'
26
27
  * Answers the `gh` calls the git scenarios make and fails on any other, so a new
27
28
  * call reaches a reader as a red arm rather than a silent pass. Every call is
28
29
  * logged as one line, quoted so an argument holding a space stays one argument.
30
+ * The pull request URL it prints reads `GITHUB_ORG` and `STUB_GH_REPO` from the
31
+ * environment `createStubRemote` returns, so it follows `STUB_ORG` and
32
+ * `ANCHOR_REPO`.
29
33
  */
30
- const STUB_GH = `#!/usr/bin/env bash
31
- # stub gh
32
- dir="$STUB_GH_DIR"
33
- line="$(printf '%q ' "$@")"
34
- line="\${line% }"
35
- printf '%s\\n' "$line" >>"$dir/gh.log"
36
-
37
- case "$1" in
38
- api) exit 0 ;;
39
- pr)
40
- case "$2" in
41
- create)
42
- count="$(cat "$dir/pr-count" 2>/dev/null || echo 0)"
43
- count=$((count + 1))
44
- echo "$count" >"$dir/pr-count"
45
- echo "https://github.com/${STUB_ORG}/${ANCHOR_REPO}/pull/$count"
46
- exit 0
47
- ;;
48
- close | list) exit 0 ;;
49
- esac
50
- ;;
51
- esac
52
-
53
- printf '%s\\n' "$line" >>"$dir/gh-unknown.log"
54
- echo "stub gh: unknown call: $line" >&2
55
- exit 1
56
- `
34
+ const STUB_GH = fileURLToPath(new URL('stub-gh.sh', import.meta.url))
57
35
 
58
36
  /**
59
37
  * Builds a local bare repository and a stub `gh` under `stubDir` and returns the
@@ -75,13 +53,14 @@ export function createStubRemote(stubDir: string): Record<string, string> {
75
53
  )
76
54
 
77
55
  const gh = join(bin, 'gh')
78
- writeFileSync(gh, STUB_GH)
56
+ copyFileSync(STUB_GH, gh)
79
57
  chmodSync(gh, 0o755)
80
58
 
81
59
  return {
82
60
  PATH: `${bin}:${process.env.PATH ?? ''}`,
83
61
  STUB_GH_DIR: stubDir,
84
62
  GITHUB_ORG: STUB_ORG,
63
+ STUB_GH_REPO: ANCHOR_REPO,
85
64
  GIT_CONFIG_COUNT: '1',
86
65
  GIT_CONFIG_KEY_0: `url.${remotes}/.insteadOf`,
87
66
  GIT_CONFIG_VALUE_0: `https://github.com/${STUB_ORG}/`,
@@ -545,7 +545,7 @@ function reportCoverage(coverage: readonly RuleCoverage[]): void {
545
545
  for (const entry of coverage) {
546
546
  logInfo(describeCoverage(entry))
547
547
  if (entry.ui) {
548
- logInfo(' Audit those files with /canon:ux-audit.')
548
+ logInfo(' Audit those files with /canon:ui-audit.')
549
549
  }
550
550
  }
551
551
  }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type Box,
3
+ CLIP_SIZES,
3
4
  type LogEvent,
4
5
  type Point,
5
6
  type TakeLog,
@@ -20,8 +21,8 @@ import {
20
21
 
21
22
  /**
22
23
  * Turns a take's log into one camera per frame, preset O4 from groundwork
23
- * track `193-ui-record`: OpenScreen's timing aimed at each click, a fixed
24
- * depth, a focus that holds still until a click leaves the inner part of the
24
+ * track `193-ui-record`, a folder name kept as written (canon-keep-retired).
25
+ * The preset is OpenScreen's timing aimed at each click, a fixed depth the clip's resolution caps, a focus that holds still until a click leaves the inner part of the
25
26
  * view, and a dead zone that keeps the cursor in frame. HyperFrames renders the
26
27
  * track as it stands, so it carries no curve a renderer would have to agree on.
27
28
  */
@@ -31,6 +32,13 @@ export const CAMERA_VERSION = 1
31
32
  /** The depth every click zooms to, unless its target already fills the view. */
32
33
  const DEPTH = 1.8
33
34
 
35
+ /**
36
+ * How far a clip with one pixel per CSS pixel zooms before it goes soft, which
37
+ * caps a screencast's depth well under `DEPTH` and leaves the stepper's 4K
38
+ * clip at it.
39
+ */
40
+ const SHARP_DEPTH = 1.35
41
+
34
42
  /** How much of the zoomed view a target may fill, which caps the depth for a big one. */
35
43
  const FILL = 0.8
36
44
 
@@ -57,6 +65,13 @@ const EDGE = 0.9
57
65
  /** How long the click ring the edit draws takes to expand. */
58
66
  const RING_MS = 450
59
67
 
68
+ /**
69
+ * How far a drag's ring grows before it holds for the drag, then runs on to 1
70
+ * from the release. A held value keeps the ring's one field, so every reader
71
+ * drawing a growing ring draws a held press with no change.
72
+ */
73
+ const GRAB_RING = 0.3
74
+
60
75
  export interface CameraFrame {
61
76
  readonly ms: number
62
77
  /** How far the view is zoomed, 1 being the whole page. */
@@ -91,16 +106,30 @@ interface Window {
91
106
  readonly aims: Aim[]
92
107
  }
93
108
 
94
- export function cameraTrack(log: TakeLog): CameraTrack {
109
+ export interface CameraOptions {
110
+ /**
111
+ * How much faster the edit plays the clip, so the track is built on the
112
+ * playback clock and a zoom settles at the speed a viewer sees. 1 by default.
113
+ */
114
+ readonly rate?: number
115
+ }
116
+
117
+ export function cameraTrack(
118
+ source: TakeLog,
119
+ options: CameraOptions = {},
120
+ ): CameraTrack {
121
+ const log = atRate(source, options.rate ?? 1)
95
122
  const { width, height } = log.viewport
123
+ const clip = log.clip ?? CLIP_SIZES[log.route]
124
+ const deepest = Math.min(DEPTH, (SHARP_DEPTH * clip.width) / width)
96
125
  const depthFor = (box: Box): number =>
97
126
  Math.min(
98
- DEPTH,
127
+ deepest,
99
128
  (width * FILL) / Math.max(box.width, 1),
100
129
  (height * FILL) / Math.max(box.height, 1),
101
130
  )
102
131
 
103
- const aims: Aim[] = log.events
132
+ const aims: Aim[] = (log.push === false ? [] : log.events)
104
133
  .flatMap((event): Aim[] => {
105
134
  if (event.kind === 'click') {
106
135
  return [
@@ -112,6 +141,19 @@ export function cameraTrack(log: TakeLog): CameraTrack {
112
141
  },
113
142
  ]
114
143
  }
144
+ if (event.kind === 'drag') {
145
+ // One aim over both ends, held to the release, so a short drag stays
146
+ // framed whole and a long one asks for a depth under MIN_ZOOM.
147
+ const box = union(event.box, event.drop)
148
+ return [
149
+ {
150
+ t: event.t,
151
+ until: event.t + event.ms,
152
+ at: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
153
+ depth: depthFor(box),
154
+ },
155
+ ]
156
+ }
115
157
  if (event.kind === 'type') {
116
158
  const box = event.box
117
159
  return [
@@ -129,9 +171,26 @@ export function cameraTrack(log: TakeLog): CameraTrack {
129
171
  .sort((a, b) => a.t - b.t)
130
172
 
131
173
  const scrolls = log.events.filter((event) => event.kind === 'scroll')
132
- // Read off every click rather than off the aims, which drop a click too large
174
+ // Read off every press rather than off the aims, which drop a click too large
133
175
  // to zoom and add a typing burst that is no press.
134
- const clicks = log.events.filter((event) => event.kind === 'click')
176
+ const presses = log.events.flatMap((event) =>
177
+ event.kind === 'click'
178
+ ? [{ t: event.t, release: event.t }]
179
+ : event.kind === 'drag'
180
+ ? [{ t: event.t, release: event.t + event.ms }]
181
+ : [],
182
+ )
183
+ const ringAt = (ms: number): number | null => {
184
+ for (const press of presses) {
185
+ if (ms < press.t) continue
186
+ const grown = (ms - press.t) / RING_MS
187
+ if (ms <= press.release) return Math.min(GRAB_RING, grown)
188
+ const held = Math.min(GRAB_RING, (press.release - press.t) / RING_MS)
189
+ const ring = held + (ms - press.release) / RING_MS
190
+ if (ring <= 1) return ring
191
+ }
192
+ return null
193
+ }
135
194
  const windows = zoomWindows(aims, scrolls)
136
195
 
137
196
  const targetAt = (ms: number) => {
@@ -196,16 +255,14 @@ export function cameraTrack(log: TakeLog): CameraTrack {
196
255
  cursor.y + half.y * EDGE,
197
256
  )
198
257
 
199
- const ring = clicks.find(
200
- (click) => ms >= click.t && ms - click.t <= RING_MS,
201
- )
258
+ const ring = ringAt(ms)
202
259
  frames.push({
203
260
  ms: round(ms, 2),
204
261
  scale: round(scale, 4),
205
262
  x: round(clamp(springs.x.value, half.x, width - half.x) - half.x, 2),
206
263
  y: round(clamp(springs.y.value, half.y, height - half.y) - half.y, 2),
207
264
  cursor: roundPoint(cursor),
208
- ring: ring ? round((ms - ring.t) / RING_MS, 3) : null,
265
+ ring: ring === null ? null : round(ring, 3),
209
266
  })
210
267
  }
211
268
 
@@ -217,6 +274,27 @@ export function cameraTrack(log: TakeLog): CameraTrack {
217
274
  }
218
275
  }
219
276
 
277
+ /** Reads a playback rate off a flag, which has to be a positive number. */
278
+ export function readRate(value: string): number | undefined {
279
+ const rate = Number(value)
280
+ return Number.isFinite(rate) && rate > 0 ? rate : undefined
281
+ }
282
+
283
+ /** The log as a clip played `rate` times faster shows it, every time divided. */
284
+ function atRate(log: TakeLog, rate: number): TakeLog {
285
+ if (rate === 1) return log
286
+ return {
287
+ ...log,
288
+ frames: Math.round(log.frames / rate),
289
+ events: log.events.map((event): LogEvent => {
290
+ const t = event.t / rate
291
+ return 'ms' in event
292
+ ? { ...event, t, ms: event.ms / rate }
293
+ : { ...event, t }
294
+ }),
295
+ }
296
+ }
297
+
220
298
  /**
221
299
  * Groups aims into zooms. One holds from its first aim to the last plus the
222
300
  * hold, a scroll ends it since the content moves under the camera, and the next
@@ -295,6 +373,17 @@ function focusAt(
295
373
  return focus
296
374
  }
297
375
 
376
+ function union(a: Box, b: Box): Box {
377
+ const x = Math.min(a.x, b.x)
378
+ const y = Math.min(a.y, b.y)
379
+ return {
380
+ x,
381
+ y,
382
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
383
+ height: Math.max(a.y + a.height, b.y + b.height) - y,
384
+ }
385
+ }
386
+
298
387
  function clamp(value: number, low: number, high: number): number {
299
388
  return Math.min(Math.max(value, low), high)
300
389
  }
package/src/take/film.ts CHANGED
@@ -4,6 +4,7 @@ import { isBrowserMissing, isServerUnreachable } from '@/browser/engine'
4
4
  import { cameraTrack } from '@/take/camera'
5
5
  import { FfmpegMissing, requireFfmpeg } from '@/take/encode'
6
6
  import {
7
+ CLIP_SIZES,
7
8
  FPS,
8
9
  LOG_VERSION,
9
10
  type LogEvent,
@@ -60,6 +61,10 @@ export type TakeResult =
60
61
  readonly reason: TakeRefusal
61
62
  readonly message: string
62
63
  readonly beat?: string
64
+ /** Why the refused beat had fallen back to a screencast, when it had. */
65
+ readonly fallback?: string
66
+ /** The page as the failing step met it, relative to the take's folder. */
67
+ readonly still?: string
63
68
  }
64
69
 
65
70
  export interface TakeRequest {
@@ -67,6 +72,8 @@ export interface TakeRequest {
67
72
  readonly plan: TakePlan
68
73
  readonly out: string
69
74
  readonly env?: NodeJS.ProcessEnv
75
+ /** How long one stepped frame may take before its beat falls back to a screencast. */
76
+ readonly stallMs?: number
70
77
  }
71
78
 
72
79
  const REACH_TIMEOUT_MS = 5000
@@ -108,9 +115,26 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
108
115
  const dir = `${String(index + 1).padStart(2, '0')}-${slug(beat.name)}`
109
116
  mkdirSync(join(out, dir), { recursive: true })
110
117
  try {
111
- beats.push(await filmBeat(request.url, beat, out, dir, executable))
118
+ beats.push(
119
+ await filmBeat(
120
+ request.url,
121
+ beat,
122
+ out,
123
+ dir,
124
+ executable,
125
+ request.stallMs,
126
+ ),
127
+ )
112
128
  } catch (error) {
113
- return failed(classify(error), error, beat.name)
129
+ const cause = error instanceof FellBack ? error.error : error
130
+ const shot = cause instanceof TargetMissing ? cause.still : undefined
131
+ const still = shot ? join(dir, 'still.jpg') : undefined
132
+ if (shot && still) writeFileSync(join(out, still), shot)
133
+ return {
134
+ ...failed(classify(cause), cause, beat.name),
135
+ ...(error instanceof FellBack ? { fallback: error.fallback } : {}),
136
+ ...(still ? { still } : {}),
137
+ }
114
138
  }
115
139
  }
116
140
 
@@ -127,6 +151,7 @@ async function filmBeat(
127
151
  out: string,
128
152
  dir: string,
129
153
  executable: string,
154
+ stallMs: number | undefined,
130
155
  ): Promise<FilmedBeat> {
131
156
  const clip = join(dir, 'clip.mp4')
132
157
  const target = join(out, clip)
@@ -136,12 +161,22 @@ async function filmBeat(
136
161
  let filmed: { frames: number; events: readonly LogEvent[]; still: Buffer }
137
162
  if (beat.route === 'step') {
138
163
  try {
139
- filmed = await filmStepped({ executable, url, beat, clip: target })
164
+ filmed = await filmStepped({
165
+ executable,
166
+ url,
167
+ beat,
168
+ clip: target,
169
+ stallMs,
170
+ })
140
171
  } catch (error) {
141
172
  if (!(error instanceof StepperStalled)) throw error
142
173
  route = 'screencast'
143
174
  fallback = error.message
144
- filmed = await filmScreencast({ url, beat, clip: target })
175
+ try {
176
+ filmed = await filmScreencast({ url, beat, clip: target })
177
+ } catch (cause) {
178
+ throw new FellBack(cause, fallback)
179
+ }
145
180
  }
146
181
  } else {
147
182
  filmed = await filmScreencast({ url, beat, clip: target })
@@ -152,6 +187,8 @@ async function filmBeat(
152
187
  viewport: VIEWPORT,
153
188
  fps: FPS,
154
189
  route,
190
+ clip: CLIP_SIZES[route],
191
+ ...(beat.push === false ? { push: false as const } : {}),
155
192
  frames: filmed.frames,
156
193
  events: filmed.events,
157
194
  }
@@ -178,6 +215,20 @@ async function filmBeat(
178
215
  }
179
216
  }
180
217
 
218
+ /**
219
+ * A beat that failed in the screencast it fell back to, carrying why it fell
220
+ * back, since the fallback loads the page a second time and a single-use link
221
+ * is spent by then.
222
+ */
223
+ class FellBack extends Error {
224
+ constructor(
225
+ readonly error: unknown,
226
+ readonly fallback: string,
227
+ ) {
228
+ super(error instanceof Error ? error.message : String(error))
229
+ }
230
+ }
231
+
181
232
  function classify(error: unknown): TakeRefusal {
182
233
  if (error instanceof TargetMissing) return 'target-missing'
183
234
  if (error instanceof ShellMissing || isBrowserMissing(error)) {