@erclx/canon 5.76.0 → 5.78.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 (90) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
  4. package/claude/skills/ci-workflow/REQUIREMENT.md +8 -2
  5. package/claude/skills/ci-workflow/SKILL.md +3 -0
  6. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  7. package/claude/skills/code-craft/SKILL.md +7 -0
  8. package/claude/skills/code-craft/references/adopted.md +8 -0
  9. package/claude/skills/pr-evidence/SKILL.md +6 -1
  10. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  11. package/claude/skills/pr-evidence/references/reel.md +9 -1
  12. package/claude/skills/review-craft/references/ui.md +13 -1
  13. package/claude/skills/review-pr/references/body-variants.md +2 -0
  14. package/claude/skills/review-pr/references/close-out.md +2 -0
  15. package/claude/skills/review-pr/references/post.md +1 -0
  16. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  17. package/claude/skills/review-ui/SKILL.md +2 -2
  18. package/claude/skills/review-ui/references/driving.md +32 -4
  19. package/claude/skills/review-ui/references/post.md +6 -2
  20. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  21. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  22. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  23. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  24. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  25. package/claude/skills/role-worker/SKILL.md +1 -0
  26. package/claude/skills/test-craft/SKILL.md +1 -0
  27. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  28. package/claude/skills/ui-checklist/SKILL.md +2 -1
  29. package/claude/skills/ui-craft/references/capture.md +16 -0
  30. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  31. package/claude/skills/ui-film/SKILL.md +6 -1
  32. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  33. package/claude/skills/ui-film/references/config.md +2 -2
  34. package/claude/skills/ui-film/references/handoff.md +1 -1
  35. package/claude/skills/ui-film/references/harness.md +71 -0
  36. package/claude/skills/ui-film/references/take-craft.md +42 -17
  37. package/docs/agents/targets.md +3 -1
  38. package/docs/workflow/operating-model.md +6 -5
  39. package/docs/workflow/visual-design-workflow.md +2 -1
  40. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  41. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  42. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  43. package/package.json +5 -4
  44. package/src/commands/pr.ts +117 -31
  45. package/src/commands/take.ts +12 -1
  46. package/src/commands/targets.ts +11 -1
  47. package/src/design/components.ts +5 -107
  48. package/src/design/favicon.ts +21 -17
  49. package/src/design/render.ts +9 -27
  50. package/src/design/template.ts +42 -0
  51. package/src/design/templates/favicon.svg +17 -0
  52. package/src/design/templates/hand-drawn-figure.css +54 -0
  53. package/src/design/templates/preview.html +27 -0
  54. package/src/design/templates/scrollbar.css +29 -0
  55. package/src/design/templates/status.css +24 -0
  56. package/src/gate/embedded.ts +400 -0
  57. package/src/gate/stages.ts +8 -0
  58. package/src/pr/evidence.ts +43 -4
  59. package/src/pr/frames.ts +1 -6
  60. package/src/pr/lift.ts +77 -9
  61. package/src/pr/rest.ts +2 -0
  62. package/src/pr/review-scope.ts +28 -0
  63. package/src/pr/review-state.ts +5 -5
  64. package/src/pr/waiver.ts +45 -0
  65. package/src/take/camera.ts +337 -208
  66. package/src/take/film.ts +7 -1
  67. package/src/take/log.ts +43 -2
  68. package/src/take/perform.ts +85 -13
  69. package/src/take/plan.ts +26 -5
  70. package/src/take/reel.ts +30 -5
  71. package/src/take/screencast.ts +110 -3
  72. package/src/take/stepper.ts +24 -4
  73. package/src/targets/registry.ts +99 -14
  74. package/src/targets/resolve.ts +3 -1
  75. package/tooling/astro/configs/card.config.mjs +4 -0
  76. package/tooling/astro/configs/scripts/check-card-exclusion.sh +7 -2
  77. package/tooling/astro/reference.md +2 -1
  78. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -1
  79. package/tooling/web/configs/.github/workflows/verify.yml +5 -2
  80. package/tooling/web/configs/scripts/install-playwright-deps.sh +52 -0
  81. package/tooling/web/reference.md +5 -0
  82. package/src/commands/fixtures/gh.sh +0 -64
  83. package/src/take/fixtures/busy.html +0 -107
  84. package/src/take/fixtures/fade.html +0 -49
  85. package/src/take/fixtures/fetch.html +0 -38
  86. package/src/take/fixtures/input.html +0 -138
  87. package/src/take/zoom/NOTICE.md +0 -45
  88. package/src/take/zoom/math-utils.ts +0 -69
  89. package/src/take/zoom/region-utils.ts +0 -55
  90. package/src/take/zoom/spring.ts +0 -133
@@ -2,11 +2,12 @@ import {
2
2
  existsSync,
3
3
  mkdirSync,
4
4
  readFileSync,
5
+ realpathSync,
5
6
  renameSync,
6
7
  writeFileSync,
7
8
  } from 'node:fs'
8
- import { homedir } from 'node:os'
9
- import { dirname, join, resolve } from 'node:path'
9
+ import { homedir, tmpdir } from 'node:os'
10
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
10
11
 
11
12
  /**
12
13
  * One project the toolkit has installed into, as the install recorded it.
@@ -116,15 +117,90 @@ function isRecord(value: Partial<TargetRecord>): value is TargetRecord {
116
117
  }
117
118
 
118
119
  /**
119
- * Reads every recorded target, sorted by path.
120
+ * The folders whose contents are never a project, resolved at call time so a
121
+ * test's `CANON_STATE_DIR` override moves the state root with it.
122
+ *
123
+ * Sandbox runs and replays live under the state folder, background jobs keep
124
+ * their scratch under `~/.claude/jobs/`, and unit tests stamp into the OS temp
125
+ * folder. Each one passes through a stamping sync, so without this list every
126
+ * run leaves a row behind for a folder that is gone a minute later.
127
+ */
128
+ export function scratchRoots(): readonly string[] {
129
+ return [tmpdir(), stateDir(), join(homedir(), '.claude', 'jobs')]
130
+ }
131
+
132
+ /**
133
+ * Whether a path sits under a scratch root or inside any `.canon/tmp/` folder.
134
+ *
135
+ * Both sides are compared in their given and their real form, since macOS
136
+ * hands out its temp folder as `/var/folders/...` while a path that went
137
+ * through a symlink resolution reads `/private/var/folders/...`. A
138
+ * `.claude/worktrees/` checkout is not scratch: it is a real checkout of a real
139
+ * project, and only a root on the list or a `.canon/tmp/` segment refuses it.
140
+ */
141
+ export function isScratchPath(
142
+ target: string,
143
+ roots: readonly string[] = scratchRoots(),
144
+ ): boolean {
145
+ const forms = bothForms(resolve(target))
146
+ const rootForms = roots.flatMap((root) => bothForms(resolve(root)))
147
+
148
+ return forms.some(
149
+ (form) =>
150
+ hasCanonTmpSegment(form) ||
151
+ rootForms.some((root) => isWithin(form, root)),
152
+ )
153
+ }
154
+
155
+ function bothForms(path: string): readonly string[] {
156
+ try {
157
+ const real = realpathSync(path)
158
+ return real === path ? [path] : [path, real]
159
+ } catch {
160
+ return [path]
161
+ }
162
+ }
163
+
164
+ function hasCanonTmpSegment(path: string): boolean {
165
+ const segments = path.split(sep)
166
+ return segments.some(
167
+ (segment, index) => segment === '.canon' && segments[index + 1] === 'tmp',
168
+ )
169
+ }
170
+
171
+ function isWithin(path: string, root: string): boolean {
172
+ const rel = relative(root, path)
173
+ return (
174
+ rel === '' ||
175
+ (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
176
+ )
177
+ }
178
+
179
+ /**
180
+ * Whether a row still names a project worth listing: a folder that exists and
181
+ * sits under no scratch root. The read filter and the write prune both call
182
+ * this, so the two can never disagree on a row.
183
+ */
184
+ function isLive(row: TargetRecord, roots: readonly string[]): boolean {
185
+ return existsSync(row.path) && !isScratchPath(row.path, roots)
186
+ }
187
+
188
+ /**
189
+ * Reads every recorded target that is still live, sorted by path.
120
190
  *
121
191
  * A row missing either field is dropped rather than reported. This module is
122
192
  * the file's only writer, so a malformed row is a hand edit or a truncated
123
193
  * write and neither is a finding the caller can act on. What a caller can act
124
194
  * on is the file being absent, which is its own kind above.
195
+ *
196
+ * A row whose folder is gone or under a scratch root is left out too, so the
197
+ * list is right on a machine whose file predates the prune, before any sync
198
+ * rewrites it. A target on a drive that is unmounted reads as gone here, and
199
+ * its next sync records it again.
125
200
  */
126
201
  export function readTargetRegistry(
127
202
  path: string = registryPath(),
203
+ roots: readonly string[] = scratchRoots(),
128
204
  ): TargetRegistry {
129
205
  let text: string
130
206
  try {
@@ -150,38 +226,46 @@ export function readTargetRegistry(
150
226
  .filter((row): row is TargetRecord =>
151
227
  isRecord(row as Partial<TargetRecord>),
152
228
  )
229
+ .filter((row) => isLive(row, roots))
153
230
  .sort((a, b) => a.path.localeCompare(b.path))
154
231
 
155
232
  return { kind: 'read', path, targets }
156
233
  }
157
234
 
158
- /** Why a record attempt did not land, so a caller can say so rather than assume it did. */
159
- export type RecordOutcome = 'recorded' | 'unwritten'
235
+ /**
236
+ * Why a record attempt did not land, so a caller can say so rather than assume
237
+ * it did. `scratch` is a deliberate refusal of a path under a scratch root,
238
+ * not a failure.
239
+ */
240
+ export type RecordOutcome = 'recorded' | 'unwritten' | 'scratch'
160
241
 
161
242
  /**
162
243
  * Records one target, keyed by its resolved path and replacing any row already
163
- * held for it.
244
+ * held for it, and refuses a path under a scratch root.
164
245
  *
165
246
  * The write is a temp file plus a rename, so a reader never meets a half
166
247
  * written file. Two syncs finishing together still resolve last-writer-wins on
167
248
  * the merged set, which can drop the row the loser added. That is left rather
168
249
  * than locked: the authoritative record of an install is the stamp inside the
169
250
  * target, this index is a cache over those, and the next sync of the dropped
170
- * target restores its row.
251
+ * target restores its row. The prune reads the same merged set the write
252
+ * already reads, so it adds no second read and does not widen that window.
171
253
  *
172
- * Nothing removes a row either, so a target that was deleted or that dropped
173
- * the toolkit stays here and the count drifts upward. `canon targets pulls`
174
- * meets that on use, since it refuses a path it cannot open rather than
175
- * reading it as a target with no work, but `canon targets list` does not: it
176
- * never opens a recorded path, and the count is its whole output.
254
+ * Every write also drops the rows `readTargetRegistry` leaves out, being a
255
+ * folder that is gone or one under a scratch root, so the file shrinks back to
256
+ * the live projects rather than growing with every sandbox run. A target on a
257
+ * drive that is unmounted drops with them, and its next sync restores it.
177
258
  */
178
259
  export function recordTarget(
179
260
  target: string,
180
261
  now: Date,
181
262
  path: string = registryPath(),
263
+ roots: readonly string[] = scratchRoots(),
182
264
  ): RecordOutcome {
183
265
  const resolved = resolve(target)
184
- const current = readTargetRegistry(path)
266
+ if (isScratchPath(resolved, roots)) return 'scratch'
267
+
268
+ const current = readTargetRegistry(path, roots)
185
269
  const existing = current.kind === 'read' ? current.targets : []
186
270
 
187
271
  const targets = [
@@ -220,7 +304,8 @@ export type BackfillOutcome = RecordOutcome | 'no-stamp'
220
304
  export function backfillTarget(
221
305
  target: BackfillTarget,
222
306
  path: string = registryPath(),
307
+ roots: readonly string[] = scratchRoots(),
223
308
  ): BackfillOutcome {
224
309
  if (target.stampedAt === null) return 'no-stamp'
225
- return recordTarget(target.path, new Date(target.stampedAt), path)
310
+ return recordTarget(target.path, new Date(target.stampedAt), path, roots)
226
311
  }
@@ -45,6 +45,8 @@ export interface ResolveTargetsOptions extends SweepOptions {
45
45
  /** Roots to walk, supplementing the record rather than replacing it. */
46
46
  readonly sweep?: readonly string[]
47
47
  readonly registryFile?: string
48
+ /** Folders whose recorded rows are left out, defaulting to `scratchRoots()`. */
49
+ readonly scratchRoots?: readonly string[]
48
50
  }
49
51
 
50
52
  /**
@@ -71,7 +73,7 @@ export async function resolveTargets(
71
73
  }
72
74
 
73
75
  const file = opts.registryFile ?? registryPath()
74
- const registry = readTargetRegistry(file)
76
+ const registry = readTargetRegistry(file, opts.scratchRoots)
75
77
 
76
78
  const recorded: KnownTarget[] =
77
79
  registry.kind === 'read'
@@ -14,7 +14,11 @@ const portOffset = Number(process.env.WORKTREE_PORT_OFFSET) || 0
14
14
  // The base port sits outside the band `astro.config.mjs` serves from, so a card
15
15
  // server and a dev server of one worktree never contend, and two worktrees
16
16
  // separate here the way they separate there.
17
+ //
18
+ // The dev toolbar is off because this server exists to be captured, and the
19
+ // toolbar paints over the viewport the capture cuts the card from.
17
20
  export default defineConfig({
21
+ devToolbar: { enabled: false },
18
22
  srcDir: './card-src',
19
23
  outDir: './card-dist',
20
24
  publicDir: './public',
@@ -50,8 +50,13 @@ if grep -rl "$MARKER" "$DIST" >/dev/null 2>&1; then
50
50
  exit 1
51
51
  fi
52
52
 
53
- if find "$DIST" -iname '*og-card*' | grep -q .; then
54
- echo "A file named for the card route reached $DIST. See card.config.mjs's srcDir." >&2
53
+ # Built pages only, since an image or font may carry the route's name without
54
+ # being the route. The path test covers both formats Astro emits, being
55
+ # og-card/index.html under build.format 'directory' and og-card.html under
56
+ # 'file'. The search runs from inside dist so a project checked out under a
57
+ # directory like blog-card-site cannot match on its own prefix.
58
+ if (cd "$DIST" && find . -ipath '*og-card*' -name '*.html' | grep -q .); then
59
+ echo "A page named for the card route reached $DIST. See card.config.mjs's srcDir." >&2
55
60
  exit 1
56
61
  fi
57
62
 
@@ -64,7 +64,7 @@ The astro stack covers Astro + TypeScript projects, being content sites, marketi
64
64
  ## Social card route
65
65
 
66
66
  - Keep the social card as a route the project owns, so a re-render after a rename is one string edit and a re-capture rather than another `design-identity` pick loop
67
- - Expect `canon:design-identity` to write the picked composition into the route and capture it through the card server
67
+ - Expect `canon:design-identity` to write the picked composition into the route and capture it through the card server, which runs with Astro's dev toolbar off so it never paints over the card
68
68
  - `card.config.mjs` is a second Astro config whose `srcDir` points at `./card-src`, which the main page router never reads, so the published build excludes the card structurally
69
69
  - It serves on base port `4421` plus `WORKTREE_PORT_OFFSET`, outside the band `astro.config.mjs` serves from, so a worktree's card and dev servers never contend
70
70
  - `bun run card:dev` serves the route and `bun run card:check` runs the exclusion check, which base's `scripts/verify.ts` also runs after its build stage
@@ -72,6 +72,7 @@ The astro stack covers Astro + TypeScript projects, being content sites, marketi
72
72
  ## Card exclusion and size
73
73
 
74
74
  - `scripts/check-card-exclusion.sh` fails when the route's `<meta name="og-card-marker">` tag reaches `dist/`, and when the route dropped the tag the leak test reads
75
+ - The filename test reads built HTML pages only, under an `og-card` path in either build format, so an image or font may carry the route's name
75
76
  - Keep the exclusion a check rather than a convention, since the card server and the capture read the route and the published build must not carry it
76
77
  - Declare `.card` at 600x315, half the 1200x630 target, since `canon capture` shoots at a fixed 2x device scale and 1200x630 would capture at 2400x1260
77
78
 
@@ -74,10 +74,12 @@ jobs:
74
74
  # Unconditional: the cache holds browser binaries and nothing apt
75
75
  # wrote, so gating this on a cache hit leaves a warm run launching
76
76
  # chromium without the libraries it needs.
77
- run: bunx playwright install-deps chromium
77
+ timeout-minutes: 10
78
+ run: bash scripts/install-playwright-deps.sh chromium
78
79
 
79
80
  - name: Install Playwright Browser
80
81
  if: steps.playwright-cache.outputs.cache-hit != 'true'
82
+ timeout-minutes: 5
81
83
  run: bunx playwright install chromium
82
84
 
83
85
  - name: Capture README Screenshot
@@ -72,7 +72,7 @@ jobs:
72
72
  - name: Install Playwright System Dependencies
73
73
  if: steps.browser.outputs.enabled == 'true'
74
74
  timeout-minutes: 10
75
- run: bunx playwright install-deps chromium
75
+ run: bash scripts/install-playwright-deps.sh chromium
76
76
 
77
77
  - name: Cache Playwright Browsers
78
78
  id: playwright-cache
@@ -84,6 +84,7 @@ jobs:
84
84
 
85
85
  - name: Install Playwright Chromium
86
86
  if: steps.browser.outputs.enabled == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
87
+ timeout-minutes: 5
87
88
  run: bunx playwright install chromium
88
89
 
89
90
  - name: Run Tests
@@ -151,7 +152,8 @@ jobs:
151
152
  path: dist/
152
153
 
153
154
  - name: Install Playwright System Dependencies
154
- run: bunx playwright install-deps chromium
155
+ timeout-minutes: 10
156
+ run: bash scripts/install-playwright-deps.sh chromium
155
157
 
156
158
  - name: Cache Playwright Browsers
157
159
  id: playwright-cache
@@ -162,6 +164,7 @@ jobs:
162
164
 
163
165
  - name: Install Playwright Chromium
164
166
  if: steps.playwright-cache.outputs.cache-hit != 'true'
167
+ timeout-minutes: 5
165
168
  run: bunx playwright install chromium
166
169
 
167
170
  - name: Run E2E Tests
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bash
2
+ # Installs the Playwright system packages for the named browsers, killing an
3
+ # attempt that outlives its bound and retrying it. A degraded apt mirror hangs
4
+ # one connection rather than failing it, so a step timeout alone turns the
5
+ # hang into a red check and never recovers it, while a fresh attempt usually
6
+ # lands on a healthy backend.
7
+ #
8
+ # Usage: install-playwright-deps.sh [--package <spec>] <browser>...
9
+ # Exits 0 on the first success, 2 when no browser is named, and the last
10
+ # attempt's code when every attempt fails.
11
+ set -e
12
+
13
+ package=playwright
14
+ if [ "$1" = "--package" ]; then
15
+ package=$2
16
+ shift 2
17
+ fi
18
+
19
+ if [ $# -eq 0 ]; then
20
+ echo "Usage: install-playwright-deps.sh [--package <spec>] <browser>..." >&2
21
+ exit 2
22
+ fi
23
+
24
+ attempts=3
25
+ bound=${INSTALL_ATTEMPT_SECONDS:-150}
26
+
27
+ # The runner image sets these, yet a stalled log shows gaps of minutes, so
28
+ # print what apt actually reads to settle whether they took effect
29
+ apt-config dump | grep -E '^Acquire::(Retries|http::Timeout) ' || true
30
+
31
+ status=0
32
+ for attempt in $(seq 1 "$attempts"); do
33
+ if [ "$attempt" -gt 1 ]; then
34
+ # timeout runs as the runner user, and Linux drops its signal to the root
35
+ # apt-get playwright spawns through sudo. sudo relays it only to its own
36
+ # child shell, so a root apt-get or dpkg can outlive the kill and hold the
37
+ # lock the next attempt needs.
38
+ sudo pkill -KILL -x 'apt-get|dpkg' || true
39
+ # A kill during unpack leaves dpkg interrupted, which refuses the next
40
+ # install. Its own failure must not end the loop.
41
+ sudo dpkg --configure -a || echo "dpkg --configure -a failed before attempt ${attempt}" >&2
42
+ fi
43
+
44
+ status=0
45
+ timeout --kill-after=10 "$bound" bunx "$package" install-deps "$@" || status=$?
46
+ if [ "$status" -eq 0 ]; then
47
+ exit 0
48
+ fi
49
+ echo "attempt ${attempt}/${attempts} failed with exit ${status}" >&2
50
+ done
51
+
52
+ exit "$status"
@@ -88,6 +88,11 @@ The starter `e2e/home.spec.ts` is seeded once. Sync copies it into a project tha
88
88
  - `.github/workflows/verify.yml` runs `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests`. `unit-tests` installs Chromium and runs `test:browser` only when the project defines that script.
89
89
  - Read `governance/rules/tooling/ci-workflow.md` for when a workflow's commit-back is right and when a report is
90
90
 
91
+ ## Playwright installs in CI
92
+
93
+ - Both workflows install Playwright's system packages through `scripts/install-playwright-deps.sh`, which sits beside `worktree-port.sh` and retries an attempt a stalled apt mirror hangs
94
+ - Expect each system install bounded at 10 minutes and each browser download at 5, while the jobs carry no bound, since test and build time differs per project
95
+
91
96
  ## Design references
92
97
 
93
98
  - Expect a seeded `canon/design/index.md` stub carrying `auto: false`, copied only when missing, so a project's own index is never replaced
@@ -1,64 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Fake gh for src/commands/pr.test.ts. Reads FAKE_GH_FX, FAKE_GH_COMMENTS, and FAKE_GH_API_LOG.
3
- set -o pipefail
4
- fx="$FAKE_GH_FX"
5
- f=.
6
- p=
7
- for a in "$@"; do
8
- [ "$p" = --jq ] && f=$a
9
- p=$a
10
- done
11
- case "$*" in
12
- *graphql* | "pr "* | "repo view"*) exit 1 ;;
13
- api*pulls/7/files*)
14
- echo "$*" >>"$fx/files-calls.log"
15
- [ -f "$fx/files-fail" ] && exit 1
16
- {
17
- if [ -f "$fx/files.tsv" ]; then
18
- cat "$fx/files.tsv"
19
- else
20
- git diff --name-status --no-renames main...HEAD | sed -e 's/^A/added/' -e 's/^M/modified/' -e 's/^D/removed/'
21
- fi
22
- } | jq -Rn '[inputs | select(length > 0) | split("\t") | {status: .[0], filename: .[1]}]' | jq -r "$f"
23
- ;;
24
- api*git/trees/*)
25
- [ -f "$fx/base-tree.json" ] || exit 1
26
- jq -r "$f" "$fx/base-tree.json"
27
- ;;
28
- api*contents/*)
29
- a="$*"
30
- p=${a##*contents/}
31
- p=${p%%\?*}
32
- r=${a##*ref=}
33
- [ -f "$fx/img/$r-$(basename "$p")" ] && cat "$fx/img/$r-$(basename "$p")" || exit 1
34
- ;;
35
- api*compare/*)
36
- if [ -f "$fx/merge-base" ]; then cat "$fx/merge-base"; else git merge-base main HEAD; fi
37
- ;;
38
- "api -X PUT repos/{owner}/{repo}/pulls/7/reviews/"*)
39
- id=${4##*/}
40
- echo "$id" >>"$fx/review-puts.log"
41
- [ -f "$fx/review-put-fail-$id" ] && exit 1
42
- printf '%s\n' "${@: -1}" >>"$fx/review-bodies.log"
43
- echo "{}"
44
- ;;
45
- api*pulls/7/reviews*) [ -f "$fx/ui-reviews.json" ] && jq -r "$f" "$fx/ui-reviews.json" ;;
46
- api*issues/7/comments*) jq '[.comments[] | {html_url: .url, body}]' "$FAKE_GH_COMMENTS" | jq -r "$f" ;;
47
- "api -X PATCH repos/{owner}/{repo}/pulls/7 "*)
48
- [ -f "$fx/body-fail" ] && exit 1
49
- printf '%s' "${@: -1}" >"$fx/body-write.log"
50
- echo "{}"
51
- ;;
52
- "api repos/{owner}/{repo}/pulls/7")
53
- if [ -f "$fx/head" ]; then h=$(cat "$fx/head"); else h=$(git rev-parse HEAD); fi
54
- b=
55
- [ -f "$fx/pr-body" ] && b=$(cat "$fx/pr-body")
56
- jq -n --arg h "$h" --arg b "$b" '{number:7,head:{ref:"feat/x",sha:$h},base:{ref:"main"},mergeable_state:"clean",body:$b}'
57
- ;;
58
- "api repos/{owner}/{repo}") echo "{\"full_name\":\"o/r\"}" ;;
59
- api*)
60
- printf '%s' "${@: -1}" >"$FAKE_GH_API_LOG"
61
- echo "{}"
62
- ;;
63
- *) exit 1 ;;
64
- esac
@@ -1,107 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <title>Busy</title>
6
- <!--
7
- A hero canvas that redraws every frame and spends busy=<ms> of main
8
- thread work on each, so a frame takes about that long to render. A card
9
- sits about 2900px down, below the window, and the rail link scrolls to it.
10
- With timed=1 each frame spins on performance.now() instead, as the
11
- groundwork page did, which never ends once the stepper pauses virtual
12
- time.
13
- -->
14
- <style>
15
- html {
16
- scroll-behavior: smooth;
17
- }
18
-
19
- body {
20
- margin: 0;
21
- font-family: sans-serif;
22
- }
23
-
24
- nav {
25
- position: fixed;
26
- top: 10px;
27
- right: 10px;
28
- z-index: 2;
29
- }
30
-
31
- nav a {
32
- display: block;
33
- padding: 12px 20px;
34
- background: #fff;
35
- }
36
-
37
- canvas {
38
- display: block;
39
- width: 100vw;
40
- height: 100vh;
41
- }
42
-
43
- section {
44
- height: 900px;
45
- border-top: 1px solid #ccc;
46
- padding: 40px;
47
- }
48
-
49
- .card {
50
- width: 400px;
51
- height: 240px;
52
- background: #ddd;
53
- }
54
-
55
- .card:hover {
56
- background: #f60;
57
- }
58
- </style>
59
- </head>
60
- <body>
61
- <nav><a id="rail" href="#cards">Cards</a></nav>
62
- <canvas id="hero"></canvas>
63
- <section>one</section>
64
- <section>two</section>
65
- <section id="cards"><div class="card" id="card">card</div></section>
66
- <section>four</section>
67
- <script>
68
- const params = new URLSearchParams(location.search)
69
- const busy = Number(params.get('busy') || 0)
70
- const timed = params.get('timed') === '1'
71
- const hero = document.getElementById('hero')
72
- hero.width = innerWidth
73
- hero.height = innerHeight
74
- const context = hero.getContext('2d')
75
- // The work is a count calibrated in real time on load, since a loop
76
- // timed by performance.now() never ends under a paused virtual clock.
77
- let sink = 0
78
- const spin = (count) => {
79
- for (let i = 0; i < count; i += 1) sink += Math.sqrt(i)
80
- }
81
- const measure = (ms) => {
82
- let count = 0
83
- const start = performance.now()
84
- while (performance.now() - start < ms) {
85
- spin(10000)
86
- count += 10000
87
- }
88
- return count / ms
89
- }
90
- // Warmed first, so the count matches the optimized loop a frame runs.
91
- measure(100)
92
- const work = Math.round(busy * measure(200))
93
- const draw = (time) => {
94
- if (timed) {
95
- const start = performance.now()
96
- while (performance.now() - start < busy) {}
97
- } else {
98
- spin(work)
99
- }
100
- context.fillStyle = `hsl(${(time / 10) % 360}, 60%, 50%)`
101
- context.fillRect(0, 0, hero.width, hero.height)
102
- requestAnimationFrame(draw)
103
- }
104
- requestAnimationFrame(draw)
105
- </script>
106
- </body>
107
- </html>
@@ -1,49 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <title>Fade</title>
6
- <!--
7
- A band that fades from black to white over exactly one second of page
8
- time once the button is pressed, so its brightness frame by frame reads
9
- how much page time each frame of a take advanced.
10
- -->
11
- <style>
12
- body {
13
- margin: 0;
14
- background: #808080;
15
- }
16
-
17
- #go {
18
- position: absolute;
19
- left: 100px;
20
- top: 100px;
21
- width: 200px;
22
- height: 60px;
23
- }
24
-
25
- #band {
26
- position: absolute;
27
- left: 0;
28
- top: 300px;
29
- width: 1920px;
30
- height: 780px;
31
- background-color: #000;
32
- transition: background-color 1000ms linear;
33
- }
34
-
35
- #band.lit {
36
- background-color: #fff;
37
- }
38
- </style>
39
- </head>
40
- <body>
41
- <button id="go">Go</button>
42
- <div id="band"></div>
43
- <script>
44
- document.getElementById('go').addEventListener('click', () => {
45
- document.getElementById('band').classList.add('lit')
46
- })
47
- </script>
48
- </body>
49
- </html>
@@ -1,38 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <title>Fetch</title>
6
- <!--
7
- A button whose press starts a fetch the server answers slowly, the way
8
- a page switch in an app loads what the next view shows. A take must keep
9
- stepping frames while the fetch is in flight.
10
- -->
11
- <style>
12
- body {
13
- margin: 0;
14
- background: #808080;
15
- }
16
-
17
- #go {
18
- position: absolute;
19
- left: 100px;
20
- top: 100px;
21
- width: 200px;
22
- height: 60px;
23
- }
24
- </style>
25
- </head>
26
- <body>
27
- <button id="go">Load</button>
28
- <p id="status">idle</p>
29
- <script>
30
- document.getElementById('go').addEventListener('click', () => {
31
- document.getElementById('status').textContent = 'loading'
32
- fetch('/slow').then(() => {
33
- document.getElementById('status').textContent = 'loaded'
34
- })
35
- })
36
- </script>
37
- </body>
38
- </html>