Sideways canvas scroll is laggy with frosted windows (production) #256

Closed
opened 2026-10-29 13:50:50 +00:00 by kayg · 10 comments
Owner

Owner report, 2026-10-29: in production, scrolling the canvas sideways is laggy when windows are frosted (the glass/backdrop-blur material).

Plan:

  1. Reproduce on the production build. Record frame times during a sideways scroll with 1, 4 and 12 frosted windows, and with frosting turned off as the baseline. Use the frame meter (#116), plus a Chromium performance trace with GPU and compositor tracks. Numbers come from a real device. Headless WebKit has no backdrop blur, so it can't measure this.
  2. Find what is expensive. Likely suspects: a backdrop filter re-rasterising every frame while its content moves underneath, the blur radius and filter area, layers being promoted or demoted mid-scroll, or the blur repainting every window instead of compositing once.
  3. Fix it without losing the look (TASTE.md: no fake glass). Candidates: freeze or cache the backdrop during the gesture, a lower-cost blur while moving, or a smaller blurred area. Each one is measured against the 120 fps target (#121).

Questions for the owner: which device and browser showed it (iPhone Safari PWA, or desktop Chrome or Safari, and which machine), and how many windows were open?

Owner report, 2026-10-29: in production, **scrolling the canvas sideways is laggy when windows are frosted** (the glass/backdrop-blur material). Plan: 1. Reproduce on the production build. Record frame times during a sideways scroll with 1, 4 and 12 frosted windows, and with frosting turned off as the baseline. Use the frame meter (#116), plus a Chromium performance trace with GPU and compositor tracks. Numbers come from a real device. Headless WebKit has no backdrop blur, so it can't measure this. 2. Find what is expensive. Likely suspects: a backdrop filter re-rasterising every frame while its content moves underneath, the blur radius and filter area, layers being promoted or demoted mid-scroll, or the blur repainting every window instead of compositing once. 3. Fix it without losing the look (TASTE.md: no fake glass). Candidates: freeze or cache the backdrop during the gesture, a lower-cost blur while moving, or a smaller blurred area. Each one is measured against the 120 fps target (#121). Questions for the owner: which device and browser showed it (iPhone Safari PWA, or desktop Chrome or Safari, and which machine), and how many windows were open?
Author
Owner

Start: branch frosted-scroll-256, base origin/dev @ 63773bb3ec. Plan: CDP trace of a 3 s sideways canvas scroll at 1/4/12 frosted windows vs frosting off (VM, no GPU, relative numbers only), then fix, ?glass=live A/B switch, spec, evidence here.

Start: branch `frosted-scroll-256`, base `origin/dev` @ 63773bb3ecd990b09d3663c1bd32b2cd8e49991f. Plan: CDP trace of a 3 s sideways canvas scroll at 1/4/12 frosted windows vs frosting off (VM, no GPU, relative numbers only), then fix, `?glass=live` A/B switch, spec, evidence here.
Author
Owner

Start: branch frosted-scroll-256, base origin/dev @ 63773bb3ec. Plan: CDP trace of a 3 s sideways canvas scroll at 1/4/12 frosted windows vs frosting off (VM, no GPU, relative numbers only), then fix, ?glass=live A/B switch, spec, evidence here.

Start: branch `frosted-scroll-256`, base `origin/dev` @ 63773bb3ecd990b09d3663c1bd32b2cd8e49991f. Plan: CDP trace of a 3 s sideways canvas scroll at 1/4/12 frosted windows vs frosting off (VM, no GPU, relative numbers only), then fix, `?glass=live` A/B switch, spec, evidence here.
Author
Owner

Finding 1 (reproduced; VM, no GPU, relative only). Scripted 3 s sideways canvas pan (free canvas, 20 px/frame sine, 1440x900 DPR 1, Tokyo, mesh, Frosted cards), Solid vs Frosted windows (D189, 24 px):

windows Solid p50 / p95 Frosted p50 / p95 compositor draw per frame (Solid → Frosted)
1 16.7 / 16.7 ms 16.7 / 16.8 ms (max 33) 7.4 → 14.2 ms
4 16.7 / 16.8 ms 33.3 / 33.4 ms 8.4 → 24.6 ms
12 16.7 / 16.7 ms 33.3 / 33.4 ms 8.6 → 23.9 ms
  • The whole cost is the backdrop blur drawn in the compositor every frame (Display::DrawAndSwap). The translucent tint alone (blur off) costs nothing (8.1 ms/frame, same as Solid). Paint, layerize and raster are flat (< 10 ms in 3 s).
  • Not the cause: will-change promotion (removing it: 27.6 ms/frame, no change); blur radius (8 px: 22 ms, 48 px: 29 ms; in software it is area-driven).
  • Dropping the window blur while data-moving is set brings 1/4/12 windows back to the Solid baseline (7.4 / ~8 / 7.2 ms per frame, p95 16.7–16.8).

Finding 2 (owner decision, not changed by me). In Chromium the window blur does not see the canvas backdrop at all: .cl-viewport has clip-path, which makes it a backdrop root, so the window's backdrop-filter only sees what is inside the viewport (other windows, shadows). I put black/white stripes under the viewport: with production CSS they show sharp inside a Frosted window (same as with the filter off); with clip-path: none they show blurred. So in Chrome and Edge a Frosted window pays the per-frame blur for a result that is almost empty, while the look at rest is the same with the filter off. Safari may differ (not measurable here). I keep the at-rest look exactly as it is.
backdrop-root-production.png production CSS
backdrop-root-without-clip-path.png clip-path: none
backdrop-root-no-filter.png filter off

Plan: windows drop the live blur while the camera moves (the world already carries data-moving), live blur returns at rest; ?glass=live keeps today's behaviour. Cards (pills, sidebar) keep blurring: the content moving under them is real.

**Finding 1 (reproduced; VM, no GPU, relative only).** Scripted 3 s sideways canvas pan (free canvas, 20 px/frame sine, 1440x900 DPR 1, Tokyo, mesh, Frosted cards), Solid vs Frosted windows (D189, 24 px): | windows | Solid p50 / p95 | Frosted p50 / p95 | compositor draw per frame (Solid → Frosted) | |---|---|---|---| | 1 | 16.7 / 16.7 ms | 16.7 / 16.8 ms (max 33) | 7.4 → 14.2 ms | | 4 | 16.7 / 16.8 ms | 33.3 / 33.4 ms | 8.4 → 24.6 ms | | 12 | 16.7 / 16.7 ms | 33.3 / 33.4 ms | 8.6 → 23.9 ms | - The whole cost is the backdrop blur drawn in the compositor every frame (`Display::DrawAndSwap`). The translucent tint alone (blur off) costs nothing (8.1 ms/frame, same as Solid). Paint, layerize and raster are flat (< 10 ms in 3 s). - Not the cause: `will-change` promotion (removing it: 27.6 ms/frame, no change); blur radius (8 px: 22 ms, 48 px: 29 ms; in software it is area-driven). - Dropping the window blur while `data-moving` is set brings 1/4/12 windows back to the Solid baseline (7.4 / ~8 / 7.2 ms per frame, p95 16.7–16.8). **Finding 2 (owner decision, not changed by me).** In Chromium the window blur does not see the canvas backdrop at all: `.cl-viewport` has `clip-path`, which makes it a backdrop root, so the window's `backdrop-filter` only sees what is inside the viewport (other windows, shadows). I put black/white stripes under the viewport: with production CSS they show sharp inside a Frosted window (same as with the filter off); with `clip-path: none` they show blurred. So in Chrome and Edge a Frosted window pays the per-frame blur for a result that is almost empty, while the look at rest is the same with the filter off. Safari may differ (not measurable here). I keep the at-rest look exactly as it is. ![backdrop-root-production.png](https://git.kayg.org/attachments/ea803c9a-2c1c-44d9-8016-418a984cfdc3) production CSS ![backdrop-root-without-clip-path.png](https://git.kayg.org/attachments/ddb6fae7-9289-4065-90ae-abb1835ee6fb) `clip-path: none` ![backdrop-root-no-filter.png](https://git.kayg.org/attachments/e140e2a6-769e-48b7-936b-a07d34849b2e) filter off Plan: windows drop the live blur while the camera moves (the world already carries `data-moving`), live blur returns at rest; `?glass=live` keeps today's behaviour. Cards (pills, sidebar) keep blurring: the content moving under them is real.
Author
Owner

Finished (not merged, not deployed). Branch frosted-scroll-256, head 88ae0a23d0294f4cb22bd063555a58b91b18f733, base origin/dev @ 63773bb3e. (The start comment above is posted twice: my shell sent it twice. Nothing else is duplicated.)

Commits (atomic, each pushed):

  1. 46f32e1b4 test: scripted sideways pan + Chromium trace harness and report spec (frosted-scroll-harness.ts, frosted-scroll-report.spec.ts).
  2. a8beb005b fix: Frosted windows draw no blur while the camera moves (data-moving on the world), live again at rest; ?glass=live keeps today's behaviour. Spec frosted-scroll.spec.ts, and clusters-glass-motion.spec.ts updated (it asserted that nothing turns the blur off while the camera moves).
  3. 88ae0a23d docs: TASTE row, camera module doc, canvas README.

Where the material is applied (all of it): Frosted windows themes.ts installLevelRules (24 px, only .ui-window[data-onscreen], D189); floating chrome packages/ui/styles.css (.ui-overlay-surface, .ui-panel, .ui-pill: blur(var(--ui-overlay-blur)), 32 px at Frosted cards; the view labels are .ui-pills too). Nothing else uses backdrop-filter in product code (transparency.ts is the mockup comparison).

What was expensive: only the backdrop blur of the windows, drawn by the compositor on every frame (Display::DrawAndSwap). Tint alone, paint, layerize, raster, layer promotion and blur radius are not the cause (numbers in the Finding 1 comment and the commit message).

What was chosen: freeze while moving (CSS only, no per-frame JS). Rejected, with measurements, in the commit body: cheaper blur while moving (22 ms/frame, area-bound), will-change changes (no effect), smaller area (windows tile the screen), a cached copy of the blurred backdrop (nothing can read the compositor's blur back; our own copy needs a counter-translated layer per window per frame), a cross-fade back at rest (cannot be checked in WebKit here).

Trace summary, before (?glass=live) / after (default), VM, no GPU, relative only, median of 3: trace-summary.md

windows frosting off p95 before p95 after p95 draw/frame off / before / after
1 16.7 ms 16.8 ms 16.8 ms 8.1 / 14.4 / 7.1 ms
4 16.8 ms 33.4 ms 16.7 ms 8.0 / 23.5 / 7.3 ms
12 16.8 ms 33.4 ms 16.7 ms 8.7 / 23.6 / 7.9 ms

DPR 2, 12 windows, one run each: p95 16.7 off / 33.4 before / 16.7 after; draw per frame 9.1 / 24.3 / 8.6 ms. Layer count is the same with and without frost (80→84 at 12 windows). Full table with paint, raster and layers per row is in the attachment; raw: report-dpr1.txt.

Screenshots. At rest, the same page before any scroll and after a pan (and with data-glass="live" switched on at runtime): pixel diff 0 of 1,296,000 pixels in both comparisons.
rest-1-before-any-scroll.png
rest-2-after-a-pan.png
Mid-scroll (default path, data-moving set, all four windows backdrop-filter: none, chrome still blurred):
mid-scroll-default.png

Tests. frosted-scroll.spec.ts fails on the base without the fix (3 of 3, spec-on-base.txt: the frame budget fails with draw 22.8 ms/frame against a bound of 11.5) and passes on the head (spec-head.txt):

$ bun x tsc --project tsconfig.json          (apps/tracer, bun run check: no output, exit 0)
  ✓   2 test/clusters-glass-motion.spec.ts:19:3 › card level 2: the blur is on during a camera flight and during a close reflow (11.1s)
  ✓   3 test/clusters-glass-motion.spec.ts:19:3 › card level 3: ... (11.1s)
  ✓   4 test/clusters-glass-motion.spec.ts:19:3 › card level 4: ... (11.2s)
  ✓   5 test/frosted-scroll.spec.ts:24:1 › the windows' blur is frozen while the canvas pans, the chrome's is not, and the blur is live at rest (15.1s)
  ✓   6 test/frosted-scroll.spec.ts:40:1 › ?glass=live keeps the windows' blur running while the canvas pans (12.5s)
FROSTED-SCROLL 12 windows, median of 3: draw per frame 8.6 ms off, 8.4 ms frosted (x0.97); frame p95 16.7 ms off, 16.8 ms frosted (x1.01); layers 84 off, 84 frosted
  ✓   7 test/frosted-scroll.spec.ts:51:1 › 12 Frosted windows pan within a small ratio of the same pan with frosting off (1.8m)

Same run: clusters-flight.spec.ts ✓; glass-ghost.spec.ts 4 failed / 19 passed: "toast at level 3/4 (ink and paper, DPR 3) lets text through", with the identical ratios (0.2553, 0.4294) on the base commit, so not caused by this branch. Unit tests (bun test over every *.test.ts): the same failures on base and head (head, base: deferred Brotli budget, recoveryAuth export).
scripts/verify fast was not run (arm64 VM without a GPU, per the brief). Playwright ran with 1 worker on port 4256; MemAvailable stayed above 43 GB.

For the owner's device test (how to test): open the app twice, once with ?glass=live (today's behaviour: the blur keeps running while you pan) and once without (the fix), Windows set to Frosted (Settings, or ?windows=4), and pan sideways with 4 and 12 windows. Report which device and browser, and whether the frozen blur is visible while moving.

Open points, not decided by me.

  1. Chromium blurs nothing of the canvas under a Frosted window (Finding 2: .cl-viewport's clip-path is a backdrop root), so in Chrome and Edge the blur I freeze was almost empty; in Safari it may blur the gradient and dots. The only visible difference while moving could be the dots showing sharp through the window (they are blurred away at rest). That is exactly what the A/B is for. Removing the clip-path would change the look at rest and add cost: your call.
  2. The first frame at the end of a gesture brings the blur back at once (no cross-fade).
  3. Window drags (state.movingWindow) are not covered: they do not set data-moving.
  4. All timings are VM, no GPU, relative only; the perf-VM rule says device numbers come from a perf VM or a device, so please treat these as the shape of the problem, not the size on your device.
**Finished (not merged, not deployed).** Branch `frosted-scroll-256`, head `88ae0a23d0294f4cb22bd063555a58b91b18f733`, base `origin/dev` @ 63773bb3e. (The start comment above is posted twice: my shell sent it twice. Nothing else is duplicated.) Commits (atomic, each pushed): 1. `46f32e1b4` test: scripted sideways pan + Chromium trace harness and report spec (`frosted-scroll-harness.ts`, `frosted-scroll-report.spec.ts`). 2. `a8beb005b` fix: Frosted windows draw no blur while the camera moves (`data-moving` on the world), live again at rest; `?glass=live` keeps today's behaviour. Spec `frosted-scroll.spec.ts`, and `clusters-glass-motion.spec.ts` updated (it asserted that nothing turns the blur off while the camera moves). 3. `88ae0a23d` docs: TASTE row, camera module doc, canvas README. **Where the material is applied** (all of it): Frosted windows `themes.ts` `installLevelRules` (24 px, only `.ui-window[data-onscreen]`, D189); floating chrome `packages/ui/styles.css` (`.ui-overlay-surface`, `.ui-panel`, `.ui-pill`: `blur(var(--ui-overlay-blur))`, 32 px at Frosted cards; the view labels are `.ui-pill`s too). Nothing else uses `backdrop-filter` in product code (`transparency.ts` is the mockup comparison). **What was expensive:** only the backdrop blur of the windows, drawn by the compositor on every frame (`Display::DrawAndSwap`). Tint alone, paint, layerize, raster, layer promotion and blur radius are not the cause (numbers in the Finding 1 comment and the commit message). **What was chosen:** freeze while moving (CSS only, no per-frame JS). Rejected, with measurements, in the commit body: cheaper blur while moving (22 ms/frame, area-bound), `will-change` changes (no effect), smaller area (windows tile the screen), a cached copy of the blurred backdrop (nothing can read the compositor's blur back; our own copy needs a counter-translated layer per window per frame), a cross-fade back at rest (cannot be checked in WebKit here). **Trace summary, before (`?glass=live`) / after (default), VM, no GPU, relative only, median of 3:** [trace-summary.md](https://git.kayg.org/attachments/b89dcb4e-c140-4684-b3f9-b1270e834608) | windows | frosting off p95 | before p95 | after p95 | draw/frame off / before / after | |---|---|---|---|---| | 1 | 16.7 ms | 16.8 ms | 16.8 ms | 8.1 / 14.4 / 7.1 ms | | 4 | 16.8 ms | 33.4 ms | 16.7 ms | 8.0 / 23.5 / 7.3 ms | | 12 | 16.8 ms | 33.4 ms | 16.7 ms | 8.7 / 23.6 / 7.9 ms | DPR 2, 12 windows, one run each: p95 16.7 off / 33.4 before / 16.7 after; draw per frame 9.1 / 24.3 / 8.6 ms. Layer count is the same with and without frost (80→84 at 12 windows). Full table with paint, raster and layers per row is in the attachment; raw: [report-dpr1.txt](https://git.kayg.org/attachments/1754110d-3429-46c4-9867-3a1b448a71dd). **Screenshots.** At rest, the same page before any scroll and after a pan (and with `data-glass="live"` switched on at runtime): pixel diff 0 of 1,296,000 pixels in both comparisons. ![rest-1-before-any-scroll.png](https://git.kayg.org/attachments/6a426a5e-b4d1-401f-84b3-70858509fa59) ![rest-2-after-a-pan.png](https://git.kayg.org/attachments/752e1b12-8cf1-4df5-b230-67f86d7b1c8c) Mid-scroll (default path, `data-moving` set, all four windows `backdrop-filter: none`, chrome still blurred): ![mid-scroll-default.png](https://git.kayg.org/attachments/9a3be156-5225-4225-9581-e26e69acdfaf) **Tests.** `frosted-scroll.spec.ts` fails on the base without the fix (3 of 3, [spec-on-base.txt](https://git.kayg.org/attachments/c9b416b8-5f10-42dd-9959-8ed0c2b4a8fa): the frame budget fails with draw 22.8 ms/frame against a bound of 11.5) and passes on the head ([spec-head.txt](https://git.kayg.org/attachments/09e9a131-8567-4f9c-a19a-62d8f67aea83)): ``` $ bun x tsc --project tsconfig.json (apps/tracer, bun run check: no output, exit 0) ✓ 2 test/clusters-glass-motion.spec.ts:19:3 › card level 2: the blur is on during a camera flight and during a close reflow (11.1s) ✓ 3 test/clusters-glass-motion.spec.ts:19:3 › card level 3: ... (11.1s) ✓ 4 test/clusters-glass-motion.spec.ts:19:3 › card level 4: ... (11.2s) ✓ 5 test/frosted-scroll.spec.ts:24:1 › the windows' blur is frozen while the canvas pans, the chrome's is not, and the blur is live at rest (15.1s) ✓ 6 test/frosted-scroll.spec.ts:40:1 › ?glass=live keeps the windows' blur running while the canvas pans (12.5s) FROSTED-SCROLL 12 windows, median of 3: draw per frame 8.6 ms off, 8.4 ms frosted (x0.97); frame p95 16.7 ms off, 16.8 ms frosted (x1.01); layers 84 off, 84 frosted ✓ 7 test/frosted-scroll.spec.ts:51:1 › 12 Frosted windows pan within a small ratio of the same pan with frosting off (1.8m) ``` Same run: `clusters-flight.spec.ts` ✓; `glass-ghost.spec.ts` 4 failed / 19 passed: "toast at level 3/4 (ink and paper, DPR 3) lets text through", with the identical ratios (0.2553, 0.4294) on the base commit, so not caused by this branch. Unit tests (`bun test` over every `*.test.ts`): the same failures on base and head ([head](https://git.kayg.org/attachments/a7af0124-d2fe-45a7-a1b3-7fd4f5140235), [base](https://git.kayg.org/attachments/69c8b1cb-7e3f-4ecc-bc35-13e57ccb0215): deferred Brotli budget, `recoveryAuth` export). `scripts/verify fast` was **not** run (arm64 VM without a GPU, per the brief). Playwright ran with 1 worker on port 4256; MemAvailable stayed above 43 GB. **For the owner's device test (how to test):** open the app twice, once with `?glass=live` (today's behaviour: the blur keeps running while you pan) and once without (the fix), Windows set to Frosted (Settings, or `?windows=4`), and pan sideways with 4 and 12 windows. Report which device and browser, and whether the frozen blur is visible while moving. **Open points, not decided by me.** 1. Chromium blurs nothing of the canvas under a Frosted window (Finding 2: `.cl-viewport`'s `clip-path` is a backdrop root), so in Chrome and Edge the blur I freeze was almost empty; in Safari it may blur the gradient and dots. The only visible difference while moving could be the dots showing sharp through the window (they are blurred away at rest). That is exactly what the A/B is for. Removing the `clip-path` would change the look at rest and add cost: your call. 2. The first frame at the end of a gesture brings the blur back at once (no cross-fade). 3. Window drags (`state.movingWindow`) are not covered: they do not set `data-moving`. 4. All timings are VM, no GPU, relative only; the perf-VM rule says device numbers come from a perf VM or a device, so please treat these as the shape of the problem, not the size on your device.
Author
Owner

J18 start (design sub-agent): side-by-side variants for the owner. Variant A frosted-scroll-256-a = the #256 fix rebased on origin/dev @ 42ab33035 (blur paused while moving, clip-path unchanged). Variant B frosted-scroll-256-b = A + windows truly blurring the canvas (Finding 2: the viewport stops being a backdrop root). Both are measured on claude@infraternal-dev-02-std (no GPU: relative numbers only) and served for the owner; I do not decide.

J18 start (design sub-agent): side-by-side variants for the owner. Variant A `frosted-scroll-256-a` = the #256 fix rebased on origin/dev @ 42ab33035 (blur paused while moving, clip-path unchanged). Variant B `frosted-scroll-256-b` = A + windows truly blurring the canvas (Finding 2: the viewport stops being a backdrop root). Both are measured on claude@infraternal-dev-02-std (no GPU: relative numbers only) and served for the owner; I do not decide.
Author
Owner

J18: side-by-side for the owner (A vs B). Not decided by me; you pick. Both branches are rebased on origin/dev @ 42ab33035, pushed, not merged, not deployed.

branch head what it is
A frosted-scroll-256-a 9023f9683 the #256 fix (3 commits from frosted-scroll-256, cherry-picked): window blur paused while the camera moves, live at rest. clip-path unchanged: today's look.
B frosted-scroll-256-b bcfd02d7f A + 1 commit: the canvas is clipped by overflow: clip on .panes (the viewport's parent) instead of clip-path on .cl-viewport, so Frosted windows really blur the canvas behind them.

What B changes (apps/tracer/clusters.css only, 1 file, +11/−4)

  • .cl-viewport loses its clip-path: inset(-12px 0 0 0) (the thing that made it a backdrop root). .panes becomes overflow: clip, reaches 12 px above the viewport (margin-top: -12px; padding-top: 12px), and the viewport sits inset: 12px 0 0 0 from its padding edge.
  • Same clipped region as before: sides and bottom at the gutter lines, 12 px of headroom on top for the lock shackle (#126/D195). .cl-viewport's own rectangle is unchanged (measured 20, 60, 1650×924 in both A and B at 1690×1000), so the camera maths and fix-canvas-side-clip (window-overhang.spec.ts, which measures that rectangle) are unaffected. overflow: clip cannot be scrolled (the #126 reason for the outer clip).
  • fix-canvas-side-clip: it touches frame.ts, geometry.ts, stops.ts, window-sizes.ts, column-els.ts, create.ts, lock.ts; neither A nor B touches those files. git merge --no-commit of each into origin/fix-canvas-side-clip: no conflicts. Its window-overhang.spec.ts (real server) was not run: it needs the Rust server build. After merging both, its commit text and comment that say ".cl-viewport … clip-path" would be out of date for B only.
  • Rejected for B: overflow-clip-margin (one value for all four sides, not in every browser), a taller viewport (moves the camera origin, collides with fix-canvas-side-clip), moving the backdrop into the viewport, dropping the clip.
  • B has a rule that the next person must keep: nothing between a window and the canvas backdrop may take a clip-path, filter, mask, opacity or blend mode, or the blur goes blind again (written in the CSS comment).

Proof that B blurs the canvas and A does not (production builds, black/white 40 px stripes on the canvas backdrop, Frosted windows, DPR 2)

A (stripe edge sharp through the window): a-stripes.png B (soft): b-stripes.png

Screenshots at rest, production build, 1690×1000 DPR 2, Tokyo, mesh, Frosted cards and windows

A (clip-path, today's look) B (windows blur the canvas)
4 windows
12 windows
moving state held still (data-moving set by hand; all window blur none)

My own reading, not a judgement of the look: over this mesh backdrop the rest difference is small. B smooths the grain and the large gradient shows through softer; the stripes above are where it is unmistakable. The dots layer is the content a real session would show; I did not see it in these shots. The Solid-window shots of A and B differ only by run-to-run noise (A vs A: 637,642 differing pixels, max channel delta 209; A vs B: 637,535, max 6), so the clip change moves nothing by itself.

Frame time, 3 s sideways pan (VM infraternal-dev-02-std, aarch64, no GPU: relative numbers only)

Median of 3 runs per cell (frosted-scroll-report.spec.ts, 1440×900, free canvas). The VM was shared: load average 10–25 on 10 cores during the runs, so single cells are noisy (see the repeat row). Cells are frame p95 ms, and compositor draw ms per frame in brackets. "Live" = blur running while moving (?glass=live). "Default" = the fix (blur paused while moving).

DPR / windows variant Solid (frosting off) Frosted, live Frosted, default (the fix)
1 / 1 A 16.8 (12.0) 33.4 (17.3) 33.3 (13.5)
1 / 1 B 33.4 (14.1) 33.4 (22.5) 16.8 (12.1)
1 / 4 A 16.8 (11.7) 50.0 (30.3) 16.8 (8.4)
1 / 4 B 16.8 (9.5) 33.4 (26.5) 16.8 (10.6)
1 / 12 A 16.7 (9.0) 50.1 (32.5) 33.3 (13.8)
1 / 12 B 16.8 (8.4) 33.4 (27.6) 16.7 (8.1)
1 / 12, repeat A 16.7 (8.6) 49.9 (30.7) 16.7 (10.9)
1 / 12, repeat B 16.7 (10.1) 50.0 (29.6) 16.8 (8.4)
2 / 12 A 16.8 (8.7) 50.0 (32.1) 16.8 (9.5)
2 / 12 B 16.7 (7.1) 33.4 (28.0) 16.7 (8.6)

Reading it:

  • In both variants the fix brings the pan back to the Solid baseline (draw ~8–14 ms/frame, p95 16.7–16.8 ms; the three cells of 33 ms are the noisy medians: one of three runs each, the other two were 16.8). Live blur costs 2–3× the draw in both (27–32 ms/frame).
  • B's live blur is not clearly more expensive than A's (28–30 vs 31–33 ms/frame at 12 windows): the compositor blur is area-bound, and A already paid it on an almost empty backdrop. So B costs nothing extra per frame while moving, because the blur is not drawn while moving in either variant.
  • Full logs: A DPR1 a-dpr1.txt, B DPR1 b-dpr1.txt, A DPR2 a-dpr2.txt, B DPR2 b-dpr2.txt, repeat A a-dpr1-12-pass2.txt, repeat B b-dpr1-12-pass2.txt.

The trade-off you are choosing (what I see, not a recommendation)

  • A keeps today's look at rest. The blur I freeze was almost empty in Chromium, so nothing visible changes while moving or at rest. Safari may differ (cannot measure here).
  • B makes Frosted windows blur the canvas behind them in Chromium, as the setting implies. Because the blur is still paused while moving, the backdrop behind a window would show sharp while the canvas pans and blur when it stops: with B that change is real content (stripes/dots), where in A it was nearly invisible. That is exactly what the device A/B is for: open both and pan with 4 and 12 windows. Also: in B the blur would cost its real price at rest and in window drags (not covered by data-moving), which the numbers above do not measure.
  • Not measured and not done: Safari/iOS (no WebKit device here), a device GPU, the real server build.

Tests (each variant, same VM, production build via the Playwright web server)

clusters-frame.spec.ts (lock glyph whole in three views; 8 handles, held edges, D195), clusters-glass-motion.spec.ts (3), frosted-scroll.spec.ts (3, including 12 windows: draw ×1.02 A / ×0.91 B of Solid, p95 ×1.01 / ×1.00): 8 passed on A, 8 passed on B (a-specs.txt, b-specs.txt). scripts/verify fast was not run: this job is a comparison, nothing to merge.

Previews (production builds, mock server, served from the VM)

  • A: http://10.69.69.117:4256/?mock=1&proto=clusters&scene=1&theme=tokyo&bg=mesh&card=4&windows=4
  • B: http://10.69.69.117:4257/?mock=1&proto=clusters&scene=1&theme=tokyo&bg=mesh&card=4&windows=4
  • Add &glass=live for the blur-while-moving behaviour. For more windows: click a window and press Ctrl+Shift+E (split down) repeatedly.
  • Reachability caveat: both listen on the VM's NetBird address (wt0, 10.69.69.117) only, and answer there. From three other NetBird peers I tried (this control host, macos-01, perf-01) only ports 22 and 22022 get through: 4256/4257 time out. That looks like a NetBird policy, which I cannot change. If your device is blocked too, use an SSH tunnel: ssh -N -L 4256:localhost:14256 -L 4257:localhost:14257 <user>@infraternal-dev-02-std.vpn.kayg.org, then open http://localhost:4256/… (A) and http://localhost:4257/… (B).

Machine: claude@infraternal-dev-02-std (aarch64, 10 cores, no GPU, shared, load 10–25). Servers stay up until you tell me to stop them. Raw evidence: ~/.cache/codex-scratch/j18/evidence/ on code and on the VM.

**J18: side-by-side for the owner (A vs B). Not decided by me; you pick.** Both branches are rebased on `origin/dev` @ 42ab33035, pushed, not merged, not deployed. | | branch | head | what it is | |---|---|---|---| | **A** | `frosted-scroll-256-a` | `9023f9683` | the #256 fix (3 commits from `frosted-scroll-256`, cherry-picked): window blur paused while the camera moves, live at rest. `clip-path` unchanged: today's look. | | **B** | `frosted-scroll-256-b` | `bcfd02d7f` | A + 1 commit: the canvas is clipped by `overflow: clip` on `.panes` (the viewport's parent) instead of `clip-path` on `.cl-viewport`, so Frosted windows really blur the canvas behind them. | ## What B changes (apps/tracer/clusters.css only, 1 file, +11/−4) - `.cl-viewport` loses its `clip-path: inset(-12px 0 0 0)` (the thing that made it a backdrop root). `.panes` becomes `overflow: clip`, reaches 12 px above the viewport (`margin-top: -12px; padding-top: 12px`), and the viewport sits `inset: 12px 0 0 0` from its padding edge. - Same clipped region as before: sides and bottom at the gutter lines, 12 px of headroom on top for the lock shackle (#126/D195). `.cl-viewport`'s own rectangle is unchanged (measured 20, 60, 1650×924 in both A and B at 1690×1000), so the camera maths and `fix-canvas-side-clip` (`window-overhang.spec.ts`, which measures that rectangle) are unaffected. `overflow: clip` cannot be scrolled (the #126 reason for the outer clip). - **`fix-canvas-side-clip`**: it touches `frame.ts`, `geometry.ts`, `stops.ts`, `window-sizes.ts`, `column-els.ts`, `create.ts`, `lock.ts`; neither A nor B touches those files. `git merge --no-commit` of each into `origin/fix-canvas-side-clip`: no conflicts. Its `window-overhang.spec.ts` (real server) was not run: it needs the Rust server build. After merging both, its commit text and comment that say "`.cl-viewport` … clip-path" would be out of date for B only. - Rejected for B: `overflow-clip-margin` (one value for all four sides, not in every browser), a taller viewport (moves the camera origin, collides with `fix-canvas-side-clip`), moving the backdrop into the viewport, dropping the clip. - B has a rule that the next person must keep: nothing between a window and the canvas backdrop may take a clip-path, filter, mask, opacity or blend mode, or the blur goes blind again (written in the CSS comment). ## Proof that B blurs the canvas and A does not (production builds, black/white 40 px stripes on the canvas backdrop, Frosted windows, DPR 2) A (stripe edge sharp through the window): ![a-stripes.png](https://git.kayg.org/attachments/ae640592-e466-4d4c-92a6-3c1b9978a33d) B (soft): ![b-stripes.png](https://git.kayg.org/attachments/2b99e7bb-05ae-494a-a1ac-3205f77d010f) ## Screenshots at rest, production build, 1690×1000 DPR 2, Tokyo, mesh, Frosted cards and windows | | A (clip-path, today's look) | B (windows blur the canvas) | |---|---|---| | 4 windows | ![](https://git.kayg.org/attachments/fd92d673-e83a-4298-81db-7f056477b2e0) | ![](https://git.kayg.org/attachments/4805fd5c-d8cd-45b8-bbfa-133c97318ac4) | | 12 windows | ![](https://git.kayg.org/attachments/2b87aa7d-7706-4cf2-ac41-c69ae52ab1ae) | ![](https://git.kayg.org/attachments/f0033d21-cafc-4820-a14d-7a4d4319e4b5) | | moving state held still (`data-moving` set by hand; all window blur `none`) | ![](https://git.kayg.org/attachments/181f7b7e-38fa-4afd-9c59-6a7b80559689) | ![](https://git.kayg.org/attachments/af96e47e-4944-4e10-bd9d-226c4840bb6c) | My own reading, not a judgement of the look: over this mesh backdrop the rest difference is small. B smooths the grain and the large gradient shows through softer; the stripes above are where it is unmistakable. The dots layer is the content a real session would show; I did not see it in these shots. The Solid-window shots of A and B differ only by run-to-run noise (A vs A: 637,642 differing pixels, max channel delta 209; A vs B: 637,535, max 6), so the clip change moves nothing by itself. ## Frame time, 3 s sideways pan (VM `infraternal-dev-02-std`, aarch64, **no GPU: relative numbers only**) Median of 3 runs per cell (`frosted-scroll-report.spec.ts`, 1440×900, free canvas). **The VM was shared: load average 10–25 on 10 cores during the runs**, so single cells are noisy (see the repeat row). Cells are frame p95 ms, and compositor draw ms per frame in brackets. "Live" = blur running while moving (`?glass=live`). "Default" = the fix (blur paused while moving). | DPR / windows | variant | Solid (frosting off) | Frosted, live | Frosted, default (the fix) | |---|---|---|---|---| | 1 / 1 | A | 16.8 (12.0) | 33.4 (17.3) | 33.3 (13.5) | | 1 / 1 | B | 33.4 (14.1) | 33.4 (22.5) | 16.8 (12.1) | | 1 / 4 | A | 16.8 (11.7) | 50.0 (30.3) | 16.8 (8.4) | | 1 / 4 | B | 16.8 (9.5) | 33.4 (26.5) | 16.8 (10.6) | | 1 / 12 | A | 16.7 (9.0) | 50.1 (32.5) | 33.3 (13.8) | | 1 / 12 | B | 16.8 (8.4) | 33.4 (27.6) | 16.7 (8.1) | | 1 / 12, repeat | A | 16.7 (8.6) | 49.9 (30.7) | 16.7 (10.9) | | 1 / 12, repeat | B | 16.7 (10.1) | 50.0 (29.6) | 16.8 (8.4) | | 2 / 12 | A | 16.8 (8.7) | 50.0 (32.1) | 16.8 (9.5) | | 2 / 12 | B | 16.7 (7.1) | 33.4 (28.0) | 16.7 (8.6) | Reading it: - In both variants the fix brings the pan back to the Solid baseline (draw ~8–14 ms/frame, p95 16.7–16.8 ms; the three cells of 33 ms are the noisy medians: one of three runs each, the other two were 16.8). Live blur costs 2–3× the draw in both (27–32 ms/frame). - **B's live blur is not clearly more expensive than A's** (28–30 vs 31–33 ms/frame at 12 windows): the compositor blur is area-bound, and A already paid it on an almost empty backdrop. So B costs nothing extra per frame while moving, because the blur is not drawn while moving in either variant. - Full logs: A DPR1 [a-dpr1.txt](https://git.kayg.org/attachments/82173471-d73b-4111-8653-d965c7ba4c6d), B DPR1 [b-dpr1.txt](https://git.kayg.org/attachments/60a5459c-90ee-4139-8fe8-1867fe351fa2), A DPR2 [a-dpr2.txt](https://git.kayg.org/attachments/1db48593-23c9-46d0-a7b6-8687404a0df5), B DPR2 [b-dpr2.txt](https://git.kayg.org/attachments/e097228c-4dfd-4745-b5c6-d81c01e2cf9d), repeat A [a-dpr1-12-pass2.txt](https://git.kayg.org/attachments/fa790ba4-e6a3-4980-b839-d3ea8a1c3028), repeat B [b-dpr1-12-pass2.txt](https://git.kayg.org/attachments/85dac4b2-88f2-47bb-8f12-cc0de4d87f7a). ## The trade-off you are choosing (what I see, not a recommendation) - **A** keeps today's look at rest. The blur I freeze was almost empty in Chromium, so nothing visible changes while moving or at rest. Safari may differ (cannot measure here). - **B** makes Frosted windows blur the canvas behind them in Chromium, as the setting implies. Because the blur is still paused while moving, the backdrop behind a window would show sharp while the canvas pans and blur when it stops: with B that change is real content (stripes/dots), where in A it was nearly invisible. That is exactly what the device A/B is for: open both and pan with 4 and 12 windows. Also: in B the blur would cost its real price at rest and in window drags (not covered by `data-moving`), which the numbers above do not measure. - Not measured and not done: Safari/iOS (no WebKit device here), a device GPU, the real server build. ## Tests (each variant, same VM, production build via the Playwright web server) `clusters-frame.spec.ts` (lock glyph whole in three views; 8 handles, held edges, D195), `clusters-glass-motion.spec.ts` (3), `frosted-scroll.spec.ts` (3, including 12 windows: draw ×1.02 A / ×0.91 B of Solid, p95 ×1.01 / ×1.00): **8 passed on A, 8 passed on B** ([a-specs.txt](https://git.kayg.org/attachments/6c48d735-8ba8-475b-8ef8-11154d683afe), [b-specs.txt](https://git.kayg.org/attachments/7bcd1506-a88d-4108-b219-9c97544c3150)). `scripts/verify fast` was not run: this job is a comparison, nothing to merge. ## Previews (production builds, mock server, served from the VM) - A: `http://10.69.69.117:4256/?mock=1&proto=clusters&scene=1&theme=tokyo&bg=mesh&card=4&windows=4` - B: `http://10.69.69.117:4257/?mock=1&proto=clusters&scene=1&theme=tokyo&bg=mesh&card=4&windows=4` - Add `&glass=live` for the blur-while-moving behaviour. For more windows: click a window and press Ctrl+Shift+E (split down) repeatedly. - **Reachability caveat:** both listen on the VM's NetBird address (`wt0`, 10.69.69.117) only, and answer there. From three other NetBird peers I tried (this control host, `macos-01`, `perf-01`) only ports 22 and 22022 get through: 4256/4257 time out. That looks like a NetBird policy, which I cannot change. If your device is blocked too, use an SSH tunnel: `ssh -N -L 4256:localhost:14256 -L 4257:localhost:14257 <user>@infraternal-dev-02-std.vpn.kayg.org`, then open `http://localhost:4256/…` (A) and `http://localhost:4257/…` (B). Machine: `claude@infraternal-dev-02-std` (aarch64, 10 cores, no GPU, shared, load 10–25). Servers stay up until you tell me to stop them. Raw evidence: `~/.cache/codex-scratch/j18/evidence/` on `code` and on the VM.
Author
Owner

J23 start (implementation sub-agent). Owner decision 2026-10-31 23:10 IST: real blur at rest and while panning, no pause during motion; optimise after; merge only after benchmarking.
Branch frosted-real-256, base origin/dev @ ec7b10111. Cherry-picked cleanly: 82839a3ad (pan harness) and bcfd02d7f (clip moves from .cl-viewport clip-path to overflow: clip on .panes). NOT included: 5393196e3 (blur paused while panning) and its docs/?glass=live switch.
Plan: stripe spec (red on dev, green on branch), benchmark dev vs branch on dev-02-std (no GPU: relative only), serve prod build on 10.69.69.117:4260, gates on compute01, finish comment here. Not merging.

J23 start (implementation sub-agent). Owner decision 2026-10-31 23:10 IST: real blur at rest and while panning, no pause during motion; optimise after; merge only after benchmarking. Branch `frosted-real-256`, base `origin/dev` @ ec7b10111. Cherry-picked cleanly: `82839a3ad` (pan harness) and `bcfd02d7f` (clip moves from `.cl-viewport` clip-path to `overflow: clip` on `.panes`). NOT included: `5393196e3` (blur paused while panning) and its docs/`?glass=live` switch. Plan: stripe spec (red on dev, green on branch), benchmark dev vs branch on dev-02-std (no GPU: relative only), serve prod build on 10.69.69.117:4260, gates on compute01, finish comment here. Not merging.
Author
Owner

Finding (J23): the stripe spec is red on dev and green on the branch, at rest and mid-pan, DPR 1 and 2.
Spec apps/tracer/test/frosted-real.spec.ts (commit c7ef1a702): the screen's mesh backdrop is replaced by 20 px black/white stripes, windows' content hidden, and the sharpest pixel step in a band inside a Frosted window is compared to the same step with the windows' backdrop-filter off. 1.000 = stripes pass untouched (fake frost); a real 24 px blur = 0.000. The mid-pan capture is checked to be taken while the world moves (per-frame x), every window must still compute blur(24px) while moving.

Tree before the clip change (c7ef1a702, = origin/dev CSS + harness + spec), then the branch head e2dfd7f3e:

--- red
FROSTED-REAL DPR 1: stripe edge as a fraction of the unblurred one: at rest 1.000, mid-pan 1.000 (sharp step 140); backdrop-filter mid-pan: blur(24px)
  ✘  1 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 1) (14.6s)
FROSTED-REAL DPR 2: stripe edge as a fraction of the unblurred one: at rest 1.000, mid-pan 1.000 (sharp step 140); backdrop-filter mid-pan: blur(24px)
  ✘  2 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 2) (12.2s)
      116 |       console.log(`FROSTED-REAL DPR ${dpr}: stripe edge as a fraction of the unblurred one: at rest ${rest.toFixed(3)}, mid-pan ${mid.toFixed(3)} (sharp step ${sharp.toFixed(0)}); backdrop-filter mid-pan: ${[...new Set(blurs)].join(" | ")}`);
      116 |       console.log(`FROSTED-REAL DPR ${dpr}: stripe edge as a fraction of the unblurred one: at rest ${rest.toFixed(3)}, mid-pan ${mid.toFixed(3)} (sharp step ${sharp.toFixed(0)}); backdrop-filter mid-pan: ${[...new Set(blurs)].join(" | ")}`);
  2 failed
--- green
FROSTED-REAL DPR 1: stripe edge as a fraction of the unblurred one: at rest 0.000, mid-pan 0.000 (sharp step 140); backdrop-filter mid-pan: blur(24px)
  ✓  1 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 1) (14.9s)
FROSTED-REAL DPR 2: stripe edge as a fraction of the unblurred one: at rest 0.000, mid-pan 0.000 (sharp step 140); backdrop-filter mid-pan: blur(24px)
  ✓  2 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 2) (11.4s)
  2 passed (32.3s)

Two measurement traps found on the way (both documented in the spec): screenshot({ clip }) at DPR 2 rendered the backdrop unblurred on a tree that blurs it (so the crop is made in the page), and the reference style tag must be removed through its own handle. The dev run is red for the right reason (ratio 1.000), not because the harness broke.

**Finding (J23): the stripe spec is red on dev and green on the branch, at rest and mid-pan, DPR 1 and 2.** Spec `apps/tracer/test/frosted-real.spec.ts` (commit `c7ef1a702`): the screen's mesh backdrop is replaced by 20 px black/white stripes, windows' content hidden, and the sharpest pixel step in a band inside a Frosted window is compared to the same step with the windows' `backdrop-filter` off. 1.000 = stripes pass untouched (fake frost); a real 24 px blur = 0.000. The mid-pan capture is checked to be taken while the world moves (per-frame x), every window must still compute `blur(24px)` while moving. Tree before the clip change (`c7ef1a702`, = origin/dev CSS + harness + spec), then the branch head `e2dfd7f3e`: ``` --- red FROSTED-REAL DPR 1: stripe edge as a fraction of the unblurred one: at rest 1.000, mid-pan 1.000 (sharp step 140); backdrop-filter mid-pan: blur(24px) ✘ 1 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 1) (14.6s) FROSTED-REAL DPR 2: stripe edge as a fraction of the unblurred one: at rest 1.000, mid-pan 1.000 (sharp step 140); backdrop-filter mid-pan: blur(24px) ✘ 2 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 2) (12.2s) 116 | console.log(`FROSTED-REAL DPR ${dpr}: stripe edge as a fraction of the unblurred one: at rest ${rest.toFixed(3)}, mid-pan ${mid.toFixed(3)} (sharp step ${sharp.toFixed(0)}); backdrop-filter mid-pan: ${[...new Set(blurs)].join(" | ")}`); 116 | console.log(`FROSTED-REAL DPR ${dpr}: stripe edge as a fraction of the unblurred one: at rest ${rest.toFixed(3)}, mid-pan ${mid.toFixed(3)} (sharp step ${sharp.toFixed(0)}); backdrop-filter mid-pan: ${[...new Set(blurs)].join(" | ")}`); 2 failed --- green FROSTED-REAL DPR 1: stripe edge as a fraction of the unblurred one: at rest 0.000, mid-pan 0.000 (sharp step 140); backdrop-filter mid-pan: blur(24px) ✓ 1 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 1) (14.9s) FROSTED-REAL DPR 2: stripe edge as a fraction of the unblurred one: at rest 0.000, mid-pan 0.000 (sharp step 140); backdrop-filter mid-pan: blur(24px) ✓ 2 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 2) (11.4s) 2 passed (32.3s) ``` Two measurement traps found on the way (both documented in the spec): `screenshot({ clip })` at DPR 2 rendered the backdrop unblurred on a tree that blurs it (so the crop is made in the page), and the reference style tag must be removed through its own handle. The dev run is red for the right reason (ratio 1.000), not because the harness broke.
Author
Owner

J23 finish (not merged, not deployed). Branch frosted-real-256, head dac675523, base origin/dev @ ec7b10111. Owner decision applied: real blur at rest and while panning, no pause during motion; optimise later.

What is on the branch (4 commits, tests + 1 CSS file, no Rust)

commit what
6e6b47d75 82839a3ad cherry-picked: the 3 s sideways-pan harness with a Chromium trace (frame gaps, compositor draw, layers)
d12965dc1 harness and report drop the ?glass=live switch and data-moving sample (that fix is not on this branch)
eb49eaec9 stripe spec frosted-real.spec.ts (below)
dac675523 bcfd02d7f cherry-picked, clean: the clip moves from .cl-viewport's clip-path to overflow: clip on .panes; the CSS comment keeps the rule nothing between a window and the canvas backdrop may take a clip-path, filter, mask, opacity or blend mode

Not included: 5393196e3 (blur paused while the canvas pans), its docs commit 9023f9683, and the ?glass=live switch. Bundle: clusters.css +772 B raw / +253 B gzip, JS unchanged (production builds of base and branch, summed over dist): far below the 16 KiB D243 line.

fix-canvas-side-clip (614930d99): merging both is clean

git merge-tree --write-tree HEAD origin/fix-canvas-side-clip → no conflicts (it touches frame.ts, geometry.ts, stops.ts, window-sizes.ts, column-els.ts, create.ts, lock.ts and real/window-overhang.spec.ts; this branch touches clusters.css and test files only). The branch also merges cleanly into the current origin/dev (6 commits ahead of my base). One text caveat: after both merge, fix-canvas-side-clip's clusters.css comment ("A clip-path rather than overflow…") and its commit text are out of date; the CSS rule on this branch replaces that sentence. Its window-overhang.spec.ts needs the real server build and was not run.

1. The stripe test: red on dev, green on the branch

Spec: the screen's mesh backdrop is replaced by 20 px black/white stripes, the windows' content hidden, and the sharpest pixel step in a band inside a Frosted window is compared to the same step with the windows' backdrop-filter off (1.000 = stripes pass untouched, a real 24 px blur ≈ 0.000). Mid-pan the page records the world's x every frame, so the test proves the capture was taken while it moved and the band stayed inside the window; every window must still compute blur(24px) while moving. DPR 1 and 2. Threshold 0.2.

Tree before the clip change (c7ef1a702 = dev CSS + harness + spec; this commit was later amended to eb49eaec9 with only a type fix):

FROSTED-REAL DPR 1: stripe edge as a fraction of the unblurred one: at rest 1.000, mid-pan 1.000 (sharp step 140); backdrop-filter mid-pan: blur(24px)
  ✘  1 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 1) (14.6s)
FROSTED-REAL DPR 2: stripe edge as a fraction of the unblurred one: at rest 1.000, mid-pan 1.000 (sharp step 140); backdrop-filter mid-pan: blur(24px)
  ✘  2 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 2) (12.2s)
      116 |       console.log(`FROSTED-REAL DPR ${dpr}: stripe edge as a fraction of the unblurred one: at rest ${rest.toFixed(3)}, mid-pan ${mid.toFixed(3)} (sharp step ${sharp.toFixed(0)}); backdrop-filter mid-pan: ${[...new Set(blurs)].join(" | ")}`);
      116 |       console.log(`FROSTED-REAL DPR ${dpr}: stripe edge as a fraction of the unblurred one: at rest ${rest.toFixed(3)}, mid-pan ${mid.toFixed(3)} (sharp step ${sharp.toFixed(0)}); backdrop-filter mid-pan: ${[...new Set(blurs)].join(" | ")}`);
  2 failed

Branch:

FROSTED-REAL DPR 1: stripe edge as a fraction of the unblurred one: at rest 0.000, mid-pan 0.000 (sharp step 140); backdrop-filter mid-pan: blur(24px)
  ✓  1 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 1) (14.9s)
FROSTED-REAL DPR 2: stripe edge as a fraction of the unblurred one: at rest 0.000, mid-pan 0.000 (sharp step 140); backdrop-filter mid-pan: blur(24px)
  ✓  2 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 2) (11.4s)
  2 passed (32.3s)

(bun x tsc --project tsconfig.json on the final head: exit 0.)

2. Benchmark: dev (today's fake blur) vs branch (real blur)

infraternal-dev-02-std, aarch64, software compositor, NO GPU: relative numbers only, not device numbers. Shared VM, load 2–8 during the runs. Harness frosted-scroll-report.spec.ts: 1440×900, free canvas, 3 s sine pan with one real wheel event per animation frame, windows Solid vs Frosted (24 px). Per cell: median of 3 runs, repeated in 2 interleaved passes (dev, branch, dev, branch…; the two values per cell are pass 1, pass 2; one pass-1/branch cell group first failed to start on a reused port and was rerun with a port per run). "draw" = compositor Display::DrawAndSwap ms per drawn frame.

DPR windows material dev p50 / p95 ms branch p50 / p95 ms dev draw ms/frame branch draw ms/frame
1 1 Solid 16.7 / 16.8 , 16.7 / 16.8 16.7 / 16.7 , 16.7 / 16.8 9.7 , 10.0 7.0 , 7.7
1 1 Frosted 16.7 / 16.7 , 16.7 / 16.8 16.7 / 16.7 , 16.7 / 16.8 14.7 , 14.9 13.8 , 13.6
1 4 Solid 16.7 / 16.7 , 16.7 / 16.7 16.7 / 16.7 , 16.7 / 16.7 7.4 , 8.7 6.6 , 6.5
1 4 Frosted 33.2 / 33.4 , 33.3 / 33.4 33.3 / 33.4 , 33.3 / 33.4 24.3 , 28.8 24.7 , 24.6
1 12 Solid 16.7 / 16.7 , 16.7 / 16.7 16.7 / 16.8 , 16.7 / 16.7 8.0 , 8.5 6.7 , 6.1
1 12 Frosted 33.3 / 33.4 , 33.3 / 33.4 33.3 / 33.4 , 33.2 / 33.4 26.9 , 25.7 25.2 , 24.9
2 1 Solid 16.7 / 16.7 , 16.7 / 16.7 16.7 / 16.8 , 16.7 / 16.7 8.1 , 8.7 7.4 , 7.2
2 1 Frosted 16.7 / 16.8 , 16.7 / 33.3 16.7 / 16.8 , 16.7 / 33.3 14.9 , 16.4 15.9 , 15.3
2 4 Solid 16.7 / 16.7 , 16.7 / 16.7 16.7 / 16.8 , 16.7 / 16.8 8.3 , 9.6 6.9 , 9.7
2 4 Frosted 33.3 / 33.4 , 33.3 / 33.4 33.3 / 33.4 , 33.3 / 50.0 30.9 , 29.3 26.2 , 27.6
2 12 Solid 16.7 / 16.7 , 16.7 / 16.8 16.7 / 16.7 , 16.7 / 16.8 8.7 , 8.4 6.3 , 6.7
2 12 Frosted 33.3 / 33.4 , 33.3 / 49.9 33.2 / 33.4 , 33.3 / 33.4 28.9 , 28.3 24.7 , 24.9

Reading it:

  • The real blur costs the same as today's fake one here. Frosted at 4 and 12 windows: draw per frame 27.9 ms on dev vs 25.4 ms on the branch (means over both DPRs and passes), against 8.4 / 6.9 ms for Solid. p50 frame time is 33.3 ms for Frosted at 4 and 12 windows on both trees (Solid 16.7). The blur is area-bound in the software compositor, and dev was already paying it on an almost empty backdrop (J18 finding).
  • Solid baseline is equal or slightly lower on the branch (6–10 ms draw), so the clip change itself costs nothing measurable.
  • So #256's lag (Frosted at ≥4 windows halves the frame rate in software) is unchanged, by decision; the optimisation work is ahead. 1 Frosted window stays at 16.7 ms p50.
  • Single p95/max cells are noisy (outliers up to 1.6 s on both trees, e.g. a first-frame hitch); read the medians.
  • Raw: attached bench-all.log and per-run logs (frame p50/p95/max, paint+layerize, raster, draw, layers, onscreen windows).

3. Screenshots (production build, DPR 2, Tokyo, mesh, Frosted cards and windows, 4 windows)

Stripes behind the windows. Mid-pan = a wheel event per frame while the capture is taken. dev: stripes sharp through the windows at rest and mid-pan; branch: soft at rest and mid-pan.

dev (fake blur) branch (real blur)
stripes, at rest dev rest branch rest
stripes, mid-pan dev pan branch pan

The real scene (mesh), at rest and mid-pan, branch (and dev at rest for comparison). Over the mesh the difference is subtle, as J18 found; the stripes are where it is unmistakable.
branch rest
branch mid-pan
dev rest

Observation, not investigated: with the high-contrast stripes, the last ~70 px of a window next to its bottom and right edge shows the stripes partly (edge handling of the blur). Over the mesh gradient this is not visible. Worth a look on the M2 Max.

4. Preview for the owner (keep up until told)

http://10.69.69.117:4260/?mock=1&proto=clusters&scene=1&theme=tokyo&bg=mesh&card=4&windows=4 : production build of the branch (apps/tracer/dist, built at e2dfd7f3e; the CSS is identical at dac675523, the head only differs by a type fix in a spec), mock server on loopback :4261 plus a small TCP forwarder bound to the mesh address, both on infraternal-dev-02-std under user claude (~/worktrees/j23-serve.sh, ~/worktrees/j23-fwd.py; reachable from the control host). Pan sideways with 4 and 12 windows (Ctrl+Shift+E splits down): the blur stays on while moving, and it costs frame rate on a machine without a GPU; the M2 Max numbers are what we need.

5. Gates

Existing browser specs on the branch (dev-02, production build): clusters-glass-motion (3), clusters-frame (lock glyph whole in three views; 8 handles, held edges: the resize spec), lock-icons, glass-ghost, canvas.spec.ts (resize, lock, shortcuts), clusters.spec.ts, frosted-real → 95 passed, 9 failed. The 9 fail identically on the tree before the clip change (same 5 non-ghost tests + 4 glass-ghost DPR 3 toast cases, the ones J18 already found on base):

branch:
  ✘   16 test/canvas.spec.ts:301:1 › touch: press and hold on a header reveals corner handles; a handle resizes; a tap outside ends it (8.7s)
  ✘   17 test/canvas.spec.ts:344:1 › sidebar footer: the person and settings pills line up, press, and open Settings with About; no version pill (12.4s)
  ✘   26 test/canvas.spec.ts:621:1 › shortcuts: the palette offers Keyboard shortcuts, lock shortcuts are listed, and a Mac sees ⌘K (11.8s)
  ✘   69 test/clusters.spec.ts:702:1 › motion rule: the work of a view jump, a window step and a column step finishes inside one frame, and keys do not animate (9.0s)
  ✘   75 test/clusters.spec.ts:837:1 › settings is a view: a gear after a divider, one window per section, admin windows only for operators, nothing modal (5.1s)
  ✘   93 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 3, ink, DPR 3 (12.5s)
  ✘   94 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 4, ink, DPR 3 (12.3s)
  ✘  101 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 3, paper, DPR 3 (11.8s)
  ✘  102 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 4, paper, DPR 3 (12.0s)
    Error: expect(locator).toBeVisible() failed
    Error: expect(locator).toHaveText(expected) failed
  9 failed
  95 passed (18.5m)
dev (c7ef1a702, the 9 reruns):
  ✘   1 test/canvas.spec.ts:301:1 › touch: press and hold on a header reveals corner handles; a handle resizes; a tap outside ends it (7.8s)
  ✘   2 test/canvas.spec.ts:344:1 › sidebar footer: the person and settings pills line up, press, and open Settings with About; no version pill (13.5s)
  ✘   3 test/canvas.spec.ts:621:1 › shortcuts: the palette offers Keyboard shortcuts, lock shortcuts are listed, and a Mac sees ⌘K (13.0s)
  ✘   4 test/clusters.spec.ts:702:1 › motion rule: the work of a view jump, a window step and a column step finishes inside one frame, and keys do not animate (9.0s)
  ✘   5 test/clusters.spec.ts:837:1 › settings is a view: a gear after a divider, one window per section, admin windows only for operators, nothing modal (5.2s)
  ✘  12 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 3, ink, DPR 3 (12.4s)
  ✘  13 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 4, ink, DPR 3 (12.0s)
  ✘  20 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 3, paper, DPR 3 (12.7s)
  ✘  21 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 4, paper, DPR 3 (12.6s)
    Error: expect(locator).toBeVisible() failed
    Error: expect(locator).toHaveText(expected) failed
  9 failed
  12 passed (5.6m)

scripts/verify fast (claude@infraternal-dev-01-compute01, through build-slot, head e2dfd7f3e; nothing in Rust/TS source differs between that and dac675523 except the spec type fix below). It did not exit green:

==> Repository artifact size gate
repository artifact gate: PASS (4 changed path(s), merge base ec7b10111e97)

==> Release deployment functional checks
.....Release build failed for aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; gate log retained
.Build deferred: host capacity limit reached
.........
----------------------------------------------------------------------
Ran 15 tests in 0.826s

OK
.
-----------------------
...
---- ctrl_c_in_json_mode_ends_with_an_interrupted_envelope_and_exit_130 stdout ----

thread 'ctrl_c_in_json_mode_ends_with_an_interrupted_envelope_and_exit_130' (2484183) panicked at crates/infraternal/tests/cli_review.rs:166:9:
still running after SIGINT
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    ctrl_c_in_json_mode_ends_with_an_interrupted_envelope_and_exit_130

te
...
| Rust gate                 | Result       |
+---------------------------+--------------+
| fmt                       | PASS         |
| VT native fmt             | PASS         |
| CLI plugin compile-out    | PASS         |
| clippy                    | PASS         |
| SSH runtime test helper   | PASS         |
| real-binary fixture paths | PASS         |
| test                      | FAIL (101)   |
| SSH inode reuse fixture   | PASS         |
| shim process-per-user     | PASS         |
| cargo-deny                | PASS         |
| static builds             | PASS         |
+---------------------------+--------------+

  • The only failing Rust test is a timing assertion (SIGINT must end the process within 5 s) on a machine at load average 14–19 on 12 cores; this branch has no Rust change. Rerun alone: test result: ok. 17 passed; 0 failed (attached verify-fast-rust-rerun-and-ts-first-try.log).
  • The Rust failure stopped the gate before its TypeScript half. Running that half separately found one real bug of mine: bun x tsc failed on my spec (frosted-real.spec.ts(81,51): TS2339 'remove' does not exist on type 'Node'). Fixed in the spec commit (tsc exit 0 now).
  • TypeScript half on the final head dac675523, run on dev-02 (aarch64; compute01's queue did not give a slot in 40 minutes, so I cancelled that job): tracer unit tests 361 pass / 2 fail. The two failures (review: removing the current shared view selects a surviving camera anchor, a remote negative pin and node update preserve the world position of an unchanged card, both in src/canvas) fail identically on the tree before this branch (bun test src/canvas on c7ef1a702: 279 pass / 2 fail, same names). Because the gate chains with &&, they stopped it before the later steps, so I ran those separately on the branch: connection tests 105 pass / 0 fail, terminal query tests 17 pass / 0 fail, bun run build + transfer-budget.ts exit 0, protocol tests 39 pass / 0 fail:
==> Compiler metadata generator checks
 2 pass
 0 fail
Ran 2 tests across 1 file. [239.00ms]
==> Vendored Ghostty artifact checksums
==> TypeScript protocol compiler
==> Tracer compiler, precompression and bundle transfer budget
(pass) a failed idle warm-up shows no toast and retries when the browser is back online [13.52ms]
(pass) a failed open shows the toast, and opening again after the network returns succeeds [14.99ms]
(pass) an open that joins a failing idle warm-up still shows the toast [6.79ms]
(fail) review: removing the current shared view selects a surviving camera anchor [3.53ms]
(fail) a remote negative pin and node update preserve the world position of an unchanged card [0.71ms]
(pass) a clean terminal exit keeps the window while host setup can still fail
(pass) clean exits still close ordinary windows, and failed exits stay visible
(pass) failed watch refreshes retain one subscription through retry and release it on disposal [0.86ms]
(pass) a pushed recovery supersedes an outstanding retry failure [0.10ms]
(pass) a node the server sees passing traffic is online, on its path, under its machine's name [0.01ms]
(pass) an already-added failure without a host id (older server) still says how to reach the terminal [0.12ms]
(pass) parseSetupEvent > ignores a failed step on a non-failure and an unknown step on a failure [0.01ms]
(pass) foldSetup > a failure marks the stage its step belongs to, and a retry clears it [0.02ms]
(pass) bootstrap failure parsing > an already-added failure keeps its existing host id [0.07ms]
(pass) bootstrap failure parsing > host ids are ignored on other failures and when malformed [0.04ms]
(pass) createSetupFeed > a failure after the prompt, and a retry on the same socket [0.08ms]
(pass) stagesOf > an auth failure before sign-in fails detect and says it in plain words, not the server's [0.22ms]
(pass) stagesOf > an unsupported host fails the system check, after sign-in [0.10ms]
(pass) stagesOf > an install failure fails the install stage and keeps the earlier ones done [0.05ms]
(pass) guideFor > installation failures never claim that incomplete cleanup succeeded [0.05ms]
(pass) guideFor > each failure kind names its fix [0.10ms]
(pass) plain words > an unknown or missing failure gets the generic sentence, never the server's text [0.06ms]
(pass) install diagnostics name the failed step and public host error [0.08ms]
(pass) a notice type from a newer server is ignored, not a protocol failure [0.03ms]
(pass) a recovering server link names its cause as routine status, never as a failure [0.03ms]
(pass) a failed load says so in the error line, offers Try again, and mounts nothing [7.19ms]
(pass) the Try again button reloads the page: a failed import stays failed in the module map [7.30ms]
2 tests failed:
(fail) review: removing the current shared view selects a surviving camera anchor [3.53ms]
(fail) a remote negative pin and node update preserve the world position of an unchanged card [0.71ms]
 361 pass
 2 fail
Ran 363 tests across 60 files. [19.15s]
ts_verify_fast exit 1
--- remaining steps (verify-ts2.log) ---
== connection tests
 105 pass
 0 fail
Ran 105 tests across 13 files. [82.00ms]
== terminal query tests
 17 pass
 0 fail
Ran 17 tests across 4 files. [105.00ms]
== build + transfer budget
built chunk-bgmz5x5y.js, chunk-qbb2e8b9.js, chunk-dr387cxc.js, chunk-yx0gjj81.js
fonts/nerd-symbols.woff2: identity=223748 gzip=223748 brotli=223748 bytes
fonts/ibm-plex-mono/italic.latin.woff2: identity=22296 gzip=22296 brotli=22296 bytes
fonts/ibm-plex-mono/roman.latin.woff2: identity=19048 gzip=19048 brotli=19047 bytes
fonts/geist-mono/italic.latin.woff2: identity=21380 gzip=21380 brotli=21380 bytes
fonts/geist-mono/roman.latin.woff2: identity=19984 gzip=19984 brotli=19981 bytes
fonts/source-code-pro/italic.latin.woff2: identity=23612 gzip=23612 brotli=23612 bytes
fonts/source-code-pro/roman.latin.woff2: identity=22872 gzip=22872 brotli=22872 bytes
fonts/maple-mono-nf/italic.latin.woff2: identity=53444 gzip=53444 brotli=53444 bytes
fonts/maple-mono-nf/roman.latin.woff2: identity=46432 gzip=46432 brotli=46432 bytes
fonts/commit-mono/italic-400.latin.woff2: identity=18716 gzip=18716 brotli=18716 bytes
fonts/commit-mono/italic-700.latin.woff2: identity=18352 gzip=18352 brotli=18352 bytes
fonts/commit-mono/roman-400.latin.woff2: identity=17484 gzip=17484 brotli=17484 bytes
fonts/commit-mono/roman-700.latin.woff2: identity=17260 gzip=17260 brotli=17260 bytes
fonts/fira-code/roman.latin.woff2: identity=36756 gzip=36756 brotli=36756 bytes
Font transfer: monaspace-neon=84340/88064 cascadia-code=85008/88064 jetbrains-mono=70384/75776 symbols=223748/235520 ibm-plex-mono=41344/45056 geist-mono=41364/45056 source-code-pro=46484/49152 maple-mono-nf=99876/106496 (critical 46432/51200) commit-mono=71812/75776 fira-code=36756/40960 bytes
transfer-budget exit 0
== protocol tests
 39 pass
 0 fail
Ran 39 tests across 10 files. [2.03s]
ts2 done

So: scripts/verify fast as one command is not green on this branch, for a load-flake Rust test and two canvas unit tests that also fail without the branch. Everything else passes. I did not touch those tests (not mine to change here); they probably deserve their own issue.

Left for the orchestrator / owner

  • Review the numbers above and merge; I did not merge, push to dev, or deploy.
  • Needs a device: the real cost on the M2 Max with 4 and 12 Frosted windows.
  • The two src/canvas unit-test failures and the SIGINT timing test on dev.
  • Machine state: dev-02 holds the preview (:4260) until told; test servers and browsers I started for benchmarks were stopped.

Attachments

**J23 finish (not merged, not deployed).** Branch `frosted-real-256`, head `dac675523`, base `origin/dev` @ `ec7b10111`. Owner decision applied: real blur at rest and while panning, no pause during motion; optimise later. ## What is on the branch (4 commits, tests + 1 CSS file, no Rust) | commit | what | |---|---| | `6e6b47d75` | `82839a3ad` cherry-picked: the 3 s sideways-pan harness with a Chromium trace (frame gaps, compositor draw, layers) | | `d12965dc1` | harness and report drop the `?glass=live` switch and `data-moving` sample (that fix is not on this branch) | | `eb49eaec9` | **stripe spec** `frosted-real.spec.ts` (below) | | `dac675523` | `bcfd02d7f` cherry-picked, clean: the clip moves from `.cl-viewport`'s `clip-path` to `overflow: clip` on `.panes`; the CSS comment keeps the rule *nothing between a window and the canvas backdrop may take a clip-path, filter, mask, opacity or blend mode* | **Not included:** `5393196e3` (blur paused while the canvas pans), its docs commit `9023f9683`, and the `?glass=live` switch. Bundle: `clusters.css` +772 B raw / +253 B gzip, JS unchanged (production builds of base and branch, summed over `dist`): far below the 16 KiB D243 line. ## fix-canvas-side-clip (`614930d99`): merging both is clean `git merge-tree --write-tree HEAD origin/fix-canvas-side-clip` → no conflicts (it touches `frame.ts`, `geometry.ts`, `stops.ts`, `window-sizes.ts`, `column-els.ts`, `create.ts`, `lock.ts` and `real/window-overhang.spec.ts`; this branch touches `clusters.css` and test files only). The branch also merges cleanly into the current `origin/dev` (6 commits ahead of my base). One text caveat: after both merge, `fix-canvas-side-clip`'s `clusters.css` comment ("A clip-path rather than overflow…") and its commit text are out of date; the CSS rule on this branch replaces that sentence. Its `window-overhang.spec.ts` needs the real server build and was not run. ## 1. The stripe test: red on dev, green on the branch Spec: the screen's mesh backdrop is replaced by 20 px black/white stripes, the windows' content hidden, and the sharpest pixel step in a band inside a Frosted window is compared to the same step with the windows' `backdrop-filter` off (1.000 = stripes pass untouched, a real 24 px blur ≈ 0.000). Mid-pan the page records the world's x every frame, so the test proves the capture was taken while it moved and the band stayed inside the window; every window must still compute `blur(24px)` while moving. DPR 1 and 2. Threshold 0.2. Tree before the clip change (`c7ef1a702` = dev CSS + harness + spec; this commit was later amended to `eb49eaec9` with only a type fix): ``` FROSTED-REAL DPR 1: stripe edge as a fraction of the unblurred one: at rest 1.000, mid-pan 1.000 (sharp step 140); backdrop-filter mid-pan: blur(24px) ✘ 1 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 1) (14.6s) FROSTED-REAL DPR 2: stripe edge as a fraction of the unblurred one: at rest 1.000, mid-pan 1.000 (sharp step 140); backdrop-filter mid-pan: blur(24px) ✘ 2 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 2) (12.2s) 116 | console.log(`FROSTED-REAL DPR ${dpr}: stripe edge as a fraction of the unblurred one: at rest ${rest.toFixed(3)}, mid-pan ${mid.toFixed(3)} (sharp step ${sharp.toFixed(0)}); backdrop-filter mid-pan: ${[...new Set(blurs)].join(" | ")}`); 116 | console.log(`FROSTED-REAL DPR ${dpr}: stripe edge as a fraction of the unblurred one: at rest ${rest.toFixed(3)}, mid-pan ${mid.toFixed(3)} (sharp step ${sharp.toFixed(0)}); backdrop-filter mid-pan: ${[...new Set(blurs)].join(" | ")}`); 2 failed ``` Branch: ``` FROSTED-REAL DPR 1: stripe edge as a fraction of the unblurred one: at rest 0.000, mid-pan 0.000 (sharp step 140); backdrop-filter mid-pan: blur(24px) ✓ 1 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 1) (14.9s) FROSTED-REAL DPR 2: stripe edge as a fraction of the unblurred one: at rest 0.000, mid-pan 0.000 (sharp step 140); backdrop-filter mid-pan: blur(24px) ✓ 2 test/frosted-real.spec.ts:61:3 › a Frosted window blurs black and white stripes on the canvas behind it, at rest and mid-pan (DPR 2) (11.4s) 2 passed (32.3s) ``` (`bun x tsc --project tsconfig.json` on the final head: exit 0.) ## 2. Benchmark: dev (today's fake blur) vs branch (real blur) **`infraternal-dev-02-std`, aarch64, software compositor, NO GPU: relative numbers only, not device numbers.** Shared VM, load 2–8 during the runs. Harness `frosted-scroll-report.spec.ts`: 1440×900, free canvas, 3 s sine pan with one real wheel event per animation frame, windows Solid vs Frosted (24 px). Per cell: median of 3 runs, repeated in **2 interleaved passes** (dev, branch, dev, branch…; the two values per cell are pass 1, pass 2; one pass-1/branch cell group first failed to start on a reused port and was rerun with a port per run). "draw" = compositor `Display::DrawAndSwap` ms per drawn frame. | DPR | windows | material | dev p50 / p95 ms | branch p50 / p95 ms | dev draw ms/frame | branch draw ms/frame | |---|---|---|---|---|---|---| | 1 | 1 | Solid | 16.7 / 16.8 , 16.7 / 16.8 | 16.7 / 16.7 , 16.7 / 16.8 | 9.7 , 10.0 | 7.0 , 7.7 | | 1 | 1 | Frosted | 16.7 / 16.7 , 16.7 / 16.8 | 16.7 / 16.7 , 16.7 / 16.8 | 14.7 , 14.9 | 13.8 , 13.6 | | 1 | 4 | Solid | 16.7 / 16.7 , 16.7 / 16.7 | 16.7 / 16.7 , 16.7 / 16.7 | 7.4 , 8.7 | 6.6 , 6.5 | | 1 | 4 | Frosted | 33.2 / 33.4 , 33.3 / 33.4 | 33.3 / 33.4 , 33.3 / 33.4 | 24.3 , 28.8 | 24.7 , 24.6 | | 1 | 12 | Solid | 16.7 / 16.7 , 16.7 / 16.7 | 16.7 / 16.8 , 16.7 / 16.7 | 8.0 , 8.5 | 6.7 , 6.1 | | 1 | 12 | Frosted | 33.3 / 33.4 , 33.3 / 33.4 | 33.3 / 33.4 , 33.2 / 33.4 | 26.9 , 25.7 | 25.2 , 24.9 | | 2 | 1 | Solid | 16.7 / 16.7 , 16.7 / 16.7 | 16.7 / 16.8 , 16.7 / 16.7 | 8.1 , 8.7 | 7.4 , 7.2 | | 2 | 1 | Frosted | 16.7 / 16.8 , 16.7 / 33.3 | 16.7 / 16.8 , 16.7 / 33.3 | 14.9 , 16.4 | 15.9 , 15.3 | | 2 | 4 | Solid | 16.7 / 16.7 , 16.7 / 16.7 | 16.7 / 16.8 , 16.7 / 16.8 | 8.3 , 9.6 | 6.9 , 9.7 | | 2 | 4 | Frosted | 33.3 / 33.4 , 33.3 / 33.4 | 33.3 / 33.4 , 33.3 / 50.0 | 30.9 , 29.3 | 26.2 , 27.6 | | 2 | 12 | Solid | 16.7 / 16.7 , 16.7 / 16.8 | 16.7 / 16.7 , 16.7 / 16.8 | 8.7 , 8.4 | 6.3 , 6.7 | | 2 | 12 | Frosted | 33.3 / 33.4 , 33.3 / 49.9 | 33.2 / 33.4 , 33.3 / 33.4 | 28.9 , 28.3 | 24.7 , 24.9 | Reading it: - **The real blur costs the same as today's fake one here.** Frosted at 4 and 12 windows: draw per frame 27.9 ms on dev vs 25.4 ms on the branch (means over both DPRs and passes), against 8.4 / 6.9 ms for Solid. p50 frame time is 33.3 ms for Frosted at 4 and 12 windows on both trees (Solid 16.7). The blur is area-bound in the software compositor, and dev was already paying it on an almost empty backdrop (J18 finding). - Solid baseline is equal or slightly lower on the branch (6–10 ms draw), so the clip change itself costs nothing measurable. - So **#256's lag (Frosted at ≥4 windows halves the frame rate in software) is unchanged, by decision**; the optimisation work is ahead. 1 Frosted window stays at 16.7 ms p50. - Single p95/max cells are noisy (outliers up to 1.6 s on both trees, e.g. a first-frame hitch); read the medians. - Raw: attached `bench-all.log` and per-run logs (frame p50/p95/max, paint+layerize, raster, draw, layers, onscreen windows). ## 3. Screenshots (production build, DPR 2, Tokyo, mesh, Frosted cards and windows, 4 windows) Stripes behind the windows. Mid-pan = a wheel event per frame while the capture is taken. dev: stripes sharp through the windows at rest and mid-pan; branch: soft at rest and mid-pan. | | dev (fake blur) | branch (real blur) | |---|---|---| | stripes, at rest | ![dev rest](https://git.kayg.org/attachments/24dfb14a-2e06-4551-a8d3-c5b218a5f2d4) | ![branch rest](https://git.kayg.org/attachments/66440a46-8b76-4f83-b240-f9d45d676db0) | | stripes, mid-pan | ![dev pan](https://git.kayg.org/attachments/499d746b-734c-41d2-900b-6672ab5f41bd) | ![branch pan](https://git.kayg.org/attachments/425f77c4-4d36-428a-83e3-da1e62d0dab5) | The real scene (mesh), at rest and mid-pan, branch (and dev at rest for comparison). Over the mesh the difference is subtle, as J18 found; the stripes are where it is unmistakable. ![branch rest](https://git.kayg.org/attachments/b0990b6d-7e9c-413a-b2f8-b80463d06e35) ![branch mid-pan](https://git.kayg.org/attachments/728a4c53-c611-4120-a923-ccb58a3248fb) ![dev rest](https://git.kayg.org/attachments/cda782a3-72df-4940-b199-2cc4fa890d88) Observation, not investigated: with the high-contrast stripes, the last ~70 px of a window next to its bottom and right edge shows the stripes partly (edge handling of the blur). Over the mesh gradient this is not visible. Worth a look on the M2 Max. ## 4. Preview for the owner (keep up until told) `http://10.69.69.117:4260/?mock=1&proto=clusters&scene=1&theme=tokyo&bg=mesh&card=4&windows=4` : production build of the branch (`apps/tracer/dist`, built at `e2dfd7f3e`; the CSS is identical at `dac675523`, the head only differs by a type fix in a spec), mock server on loopback `:4261` plus a small TCP forwarder bound to the mesh address, both on `infraternal-dev-02-std` under user `claude` (`~/worktrees/j23-serve.sh`, `~/worktrees/j23-fwd.py`; reachable from the control host). Pan sideways with 4 and 12 windows (Ctrl+Shift+E splits down): the blur stays on while moving, and it costs frame rate on a machine without a GPU; the M2 Max numbers are what we need. ## 5. Gates **Existing browser specs on the branch** (dev-02, production build): `clusters-glass-motion` (3), `clusters-frame` (lock glyph whole in three views; 8 handles, held edges: the resize spec), `lock-icons`, `glass-ghost`, `canvas.spec.ts` (resize, lock, shortcuts), `clusters.spec.ts`, `frosted-real` → **95 passed, 9 failed**. The 9 fail identically on the tree before the clip change (same 5 non-ghost tests + 4 `glass-ghost` DPR 3 toast cases, the ones J18 already found on base): ``` branch: ✘ 16 test/canvas.spec.ts:301:1 › touch: press and hold on a header reveals corner handles; a handle resizes; a tap outside ends it (8.7s) ✘ 17 test/canvas.spec.ts:344:1 › sidebar footer: the person and settings pills line up, press, and open Settings with About; no version pill (12.4s) ✘ 26 test/canvas.spec.ts:621:1 › shortcuts: the palette offers Keyboard shortcuts, lock shortcuts are listed, and a Mac sees ⌘K (11.8s) ✘ 69 test/clusters.spec.ts:702:1 › motion rule: the work of a view jump, a window step and a column step finishes inside one frame, and keys do not animate (9.0s) ✘ 75 test/clusters.spec.ts:837:1 › settings is a view: a gear after a divider, one window per section, admin windows only for operators, nothing modal (5.1s) ✘ 93 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 3, ink, DPR 3 (12.5s) ✘ 94 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 4, ink, DPR 3 (12.3s) ✘ 101 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 3, paper, DPR 3 (11.8s) ✘ 102 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 4, paper, DPR 3 (12.0s) Error: expect(locator).toBeVisible() failed Error: expect(locator).toHaveText(expected) failed 9 failed 95 passed (18.5m) dev (c7ef1a702, the 9 reruns): ✘ 1 test/canvas.spec.ts:301:1 › touch: press and hold on a header reveals corner handles; a handle resizes; a tap outside ends it (7.8s) ✘ 2 test/canvas.spec.ts:344:1 › sidebar footer: the person and settings pills line up, press, and open Settings with About; no version pill (13.5s) ✘ 3 test/canvas.spec.ts:621:1 › shortcuts: the palette offers Keyboard shortcuts, lock shortcuts are listed, and a Mac sees ⌘K (13.0s) ✘ 4 test/clusters.spec.ts:702:1 › motion rule: the work of a view jump, a window step and a column step finishes inside one frame, and keys do not animate (9.0s) ✘ 5 test/clusters.spec.ts:837:1 › settings is a view: a gear after a divider, one window per section, admin windows only for operators, nothing modal (5.2s) ✘ 12 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 3, ink, DPR 3 (12.4s) ✘ 13 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 4, ink, DPR 3 (12.0s) ✘ 20 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 3, paper, DPR 3 (12.7s) ✘ 21 test/glass-ghost.spec.ts:47:3 › ghost text: every glass surface at level 4, paper, DPR 3 (12.6s) Error: expect(locator).toBeVisible() failed Error: expect(locator).toHaveText(expected) failed 9 failed 12 passed (5.6m) ``` **`scripts/verify fast`** (claude@infraternal-dev-01-compute01, through build-slot, head `e2dfd7f3e`; nothing in Rust/TS source differs between that and `dac675523` except the spec type fix below). It did **not** exit green: ``` ==> Repository artifact size gate repository artifact gate: PASS (4 changed path(s), merge base ec7b10111e97) ==> Release deployment functional checks .....Release build failed for aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; gate log retained .Build deferred: host capacity limit reached ......... ---------------------------------------------------------------------- Ran 15 tests in 0.826s OK . ----------------------- ... ---- ctrl_c_in_json_mode_ends_with_an_interrupted_envelope_and_exit_130 stdout ---- thread 'ctrl_c_in_json_mode_ends_with_an_interrupted_envelope_and_exit_130' (2484183) panicked at crates/infraternal/tests/cli_review.rs:166:9: still running after SIGINT note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace failures: ctrl_c_in_json_mode_ends_with_an_interrupted_envelope_and_exit_130 te ... | Rust gate | Result | +---------------------------+--------------+ | fmt | PASS | | VT native fmt | PASS | | CLI plugin compile-out | PASS | | clippy | PASS | | SSH runtime test helper | PASS | | real-binary fixture paths | PASS | | test | FAIL (101) | | SSH inode reuse fixture | PASS | | shim process-per-user | PASS | | cargo-deny | PASS | | static builds | PASS | +---------------------------+--------------+ ``` - The only failing Rust test is a timing assertion (SIGINT must end the process within 5 s) on a machine at load average 14–19 on 12 cores; this branch has no Rust change. Rerun alone: `test result: ok. 17 passed; 0 failed` (attached `verify-fast-rust-rerun-and-ts-first-try.log`). - The Rust failure stopped the gate before its TypeScript half. Running that half separately found one real bug **of mine**: `bun x tsc` failed on my spec (`frosted-real.spec.ts(81,51): TS2339 'remove' does not exist on type 'Node'`). Fixed in the spec commit (`tsc` exit 0 now). - TypeScript half on the final head `dac675523`, run on dev-02 (aarch64; compute01's queue did not give a slot in 40 minutes, so I cancelled that job): tracer unit tests `361 pass / 2 fail`. The two failures (`review: removing the current shared view selects a surviving camera anchor`, `a remote negative pin and node update preserve the world position of an unchanged card`, both in `src/canvas`) fail identically on the tree before this branch (`bun test src/canvas` on `c7ef1a702`: 279 pass / 2 fail, same names). Because the gate chains with `&&`, they stopped it before the later steps, so I ran those separately on the branch: connection tests 105 pass / 0 fail, terminal query tests 17 pass / 0 fail, `bun run build` + `transfer-budget.ts` exit 0, protocol tests 39 pass / 0 fail: ``` ==> Compiler metadata generator checks 2 pass 0 fail Ran 2 tests across 1 file. [239.00ms] ==> Vendored Ghostty artifact checksums ==> TypeScript protocol compiler ==> Tracer compiler, precompression and bundle transfer budget (pass) a failed idle warm-up shows no toast and retries when the browser is back online [13.52ms] (pass) a failed open shows the toast, and opening again after the network returns succeeds [14.99ms] (pass) an open that joins a failing idle warm-up still shows the toast [6.79ms] (fail) review: removing the current shared view selects a surviving camera anchor [3.53ms] (fail) a remote negative pin and node update preserve the world position of an unchanged card [0.71ms] (pass) a clean terminal exit keeps the window while host setup can still fail (pass) clean exits still close ordinary windows, and failed exits stay visible (pass) failed watch refreshes retain one subscription through retry and release it on disposal [0.86ms] (pass) a pushed recovery supersedes an outstanding retry failure [0.10ms] (pass) a node the server sees passing traffic is online, on its path, under its machine's name [0.01ms] (pass) an already-added failure without a host id (older server) still says how to reach the terminal [0.12ms] (pass) parseSetupEvent > ignores a failed step on a non-failure and an unknown step on a failure [0.01ms] (pass) foldSetup > a failure marks the stage its step belongs to, and a retry clears it [0.02ms] (pass) bootstrap failure parsing > an already-added failure keeps its existing host id [0.07ms] (pass) bootstrap failure parsing > host ids are ignored on other failures and when malformed [0.04ms] (pass) createSetupFeed > a failure after the prompt, and a retry on the same socket [0.08ms] (pass) stagesOf > an auth failure before sign-in fails detect and says it in plain words, not the server's [0.22ms] (pass) stagesOf > an unsupported host fails the system check, after sign-in [0.10ms] (pass) stagesOf > an install failure fails the install stage and keeps the earlier ones done [0.05ms] (pass) guideFor > installation failures never claim that incomplete cleanup succeeded [0.05ms] (pass) guideFor > each failure kind names its fix [0.10ms] (pass) plain words > an unknown or missing failure gets the generic sentence, never the server's text [0.06ms] (pass) install diagnostics name the failed step and public host error [0.08ms] (pass) a notice type from a newer server is ignored, not a protocol failure [0.03ms] (pass) a recovering server link names its cause as routine status, never as a failure [0.03ms] (pass) a failed load says so in the error line, offers Try again, and mounts nothing [7.19ms] (pass) the Try again button reloads the page: a failed import stays failed in the module map [7.30ms] 2 tests failed: (fail) review: removing the current shared view selects a surviving camera anchor [3.53ms] (fail) a remote negative pin and node update preserve the world position of an unchanged card [0.71ms] 361 pass 2 fail Ran 363 tests across 60 files. [19.15s] ts_verify_fast exit 1 --- remaining steps (verify-ts2.log) --- == connection tests 105 pass 0 fail Ran 105 tests across 13 files. [82.00ms] == terminal query tests 17 pass 0 fail Ran 17 tests across 4 files. [105.00ms] == build + transfer budget built chunk-bgmz5x5y.js, chunk-qbb2e8b9.js, chunk-dr387cxc.js, chunk-yx0gjj81.js fonts/nerd-symbols.woff2: identity=223748 gzip=223748 brotli=223748 bytes fonts/ibm-plex-mono/italic.latin.woff2: identity=22296 gzip=22296 brotli=22296 bytes fonts/ibm-plex-mono/roman.latin.woff2: identity=19048 gzip=19048 brotli=19047 bytes fonts/geist-mono/italic.latin.woff2: identity=21380 gzip=21380 brotli=21380 bytes fonts/geist-mono/roman.latin.woff2: identity=19984 gzip=19984 brotli=19981 bytes fonts/source-code-pro/italic.latin.woff2: identity=23612 gzip=23612 brotli=23612 bytes fonts/source-code-pro/roman.latin.woff2: identity=22872 gzip=22872 brotli=22872 bytes fonts/maple-mono-nf/italic.latin.woff2: identity=53444 gzip=53444 brotli=53444 bytes fonts/maple-mono-nf/roman.latin.woff2: identity=46432 gzip=46432 brotli=46432 bytes fonts/commit-mono/italic-400.latin.woff2: identity=18716 gzip=18716 brotli=18716 bytes fonts/commit-mono/italic-700.latin.woff2: identity=18352 gzip=18352 brotli=18352 bytes fonts/commit-mono/roman-400.latin.woff2: identity=17484 gzip=17484 brotli=17484 bytes fonts/commit-mono/roman-700.latin.woff2: identity=17260 gzip=17260 brotli=17260 bytes fonts/fira-code/roman.latin.woff2: identity=36756 gzip=36756 brotli=36756 bytes Font transfer: monaspace-neon=84340/88064 cascadia-code=85008/88064 jetbrains-mono=70384/75776 symbols=223748/235520 ibm-plex-mono=41344/45056 geist-mono=41364/45056 source-code-pro=46484/49152 maple-mono-nf=99876/106496 (critical 46432/51200) commit-mono=71812/75776 fira-code=36756/40960 bytes transfer-budget exit 0 == protocol tests 39 pass 0 fail Ran 39 tests across 10 files. [2.03s] ts2 done ``` So: **`scripts/verify fast` as one command is not green on this branch**, for a load-flake Rust test and two canvas unit tests that also fail without the branch. Everything else passes. I did not touch those tests (not mine to change here); they probably deserve their own issue. ## Left for the orchestrator / owner - Review the numbers above and merge; I did not merge, push to dev, or deploy. - Needs a device: the real cost on the M2 Max with 4 and 12 Frosted windows. - The two `src/canvas` unit-test failures and the SIGINT timing test on dev. - Machine state: dev-02 holds the preview (`:4260`) until told; test servers and browsers I started for benchmarks were stopped. ## Attachments - [bench-all.log](https://git.kayg.org/attachments/a44267f1-00bc-43f6-bf53-e7ea673fd5fb) - [table.md](https://git.kayg.org/attachments/6dd574eb-9055-49bd-a416-5eabe53495df) - [stripe-red.log](https://git.kayg.org/attachments/b96a1f54-11b8-4b47-8348-fed2f834463e) - [stripe-green.log](https://git.kayg.org/attachments/a671de3f-3826-48af-808f-5a027d45e247) - [gates-branch.log](https://git.kayg.org/attachments/51b38510-5a68-49eb-bf83-05d8cab23c0d) - [gates-base.log](https://git.kayg.org/attachments/d1ed617e-898a-4714-806f-b9711fc929e9) - [verify-fast.log](https://git.kayg.org/attachments/f9597cf0-e4f2-4d59-9543-5ab7d6250593) - [verify-fast-rust-rerun-and-ts-first-try.log](https://git.kayg.org/attachments/f734242a-a45d-4c4a-b5a8-7a094c7729d4) - [verify-ts-arm.log](https://git.kayg.org/attachments/a60a8a94-a089-4039-903f-4ab16736aa73) - [verify-ts2.log](https://git.kayg.org/attachments/1d1a8170-7a1f-4176-900d-0c1874ec8d3e)
Author
Owner

Closed by the orchestrator during the 2026-11-09 night housekeeping (owner order: "close stale prs/issues"). The work has landed on dev, and the latest note here is a finish or merge note (J387 inventory on #436). Reopen if anything is still missing.

Closed by the orchestrator during the 2026-11-09 night housekeeping (owner order: "close stale prs/issues"). The work has landed on dev, and the latest note here is a finish or merge note (J387 inventory on #436). Reopen if anything is still missing.
kayg closed this issue 2026-11-08 19:04:27 +00:00
Sign in to join this conversation.
No labels
launch-qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/infraternal#256
No description provided.