fix(app): take the gate's coach marks out of #splash's stacking context - #565
Conversation
The cold-start gate draws three coach marks, each a ring on its target with a thin leader line to a box. Two of the three rings were painted underneath the top bar, so the line ran up to the bar's edge and stopped at nothing. #splash is position: fixed with a z-index, which makes it a stacking context. Its children are capped at its own 550 whatever they declare, and the gate lifts #topbar to 560 so Settings stays reachable. The rings on #filter-pill and #settings-btn therefore landed under the bar; only the rail's ring, whose target sits at 500, was visible. Raising .splash-ring's z-index cannot fix that from inside #splash, so the nine elements become siblings of it instead. The design decision the gate was built on is unchanged: the FABs stay under the scrim, visible through it and untouchable, and #settings-btn stays the one live control. Measured at 412x915 after the change, elementFromPoint at each ring's centre returns the ring itself (it returned #topbar for two of them before), the ring positions are byte-identical, #settings-btn is still clickable and #layer-toggle still resolves to the scrim. refreshSplash() already sets `hidden` on all nine on every state change, so moving them out of #splash does not change when they appear: verified that they hide when the gate is dismissed and stay hidden when the "?" tour is reopened. Tests: app 851 passed, web 504 passed, app build clean. The new assertions in splash.test.js were mutation-checked by moving one ring back inside #splash. Closes efiten#562 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # app/changelog.json # web/changelog.json
efiten
left a comment
There was a problem hiding this comment.
Rebased onto master for you (changelog conflicts only, both copies merged and identical afterwards, 45 entries) and pushed.
The diagnosis is the kind that only yields to measurement: #splash is position: fixed with a z-index, so it is a stacking context, and a ring nested inside it cannot rise above a bar the gate lifts to a higher layer, whatever z-index the ring declares. elementFromPoint at each ring's centre returning #topbar is the right instrument and the right evidence.
Both halves are pinned, checked by breaking them:
- Nesting one ring back inside the
#splashaside turnskeeps every coach-mark element out of #splashred. That is the actual bug, and the test catches it structurally rather than by measuring pixels, which is what makes it durable. - Dropping
.splash-ring's z-index below the gate top bar turns the ordering test red.
That second one matters as much as the first: taking the marks out of the stacking context is only half the fix, since siblings still need to sort above the bar. Two independent guards for one behaviour is right here.
app/ 882 tests and a clean build with master merged in, eslint clean.
Reading a z-index out of the stylesheet in a unit test is unusual, and I think justified here: the alternative is a browser test for a paint-order property, and this catches the regression at the layer where it would be introduced.
🤖 I have created a release *beep* *boop* --- <details><summary>app: 1.24.0</summary> ## [1.24.0](app-v1.23.0...app-v1.24.0) (2026-08-30) ### Features * **app:** give the theme three states and a memory, and end the Settings tab ([#566](#566)) ([e67c319](e67c319)) * **app:** keep the SNR the repeater heard us at ([#489](#489)) ([0cd0a7c](0cd0a7c)) * **app:** make every recorded reception audible, one instrument per type (+ sound tweaks) ([#470](#470)) ([6244c0f](6244c0f)) * **web:** gate the point layer, and say what an account opens ([#513](#513)) ([71856f1](71856f1)) ### Bug Fixes * **app:** take the gate's coach marks out of #splash's stacking context ([#565](#565)) ([6fba59e](6fba59e)) </details> <details><summary>web: 1.20.0</summary> ## [1.20.0](web-v1.19.0...web-v1.20.0) (2026-08-30) ### Features * **app:** give the theme three states and a memory, and end the Settings tab ([#566](#566)) ([e67c319](e67c319)) * **app:** keep the SNR the repeater heard us at ([#489](#489)) ([0cd0a7c](0cd0a7c)) * **app:** make every recorded reception audible, one instrument per type (+ sound tweaks) ([#470](#470)) ([6244c0f](6244c0f)) * **web:** gate the point layer, and say what an account opens ([#513](#513)) ([71856f1](71856f1)) ### Bug Fixes * **app:** take the gate's coach marks out of #splash's stacking context ([#565](#565)) ([6fba59e](6fba59e)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Closes #562
What was happening
The cold-start gate draws three coach marks, each a small ring on its target with a thin leader
line to a box beside it. Two of the three lines ran up to the top bar and stopped at nothing,
because their rings were painted underneath it.
Measured on
ae9ba97at 412x915 with the gate showing,document.elementFromPointat eachring's centre:
cm-controls#filter-pill#topbarcm-menu#settings-btn#topbarcm-fabs#layer-toggleWhy raising the z-index could not fix it
#splashisposition: fixedwith az-index, so it is a stacking context. Its childrencompete only inside it and are capped at its own 550, whatever they declare for themselves. The
gate lifts
#topbarto 560 so Settings stays reachable, which puts the bar above the whole#splashsubtree..splash-ringalready declared 561 and it made no difference.The rail's ring was fine only because its target sits at 500.
The change
The nine coach-mark elements become siblings of
#splashinstead of children. That is the wholefix. The z-index values are untouched: once the elements are out of the trapped context, the
existing 561 is already above the bar's 560.
Both places that would tempt someone to "fix" this by raising numbers now carry a comment saying
why that cannot work.
What is deliberately unchanged
The decision the gate was built on stands: the FABs stay under the scrim, visible through it for
the coach mark to point at and untouchable, and
#settings-btnremains the one live control.Measured after the change:
elementFromPointat each of the three ring centres returns the ring itself.#settings-btnis still clickable.#layer-togglestill resolves to#splash, so the rail is still inert.The regression this invites
With the elements outside
#splash, hiding#splashno longer hides them. It does not have to:refreshSplash()setshiddenon all nine on every state change, and that is the only place#splash's ownhiddenis set, so the two cannot drift. Verified on the running app that themarks disappear when the gate is dismissed and stay hidden when the "?" tour is reopened.
The outside-tap handler on
#splashis unaffected: it returns unlesse.target === e.currentTarget,so it never fired on these elements anyway, and it is gated on the tour.
Tests
splash.test.jsgains three assertions: that the gate's bar really does outrank#splash(thepremise), that no coach-mark element sits inside
#splash, and that the three classes declare az-index above the gate's bar.
Mutation-checked by moving one ring back inside
#splash, which fails withcm-menu-ring is outside #splash's stacking context. The premise and z-index assertions passbefore and after by design; they are guards against the two ways this can regress, not the
red-to-green pair.
Changelog
One entry,
apponly, in both copies.🤖 Generated with Claude Code