On a phone the card dialog slides alone, so it slides smoothly #224

Merged
Grandiras merged 1 commit from phone-card-dialog-smooth into main 2026-09-23 11:00:45 +00:00
Owner

The card dialog's entrance on a phone felt sluggish next to every other animation in the app. A Chrome trace of a tap on an emulated phone shows that the slide itself was never the cost: it is a transform, and the compositor runs it. The cost was everything happening around it.

What the trace showed

before after
animations running while the dialog opens card box-shadow + transform, column hairline opacity, the scrim fade on the dialog's own parent, the slide card scale press, the scrim fade (on ::before), the slide
any animation Chrome could not composite yes, the card's box-shadow (compositeFailed, unsupportedProperties: box-shadow) none (compositeFailed: 0 for all three)
  1. The tap that opens a card leaves :hover stuck on it. The hover lift is a box-shadow, so the board repainted on every frame of the slide. The lift and the column hairline's hover are now @media (hover: hover) only. A finger gets a scale: 0.98 press instead, which the compositor runs by itself. It comes before .card-chosen, so a long press still grows the card.
  2. The scrim faded as an opacity on .modal-backdrop, the dialog's parent. That puts the whole full-screen sheet into an offscreen surface that has to be blended every frame of the fade. The scrim's colour, blur and fade now live on .modal-backdrop::before at z-index: -1. This applies to all three dialogs at every width, and the desktop blur still works (checked by screenshot).
  3. The card sheet is full height from its first frame (below 600px). The comment thread arrives a moment after the dialog does, which is during the slide, and it used to grow a content-sized sheet while slideUpMobile's 100% was being measured against its height. max-height is also the backdrop's 100% now instead of 100vh, because 100vh is taller than the window while the browser's toolbar is showing. The feedback and release-notes dialogs are still content-sized sheets.

Tests

mobile.spec.js › Card dialog by touch (Pixel 7, touch) checks these in the browser's own terms rather than by timing frames. Headless Chromium on a desktop GPU would pass a frame-timing test whatever the CSS did.

  • In a (hover: none) context, a pointer resting on a card must not change its shadow or transform.
  • It records every transitionrun/animationstart from the tap on and refuses any board property the compositor can't animate.
  • The scrim's fadeIn must be on the ::before, not the backdrop.
  • The sheet must be exactly the screen's height.

Checked against the old stylesheet: the test fails there, first on the hover lift and then on the fade.

The test stack was in use by another session, so I ran mobile.spec.js and board.spec.js against a host dotnet run with its own Postgres. 29 of 30 passed. The one failure, the menu opens as a sheet, also fails on main in that setup: it counts menu rows, and the host app has no feedback repository configured. dotnet test: 560 passed.

Docs: docs/ui.md (card dialog section: why only the sheet and scrim may move, the three rules, and what the test pins) and docs/frontend.md (.card-chosen and the finger's :active both use scale). Changelog entry added in both locales.

Not verified on a real phone. The trace shows no main-thread work left during the slide, but the proof is how it feels in your hand.

🤖 Generated with Claude Code

The card dialog's entrance on a phone felt sluggish next to every other animation in the app. A Chrome trace of a tap on an emulated phone shows that the slide itself was never the cost: it is a `transform`, and the compositor runs it. The cost was everything happening around it. ## What the trace showed | | before | after | |---|---|---| | animations running while the dialog opens | card `box-shadow` + `transform`, column hairline `opacity`, the scrim fade on the dialog's own parent, the slide | card `scale` press, the scrim fade (on `::before`), the slide | | any animation Chrome could not composite | yes, the card's `box-shadow` (`compositeFailed`, `unsupportedProperties: box-shadow`) | none (`compositeFailed: 0` for all three) | 1. **The tap that opens a card leaves `:hover` stuck on it.** The hover lift is a `box-shadow`, so the board repainted on every frame of the slide. The lift and the column hairline's hover are now `@media (hover: hover)` only. A finger gets a `scale: 0.98` press instead, which the compositor runs by itself. It comes before `.card-chosen`, so a long press still grows the card. 2. **The scrim faded as an `opacity` on `.modal-backdrop`, the dialog's parent.** That puts the whole full-screen sheet into an offscreen surface that has to be blended every frame of the fade. The scrim's colour, blur and fade now live on `.modal-backdrop::before` at `z-index: -1`. This applies to all three dialogs at every width, and the desktop blur still works (checked by screenshot). 3. **The card sheet is full height from its first frame** (below 600px). The comment thread arrives a moment after the dialog does, which is during the slide, and it used to grow a content-sized sheet while `slideUpMobile`'s `100%` was being measured against its height. `max-height` is also the backdrop's `100%` now instead of `100vh`, because `100vh` is taller than the window while the browser's toolbar is showing. The feedback and release-notes dialogs are still content-sized sheets. ## Tests `mobile.spec.js` › *Card dialog by touch* (Pixel 7, touch) checks these in the browser's own terms rather than by timing frames. Headless Chromium on a desktop GPU would pass a frame-timing test whatever the CSS did. - In a `(hover: none)` context, a pointer resting on a card must not change its shadow or transform. - It records every `transitionrun`/`animationstart` from the tap on and refuses any board property the compositor can't animate. - The scrim's `fadeIn` must be on the `::before`, not the backdrop. - The sheet must be exactly the screen's height. Checked against the old stylesheet: the test fails there, first on the hover lift and then on the fade. The test stack was in use by another session, so I ran `mobile.spec.js` and `board.spec.js` against a host `dotnet run` with its own Postgres. 29 of 30 passed. The one failure, *the menu opens as a sheet*, also fails on `main` in that setup: it counts menu rows, and the host app has no feedback repository configured. `dotnet test`: 560 passed. Docs: `docs/ui.md` (card dialog section: why only the sheet and scrim may move, the three rules, and what the test pins) and `docs/frontend.md` (`.card-chosen` and the finger's `:active` both use `scale`). Changelog entry added in both locales. Not verified on a real phone. The trace shows no main-thread work left during the slide, but the proof is how it feels in your hand. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
On a phone the card dialog slides alone, so it slides smoothly
All checks were successful
CI / build (pull_request) Successful in 44s
dc47d2bdd8
The slide was never the cost — it is a transform, and the compositor runs
it. What made the phone's entrance sluggish was everything around it, as
a Chrome trace of a tap shows:

- The tap that opens a card leaves :hover stuck on it, and the hover lift
  is a box-shadow, which cannot be composited (the trace:
  unsupportedProperties: box-shadow). The board repainted on every frame
  of the slide. The lift and the column hairline's hover are hover-only
  now; a finger gets a press on `scale` instead.
- The scrim faded as an opacity on .modal-backdrop — the dialog's parent —
  so the full-screen sheet was drawn offscreen and blended every frame of
  the fade. The colour, blur and fade are on its ::before now.
- The card sheet is full height from its first frame, so the thread
  loading mid-slide cannot grow it under slideUpMobile's 100%; and its
  max-height is the backdrop's 100% rather than 100vh, which is taller than
  the window while the browser's toolbar shows.

mobile.spec.js pins all three in the browser's terms (no hover lift in a
(hover: none) context, no non-composited transition on the board from the
tap, the fade on the ::before, the sheet the screen's height), and fails
on the old stylesheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review -->
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Solid, well-evidenced change. The three fixes are mechanically correct in the stylesheet: the ::before scrim at z-index: -1 layers properly inside the backdrop's own stacking context (and the mobile override retargets it correctly), max-height: 100% resolves against the fixed backdrop's definite height so the full-height card sheet matches innerHeight, and the (hover: none) press on scale sits before .card-chosen (which also wins on specificity), so a held press still grows the card. The test pins the behaviour in the browser's own terms — event-based recording with pseudo-element checks rather than frame timing — which is the right call for headless runs. Docs and both changelog locales are consistent with the code. Minor non-blocking note: the hover check relies on (hover: none) matching under Playwright's Pixel 7 emulation, which device descriptors don't set explicitly — but the reported failure of the test against the old stylesheet shows it discriminates in practice.



⏱ 1m 42s · 5 steps · $0.0091 · tokens: 44.6k in / 3.1k out (+29.2k cache-read) · Go: 5h 4% · wk 75% · mo 70%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Solid, well-evidenced change. The three fixes are mechanically correct in the stylesheet: the `::before` scrim at `z-index: -1` layers properly inside the backdrop's own stacking context (and the mobile override retargets it correctly), `max-height: 100%` resolves against the fixed backdrop's definite height so the full-height card sheet matches `innerHeight`, and the `(hover: none)` press on `scale` sits before `.card-chosen` (which also wins on specificity), so a held press still grows the card. The test pins the behaviour in the browser's own terms — event-based recording with pseudo-element checks rather than frame timing — which is the right call for headless runs. Docs and both changelog locales are consistent with the code. Minor non-blocking note: the hover check relies on `(hover: none)` matching under Playwright's Pixel 7 emulation, which device descriptors don't set explicitly — but the reported failure of the test against the old stylesheet shows it discriminates in practice. --- --- ⏱ 1m 42s · 5 steps · $0.0091 · tokens: 44.6k in / 3.1k out (+29.2k cache-read) · Go: 5h 4% · wk 75% · mo 70% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-224/run-2026-09-23T10-30-03-943Z.md) <!-- opencode-reviewed-head:dc47d2bdd8b865c9df7c2b3a40d21743aa632e44 --> <!-- opencode-verdict:approve -->
Grandiras deleted branch phone-card-dialog-smooth 2026-09-23 11:00:45 +00:00
Grandiras referenced this pull request from a commit 2026-09-23 11:47:01 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
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
Grandiras/Ponente!224
No description provided.