@erclx/canon 5.69.0 → 5.71.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 (67) hide show
  1. package/CHANGELOG.md +25 -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/SKILL.md +3 -2
  6. package/claude/skills/app-drive/references/drive-record.md +52 -10
  7. package/claude/skills/app-drive/references/isolate.md +1 -1
  8. package/claude/skills/design-pick/SKILL.md +1 -0
  9. package/claude/skills/git-pr/references/task-number.md +13 -0
  10. package/claude/skills/markdown-craft/references/markdown.md +1 -0
  11. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +1 -0
  12. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -20
  13. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +10 -0
  14. package/claude/skills/role-orchestrator/references/orchestrator-talk-launch.md +30 -0
  15. package/claude/skills/task-board/references/tasks-record-lines.md +40 -0
  16. package/claude/skills/task-board/references/tasks.md +4 -8
  17. package/claude/skills/ui-craft/references/copy.md +4 -1
  18. package/claude/skills/ui-film/REQUIREMENT.md +4 -2
  19. package/claude/skills/ui-film/SKILL.md +24 -11
  20. package/claude/skills/ui-film/references/compose-craft.md +81 -0
  21. package/claude/skills/ui-film/references/config.md +6 -1
  22. package/claude/skills/ui-film/references/handoff.md +13 -3
  23. package/claude/skills/ui-film/references/take-craft.md +27 -1
  24. package/docs/index.md +1 -0
  25. package/docs/migrations/by-hand.md +107 -0
  26. package/docs/migrations/by-verb.md +118 -0
  27. package/docs/migrations/index.md +14 -0
  28. package/docs/target/index.md +1 -2
  29. package/docs/target/projects.md +2 -2
  30. package/docs/target/sync.md +22 -2
  31. package/docs/workflow/ai-workflow.md +1 -1
  32. package/governance/ledger/skills/ui-craft/copy.md +8 -4
  33. package/package.json +3 -1
  34. package/scripts/core/design-sync.ts +204 -0
  35. package/scripts/core/regen-web-favicon.ts +27 -12
  36. package/scripts/core/regen-web-previews.ts +8 -1
  37. package/scripts/core/render-film.ts +179 -0
  38. package/src/capture/frame-values.ts +12 -12
  39. package/src/commands/markdown.ts +6 -1
  40. package/src/commands/records.ts +3 -2
  41. package/src/commands/sandbox.ts +9 -2
  42. package/src/commands/take.ts +159 -11
  43. package/src/commands/tasks.ts +133 -0
  44. package/src/design/favicon.ts +85 -13
  45. package/src/design/reference.ts +98 -0
  46. package/src/design/render.ts +16 -14
  47. package/src/design/tokens.ts +4 -3
  48. package/src/gate/stages.ts +6 -0
  49. package/src/init/steps.ts +2 -2
  50. package/src/markdown/colons.ts +102 -16
  51. package/src/migrate/status.ts +3 -3
  52. package/src/records/validate.ts +14 -2
  53. package/src/sandbox/expect.ts +32 -0
  54. package/src/sandbox/headless/run.ts +6 -3
  55. package/src/take/camera.ts +99 -10
  56. package/src/take/film.ts +35 -2
  57. package/src/take/fixtures/input.html +138 -0
  58. package/src/take/keys.ts +193 -0
  59. package/src/take/log.ts +94 -1
  60. package/src/take/perform.ts +165 -10
  61. package/src/take/plan.ts +101 -6
  62. package/src/take/screencast.ts +28 -3
  63. package/src/take/stepper.ts +122 -18
  64. package/src/tasks/archive.ts +54 -1
  65. package/src/tasks/record.ts +118 -0
  66. package/src/tasks/validate.ts +37 -0
  67. package/docs/target/migrations.md +0 -205
@@ -3,6 +3,7 @@ import { join } from 'node:path'
3
3
  import type { Cell, DesignDoc, Row } from '@/design/parse'
4
4
  import { parseDesignDoc } from '@/design/parse'
5
5
  import { fontFaceBlock } from '@/design/css'
6
+ import { faviconLink } from '@/design/favicon'
6
7
  import type { FontFace } from '@/design/fonts'
7
8
  import { FONT_FACES } from '@/design/fonts'
8
9
  import { colorValue } from '@/design/tokens'
@@ -12,16 +13,6 @@ export interface RenderResult {
12
13
  cssPath: string
13
14
  }
14
15
 
15
- /**
16
- * The same path data as `assets/brand/mark.svg`, colored via `colorValue`
17
- * since a data URI has no CSS context to resolve `currentColor` against and
18
- * this page renders on the light chrome `previewChrome()` sets up.
19
- */
20
- const FAVICON_COLOR = colorValue('light-accent') ?? '#a4471c'
21
- const FAVICON_HREF = `data:image/svg+xml,${encodeURIComponent(
22
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80"><path d="M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z" fill="${FAVICON_COLOR}" /><rect x="44" y="15" width="12" height="70" rx="2" fill="${FAVICON_COLOR}" /></svg>`,
23
- )}`
24
-
25
16
  export interface RenderOptions {
26
17
  /**
27
18
  * Declare each vendored face the typography table names, and let the page
@@ -29,6 +20,11 @@ export interface RenderOptions {
29
20
  * base64 block a target's render would otherwise grow by unasked.
30
21
  */
31
22
  readonly embedFonts?: boolean
23
+ /**
24
+ * A rendered favicon SVG to inline in the head. The caller reads and renders
25
+ * the mark, so this module reads no brand file a target does not carry.
26
+ */
27
+ readonly favicon?: string
32
28
  }
33
29
 
34
30
  export function renderDesignDoc(
@@ -42,7 +38,10 @@ export function renderDesignDoc(
42
38
  const htmlPath = join(outDir, 'index.html')
43
39
  const faces = options.embedFonts ? namedFaces(doc) : []
44
40
  writeFileSync(cssPath, buildCss(doc, faces))
45
- writeFileSync(htmlPath, buildHtml(doc, bodyFamily(doc, faces)))
41
+ writeFileSync(
42
+ htmlPath,
43
+ buildHtml(doc, bodyFamily(doc, faces), options.favicon),
44
+ )
46
45
  return { htmlPath, cssPath }
47
46
  }
48
47
 
@@ -218,7 +217,11 @@ function previewChrome(): string {
218
217
  return [' :root {', ...lines, ' }'].join('\n')
219
218
  }
220
219
 
221
- function buildHtml(doc: DesignDoc, bodyStack: string): string {
220
+ function buildHtml(
221
+ doc: DesignDoc,
222
+ bodyStack: string,
223
+ favicon: string | undefined,
224
+ ): string {
222
225
  const sections = [
223
226
  sectionPersonality(doc.personality),
224
227
  sectionColor(doc.color),
@@ -239,8 +242,7 @@ function buildHtml(doc: DesignDoc, bodyStack: string): string {
239
242
  return `<!doctype html>
240
243
  <html lang="en">
241
244
  <head>
242
- <meta charset="utf-8">
243
- <link rel="icon" href="${FAVICON_HREF}">
245
+ <meta charset="utf-8">${favicon === undefined ? '' : `\n${faviconLink(favicon)}`}
244
246
  <title>Design tokens</title>
245
247
  <link rel="stylesheet" href="design.css">
246
248
  <style>
@@ -100,7 +100,7 @@ export const REVIEWED = '2026-10-24'
100
100
 
101
101
  export const TOKENS: DesignTokens = {
102
102
  personality:
103
- 'Warm neutrals under one rust accent, set in Geist. The page reads as prose, and monospace appears only where a surface shows what a shell printed. One accent carries every count, link, and primary action.',
103
+ 'Warm neutrals with actions in ink, set in Geist. The page reads as prose, and monospace appears only where a surface shows a path, a command, or what a shell printed. The rust accent marks a status the surface it shows would color, and the landing page carries none, leaving the vermilion pip of the mark as its one hue.',
104
104
 
105
105
  colorRules: [
106
106
  'One accent. Never promote a second color into a structural role.',
@@ -155,7 +155,7 @@ export const TOKENS: DesignTokens = {
155
155
  },
156
156
  {
157
157
  role: 'accent',
158
- intent: 'install command, mark, primary action',
158
+ intent: 'a status the surface would color, such as an open pull request',
159
159
  value: '#c76b5f',
160
160
  grounds: DARK_GROUNDS,
161
161
  },
@@ -208,7 +208,7 @@ export const TOKENS: DesignTokens = {
208
208
  },
209
209
  {
210
210
  role: 'light-accent',
211
- intent: 'links and primary action on light',
211
+ intent: 'a status the surface would color, on light',
212
212
  value: '#ad4a4b',
213
213
  grounds: LIGHT_GROUNDS,
214
214
  },
@@ -361,6 +361,7 @@ export const TOKENS: DesignTokens = {
361
361
 
362
362
  iconography: [
363
363
  'No icon library. `assets/brand/mark.svg` is the one authored icon.',
364
+ 'The mark takes its own colors from `src/design/favicon.ts`, never the accent: the c in the text ink and the pip in a vermilion pair.',
364
365
  'The terminal draws glyphs: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯`.',
365
366
  ],
366
367
  }
@@ -248,6 +248,12 @@ export const STAGES: readonly Stage[] = [
248
248
  failure:
249
249
  'The tab icon drifted from the token source. Run bun run check and commit web/public/favicon.svg.',
250
250
  },
251
+ {
252
+ kind: 'drift',
253
+ pathspec: 'assets/brand/mark-color*.svg',
254
+ failure:
255
+ 'The README mark pair drifted from the mark source. Run bun run check and commit assets/brand/mark-color.svg and assets/brand/mark-color-light.svg.',
256
+ },
251
257
  { kind: 'measure', measure: designProse },
252
258
  ],
253
259
  success: 'Design source clean',
package/src/init/steps.ts CHANGED
@@ -7,8 +7,8 @@ import { GATED_CONFIG_REL, gateScopeRoots } from '@/markdown/gated'
7
7
  export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
8
8
 
9
9
  /**
10
- * States the same fact as `docs/target/migrations.md`'s backup section in its
11
- * own words. Keep the two in step by hand; nothing compares them.
10
+ * States the same fact as `docs/migrations/by-hand.md`'s backup section
11
+ * in its own words. Keep the two in step by hand; nothing compares them.
12
12
  */
13
13
  const RECORDS_SETUP_NOTICE =
14
14
  "No private repository to push to yet. Run 'canon records push' once one exists to print the one-time setup command, or --skip records to silence this."
@@ -22,8 +22,14 @@ const QUOTE_MARKER = /^(?:\s*>\s?)+/
22
22
  /** A colon followed by whitespace and more text, ruling out `10:30` and a scheme. */
23
23
  const COLON = /:(?=\s+\S)/g
24
24
 
25
+ /** A colon followed by whitespace, which a masked code span leaves behind. */
26
+ const LABEL_COLON = /:\s/
27
+
25
28
  const BOLD_LABEL = /^(\*\*|__)[^*_]+\1$/
26
29
 
30
+ /** What may stand in a lead once its code spans and links are masked. */
31
+ const CONNECTIVE = /\b(?:and|or|to)\b|,/g
32
+
27
33
  /** Blanks a span while holding its width, so a column stays where it was. */
28
34
  function blank(match: string): string {
29
35
  return ' '.repeat(match.length)
@@ -32,14 +38,74 @@ function blank(match: string): string {
32
38
  /**
33
39
  * Whether the text before a line's first colon is a label rather than a
34
40
  * clause: the lead of a list item, or a prefix that is only emphasis and a
35
- * masked code span.
41
+ * masked code span. A list item's lead also reads as a label at any length
42
+ * when only code spans, links, commas, and `and`, `or`, or `to` stand in it,
43
+ * which is a file entry naming several paths.
36
44
  */
37
45
  function isLabel(prefix: string, listItem: boolean): boolean {
38
46
  if (BOLD_LABEL.test(prefix.trim())) return true
39
47
 
40
48
  const words = prefix.replace(/[*_]/g, ' ').trim()
41
49
  if (words === '') return true
42
- return listItem && words.split(/\s+/).length <= 4
50
+ if (!listItem) return false
51
+ if (words.replace(CONNECTIVE, ' ').trim() === '') return true
52
+ return words.split(/\s+/).length <= 4
53
+ }
54
+
55
+ interface Entry {
56
+ readonly line: BodyLine
57
+ readonly masked: string
58
+ readonly start: number
59
+ /** Width of the quote marker and the item's own leading whitespace. */
60
+ readonly indent: number
61
+ readonly quote: string
62
+ readonly isItem: boolean
63
+ /** Matches at or past the content start, the only ones a line can report. */
64
+ readonly colons: readonly RegExpExecArray[]
65
+ /** A colon stands past the content start, even with only a masked span after it. */
66
+ readonly labelColon: boolean
67
+ /** Items sharing one list at one indent, shared by reference. */
68
+ group?: Entry[]
69
+ }
70
+
71
+ /**
72
+ * Collects consecutive items at one indent into a list. A blank line, a
73
+ * heading, or a change of quote depth ends every open list, and a deeper child
74
+ * neither joins its parent's list nor ends it.
75
+ */
76
+ function groupItems(entries: readonly Entry[]): void {
77
+ const open = new Map<number, Entry[]>()
78
+ let quote = ''
79
+
80
+ for (const entry of entries) {
81
+ if (entry.line.fenced) continue
82
+
83
+ const empty = entry.masked.slice(entry.quote.length).trim() === ''
84
+ if (entry.quote !== quote || empty || HEADING.test(entry.line.text)) {
85
+ open.clear()
86
+ }
87
+ quote = entry.quote
88
+ if (!entry.isItem) continue
89
+
90
+ for (const level of open.keys()) {
91
+ if (level > entry.indent) open.delete(level)
92
+ }
93
+
94
+ const group = open.get(entry.indent) ?? []
95
+ open.set(entry.indent, group)
96
+ group.push(entry)
97
+ entry.group = group
98
+ }
99
+ }
100
+
101
+ /** Whether the item sits in a list of two or more where every item has a colon. */
102
+ function uniform(entry: Entry): boolean {
103
+ const group = entry.group
104
+ return (
105
+ group !== undefined &&
106
+ group.length >= 2 &&
107
+ group.every((item) => item.labelColon)
108
+ )
43
109
  }
44
110
 
45
111
  /**
@@ -47,30 +113,50 @@ function isLabel(prefix: string, listItem: boolean): boolean {
47
113
  *
48
114
  * A heading, a table row, a fence, a code span, a link, and a line-final colon
49
115
  * are left alone, and so is the first colon of a list item or of a line opening
50
- * on a bold or code label. The measure is advisory, because a colon is
51
- * sometimes right and the corpus count shows a gate would be a sweep nobody
52
- * asked for.
116
+ * on a bold or code label, and of every item in a list where each one carries a
117
+ * colon. The measure is advisory, because a colon is sometimes right and the
118
+ * corpus count shows a gate would be a sweep nobody asked for.
53
119
  */
54
120
  export function findColons(lines: readonly BodyLine[]): ColonFinding[] {
55
- const found: ColonFinding[] = []
56
-
57
- for (const line of lines) {
58
- if (line.fenced || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
59
- continue
60
- }
61
-
121
+ const entries: Entry[] = lines.map((line) => {
62
122
  const masked = maskDisplayed(line.text.replace(LINK, blank))
63
123
  const quote = QUOTE_MARKER.exec(masked)?.[0] ?? ''
64
124
  const marker = LIST_MARKER.exec(masked.slice(quote.length))?.[0] ?? ''
65
125
  const start = quote.length + marker.length
66
- let labelled = false
126
+ const skipped =
127
+ line.fenced || HEADING.test(line.text) || TABLE_ROW.test(line.text)
128
+
129
+ return {
130
+ line,
131
+ masked,
132
+ start,
133
+ indent: quote.length + marker.length - marker.trimStart().length,
134
+ quote: quote.replace(/\s/g, ''),
135
+ isItem: !skipped && marker !== '',
136
+ labelColon: !skipped && LABEL_COLON.test(masked.slice(start)),
137
+ colons: skipped
138
+ ? []
139
+ : [...masked.matchAll(COLON)].filter((match) => match.index >= start),
140
+ }
141
+ })
142
+
143
+ groupItems(entries)
67
144
 
68
- for (const match of masked.matchAll(COLON)) {
69
- if (match.index < start) continue
145
+ const found: ColonFinding[] = []
146
+
147
+ for (const entry of entries) {
148
+ const { line, masked, start } = entry
149
+ let labelled = false
70
150
 
151
+ for (const match of entry.colons) {
71
152
  if (!labelled) {
72
153
  labelled = true
73
- if (isLabel(masked.slice(start, match.index), marker !== '')) continue
154
+ if (
155
+ isLabel(masked.slice(start, match.index), entry.isItem) ||
156
+ (entry.isItem && uniform(entry))
157
+ ) {
158
+ continue
159
+ }
74
160
  }
75
161
 
76
162
  found.push({
@@ -8,9 +8,9 @@ interface Migration {
8
8
 
9
9
  /**
10
10
  * Every `canon migrate` subcommand in the order a project runs them, records
11
- * first. This is the source for that order, and `docs/target/migrations.md`
12
- * cites the verb rather than keeping a second list. A new migration joins by
13
- * one line here.
11
+ * first. This is the source for that order, and
12
+ * `docs/migrations/by-verb.md` cites the verb rather than keeping a
13
+ * second list. A new migration joins by one line here.
14
14
  */
15
15
  export const MIGRATIONS: readonly Migration[] = [
16
16
  { name: 'records', flags: [] },
@@ -807,6 +807,13 @@ const STRUCTURE_FILES: Readonly<
807
807
  intake: (file) => file !== INTAKE_INDEX && file !== INTAKE_HANDOFF,
808
808
  }
809
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
+
810
817
  async function folderStructure(
811
818
  dir: string,
812
819
  kind: RecordKind,
@@ -817,9 +824,14 @@ async function folderStructure(
817
824
 
818
825
  const report = kind === 'intake' ? intakeStructure : structureFindings
819
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
+
820
832
  const perFile = await Promise.all(
821
- files.map(async (file) =>
822
- 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) => ({
823
835
  ...entry,
824
836
  record: slug,
825
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({
@@ -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
@@ -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,
@@ -21,7 +22,7 @@ import {
21
22
  /**
22
23
  * Turns a take's log into one camera per frame, preset O4 from groundwork
23
24
  * track `193-ui-record`, a folder name kept as written (canon-keep-retired).
24
- * The preset is OpenScreen's timing aimed at each click, a fixed depth, a focus that holds still until a click leaves the inner part of the
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 {
@@ -121,7 +126,15 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
121
126
  ),
122
127
  )
123
128
  } catch (error) {
124
- 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
+ }
125
138
  }
126
139
  }
127
140
 
@@ -159,7 +172,11 @@ async function filmBeat(
159
172
  if (!(error instanceof StepperStalled)) throw error
160
173
  route = 'screencast'
161
174
  fallback = error.message
162
- 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
+ }
163
180
  }
164
181
  } else {
165
182
  filmed = await filmScreencast({ url, beat, clip: target })
@@ -170,6 +187,8 @@ async function filmBeat(
170
187
  viewport: VIEWPORT,
171
188
  fps: FPS,
172
189
  route,
190
+ clip: CLIP_SIZES[route],
191
+ ...(beat.push === false ? { push: false as const } : {}),
173
192
  frames: filmed.frames,
174
193
  events: filmed.events,
175
194
  }
@@ -196,6 +215,20 @@ async function filmBeat(
196
215
  }
197
216
  }
198
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
+
199
232
  function classify(error: unknown): TakeRefusal {
200
233
  if (error instanceof TargetMissing) return 'target-missing'
201
234
  if (error instanceof ShellMissing || isBrowserMissing(error)) {