@fusion.dev/helix-cli 0.199.0 → 0.200.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-cli",
3
3
  "description": "The Helix command line, for a workspace that reaches it through npm, and the visual tests it runs.",
4
- "version": "0.199.0",
4
+ "version": "0.200.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -41,11 +41,11 @@
41
41
  "visual-regression"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@fusion.dev/helix-cli-darwin-arm64": "0.199.0",
45
- "@fusion.dev/helix-cli-darwin-x64": "0.199.0",
46
- "@fusion.dev/helix-cli-linux-arm64": "0.199.0",
47
- "@fusion.dev/helix-cli-linux-x64": "0.199.0",
48
- "@fusion.dev/helix-cli-win32-x64": "0.199.0"
44
+ "@fusion.dev/helix-cli-darwin-arm64": "0.200.0",
45
+ "@fusion.dev/helix-cli-darwin-x64": "0.200.0",
46
+ "@fusion.dev/helix-cli-linux-arm64": "0.200.0",
47
+ "@fusion.dev/helix-cli-linux-x64": "0.200.0",
48
+ "@fusion.dev/helix-cli-win32-x64": "0.200.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@anthropic-ai/sdk": ">=0.132",
package/visual/README.md CHANGED
@@ -242,8 +242,11 @@ picture is committed.
242
242
  the page's boxes and scroll offsets to stay the same for three frames. It
243
243
  emulates reduced motion and takes the shot with animations disabled. A font
244
244
  that fails to load fails the entry.
245
- - **One page load for all the shots of an entry.** The viewport, the color
246
- scheme, and the harness style change between shots on the same page.
245
+ - **One page load for each width an entry is pictured at.** Shots at the same
246
+ width change the color scheme and the harness style on the same page, in the
247
+ order the config lists them. A shot at a new width loads the page again, so
248
+ nothing a page keeps from being resized reaches its picture. List the shots
249
+ of one width together.
247
250
  - **A difference is taken again from a fresh page load.** A shot that matches
248
251
  on the second take passes, and the run names it.
249
252
  - **Changes are grouped by what they happened to.** Each region of a shot
package/visual/driver.mjs CHANGED
@@ -22,11 +22,19 @@
22
22
  *
23
23
  * Any request that fails answers `error`, with the reason.
24
24
  *
25
- * **One navigation per entry for every shot.** The first shot loads the page;
26
- * each later one resizes the viewport, switches the emulated scheme, rewrites
27
- * the harness style and settles again. Measured on October 8, 2026 over 153
28
- * pictures of 51 examples, that took 242.6 ms a picture against 287.3 ms for a
29
- * navigation each, and every picture was pixel for pixel the same.
25
+ * **One navigation for each width an entry is pictured at.** The first shot
26
+ * loads the page; a later one at the same width switches the emulated scheme,
27
+ * rewrites the harness style and settles again. Measured on October 8, 2026
28
+ * over 153 pictures of 51 examples, that took 242.6 ms a picture against 287.3
29
+ * ms for a navigation each, and every picture was pixel for pixel the same.
30
+ *
31
+ * **A new width loads the page again**, served styled for that shot. A page
32
+ * resized in place can keep what a fresh one never has, and the second take,
33
+ * which a baseline is written from, is always a fresh load: on Linux a
34
+ * dialog's text resized from 640 to 390 kept grayscale antialiasing where a
35
+ * fresh load at 390 drew it in color, so the shot needed a second take in
36
+ * every run. A change of scheme stays in place, where it tests a component
37
+ * that has to follow the page's colors.
30
38
  *
31
39
  * **Node and Playwright, decided on October 8, 2026.** Chromium's work is about
32
40
  * 94% of the time of a picture, so a capture written in Rust over the browser
@@ -109,6 +117,8 @@ const state = {
109
117
  * the document is named in the query and the path is the entry's.
110
118
  */
111
119
  documents: new Map(),
120
+ /** Each entry's harness rules by shot, so a later shot loads styled for itself. */
121
+ harnesses: new Map(),
112
122
  lanes: [],
113
123
  /** Selectors painted over in every picture. */
114
124
  mask: [],
@@ -173,18 +183,29 @@ const lookup = (path, document) => {
173
183
  const respond = (request, response) => {
174
184
  let path
175
185
  let document
186
+ let shot
176
187
  try {
177
188
  const url = new URL(request.url ?? '/', 'http://visual')
178
189
  path = decodeURIComponent(url.pathname)
179
190
  document = url.searchParams.get('visual')
191
+ shot = url.searchParams.get('shot')
180
192
  } catch {
181
193
  response.writeHead(400).end()
182
194
  return
183
195
  }
184
196
  const found = lookup(path, document)
185
197
  if (found.html !== undefined) {
198
+ /* Styled for the shot it is loaded for, where that is not the first. */
199
+ const rules = shot === null ? undefined : state.harnesses.get(document)?.[Number(shot)]
186
200
  response.writeHead(200, { 'content-type': 'text/html' })
187
- response.end(found.html)
201
+ response.end(
202
+ typeof rules === 'string'
203
+ ? found.html.replace(
204
+ new RegExp(`(<style id="${STYLE_ID}">)[^<]*(</style>)`),
205
+ (_, open, close) => `${open}${rules}${close}`,
206
+ )
207
+ : found.html,
208
+ )
188
209
  return
189
210
  }
190
211
  let body
@@ -381,17 +402,23 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
381
402
  const read = new Set()
382
403
  lane.errors.length = 0
383
404
  lane.messages.length = 0
405
+ /* The width the page was last loaded at. */
406
+ let loaded
384
407
  try {
385
408
  for (const [index, shot] of item.shots.entries()) {
386
- progress.stage = index === 0 ? 'loading the page' : `settling for ${shot.name}`
409
+ const fresh = index === 0 || shot.width !== loaded
410
+ progress.stage = fresh ? 'loading the page' : `settling for ${shot.name}`
387
411
  await page.setViewportSize({ height: shot.height, width: shot.width })
388
412
  await page.emulateMedia({ colorScheme: shot.scheme })
389
- if (index === 0) {
413
+ if (fresh) {
390
414
  /* An entry on a server has its own address. An HTML entry is served
391
- here, its document named in the query. */
415
+ here, its document named in the query, and the shot it is styled
416
+ for where that is not the first. */
392
417
  const address =
393
- item.url ?? `${state.origin}${item.path}?visual=${encodeURIComponent(item.key)}`
418
+ item.url ??
419
+ `${state.origin}${item.path}?visual=${encodeURIComponent(item.key)}${index > 0 ? `&shot=${index}` : ''}`
394
420
  await page.goto(address, { waitUntil: 'load' })
421
+ loaded = shot.width
395
422
  const missing = await upgrades(page, elements)
396
423
  if (missing.length > 0) {
397
424
  throw new Error(
@@ -599,6 +626,10 @@ const handlers = {
599
626
  async capture({ elements, items, readyProperty }) {
600
627
  for (const item of items.filter((one) => one.path !== undefined)) {
601
628
  state.documents.set(item.key, item.html)
629
+ state.harnesses.set(
630
+ item.key,
631
+ item.shots.map((shot) => shot.harness),
632
+ )
602
633
  state.pages.set(item.path, { dir: item.dir, files: item.files ?? {} })
603
634
  }
604
635
  /* **Worker `k` takes items `k`, `k + n`, and so on**, rather than the next