@erclx/canon 5.77.0 → 5.79.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 +43 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
  12. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  13. package/claude/skills/code-craft/SKILL.md +7 -0
  14. package/claude/skills/code-craft/references/adopted.md +8 -0
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  17. package/claude/skills/git-pr/SKILL.md +2 -0
  18. package/claude/skills/git-pr/references/final-command.md +34 -2
  19. package/claude/skills/git-pr/references/pr.md +1 -1
  20. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  21. package/claude/skills/git-ship/SKILL.md +16 -11
  22. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  23. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  24. package/claude/skills/plan-feature/references/plan.md +2 -0
  25. package/claude/skills/pr-evidence/SKILL.md +6 -1
  26. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  27. package/claude/skills/pr-evidence/references/reel.md +9 -1
  28. package/claude/skills/review-address/SKILL.md +2 -1
  29. package/claude/skills/review-craft/references/ui.md +13 -1
  30. package/claude/skills/review-pr/references/body-variants.md +2 -0
  31. package/claude/skills/review-pr/references/close-out.md +2 -0
  32. package/claude/skills/review-pr/references/key-changes.md +2 -1
  33. package/claude/skills/review-pr/references/post.md +1 -0
  34. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  35. package/claude/skills/review-ui/SKILL.md +2 -2
  36. package/claude/skills/review-ui/references/driving.md +32 -4
  37. package/claude/skills/review-ui/references/post.md +6 -2
  38. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  39. package/claude/skills/role-orchestrator/SKILL.md +4 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  42. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  43. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  44. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  45. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  46. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  47. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  48. package/claude/skills/role-worker/SKILL.md +2 -1
  49. package/claude/skills/task-board/SKILL.md +1 -0
  50. package/claude/skills/task-board/references/tasks.md +1 -0
  51. package/claude/skills/test-craft/SKILL.md +1 -0
  52. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  53. package/claude/skills/ui-checklist/SKILL.md +2 -1
  54. package/claude/skills/ui-craft/references/capture.md +16 -0
  55. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  56. package/claude/skills/ui-film/SKILL.md +6 -1
  57. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  58. package/claude/skills/ui-film/references/config.md +2 -2
  59. package/claude/skills/ui-film/references/handoff.md +1 -1
  60. package/claude/skills/ui-film/references/harness.md +71 -0
  61. package/claude/skills/ui-film/references/take-craft.md +42 -17
  62. package/docs/workflow/ai-workflow.md +1 -1
  63. package/docs/workflow/operating-model.md +6 -5
  64. package/docs/workflow/parallel-features.md +2 -1
  65. package/docs/workflow/visual-design-workflow.md +2 -1
  66. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  67. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  69. package/package.json +4 -3
  70. package/src/commands/pr.ts +255 -46
  71. package/src/commands/sessions.ts +88 -6
  72. package/src/commands/take.ts +12 -1
  73. package/src/commands/tasks.ts +41 -2
  74. package/src/design/components.ts +5 -107
  75. package/src/design/favicon.ts +21 -17
  76. package/src/design/render.ts +9 -27
  77. package/src/design/template.ts +42 -0
  78. package/src/design/templates/favicon.svg +17 -0
  79. package/src/design/templates/hand-drawn-figure.css +54 -0
  80. package/src/design/templates/preview.html +27 -0
  81. package/src/design/templates/scrollbar.css +29 -0
  82. package/src/design/templates/status.css +24 -0
  83. package/src/gate/embedded.ts +400 -0
  84. package/src/gate/stages.ts +8 -0
  85. package/src/git/files.ts +5 -2
  86. package/src/indexes/frontmatter.ts +27 -6
  87. package/src/indexes/render.ts +21 -3
  88. package/src/markdown/colons.ts +14 -4
  89. package/src/markdown/heading-case.ts +9 -2
  90. package/src/pr/bijection.ts +8 -0
  91. package/src/pr/evidence.ts +81 -13
  92. package/src/pr/frames.ts +1 -6
  93. package/src/pr/health.ts +63 -15
  94. package/src/pr/lift.ts +77 -9
  95. package/src/pr/links.ts +160 -0
  96. package/src/pr/local.ts +17 -4
  97. package/src/pr/paths.ts +20 -2
  98. package/src/pr/rest.ts +2 -0
  99. package/src/pr/review-scope.ts +28 -0
  100. package/src/pr/review-state.ts +5 -5
  101. package/src/pr/waiver.ts +45 -0
  102. package/src/sessions/stop.ts +145 -0
  103. package/src/sessions/transfer/bundle.ts +1 -1
  104. package/src/take/camera.ts +337 -208
  105. package/src/take/encode.ts +16 -42
  106. package/src/take/film.ts +7 -1
  107. package/src/take/frame-clock.ts +46 -0
  108. package/src/take/log.ts +43 -2
  109. package/src/take/perform.ts +85 -13
  110. package/src/take/plan.ts +26 -5
  111. package/src/take/reel.ts +30 -5
  112. package/src/take/screencast.ts +223 -13
  113. package/src/take/stepper.ts +24 -4
  114. package/src/tasks/holders.ts +198 -0
  115. package/src/tasks/validate.ts +148 -0
  116. package/src/commands/fixtures/gh.sh +0 -64
  117. package/src/take/fixtures/busy.html +0 -107
  118. package/src/take/fixtures/fade.html +0 -49
  119. package/src/take/fixtures/fetch.html +0 -38
  120. package/src/take/fixtures/input.html +0 -138
  121. package/src/take/zoom/NOTICE.md +0 -45
  122. package/src/take/zoom/math-utils.ts +0 -69
  123. package/src/take/zoom/region-utils.ts +0 -55
  124. package/src/take/zoom/spring.ts +0 -133
  125. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -5,26 +5,17 @@ import {
5
5
  type Point,
6
6
  type TakeLog,
7
7
  cursorAt,
8
+ easeInOutCubic,
8
9
  } from '@/take/log'
9
- import {
10
- type SpringState,
11
- createSpringState,
12
- stepSpring,
13
- } from '@/take/zoom/spring'
14
- import {
15
- CHAINED_ZOOM_PAN_GAP_MS,
16
- CONNECTED_ZOOM_PAN_DURATION_MS,
17
- computeRegionStrength,
18
- easeConnectedPan,
19
- lerp,
20
- } from '@/take/zoom/region-utils'
21
10
 
22
11
  /**
23
- * Turns a take's log into one camera per frame, preset O4 from groundwork
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
26
- * view, and a dead zone that keeps the cursor in frame. HyperFrames renders the
27
- * track as it stands, so it carries no curve a renderer would have to agree on.
12
+ * Turns a take's log into one camera per frame, on the motion the operator
13
+ * settled by eye in groundwork track `210-film-craft-and-soundtrack`. A zoom in
14
+ * rides the glide that reaches its target and lands as the cursor arrives, a
15
+ * zoom out rides the glide that leaves it, and the view scales about the
16
+ * target so it holds still on screen. The view pans only at a steady scale,
17
+ * along the glide between two targets. HyperFrames renders the track as it
18
+ * stands, so it carries no curve a renderer would have to agree on.
28
19
  */
29
20
 
30
21
  export const CAMERA_VERSION = 1
@@ -45,22 +36,20 @@ const FILL = 0.8
45
36
  /** A click whose depth comes out under this zooms nowhere, being nearly the whole page. */
46
37
  const MIN_ZOOM = 1.05
47
38
 
48
- /** How long a zoom holds past its last click or keystroke. */
49
- const HOLD_MS = 300
50
-
51
- /** A click inside this share of the current view keeps the focus where it is. */
52
- const STICKY = 0.7
53
-
54
- /** The cursor stays inside this share of the zoomed view. */
55
- const SAFE = 0.75
39
+ /** How long a zoom or a pan runs when no glide carries it. */
40
+ const EASE_MS = 600
56
41
 
57
42
  /**
58
- * The cursor never passes this share of the rendered view. The dead zone above
59
- * aims the camera, and the spring chasing it lags a long fast reach by more
60
- * than the dead zone's margin, so the rendered view is pushed along with the
61
- * cursor at this edge rather than letting it leave the frame.
43
+ * How long a zoom holds past its last aim when no glide leaves it, matching
44
+ * the hold a take films on a press's result.
62
45
  */
63
- const EDGE = 0.9
46
+ const HOLD_MS = 500
47
+
48
+ /** An aim inside this share of the held view around its stop keeps the view where it is. */
49
+ const STICKY = 0.7
50
+
51
+ /** And it stays at least this share of the view clear of the view's edge. */
52
+ const MARGIN = 0.05
64
53
 
65
54
  /** How long the click ring the edit draws takes to expand. */
66
55
  const RING_MS = 450
@@ -92,18 +81,34 @@ export interface CameraTrack {
92
81
  readonly frames: readonly CameraFrame[]
93
82
  }
94
83
 
95
- /** A click, or a typing burst read as a click on its field held to the last key. */
84
+ /**
85
+ * What the camera frames: a press, a typing burst, or a move's hold, from the
86
+ * moment it starts to the moment the pointer may leave.
87
+ */
96
88
  interface Aim {
97
89
  readonly t: number
98
90
  readonly until: number
99
91
  readonly at: Point
92
+ /** What the view has to keep in frame, being both ends of a drag. */
93
+ readonly box: Box
100
94
  readonly depth: number
101
95
  }
102
96
 
103
- interface Window {
104
- start: number
105
- end: number
106
- readonly aims: Aim[]
97
+ /** A stretch of clip time, eased from its start to its end. */
98
+ interface Span {
99
+ readonly start: number
100
+ readonly end: number
101
+ }
102
+
103
+ /**
104
+ * One zoom: in onto its first stop, a pan at its depth to each later stop,
105
+ * and out from its last.
106
+ */
107
+ interface Zoom {
108
+ readonly depth: number
109
+ readonly into: Span
110
+ readonly stops: readonly { readonly at: Point; readonly pan: Span }[]
111
+ readonly out: Span
107
112
  }
108
113
 
109
114
  export interface CameraOptions {
@@ -129,48 +134,14 @@ export function cameraTrack(
129
134
  (height * FILL) / Math.max(box.height, 1),
130
135
  )
131
136
 
132
- const aims: Aim[] = (log.push === false ? [] : log.events)
133
- .flatMap((event): Aim[] => {
134
- if (event.kind === 'click') {
135
- return [
136
- {
137
- t: event.t,
138
- until: event.t,
139
- at: event.at,
140
- depth: depthFor(event.box),
141
- },
142
- ]
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
- }
157
- if (event.kind === 'type') {
158
- const box = event.box
159
- return [
160
- {
161
- t: event.t,
162
- until: event.t + event.ms,
163
- at: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
164
- depth: depthFor(box),
165
- },
166
- ]
167
- }
168
- return []
169
- })
137
+ const aims = (log.push === false ? [] : log.events)
138
+ .flatMap((event): Aim[] => aimsOf(event, depthFor, deepest))
170
139
  .filter((aim) => aim.depth >= MIN_ZOOM)
171
140
  .sort((a, b) => a.t - b.t)
172
141
 
173
- const scrolls = log.events.filter((event) => event.kind === 'scroll')
142
+ const end = (log.frames * 1000) / log.fps
143
+ const zooms = zoomsOf(aims, log.events, { width, height }, end)
144
+
174
145
  // Read off every press rather than off the aims, which drop a click too large
175
146
  // to zoom and add a typing burst that is no press.
176
147
  const presses = log.events.flatMap((event) =>
@@ -191,77 +162,19 @@ export function cameraTrack(
191
162
  }
192
163
  return null
193
164
  }
194
- const windows = zoomWindows(aims, scrolls)
195
-
196
- const targetAt = (ms: number) => {
197
- let best = { strength: 0, scale: 1, x: width / 2, y: height / 2 }
198
- for (const window of windows) {
199
- const strength = computeRegionStrength(
200
- { startMs: window.aims[0]?.t ?? window.start, endMs: window.end },
201
- ms,
202
- )
203
- if (strength <= best.strength) continue
204
-
205
- const focus = focusAt(window.aims, ms, width, height)
206
- const half = {
207
- x: width / (2 * focus.depth),
208
- y: height / (2 * focus.depth),
209
- }
210
- const cursor = cursorAt(log.events, ms)
211
- const x = clamp(
212
- focus.x,
213
- cursor.x - half.x * SAFE,
214
- cursor.x + half.x * SAFE,
215
- )
216
- const y = clamp(
217
- focus.y,
218
- cursor.y - half.y * SAFE,
219
- cursor.y + half.y * SAFE,
220
- )
221
-
222
- best = {
223
- strength,
224
- scale: lerp(1, focus.depth, strength),
225
- x: lerp(width / 2, x, strength),
226
- y: lerp(height / 2, y, strength),
227
- }
228
- }
229
- return best
230
- }
231
165
 
232
166
  const frameMs = 1000 / log.fps
233
- const springs: Record<'scale' | 'x' | 'y', SpringState> = {
234
- scale: createSpringState(1),
235
- x: createSpringState(width / 2),
236
- y: createSpringState(height / 2),
237
- }
238
-
239
167
  const frames: CameraFrame[] = []
240
168
  for (let index = 0; index < log.frames; index += 1) {
241
169
  const ms = index * frameMs
242
- const target = targetAt(ms)
243
- const scale = Math.max(1, stepSpring(springs.scale, target.scale, frameMs))
244
- const half = { x: width / (2 * scale), y: height / (2 * scale) }
245
- const cursor = cursorAt(log.events, ms)
246
- // Written back so the spring carries on from where the view was pushed.
247
- springs.x.value = clamp(
248
- stepSpring(springs.x, target.x, frameMs),
249
- cursor.x - half.x * EDGE,
250
- cursor.x + half.x * EDGE,
251
- )
252
- springs.y.value = clamp(
253
- stepSpring(springs.y, target.y, frameMs),
254
- cursor.y - half.y * EDGE,
255
- cursor.y + half.y * EDGE,
256
- )
257
-
170
+ const { scale, anchor } = viewAt(zooms, ms)
258
171
  const ring = ringAt(ms)
259
172
  frames.push({
260
173
  ms: round(ms, 2),
261
174
  scale: round(scale, 4),
262
- x: round(clamp(springs.x.value, half.x, width - half.x) - half.x, 2),
263
- y: round(clamp(springs.y.value, half.y, height - half.y) - half.y, 2),
264
- cursor: roundPoint(cursor),
175
+ x: round(originFor(anchor.x, scale, width), 2),
176
+ y: round(originFor(anchor.y, scale, height), 2),
177
+ cursor: roundPoint(cursorAt(log.events, ms)),
265
178
  ring: ring === null ? null : round(ring, 3),
266
179
  })
267
180
  }
@@ -280,97 +193,313 @@ export function readRate(value: string): number | undefined {
280
193
  return Number.isFinite(rate) && rate > 0 ? rate : undefined
281
194
  }
282
195
 
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
- }),
196
+ function aimsOf(
197
+ event: LogEvent,
198
+ depthFor: (box: Box) => number,
199
+ deepest: number,
200
+ ): Aim[] {
201
+ switch (event.kind) {
202
+ case 'click':
203
+ return [
204
+ {
205
+ t: event.t,
206
+ until: event.t,
207
+ at: event.at,
208
+ box: event.box,
209
+ depth: depthFor(event.box),
210
+ },
211
+ ]
212
+ case 'drag': {
213
+ // One aim over both ends, held to the release, so a short drag stays
214
+ // framed whole and a long one asks for a depth under MIN_ZOOM.
215
+ const box = union(event.box, event.drop)
216
+ return [
217
+ {
218
+ t: event.t,
219
+ until: event.t + event.ms,
220
+ at: centerOf(box),
221
+ box,
222
+ depth: depthFor(box),
223
+ },
224
+ ]
225
+ }
226
+ case 'type':
227
+ return [
228
+ {
229
+ t: event.t,
230
+ until: event.t + event.ms,
231
+ at: centerOf(event.box),
232
+ box: event.box,
233
+ depth: depthFor(event.box),
234
+ },
235
+ ]
236
+ case 'move': {
237
+ if (event.hold === undefined) return []
238
+ // A move logs no box, so a hold aims at the full depth.
239
+ const arrived = event.t + event.ms
240
+ return [
241
+ {
242
+ t: arrived,
243
+ until: arrived + event.hold,
244
+ at: event.to,
245
+ box: { ...event.to, width: 0, height: 0 },
246
+ depth: deepest,
247
+ },
248
+ ]
249
+ }
250
+ default:
251
+ return []
295
252
  }
296
253
  }
297
254
 
298
255
  /**
299
- * Groups aims into zooms. One holds from its first aim to the last plus the
300
- * hold, a scroll ends it since the content moves under the camera, and the next
301
- * aim joins it while the gap stays under OpenScreen's chained-pan gap.
256
+ * Groups aims into zooms and times each one off the glides around it. The next
257
+ * aim joins a zoom while at most one glide and no scroll lies between them,
258
+ * since that glide then both leaves one target and reaches the other, and the
259
+ * view pans along it rather than pulling back and pushing in at once.
302
260
  */
303
- function zoomWindows(
261
+ function zoomsOf(
304
262
  aims: readonly Aim[],
305
- scrolls: readonly LogEvent[],
306
- ): Window[] {
307
- const windows: Window[] = []
308
- for (const aim of aims) {
309
- let end = aim.until + HOLD_MS
310
- const cut = scrolls.find((scroll) => scroll.t > aim.t && scroll.t < end)
311
- if (cut) end = cut.t
263
+ events: readonly LogEvent[],
264
+ page: { readonly width: number; readonly height: number },
265
+ end: number,
266
+ ): Zoom[] {
267
+ const glides = events.flatMap((event): Span[] =>
268
+ event.kind === 'move' && event.ms > 0
269
+ ? [{ start: event.t, end: event.t + event.ms }]
270
+ : [],
271
+ )
272
+ const scrolls = events
273
+ .filter((event) => event.kind === 'scroll')
274
+ .map((event) => event.t)
275
+ const glidesIn = (from: number, to: number) =>
276
+ glides.filter((glide) => glide.start >= from && glide.end <= to)
312
277
 
313
- const last = windows.at(-1)
314
- const scrolledBetween =
278
+ const groups: Aim[][] = []
279
+ for (const aim of aims) {
280
+ const group = groups.at(-1)
281
+ const last = group?.at(-1)
282
+ const joins =
283
+ group !== undefined &&
315
284
  last !== undefined &&
316
- scrolls.some((scroll) => scroll.t >= last.end && scroll.t <= aim.t)
317
- if (
318
- last &&
319
- !scrolledBetween &&
320
- aim.t - last.end < CHAINED_ZOOM_PAN_GAP_MS
321
- ) {
322
- last.end = end
323
- last.aims.push(aim)
324
- } else {
325
- windows.push({ start: aim.t, end, aims: [aim] })
285
+ glidesIn(last.until, aim.t).length <= 1 &&
286
+ !scrolls.some((t) => t >= last.until && t <= aim.t)
287
+ if (joins) group.push(aim)
288
+ else groups.push([aim])
289
+ }
290
+
291
+ const zooms: Zoom[] = []
292
+ for (const group of groups) {
293
+ const first = group[0]
294
+ const last = group.at(-1)
295
+ if (!first || !last) continue
296
+ const depth = Math.min(...group.map((aim) => aim.depth))
297
+ const floor = zooms.at(-1)?.out.end ?? 0
298
+
299
+ const into = arriving(glides, first.t, floor) ?? {
300
+ start: Math.max(floor, first.t - EASE_MS),
301
+ end: first.t,
326
302
  }
303
+
304
+ const stops = [
305
+ {
306
+ at: framed(first, depth, page),
307
+ pan: { start: into.end, end: into.end },
308
+ },
309
+ ]
310
+ let previous = first
311
+ for (const aim of group.slice(1)) {
312
+ const stop = stops.at(-1) ?? stops[0]
313
+ if (stop && !inside(aim, stop.at, depth, page)) {
314
+ stops.push({
315
+ at: framed(aim, depth, page),
316
+ pan: arriving(glides, aim.t, previous.until) ?? {
317
+ start: Math.max(previous.until, aim.t - EASE_MS),
318
+ end: aim.t,
319
+ },
320
+ })
321
+ }
322
+ previous = aim
323
+ }
324
+
325
+ const leave = glides.find((glide) => glide.start >= last.until)
326
+ const cut = scrolls.find((t) => t >= last.until)
327
+ const out =
328
+ leave && (cut === undefined || leave.start <= cut)
329
+ ? leave
330
+ : {
331
+ start: Math.min(cut ?? Infinity, last.until + HOLD_MS, end),
332
+ end: Math.min(cut ?? Infinity, last.until + HOLD_MS, end) + EASE_MS,
333
+ }
334
+
335
+ zooms.push({ depth, into, stops, out })
327
336
  }
328
- return windows
337
+ return zooms
338
+ }
339
+
340
+ /** The glide ending as an aim starts, when one does after `floor`. */
341
+ function arriving(
342
+ glides: readonly Span[],
343
+ t: number,
344
+ floor: number,
345
+ ): Span | undefined {
346
+ return glides.findLast(
347
+ (glide) => glide.start >= floor && glide.end <= t + 0.5,
348
+ )
329
349
  }
330
350
 
331
351
  /**
332
- * Where a zoom aims at `ms`. The focus stays on a stop until an aim leaves the
333
- * inner `STICKY` share of the view around it, which becomes the next stop, and
334
- * it pans to each stop on OpenScreen's connected-pan curve, arriving as the
335
- * click lands.
352
+ * Whether an aim lies near enough the stop, with its whole box far enough
353
+ * inside the view anchored on it, that the view can stay where it is.
336
354
  */
337
- function focusAt(
338
- aims: readonly Aim[],
355
+ function inside(
356
+ aim: Aim,
357
+ stop: Point,
358
+ depth: number,
359
+ page: { readonly width: number; readonly height: number },
360
+ ): boolean {
361
+ const near = (
362
+ value: number,
363
+ low: number,
364
+ high: number,
365
+ anchor: number,
366
+ size: number,
367
+ ) => {
368
+ const span = size / depth
369
+ const origin = originFor(anchor, depth, size)
370
+ const margin = span * MARGIN
371
+ return (
372
+ Math.abs(value - anchor) < (span / 2) * STICKY &&
373
+ low > origin + margin &&
374
+ high < origin + span - margin
375
+ )
376
+ }
377
+ const { box } = aim
378
+ return (
379
+ near(aim.at.x, box.x, box.x + box.width, stop.x, page.width) &&
380
+ near(aim.at.y, box.y, box.y + box.height, stop.y, page.height)
381
+ )
382
+ }
383
+
384
+ /**
385
+ * The point a zoom onto `aim` scales about: its own point where the view
386
+ * anchored there keeps the aim's box in frame, and otherwise the nearest point
387
+ * that does, so both ends of a drag near the page's edge stay in view.
388
+ */
389
+ function framed(
390
+ aim: Aim,
391
+ depth: number,
392
+ page: { readonly width: number; readonly height: number },
393
+ ): Point {
394
+ const axis = (at: number, low: number, high: number, size: number) => {
395
+ const span = size / depth
396
+ const k = 1 - 1 / depth
397
+ const margin = Math.min(
398
+ span * MARGIN,
399
+ Math.max(0, (span - (high - low)) / 2),
400
+ )
401
+ const least = (high + margin - span) / k
402
+ const most = (low - margin) / k
403
+ if (least > most) return ((low + high) / 2 - span / 2) / k
404
+ return clamp(at, least, most)
405
+ }
406
+ const { box } = aim
407
+ return {
408
+ x: axis(aim.at.x, box.x, box.x + box.width, page.width),
409
+ y: axis(aim.at.y, box.y, box.y + box.height, page.height),
410
+ }
411
+ }
412
+
413
+ /**
414
+ * The scale and the page point held still on screen at `ms`. Inside a zoom
415
+ * the anchor is a stop while the scale changes and slides between stops only
416
+ * while it holds, so the view never pans and scales at once.
417
+ */
418
+ function viewAt(
419
+ zooms: readonly Zoom[],
339
420
  ms: number,
340
- width: number,
341
- height: number,
342
- ): Point & { depth: number } {
343
- const [first, ...rest] = aims
344
- if (!first) return { x: width / 2, y: height / 2, depth: 1 }
345
-
346
- const stops = [first]
347
- for (const aim of rest) {
348
- const stop = stops.at(-1) ?? first
349
- const inside =
350
- Math.abs(aim.at.x - stop.at.x) < (width / (2 * stop.depth)) * STICKY &&
351
- Math.abs(aim.at.y - stop.at.y) < (height / (2 * stop.depth)) * STICKY
352
- if (!inside) stops.push(aim)
421
+ ): { scale: number; anchor: Point } {
422
+ const rest = { scale: 1, anchor: { x: 0, y: 0 } }
423
+ const zoom = zooms.findLast((each) => ms >= each.into.start)
424
+ if (!zoom || ms > zoom.out.end) return rest
425
+
426
+ const first = zoom.stops[0]
427
+ const last = zoom.stops.at(-1)
428
+ if (!first || !last) return rest
429
+ const lift = zoom.depth - 1
430
+
431
+ if (ms < zoom.into.end) {
432
+ return {
433
+ scale: 1 + lift * easeInOutCubic(progress(zoom.into, ms)),
434
+ anchor: first.at,
435
+ }
436
+ }
437
+ if (ms >= zoom.out.start) {
438
+ return {
439
+ scale: 1 + lift * (1 - easeInOutCubic(progress(zoom.out, ms))),
440
+ anchor: last.at,
441
+ }
353
442
  }
354
443
 
355
- let focus = { x: first.at.x, y: first.at.y, depth: first.depth }
356
- for (let i = 1; i < stops.length; i += 1) {
357
- const from = stops[i - 1] ?? first
358
- const to = stops[i] ?? first
359
- const panStart = to.t - CONNECTED_ZOOM_PAN_DURATION_MS
360
- if (ms >= to.t) {
361
- focus = { x: to.at.x, y: to.at.y, depth: to.depth }
362
- } else if (ms > panStart) {
363
- const k = easeConnectedPan(
364
- (ms - panStart) / CONNECTED_ZOOM_PAN_DURATION_MS,
365
- )
366
- focus = {
367
- x: lerp(from.at.x, to.at.x, k),
368
- y: lerp(from.at.y, to.at.y, k),
369
- depth: lerp(from.depth, to.depth, k),
370
- }
444
+ let anchor = first.at
445
+ for (const [i, stop] of zoom.stops.entries()) {
446
+ if (i === 0 || ms < stop.pan.start) continue
447
+ const from = zoom.stops[i - 1]?.at ?? first.at
448
+ const k = easeInOutCubic(progress(stop.pan, ms))
449
+ anchor = {
450
+ x: from.x + (stop.at.x - from.x) * k,
451
+ y: from.y + (stop.at.y - from.y) * k,
371
452
  }
372
453
  }
373
- return focus
454
+ return { scale: zoom.depth, anchor }
455
+ }
456
+
457
+ function progress(span: Span, ms: number): number {
458
+ return span.end > span.start ? (ms - span.start) / (span.end - span.start) : 1
459
+ }
460
+
461
+ /**
462
+ * The view's top-left along one axis when it is zoomed `scale` about `anchor`,
463
+ * which leaves the anchor where it sits on the unzoomed page.
464
+ */
465
+ function originFor(anchor: number, scale: number, size: number): number {
466
+ const origin = anchor * (1 - 1 / scale)
467
+ return clamp(origin, 0, size - size / scale)
468
+ }
469
+
470
+ /** The log as a clip played `rate` times faster shows it, every time divided. */
471
+ function atRate(log: TakeLog, rate: number): TakeLog {
472
+ if (rate === 1) return log
473
+ return {
474
+ ...log,
475
+ frames: Math.round(log.frames / rate),
476
+ events: log.events.map((event): LogEvent => {
477
+ const t = event.t / rate
478
+ if (event.kind === 'move') {
479
+ return {
480
+ ...event,
481
+ t,
482
+ ms: event.ms / rate,
483
+ ...(event.points
484
+ ? {
485
+ points: event.points.map((point) => ({
486
+ ...point,
487
+ t: point.t / rate,
488
+ })),
489
+ }
490
+ : {}),
491
+ ...(event.hold !== undefined ? { hold: event.hold / rate } : {}),
492
+ }
493
+ }
494
+ return 'ms' in event
495
+ ? { ...event, t, ms: event.ms / rate }
496
+ : { ...event, t }
497
+ }),
498
+ }
499
+ }
500
+
501
+ function centerOf(box: Box): Point {
502
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
374
503
  }
375
504
 
376
505
  function union(a: Box, b: Box): Box {