Skip to content

fix(desktop): stop the terminal fade clobbering the app surface's compositor hint - #4526

Merged
tlongwell-block merged 1 commit into
max/tui-rendererfrom
dawn/tui-flicker
Aug 3, 2026
Merged

fix(desktop): stop the terminal fade clobbering the app surface's compositor hint#4526
tlongwell-block merged 1 commit into
max/tui-rendererfrom
dawn/tui-flicker

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

Sub-PR of #4347 (flicker lane). Base max/tui-renderer @ 90896bebecc6c56a62cf3b27992d074eccc1477c; head 3835378aee16f332621e93d7c43bd644be65884e. Branched from d8a4d63a8, which is still an ancestor of the base — the 3-dot diff is exactly the 2 files below.

Read this first: the assigned symptom is NOT fixed

Tyler's intermittent ⌘J conceal flicker is still unreproduced. I ran a full measurement campaign before touching code and could not make it fire. What's here is a different, proved defect I found while instrumenting — a real break in the compositor-hint contract and a plausible contributor, but I have no measurement tying it to Tyler's symptom and I'm not claiming one.

The defect

FadeController borrows will-change: opacity on .buzz-huddle-app-surface for the duration of a fade, then "releases" it by writing the literal string "auto" inline.

That element has an authored stylesheet hint for the huddle-drawer transition (components.css:30-31: bottom, border-bottom-left-radius, border-bottom-right-radius, box-shadow). An inline declaration outranks the stylesheet, so writing "auto" doesn't clear the borrow — it permanently replaces the drawer's hint for the life of the page. After the first ⌘J of a session, every subsequent drawer open/close runs without the hint it was written to have.

Measured in a real browser before the fix: computed will-change was auto at all three stages of a reveal/conceal cycle.

The fix

fadeController.ts only. New private #releaseHint()this.#surface.style.removeProperty("will-change"), called from settle() and the finished-completion path. SurfaceLike widened to include removeProperty. Cascade reasoning is in a comment at the call site.

Receipts

  • Real browser, after fix: authored hint survives all three stages (bottom, border-bottom-left-radius, ...), inline empty.
  • Revert sensitivity: reverting the single production line with the tests kept → 5 of 7 fadeController tests fail. All 7 pass with the fix.
  • Fixture was blind: the old test double modelled style as a plain object with no removeProperty, where "auto" and absent are indistinguishable — it scored the clobbering write as a correct release. It now models CSSOM presence/absence and what the element would compute with the stylesheet included. That's what makes the defect observable.
  • Mutation sweep 6/6 killed, each mutant's produced diff printed and non-empty. M1 = the original defect (literal "auto"), M2 wrong property name, M3 settle() doesn't release, M4 completion doesn't release, M5 stale completion releases during reversal, M6 never sets the hint. Tree restored and hash-verified after.
  • Full frontend suite 3994/3994, 0 fail. tsc --noEmit clean. biome/pnpm check clean except 2 warnings in personaCatalogRelay.test.mjs proven pre-existing at base.
  • Pre-push hooks green at this SHA: branch-skew, desktop-check, desktop-test, mobile-test, rust-tests (229s), desktop-tauri-checks.

Hypotheses I refuted with my own data (don't re-walk these)

  • will-change de-promotion. A per-node layer instrument shows the app surface owns a layer at every stage; A/B toggling will-change alone is 1→1→1. Global layer counts (43/42/42/47/44) are noise.
  • Conceal-specific inert asymmetry. inert cost does scale with subtree size (2.4ms @648 nodes → 53ms @20.6k) and runs synchronously before the fade, and bulk-DOM conceal showed a real 71ms dropped frame at 12k nodes — but interleaved arms killed the asymmetry claim: reveal 75–83ms vs conceal 70–80ms. Symmetric synthetic-DOM jank, not a conceal defect.
  • content-visibility. Negative: 255 skipped / 45 unskipped, identical before and after, both directions.

Campaign detail (for the reviewer)

  • Playwright screencast flicker detector, glob registered in the smoke testMatch and selection confirmed via --list (a spec outside testMatch selects 0 tests and exits 0 — silent pass). Positive control = one forced opacity-0 frame mid-conceal → spike 0.585; five negative runs → 0.000. Clean separation.
  • 17 runs headless + headed Chromium with that calibrated detector: no reproduction. A tiled variant was noisier and worse-separated than whole-frame; discarded.
  • Opacity traces through conceal (Chromium + WebKit) monotonic, no backward steps, final 1.0, zero drops. Rapid-toggle sweep (gaps 40/80/150/200/300ms × 6 cycles): tail always owner=buzz, opacity=1.
  • Bleed probe: at rest with Buzz owning, 0 magenta px (0.00%); positive control with terminal owning, 96.00%. Detector proven live, no bleed at rest.
  • I fixed two of my own broken instruments mid-campaign (a positive control that fired while opacity was already 0 — a no-op; a tiled detector that missed its own control).

Probe specs and the playwright.config.ts testMatch entry were deliberately reverted out of this commit. The lane's deliverable is the minimal fix; a durable terminal e2e home is a parked follow-up.

Strongest open lead on the actual flicker

WebKit shows zero intermediate fade frames at 12k nodes — the conceal pops instead of animating. Not yet root-caused. WebKit is the right engine family here (Tauri ships WKWebView), so Chromium-only evidence is weaker than it looks. Next step I'd take: drive that path in WebKit against a realistic channel (many real messages) rather than synthetic filler nodes.

…positor hint

`FadeController` borrows `will-change: opacity` on the app surface for the
duration of a ⌘J fade and released it by writing the literal string "auto".
An inline declaration outranks the stylesheet, so that write does not restore
the element to its authored state — it permanently replaces it.

`.buzz-huddle-app-surface` carries an authored hint for the huddle drawer
transition (components.css:30-31: bottom, border-bottom-left-radius,
border-bottom-right-radius, box-shadow). After the first fade of a session the
surface computed `will-change: auto` for the rest of the session, so every
subsequent drawer open/close ran without the hint it was written to have.

Release by removing the inline property instead, which hands the element back
to CSS. Verified in a real browser: before, the surface computed "auto" at
every stage of a reveal/conceal cycle; after, it computes the authored hint at
all three.

The fixture previously modelled `style` as a plain object, where "auto" and
"absent" are indistinguishable — it scored the clobbering write as a correct
release. It now models presence/absence and what the element would compute
with the stylesheet included, which is what makes the defect observable.

Five tests fail with the production line reverted and the tests kept. Mutation
sweep 6/6 killed, including the original defect as M1 and a mutant that removes
the wrong property name.

This is not a fix for the intermittent conceal flicker; that remains open and
unreproduced. Found while instrumenting it.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
@tlongwell-block
tlongwell-block requested a review from a team as a code owner August 3, 2026 13:35
@tlongwell-block
tlongwell-block merged commit baa5b42 into max/tui-renderer Aug 3, 2026
23 of 25 checks passed
@tlongwell-block
tlongwell-block deleted the dawn/tui-flicker branch August 3, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant