@graphty/visual-review 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -170,8 +170,10 @@ copy the `visual` job and the gate's steps into it, keep those names, and set `w
170
170
  file.
171
171
 
172
172
  **`visual-seed.yml`** is started by hand to capture an older commit with the default branch's
173
- tool: `gh workflow run visual-seed.yml --ref main -f ref=<sha>`. See
174
- [Seeding](#seeding-one-story-at-a-time).
173
+ tool: `gh workflow run visual-seed.yml --ref main -f ref=<sha>`. It captures every project seeded
174
+ from the default branch; add `-f projects="web charts"` to capture only those. Each project is
175
+ built and captured on its own, so one whose Storybook does not build at that commit fails alone.
176
+ See [Seeding](#seeding-one-story-at-a-time).
175
177
 
176
178
  Both need nothing but the default `GITHUB_TOKEN`: the capture job reads Actions artifacts
177
179
  (`actions: read`); nothing in CI writes to the repository.
@@ -462,6 +464,14 @@ the pull request.
462
464
  wider. It is never cropped to the content, so a small component sits in the full canvas and
463
465
  every capture of a project has the same size unless its story overflows. results.json records the scale as `scale`, and each review record
464
466
  copies it into its `subject`.
467
+ - **Why captures rasterize on the CPU.** Chromium runs with `--disable-gpu-rasterization`, so the
468
+ page's text and shapes are drawn by the CPU; WebGL still runs on SwiftShader. Drawn through
469
+ SwiftShader, a glyph that sat on a sub-pixel boundary landed on either side of it from one
470
+ render to the next (a quarter-pixel shift of one letter, in 1 to 7 of 48 renders of the same
471
+ story), so stories with nothing moving read `unstable`, a different few on each run. With the
472
+ switch, 48 of 48 renders matched. The repository owner chose this on 2026-09-30, knowing it
473
+ changes how text is drawn in every story of every project: a baseline captured before it can
474
+ read `changed` once, and is accepted again.
465
475
  - **From GitHub Actions to the page.** Each `visual` job uploads `results.json` and the PNGs to
466
476
  review as an artifact `visual-<project>-<attempt>`, kept 30 days. The server lists open pull
467
477
  requests with `gh`, finds each one's newest run of the capturing workflow, and downloads those
@@ -54,10 +54,17 @@ const VIEWPORT = { width: 1200, height: 900 };
54
54
  /** Device pixels per CSS pixel, as Chromatic captures; recorded in results.json as `scale`. */
55
55
  const SCALE = 2;
56
56
  const RENDER_TIMEOUT = 30_000;
57
- const CHROMIUM_ARGS = [
57
+ /**
58
+ * Chromium's switches for every capture. `--disable-gpu-rasterization` draws the page's text and
59
+ * shapes on the CPU: rasterized through SwiftShader, a glyph that sits on a sub-pixel boundary
60
+ * landed on either side of it from one render to the next, so a story with nothing moving read
61
+ * `unstable` (the README, "Why captures rasterize on the CPU"). WebGL still runs on SwiftShader.
62
+ */
63
+ export const CHROMIUM_ARGS = [
58
64
  "--use-gl=angle",
59
65
  "--use-angle=swiftshader",
60
66
  "--enable-unsafe-swiftshader",
67
+ "--disable-gpu-rasterization",
61
68
  "--force-color-profile=srgb",
62
69
  "--disable-lcd-text",
63
70
  "--font-render-hinting=none",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/visual-review",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Self-hosted visual review of Storybook stories: capture in GitHub Actions, keep the baselines in git (Git LFS), accept or reject each change in a local page, and gate pull requests",
5
5
  "author": "Adam Powers <apowers@ato.ms>",
6
6
  "type": "module",
@@ -5,9 +5,11 @@
5
5
  # baselines can be seeded from it: `visual-review serve --master-run <this run's id>` lists the run
6
6
  # as the default branch, and Finish opens the seed pull request. Dispatch it from __BRANCH__:
7
7
  # gh workflow run visual-seed.yml --ref __BRANCH__ -f ref=<sha>
8
- # Only the projects whose "seedFromDefaultBranch" is not false are captured. The capture job
9
- # repeats the "visual" job of the review workflow, except that it has no reference (every story
10
- # without a baseline is "new") and results.json names the captured commit.
8
+ # Only the projects whose "seedFromDefaultBranch" is not false are captured, or, with
9
+ # `-f projects="a b"`, only those of them named there. Each project is built and captured on its
10
+ # own: one whose Storybook fails to build at that commit fails its own jobs and no other project's.
11
+ # The capture job repeats the "visual" job of the review workflow, except that it has no reference
12
+ # (every story without a baseline is "new") and results.json names the captured commit.
11
13
 
12
14
  name: Visual seed capture
13
15
 
@@ -18,6 +20,11 @@ on:
18
20
  description: "The commit (or branch or tag) whose Storybooks are captured"
19
21
  required: true
20
22
  type: string
23
+ projects:
24
+ description: "The projects to capture, separated by spaces or commas (default: every one seeded from the default branch)"
25
+ required: false
26
+ type: string
27
+ default: ""
21
28
 
22
29
  permissions:
23
30
  contents: read
@@ -34,13 +41,23 @@ jobs:
34
41
  sparse-checkout: visual-review.config.json
35
42
  sparse-checkout-cone-mode: false
36
43
 
44
+ # A name that is not a project seeded from the default branch stops the run here, rather
45
+ # than seeding fewer projects than were asked for.
37
46
  - name: List the projects
38
47
  id: plan
48
+ env:
49
+ WANT: ${{ inputs.projects }}
39
50
  run: |
40
- echo "projects=$(jq -c '(.baselines // "visual-baselines") as $b
51
+ projects=$(jq -c --arg want "$WANT" '(.baselines // "visual-baselines") as $b
52
+ | [$want | splits("[ ,]+") | select(. != "")] as $w
41
53
  | [.projects | to_entries[] | select(.value.seedFromDefaultBranch != false)
42
- | {project: .key, storybook: .value.storybook, build: .value.build, baselines: $b}]' \
43
- visual-review.config.json)" >> "$GITHUB_OUTPUT"
54
+ | {project: .key, storybook: .value.storybook, build: .value.build, baselines: $b}] as $all
55
+ | ($w - [$all[].project]) as $unknown
56
+ | if ($unknown | length) > 0
57
+ then error("not a project seeded from the default branch: \($unknown | join(", "))")
58
+ else [$all[] | select(($w | length) == 0 or (.project | IN($w[])))] end' \
59
+ visual-review.config.json)
60
+ echo "projects=$projects" >> "$GITHUB_OUTPUT"
44
61
 
45
62
  storybook:
46
63
  name: Build ${{ matrix.project }} at ${{ inputs.ref }}
@@ -71,9 +88,12 @@ jobs:
71
88
  name: storybook-${{ matrix.project }}
72
89
  path: ${{ matrix.storybook }}/
73
90
 
91
+ # Runs even when another project's build failed. A project whose own build failed has no
92
+ # Storybook artifact, so its job stops at the download, before installing anything.
74
93
  visual:
75
94
  name: visual (${{ matrix.project }})
76
95
  needs: [plan, storybook]
96
+ if: ${{ !cancelled() && needs.plan.result == 'success' }}
77
97
  runs-on: ubuntu-24.04
78
98
  timeout-minutes: 45
79
99
  strategy:
@@ -84,6 +104,12 @@ jobs:
84
104
  # The default branch's checkout: the capture tool, the config and the baselines come from here.
85
105
  - uses: actions/checkout@v4
86
106
 
107
+ - name: Download Storybook build
108
+ uses: actions/download-artifact@v4
109
+ with:
110
+ name: storybook-${{ matrix.project }}
111
+ path: ${{ matrix.storybook }}/
112
+
87
113
  - name: Restore this project's baseline images
88
114
  uses: actions/cache@v4
89
115
  with:
@@ -99,12 +125,6 @@ jobs:
99
125
  - name: Install Chromium
100
126
  run: __CLI__ install-browser
101
127
 
102
- - name: Download Storybook build
103
- uses: actions/download-artifact@v4
104
- with:
105
- name: storybook-${{ matrix.project }}
106
- path: ${{ matrix.storybook }}/
107
-
108
128
  - name: Capture
109
129
  run: __CLI__ capture --project ${{ matrix.project }} --out "$RUNNER_TEMP/visual"
110
130
 
@@ -14,6 +14,6 @@
14
14
  <span id="status" role="status"></span>
15
15
  <button type="button" id="copy-link" title="Copy a link to this screen">Copy link</button>
16
16
  </header>
17
- <main id="app"><p>Loading...</p></main>
17
+ <main id="app" tabindex="-1"><p>Loading...</p></main>
18
18
  </body>
19
19
  </html>
@@ -507,3 +507,7 @@ td {
507
507
  word-break: break-all;
508
508
  color: var(--accent);
509
509
  }
510
+
511
+ #app:focus {
512
+ outline: none;
513
+ }
@@ -1759,6 +1759,18 @@ function toggleView(view) {
1759
1759
  showStory();
1760
1760
  }
1761
1761
 
1762
+ // Safari on an iPad sends a hardware keyboard's keys only to a focused element, and tapping an
1763
+ // image or a button focuses nothing, so the shortcuts never arrived. The page itself holds focus
1764
+ // whenever nothing else does.
1765
+ function keepKeys() {
1766
+ if (document.activeElement === null || document.activeElement === document.body) {
1767
+ app.focus({ preventScroll: true });
1768
+ }
1769
+ }
1770
+ document.addEventListener("pointerup", () => setTimeout(keepKeys));
1771
+ document.addEventListener("focusout", () => setTimeout(keepKeys));
1772
+ keepKeys();
1773
+
1762
1774
  document.addEventListener("keydown", (e) => {
1763
1775
  if (!["story", "grid"].includes(state.screen) || e.ctrlKey || e.metaKey || e.altKey) {
1764
1776
  return;