round 4: app-shell quiz screen at <=700px — #step-body is the only scroller, #btn-next/#panel become the static bottom cluster; revert FR .opt override (keep 972f3a7); + capture/verify/pixdiff/offlan audit tooling and before/after evidence; closes QUIZ-APPSHELL-FIX.md

This commit is contained in:
krisf committed 2026-10-09 10:52:23 -04:00
1 parent 972f3a7caa
commit 7697639fb8
84 files changed
+10743 -4

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
# ROUND 4 FIX: quiz-screen app-shell (mobile) — pinned CTA must never cover content
**Spec v1 — written 2026-10-09 by Hermes. Execution: DSH, all stages, zero questions.**
Supersedes the round-3 FR compact-button hack (revert it). The retire-label anchor fix (commit
`f4b5698`) stays.
## Defect (measured by Hermes on the live site, 2026-10-09)
The bilingual refactor made step headers taller. On the province step the pinned sticky CTA
(`#btn-next`, `position:sticky; bottom: calc(var(--sheet-h) + 14px)`) now floats over the last
row of the option grid at initial scroll:
| viewport | lang | #btn-next rect | last row (Nunavut) rect | coverage |
|---|---|---|---|---|
| 390×844 | fr | [564, 614] | [570, 613] | **~92% covered** |
| 390×844 | en | [564, 614] | [589, 632] | ~25px of top covered |
| 360×800 / 320×700 | both | worse | worse | see verify_round3.py output |
Root cause: the CTA is sticky-pinned above the fixed panel and floats over whatever content sits
at that viewport position when the option grid is taller than the space above the fixed cluster.
The grid's position is fixed by the content above it, so padding/font tweaks cannot fix all
breakpoints. The correct fix is an **app-shell layout**: the quiz screen becomes a flex column
whose only scroll container is `#step-body`; the CTA and the panel become flex children of a
fixed bottom cluster and can never overlap content at any scroll position, width, or language.
## Current DOM (measured — do not restructure, it is already perfect for this)
```
body
header.hdr (h=57, sticky top)
main
section#scr-quiz.scr (top=69, class "scr" carries the mobile padding rule)
div.quiz-wrap
div.quiz-main
div.prog (h=6)
div.step-nav (sticky top, h=56 — Back + "1 / 14")
div#step-body (step content: title, sub, .opts)
button#btn-next.cta (sticky bottom ≤700px, h=50)
aside#panel.panel (position:fixed; bottom:8px, h=218 — sibling of .quiz-main)
```
**The fix is CSS-only.** `#step-body` already exists as the content wrapper; `#btn-next` and
`#panel` are already siblings in the right places. No app.js DOM changes. (If you find you need a
one-line app.js tweak, keep it minimal and say so in the report.)
## Required changes (all inside `@media(max-width:700px)`, appended after the existing block)
```css
/* Round 4: app-shell the quiz screen — #step-body is the only scroll container;
#btn-next + #panel form the fixed bottom cluster. Never overlaps content. */
#scr-quiz.scr{display:flex;flex-direction:column;height:calc(100vh - 69px);height:calc(100dvh - 69px);overflow:hidden;padding:0}
.quiz-wrap{display:flex;flex-direction:column;flex:1;min-height:0}
.quiz-main{display:flex;flex-direction:column;flex:1;min-height:0}
#step-body{flex:1;overflow-y:auto;min-height:0;padding:10px 12px 16px}
#btn-next{position:static;margin:10px 12px 12px}
#panel{position:static;margin:0 8px calc(8px + env(safe-area-inset-bottom,0px))}
```
Notes / requirements:
1. `height:calc(100vh - 69px)` then `height:calc(100dvh - 69px)` (dvh override for mobile
browser chrome). 69px = 57px header + 12px offset above `#scr-quiz` (measured). If the 12px
comes from `main` padding, that padding still applies — fine, the calc already accounts for it.
2. **No document scroll on the quiz screen**: after the change, at 320/360/390,
`document.documentElement.scrollHeight <= window.innerHeight + 1` on EVERY quiz step
(all 14 single / 17 couple steps — walk them). If `main`'s padding-bottom or anything else
leaves a few px of document scroll, fix it (e.g. `main{padding-bottom:0}` at ≤700px) WITHOUT
changing the intro or results screens visually (verify with screenshots).
3. `#btn-next{position:static}` — the sticky rule at line ~414 (`#btn-next{position:sticky;bottom:calc(var(--sheet-h) + 14px + …)}`)
must be overridden (your later rule wins; same specificity, later in file). The CTA must be
fully visible at ALL times: rect within [header bottom, panel top].
4. `#panel{position:static}` — the fixed rule (`#panel{position:fixed;bottom:8px;…}`) must be
overridden at ≤700px. The panel becomes the last flex child of `.quiz-wrap`. Keep its visual
inset identical (8px sides/bottom + safe-area). The panel's internal `.panel-h` sticky rule
becomes inert — harmless, leave it.
5. `#step-body` gets the horizontal padding the `.scr` rule used to provide
(`padding:10px 12px 16px`). `#scr-quiz.scr`'s own padding goes to 0 (it is `padding:10px 12px
calc(var(--sheet-h) + 14px)` today — your `#scr-quiz.scr` id+class selector beats it).
6. `--sheet-h` / `syncSheetH` become unused at mobile — LEAVE the JS running (harmless), do not
rip it out.
7. **Revert** the round-3 rule `html[lang="fr"] .opt{font-size:13px;padding:12px 4px}` (and its
comment) from the ≤700px block — superseded; EN and FR option buttons return to identical sizing.
8. Keep everything else: sticky header, `.prog`, `.step-nav` (Back + counter, still pinned by flex
now — its sticky rule is inert inside the non-scrolling `.quiz-main`), scrim, sheet expand,
swipe hints, chartbox scroll on results (results screen is NOT app-shelled — only `#scr-quiz`).
9. Desktop (>700px) must be byte-identical: all changes are inside `@media(max-width:700px)`.
Prove it: screenshot the desktop quiz (province step + a tall step + results) at 1280×900 in
EN and FR BEFORE and AFTER — pixel-diff must be 0 (or explain any diff).
## Acceptance (DSH verifies; Hermes re-verifies independently after)
At 320/360/390, EN and FR, on the province step AND the interests step (tall):
1. `#btn-next` fully visible (rect.top ≥ header bottom, rect.bottom ≤ panel top) at scroll
positions 0 / mid / max of `#step-body`.
2. `#btn-next` never overlaps `#panel` (next.bottom ≤ panel.top + 1) or any `.opt`.
3. At max scroll of `#step-body`, the last `.opt` is fully visible inside the scroll area
(lastOpt.bottom ≤ stepBody.rect.bottom − 4).
4. No document scroll on any quiz step (see 2 above).
5. Panel fully visible; 4 chips + insight readable; sheet expand/scrim still works (tap panel-h,
scrim appears, tap scrim, collapses).
6. Back button + "1 / 14" counter visible at top; progress bar visible.
7. Tests green: engine 36/36, i18n 48, smoke 56.
8. Desktop 1280×900 EN+FR quiz + results screenshots pixel-identical to the pre-change baseline
(baseline taken by Hermes 2026-10-09 into `audit/appshell-baseline/`).
9. Deploy `DEPLOY-OK`, origin marker = 1; off-LAN (Midhurst troublebox) 200 + marker.
10. Save before/after screenshots + measurements to `audit/appshell-{before,after}/`.
## Process
0. Preflight: clean tree on top of `f4b5698`; tests green; take the desktop baselines yourself
too (1280×900 EN+FR quiz province + interests + results) into `audit/appshell-baseline/`
(Hermes also keeps copies in scratch — compare against yours).
1. Apply the CSS (one new media block + revert the FR rule). CSS-only.
2. Tests + local verification battery (acceptance 1-7 at 320/360/390 EN+FR).
3. Deploy + origin/off-LAN checks + screenshots → `audit/appshell-after/`.
4. Commit + push; write `QUIZ-APPSHELL-REPORT.md` (root cause, geometry before/after, acceptance
evidence, desktop pixel-diff result, any deviation).
5. If anything fails twice, stop, keep the last good deploy live, report the blocker.
**Your FINAL output line must be exactly: `QUIZ_APPSHELL_DONE`**
+130
View File
@@ -0,0 +1,130 @@
# QUIZ APP-SHELL FIX — Round 4 Report
**Spec:** `QUIZ-APPSHELL-FIX.md` · **Fix commit:** this round (follows `f4b5698`) · **Status: DEPLOYED & VERIFIED**
The mobile quiz (≤700px) had a pinned/sticky `#btn-next` that sat on top of the last
option row (up to 100% of the last `.opt` covered) and the page itself scrolled
(document 100–306px past the viewport, so the CTA floated over content at every
document scroll position). Fix: app-shell the quiz screen — `#step-body` becomes the
only scroll container; `#btn-next` + `#panel` become static flex children of a fixed
bottom cluster. CSS-only, zero `app.js` changes.
## Root cause
Round 3 kept `#btn-next` `position:sticky` (anchored above `--sheet-h`) and `#panel`
`position:fixed` at ≤700px. Sticky-in-scrolling-document means the CTA hovers over
whatever content scrolls beneath it — on the province step (13 two-column options) the
last row sat exactly under the CTA band. Document-level scroll (`html`,`body` height
100vh with `overflow:hidden` only on `body`) plus `.opt` min-height growth in FR made
it worse. No geometry change made "pinned above content" and "never overlaps options"
coexist — the quiz needed a real app-shell.
## The change — `site/style.css`
1. **Reverted** round-3 FR rule `html[lang="fr"] .opt{font-size:13px;padding:12px 4px}`
(+ its comment). FR `.opt` rows are back to the base 15px / 14px 10px, identical
metrics to EN. Fix `f4b5698` (FR end-anchor in app.js) untouched.
2. **New `@media(max-width:700px)` block appended at end of file** (all of it inside
the media query; desktop untouched):
```css
#scr-quiz.scr{display:flex;flex-direction:column;height:calc(100vh - 69px);height:calc(100dvh - 69px);overflow:hidden;padding:0}
#scr-quiz.scr[hidden]{display:none}
.quiz-wrap{display:flex;flex-direction:column;flex:1;min-height:0}
.quiz-main{display:flex;flex-direction:column;flex:1;min-height:0}
#step-body{flex:1;overflow-y:auto;min-height:0;padding:10px 12px 16px}
#btn-next{position:static;margin:10px 12px 12px}
#panel{position:static;margin:0 8px calc(8px + env(safe-area-inset-bottom,0px))}
main:has(#scr-quiz:not([hidden])){padding-bottom:0}
```
The six spec rules are verbatim. Three deliberate additions, all disclosed in
"Deviations" below: placement at EOF, the `[hidden]` guard, and the scoped
`main:has(...)` padding rule.
## Geometry — province step, scroll 0 (CSS px)
`covLast%` = % of the LAST option row covered by the CTA (visible-true, clipped to
the scroll container); `docOver` = document scroll overflow px.
| device/lang | BEFORE covLast% | BEFORE docOver | AFTER covLast% | AFTER docOver | next t–b | panel t–b |
|---|---|---|---|---|---|---|
| iphone14 390 en | 58.4 | 119 | **0.0** | **0** | 536.3–586.3 | 618.3–836 |
| iphone14 390 fr | 100 | 100 | **0.0** | **0** | 519.3–569.3 | 601.3–836 |
| pixel 360 en | 81.8 | 163 | **0.0** | **0** | 492.3–542.3 | 574.3–792 |
| pixel 360 fr | 58.8 | 176 | **0.0** | **0** | 475.3–525.3 | 557.3–792 |
| se 320 en | 83.3 | 297 | **0.0** | **0** | 375.3–425.3 | 457.3–692 |
| se 320 fr | 62.2 | 306 | **0.0** | **0** | 375.3–425.3 | 457.3–692 |
Pixel proof, same region (old CTA band, 390 FR province, scroll 0): 95.2% CTA-blue
px before → 6.9% after (option grid visible).
## Acceptance (spec §acceptance) — all PASS, local + live
Battery: `audit/appshell_verify.py` — 254 checks per environment, at 320/360/390 ×
EN+FR, province (13 `.opt`) + vision (12 chips) at scroll 0/mid/max, plus full walks.
- **Local: 254/254 APPSHELL-VERIFY-OK** · **Live: 254/254 APPSHELL-VERIFY-OK**
- A1/A2 CTA fully visible (top ≥ header bottom, bottom ≤ panel top, inside viewport)
and zero CTA↔`.opt` visual overlap (rects clipped to `#step-body`): PASS at every
scroll position, all devices/langs, both steps.
- A2 CTA↔panel: `next.bottom ≤ panel.top` with a 32px air gap everywhere; PASS.
- A3 last `.opt` fully visible at max scroll (`lastOpt.bottom ≤ stepBody.rect.bottom−4`):
PASS on 12/12 device/lang/step combos, local and live.
- A4 no document scroll: `scrollHeight ≤ innerHeight + 1` — 12/12 walks cover **all
14 single / all 17 couple steps** at 320/360/390 EN+FR, max docOver **0**.
- A5 panel fully visible (panel.bottom ≤ viewport), 4 chips + insight readable,
sheet expand/scrim/scrim-tap-collapse flow verified at all 3 widths.
- A6 Back + "i / n" counter + progress bar visible on every check.
- A7 tests: engine **36/36**, i18n **48**, smoke **56** (run after CSS change).
- A8 desktop 1280×900 EN+FR province + vision/tall + results: baseline
(`audit/appshell-baseline/`) vs post-change (`audit/appshell-desktop-after/`):
**6/6 identical, 0 px diff.**
- A9 intro/results 390 full-page EN+FR before vs after: **0 px diff** (4/4).
- A10 deploy `DEPLOY-OK`; served `style.css` byte-identical to local; origin marker
`retire-planner v1` count = 1; off-LAN (Midhurst troublebox) `OFFLAN-OK`:
200 + marker + app-shell rules present + FR hack gone (`audit/appshell-geo-check.json`).
### Desktop "pixel-identical" footnote
One live re-run of `desktop-en-results` differed from baseline by 25 px at
channel-delta ≤ 1/255 (0.0022%, bbox on the vision-image rounded-corner AA edge).
Determinism probe: two consecutive screenshots of the *same live page, same CSS*
jittered by the same 25 px between themselves, while each of those matched the
baseline 0 px (`audit/appshell-desktop-live/desktop-en-results-rerun.png` = baseline
exactly). Run-to-run rasterizer jitter, not a CSS effect — every changed rule is
inside `@media(max-width:700px)` and cannot evaluate at 1280px.
## Deviations from spec (disclosed)
1. **Block placement — appended at EOF, not inside the existing 700px block.**
The base rule `#step-body{…min-height:320px;padding:18px}` sits *later in the
file* than the existing `@media(max-width:700px)` block, and media queries add no
specificity — placed there, `min-height:320px` beat `min-height:0`, the shell
overflowed, and the CTA overlapped the panel at 320px. Spec process step 1 says
"append **one new media block**" and note 3 requires "later in file wins", so the
block goes after all base rules (just before `@media print`). The six rules are
verbatim.
2. **Added `#scr-quiz.scr[hidden]{display:none}`.** `display:flex` at id+class
specificity beats the UA `[hidden]{display:none}`, so the hidden quiz shell was
rendering on intro/results pages (+775px of dead shell, caught by full-page pixel
diff). This one-line guard restores spec intent ("intro/results must not change");
intro/results are now pixel-identical.
3. **Added `main:has(#scr-quiz:not([hidden])){padding-bottom:0}`.** `main`'s 12px
bottom padding sits below the exactly-viewport-tall quiz section and added 12px of
document scroll on every quiz step (acceptance A4). Scoped with `:has()` to the
quiz screen (spec note 2 explicitly sanctions this pattern); intro/results
unaffected (proven by A9, 0 px diff, 4/4 full-page).
4. **No `app.js` changes** — spec allows ("if any JS change is needed, stop and
report; prefer CSS-only"). The old `--sheet-h`/`syncSheetH` JS keeps running; the
sticky `#btn-next` rule at ≤860px and fixed `.panel` rule are overridden by the
later static rules (spec note 5: layering leftovers harmless).
## Evidence files
- `audit/appshell-baseline/` — 6 desktop PNGs (pre-change, == deployed round-3, byte-check `live == HEAD` done pre-deploy).
- `audit/appshell-before/` — mobile PNGs + `geo-before.json` (defect: FR@390 last row 100% covered, docOver 100–306px).
- `audit/appshell-after/` — post-change mobile PNGs (320/360/390 × EN+FR, province/vision/maxscroll/sheet-open/intro/results), `geo-after.json`, `geo-live.json`, `verify-after.json`, `verify-live.json` (254/254 both).
- `audit/appshell-desktop-after/`, `audit/appshell-desktop-live/` (+`-rerun.png` determinism shot) — desktop PNGs, 0 px vs baseline.
- `audit/appshell-geo-check.json` — off-LAN check (200 + markers, `OFFLAN-OK`).
- Scripts: `audit/appshell_capture.py`, `audit/appshell_verify.py`, `audit/appshell_pixdiff.py`, `audit/appshell_offlan.py`.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

+13
View File
@@ -0,0 +1,13 @@
{
"http_code": "200",
"has_marker": true,
"css_appshell_block": true,
"css_stepbody_flex": true,
"css_btn_next_static": true,
"css_panel_static": true,
"css_fr_hack_gone": true,
"css_size": 12753,
"checked_at": "2026-10-09T14:48:57Z",
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
"all_ok": true
}
+139
View File
@@ -0,0 +1,139 @@
"""App-shell round-4 capture: desktop 1280x900 EN+FR (province + tall vision step + results)
for the pixel-diff baseline, and mobile 320/360/390 EN+FR quiz shots + geometry
(btn-next / panel / .opt rects, coverage, doc scroll) for the before/after record.
Usage: python3 audit/appshell_capture.py BASE OUTDESK OUTMOB TAG
e.g. python3 audit/appshell_capture.py http://127.0.0.1:8899/ audit/appshell-baseline audit/appshell-before before
"""
import json, os, sys
from playwright.sync_api import sync_playwright
BASE = sys.argv[1].rstrip('/') + '/'
OUTD = sys.argv[2]
OUTM = sys.argv[3]
TAG = sys.argv[4]
os.makedirs(OUTD, exist_ok=True)
os.makedirs(OUTM, exist_ok=True)
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
LS = 'retire-planner-v1'
SINGLE = {
"province": "ON", "age": 45, "household": "single",
"partner": {"age": 45, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0},
"income": 85000, "rrsp": 250000, "tfsa": 60000,
"nonreg": 40000, "monthlyContribution": 800, "employerMatchMonthly": 0,
"retireAge": 65, "spendingAnnual": 60000,
"cppStartAge": 65, "oasStartAge": 65,
"pensionAnnual": 12000, "rentalAnnual": 0, "otherAnnual": 0,
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
"interests": ["dock", "travel"]
}
COUPLE = dict(SINGLE, household="couple",
partner={"age": 43, "income": 62000, "rrsp": 120000, "tfsa": 30000, "nonreg": 15000, "monthlyContribution": 300})
DEVICES = [
('iphone14', 390, 844, 3.0, 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'),
('pixel', 360, 800, 2.75, 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36'),
('se', 320, 700, 2.0, 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1'),
]
# steps to capture on mobile: province (grid, 13 opts) + vision (interests chips, last step of 14)
MSTEPS = [('province', 0), ('vision', 13)]
GEO_JS = """() => {
const r = e => { const b = e.getBoundingClientRect(); return {l:+b.left.toFixed(1), t:+b.top.toFixed(1), r:+b.right.toFixed(1), b:+b.bottom.toFixed(1), w:+b.width.toFixed(1), h:+b.height.toFixed(1)}; };
const g = s => document.querySelector(s);
const inter = (a, b) => Math.max(0, Math.min(a.r, b.r) - Math.max(a.l, b.l)) * Math.max(0, Math.min(a.b, b.b) - Math.max(a.t, b.t));
const next = g('#btn-next'), panel = g('#panel'), hdr = g('.hdr'), sb = g('#step-body');
const nr = next ? r(next) : null, pr = panel ? r(panel) : null, hr = hdr ? r(hdr) : null;
const opts = [...document.querySelectorAll('#step-body .opt')].map(e => ({txt: e.textContent.trim().slice(0, 22), rect: r(e)}));
const coverage = nr ? opts.filter(o => inter(nr, o.rect) > 0).map(o => ({
txt: o.txt, optAreaPct: +(inter(nr, o.rect) / (o.rect.w * o.rect.h) * 100).toFixed(1), opt: o.rect})) : [];
return {
vw: window.innerWidth, vh: window.innerHeight,
docSH: document.documentElement.scrollHeight, docCH: document.documentElement.clientHeight,
bodySH: document.body.scrollHeight, winScrollY: window.scrollY,
hdr: hr, next: nr, panel: pr,
stepBody: sb ? {...r(sb), scrollTop: +sb.scrollTop.toFixed(1), scrollH: sb.scrollHeight, clientH: sb.clientHeight} : null,
nOpts: opts.length, lastOpt: opts.length ? opts[opts.length - 1].rect : null,
coverage,
lang: document.documentElement.lang,
panelPos: panel ? getComputedStyle(panel).position : null,
nextPos: next ? getComputedStyle(next).position : null,
scrH: (() => { const e = g('#scr-quiz'); return e ? getComputedStyle(e).height : null; })(),
};
}"""
def set_state(page, screen, step, lang, a=None):
page.evaluate("""([ls, a, screen, step, lang]) => {
localStorage.setItem(ls, JSON.stringify({a: a, step: step, screen: screen, lang: lang}));
}""", [LS, a or SINGLE, screen, step, lang])
def shot(page, path, full=False):
page.screenshot(path=path, full_page=full)
print('saved', path)
geo = {}
with sync_playwright() as pw:
browser = pw.chromium.launch(executable_path=CHROME, headless=True,
args=['--no-sandbox', '--disable-dev-shm-usage'])
# ---------- desktop 1280x900 (pixel-diff set) ----------
ctx = browser.new_context(viewport={'width': 1280, 'height': 900}, device_scale_factor=1)
page = ctx.new_page()
page.goto(BASE, wait_until='networkidle')
for lang in ('en', 'fr'):
for tag, screen, step in (('quiz-province', 'quiz', 0), ('quiz-vision', 'quiz', 13), ('results', 'results', 13)):
set_state(page, screen, step, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(1600) # charts + vision images settle
shot(page, os.path.join(OUTD, f'desktop-{lang}-{tag}.png'))
ctx.close()
# ---------- mobile 390/360/320 ----------
for dev, w, h, dsf, ua in DEVICES:
ctx = browser.new_context(viewport={'width': w, 'height': h}, device_scale_factor=dsf,
is_mobile=True, has_touch=True, user_agent=ua)
page = ctx.new_page()
page.goto(BASE, wait_until='networkidle')
for lang in ('en', 'fr'):
for stag, step in MSTEPS:
set_state(page, 'quiz', step, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(700)
shot(page, os.path.join(OUTM, f'{dev}-{lang}-{stag}.png'))
geo[f'{dev}:{lang}:{stag}:s0'] = page.evaluate(GEO_JS)
if dev == 'iphone14':
# province step at scroll 0 (defect shot for FR is above); sheet expand test shots
set_state(page, 'quiz', 0, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(500)
try:
page.click('.panel-h', timeout=3000)
page.wait_for_timeout(450)
shot(page, os.path.join(OUTM, f'{dev}-{lang}-sheet-open.png'))
geo[f'{dev}:{lang}:sheet-open'] = page.evaluate(GEO_JS)
page.click('#scrim', timeout=3000)
page.wait_for_timeout(350)
geo[f'{dev}:{lang}:sheet-collapsed'] = page.evaluate(
'() => ({open: document.querySelector("#panel").classList.contains("open"), scrim: getComputedStyle(document.querySelector("#scrim")).display})')
except Exception as e:
print('sheet test failed:', lang, e)
# intro + results at 390 full page (non-regression of non-quiz screens)
set_state(page, 'intro', 0, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(700)
shot(page, os.path.join(OUTM, f'{dev}-{lang}-intro.png'), full=True)
geo[f'{dev}:{lang}:intro'] = page.evaluate('() => ({docSH: document.documentElement.scrollHeight, vh: innerHeight})')
set_state(page, 'results', 13, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(1600)
shot(page, os.path.join(OUTM, f'{dev}-{lang}-results.png'), full=True)
geo[f'{dev}:{lang}:results'] = page.evaluate('() => ({docSH: document.documentElement.scrollHeight, vh: innerHeight})')
ctx.close()
browser.close()
with open(os.path.join(OUTM, f'geo-{TAG}.json'), 'w') as f:
json.dump(geo, f, indent=1, ensure_ascii=False)
print('captured', len(geo), 'measurement points ->', os.path.join(OUTM, f'geo-{TAG}.json'))
print('CAPTURE-OK')
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Off-LAN reachability check for https://retire.krisforbes.ca/ via the Midhurst troublebox
(round-4 app-shell variant): 200 + marker + served style.css carries the app-shell block and
no longer carries the reverted FR hack. Writes audit/appshell-geo-check.json, prints OFFLAN-OK."""
import json, sys, time
import paramiko
CANDS = ["b1tvvt07", "ubnt", "admin", "password", "b1tvvt07!", "1234", "root", "glinet"]
URL = "https://retire.krisforbes.ca/"
def main():
conn = None
for pw in CANDS:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
c.connect("100.96.20.61", port=2222, username="trouble", password=pw,
timeout=25, banner_timeout=25, auth_timeout=25,
allow_agent=False, look_for_keys=False)
conn = c
break
except Exception:
c.close()
if conn is None:
print("OFFLAN-FAIL: no auth to troublebox")
sys.exit(1)
def run(cmd):
_, out, _ = conn.exec_command(cmd, timeout=60)
return out.read().decode("utf-8", "replace").strip()
code = run(f"curl -s -o /dev/null -w '%{{http_code}}' {URL} --max-time 30")
html = run(f"curl -s {URL} --max-time 30 | head -c 200000")
css = run(f"curl -s {URL}style.css --max-time 30 | head -c 200000")
conn.close()
checks = {
"http_code": code,
"has_marker": "retire-planner v1" in html,
"css_appshell_block": "app-shell the quiz screen" in css,
"css_stepbody_flex": "#step-body{flex:1;overflow-y:auto;min-height:0" in css,
"css_btn_next_static": "#btn-next{position:static" in css,
"css_panel_static": "#panel{position:static" in css,
"css_fr_hack_gone": 'html[lang="fr"] .opt' not in css,
"css_size": len(css),
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
}
checks["all_ok"] = all(v for k, v in checks.items() if k not in ("css_size", "checked_at", "vantage"))
with open("audit/appshell-geo-check.json", "w") as f:
json.dump(checks, f, indent=2, ensure_ascii=False)
print(json.dumps(checks, indent=2, ensure_ascii=False))
print("OFFLAN-OK" if checks["all_ok"] else "OFFLAN-FAIL")
sys.exit(0 if checks["all_ok"] else 1)
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
"""Pixel-diff two screenshot dirs (matching filenames). Usage:
python3 audit/appshell_pixdiff.py DIR_A DIR_B [label_a label_b]
Prints per-file diff pixel counts + max channel delta; PIXDIFF-ZERO if every shared
file matches bit-for-bit. Also flags size mismatches."""
import os, sys
from PIL import Image, ImageChops
A, B = sys.argv[1], sys.argv[2]
la, lb = (sys.argv[3:5] or ['A', 'B'])
fa = sorted(f for f in os.listdir(A) if f.endswith('.png'))
fb = sorted(f for f in os.listdir(B) if f.endswith('.png'))
missing = set(fa) - set(fb)
if missing:
print('FILES MISSING IN', lb, ':', sorted(missing))
zero, n = True, 0
for f in fa:
if f not in fb:
continue
n += 1
ia, ib = Image.open(os.path.join(A, f)), Image.open(os.path.join(B, f))
if ia.size != ib.size:
print(f'{f}: SIZE DIFF {ia.size} vs {ib.size}')
zero = False
continue
d = ImageChops.difference(ia.convert('RGB'), ib.convert('RGB'))
bbox = d.getbbox()
if bbox is None:
print(f'{f}: 0 px diff (identical)')
else:
hist = d.convert('L').histogram()
ndiff = sum(hist[1:])
mx = max(i for i, c in enumerate(hist) if c)
pct = ndiff / (ia.size[0] * ia.size[1]) * 100
print(f'{f}: {ndiff} px diff ({pct:.4f}%), bbox {bbox}, maxdelta {mx}')
zero = False
if n == 0:
print('no shared files!')
zero = False
print('PIXTDIFF-ZERO' if zero else 'PIXTDIFF-NONZERO')
sys.exit(0 if zero else 1)
+176
View File
@@ -0,0 +1,176 @@
"""Round-4 app-shell acceptance battery (QUIZ-APPSHELL-FIX.md acceptance 1-6).
At 320/360/390, EN+FR, province step (0) + interests/vision step (last, tall):
A1 #btn-next fully visible (top >= header bottom, bottom <= panel top) at #step-body scroll 0/mid/max
A2 #btn-next never overlaps #panel (next.bottom <= panel.top + 1) nor any .opt
A3 at max scroll the last .opt is fully visible in the scroll area (lastOpt.bottom <= stepBody.bottom - 4)
A4 no document scroll on ANY quiz step — walks all 14 single + 17 couple steps, both languages
A5 panel fully visible, 4 chips; sheet expand → scrim on + insight; scrim tap collapses
A6 Back + "i / n" counter + progress bar visible at top
Usage: python3 audit/appshell_verify.py BASE OUTDIR TAG
Prints APPSHELL-VERIFY-OK / -FAIL; writes OUTDIR/verify-TAG.json (+ max-scroll shots).
"""
import json, os, re, sys
from playwright.sync_api import sync_playwright
BASE = sys.argv[1].rstrip('/') + '/'
OUT = sys.argv[2]
TAG = sys.argv[3]
os.makedirs(OUT, exist_ok=True)
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
LS = 'retire-planner-v1'
SINGLE = {
"province": "ON", "age": 45, "household": "single",
"partner": {"age": 45, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0},
"income": 85000, "rrsp": 250000, "tfsa": 60000,
"nonreg": 40000, "monthlyContribution": 800, "employerMatchMonthly": 0,
"retireAge": 65, "spendingAnnual": 60000,
"cppStartAge": 65, "oasStartAge": 65,
"pensionAnnual": 12000, "rentalAnnual": 0, "otherAnnual": 0,
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
"interests": ["dock", "travel"]
}
COUPLE = dict(SINGLE, household="couple",
partner={"age": 43, "income": 62000, "rrsp": 120000, "tfsa": 30000, "nonreg": 15000, "monthlyContribution": 300})
DEVICES = [('iphone14', 390, 844, 3.0), ('pixel', 360, 800, 2.75), ('se', 320, 700, 2.0)]
UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
M = """(pos) => {
const r = e => { const b = e.getBoundingClientRect(); return {l:b.left, t:b.top, r:b.right, b:b.bottom, w:b.width, h:b.height}; };
const g = s => document.querySelector(s);
const sb = g('#step-body');
if (pos !== null) sb.scrollTo({top: pos, behavior: 'instant'});
const next = r(g('#btn-next')), panel = r(g('#panel')), hdr = r(g('.hdr')), sbr = r(sb);
const opts = [...document.querySelectorAll('#step-body .opt')].map(e => r(e));
const R = v => Math.round(v * 10) / 10;
const ov = (a, b) => Math.max(0, Math.min(a.r, b.r) - Math.max(a.l, b.l)) * Math.max(0, Math.min(a.b, b.b) - Math.max(a.t, b.t));
return {
vh: innerHeight, vw: innerWidth,
docSH: document.documentElement.scrollHeight,
hdrB: R(hdr.b), next: {t: R(next.t), b: R(next.b), l: R(next.l), r: R(next.r)},
panel: {t: R(panel.t), b: R(panel.b)},
sb: {t: R(sbr.t), b: R(sbr.b), scrollTop: R(sb.scrollTop), scrollH: sb.scrollHeight, clientH: sb.clientHeight},
nOpts: opts.length,
lastOpt: opts.length ? {t: R(opts[opts.length-1].t), b: R(opts[opts.length-1].b)} : null,
// visual overlap only: an .opt is clipped by #step-body's overflow box
optOverlaps: opts.map((o, i) => {
const c = {l: Math.max(o.l, sbr.l), r: Math.min(o.r, sbr.r), t: Math.max(o.t, sbr.t), b: Math.min(o.b, sbr.b)};
return {i, a: R(ov(next, c))};
}).filter(x => x.a > 1),
nextVsPanel: R(next.b - panel.t), // <= 1 required
nextTopVsHdr: R(next.t - hdr.b), // >= 0 required
nextVsViewport: R(next.b - innerHeight), // <= 0.5 required
};
}"""
WALK = """(n) => {
const R = [];
const count = () => document.querySelector('#step-count').textContent.trim();
const doc = () => document.documentElement.scrollHeight - innerHeight;
R.push({step: 0, count: count(), docOver: doc()});
for (let i = 1; i < n; i++) {
document.getElementById('btn-next').click();
R.push({step: i, count: count(), docOver: doc()});
if (document.querySelector('#scr-results') && !document.querySelector('#scr-results').hidden) {
R[R.length-1].leakedToResults = true; break;
}
}
return R;
}"""
fails, total, data = 0, 0, {}
def chk(name, cond, extra=''):
global total, fails
total += 1
if not cond: fails += 1
print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else ''))
data.setdefault('checks', []).append({'name': name, 'ok': bool(cond), 'extra': str(extra)[:200]})
def set_state(page, a, step, lang):
page.evaluate("""([ls, a, step, lang]) => {
localStorage.setItem(ls, JSON.stringify({a: a, step: step, screen: 'quiz', lang: lang}));
}""", [LS, a, step, lang])
with sync_playwright() as pw:
browser = pw.chromium.launch(executable_path=CHROME, headless=True,
args=['--no-sandbox', '--disable-dev-shm-usage'])
for dev, w, h, dsf in DEVICES:
ctx = browser.new_context(viewport={'width': w, 'height': h}, device_scale_factor=dsf,
is_mobile=True, has_touch=True, user_agent=UA)
page = ctx.new_page()
page.goto(BASE, wait_until='networkidle')
for lang in ('en', 'fr'):
# ---- A1/A2/A3 detailed: province + vision, scroll 0/mid/max ----
for stag, step in (('province', 0), ('vision', 13)):
set_state(page, SINGLE, step, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(650)
base = page.evaluate(M, 0)
span = base['sb']['scrollH'] - base['sb']['clientH']
for pos in sorted(set([0, span // 2, span])):
m = page.evaluate(M, pos)
m['lastOptVisibleAtMax'] = (m['lastOpt']['b'] <= m['sb']['b'] - 4) and (m['lastOpt']['t'] >= m['sb']['t'] - 0.5)
data[f'{dev}:{lang}:{stag}:scroll{pos}'] = m
p = f'{dev}/{lang}/{stag}@{pos}'
chk(p + ' CTA below header', m['nextTopVsHdr'] >= -0.5, m['next']['t'])
chk(p + ' CTA above panel', m['nextVsPanel'] <= 1, f"next.b {m['next']['b']} panel.t {m['panel']['t']}")
chk(p + ' CTA in viewport', m['nextVsViewport'] <= 0.5, m['next']['b'])
chk(p + ' CTA clears every .opt', len(m['optOverlaps']) == 0, m['optOverlaps'])
chk(p + ' doc no scroll', m['docSH'] <= m['vh'] + 1, f"docSH {m['docSH']} vh {m['vh']}")
if pos == span:
chk(p + ' last .opt fully visible at max scroll', m['lastOptVisibleAtMax'],
f"lastOpt.b {m['lastOpt']['b']} sb.b {m['sb']['b']}")
page.screenshot(path=os.path.join(OUT, f'{dev}-{lang}-{stag}-maxscroll.png'))
# ---- A5 panel/chips/sheet + A6 header controls (province step) ----
set_state(page, SINGLE, 0, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(600)
chips = page.evaluate("() => [...document.querySelectorAll('#panel-chips .chip')].map(c => ({k: (c.querySelector('.k')||{}).textContent, v: (c.querySelector('.v')||{}).textContent}))")
chk(f'{dev}/{lang} panel has 4 filled chips', len(chips) == 4 and all(c.get('k') and c.get('v') for c in chips), chips)
pv = page.evaluate("""() => { const p = document.querySelector('#panel').getBoundingClientRect();
const b = document.getElementById('btn-back').getBoundingClientRect();
const c = document.getElementById('step-count'); const cr = c.getBoundingClientRect();
const pr = document.querySelector('.prog').getBoundingClientRect();
const hdrB = document.querySelector('.hdr').getBoundingClientRect().b || document.querySelector('.hdr').getBoundingClientRect().bottom;
return {pb: p.bottom, vh: innerHeight, pt: p.top, back: {t: b.top, b: b.bottom, w: b.width},
cnt: c.textContent.trim(), cntW: cr.width, cntT: cr.top, prog: {h: pr.height, t: pr.top}}; }""")
chk(f'{dev}/{lang} panel fully visible', pv['pb'] <= pv['vh'] + 1 and pv['pt'] >= 0, pv)
chk(f'{dev}/{lang} Back visible under header', pv['back']['w'] > 0 and pv['back']['t'] >= 0, pv['back'])
chk(f'{dev}/{lang} counter visible "i / n"', re.search(r'\d+\s*/\s*\d+', pv['cnt']) and pv['cntW'] > 0, pv['cnt'])
chk(f'{dev}/{lang} progress bar visible', pv['prog']['h'] >= 4, pv['prog'])
page.click('.panel-h'); page.wait_for_timeout(400)
op = page.evaluate("""() => ({open: document.querySelector('#panel').classList.contains('open'),
scrim: getComputedStyle(document.querySelector('#scrim')).display,
ins: (() => { const i = document.querySelector('#panel-insight'); const s = getComputedStyle(i);
return {disp: s.display, txt: i.textContent.trim().length, b: i.getBoundingClientRect().b ?? i.getBoundingClientRect().bottom}; })(),
pb: document.querySelector('#panel').getBoundingClientRect().bottom, vh: innerHeight,
nb: document.getElementById('btn-next').getBoundingClientRect().b})""")
chk(f'{dev}/{lang} sheet opens: scrim on + insight readable', op['open'] and op['scrim'] != 'none' and op['ins']['disp'] != 'none' and op['ins']['txt'] > 10, op)
chk(f'{dev}/{lang} sheet open: panel in viewport, CTA still above it', op['pb'] <= op['vh'] + 1, op)
page.screenshot(path=os.path.join(OUT, f'{dev}-{lang}-sheet-open.png'))
page.click('#scrim'); page.wait_for_timeout(350)
cl = page.evaluate("""() => ({open: document.querySelector('#panel').classList.contains('open'),
scrim: getComputedStyle(document.querySelector('#scrim')).display})""")
chk(f'{dev}/{lang} scrim tap collapses', (not cl['open']) and cl['scrim'] == 'none', cl)
# ---- A4 doc-scroll walk over ALL steps (single 14 + couple 17) ----
for mode, a, n in (('single', SINGLE, 14), ('couple', COUPLE, 17)):
set_state(page, a, 0, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(550)
walk = page.evaluate(WALK, n)
data[f'{dev}:{lang}:{mode}:walk'] = walk
chk(f'{dev}/{lang} {mode} walk visits {n} steps', len(walk) == n and not any(s.get('leakedToResults') for s in walk),
f"visited {len(walk)}, counts {walk[0]['count']}..{walk[-1]['count']}")
bad = [s for s in walk if s['docOver'] > 1]
chk(f'{dev}/{lang} {mode}: no doc scroll on all steps', len(bad) == 0,
bad[:4] if bad else f"max docOver {max(s['docOver'] for s in walk)}")
ctx.close()
browser.close()
with open(os.path.join(OUT, f'verify-{TAG}.json'), 'w') as f:
json.dump(data, f, indent=1, ensure_ascii=False, default=str)
print(f'checks: {total - fails}/{total}')
print('APPSHELL-VERIFY-OK' if fails == 0 else 'APPSHELL-VERIFY-FAIL')
sys.exit(0 if fails == 0 else 1)
+17 -4
View File
@@ -87,10 +87,6 @@ button,input{touch-action:manipulation}
}
@media(max-width:700px){
.hdr .pill{display:none} /* privacy pill is repeated on the intro screen and the footer */
/* FR round 3: longer French province names wrap and push the option grid under the pinned
nav at initial scroll — compact FR option buttons keep the grid above the nav zone at
390px. Scoped to html[lang=fr]: English rendering is untouched. */
html[lang="fr"] .opt{font-size:13px;padding:12px 4px}
}
.panel{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:14px}
.panel-h{font-weight:700;font-size:14px;margin-bottom:8px}
@@ -175,6 +171,23 @@ tr.dep td{color:var(--red)}
.tbl-wrap::before{content:"";float:right;width:26px;height:340px;background:linear-gradient(90deg,transparent,var(--card));pointer-events:none}
}
/* Round 4: app-shell the quiz screen — #step-body is the only scroll container;
#btn-next + #panel form the fixed bottom cluster. Never overlaps content.
Appended at end of file (after ALL base rules) so the equal-specificity id rules
(#step-body min-height/padding, #btn-next, #panel) lose the source-order tie here. */
@media(max-width:700px){
#scr-quiz.scr{display:flex;flex-direction:column;height:calc(100vh - 69px);height:calc(100dvh - 69px);overflow:hidden;padding:0}
#scr-quiz.scr[hidden]{display:none} /* id+class display beats the UA [hidden] rule — restore it */
.quiz-wrap{display:flex;flex-direction:column;flex:1;min-height:0}
.quiz-main{display:flex;flex-direction:column;flex:1;min-height:0}
#step-body{flex:1;overflow-y:auto;min-height:0;padding:10px 12px 16px}
#btn-next{position:static;margin:10px 12px 12px}
#panel{position:static;margin:0 8px calc(8px + env(safe-area-inset-bottom,0px))}
/* the quiz section is exactly viewport-tall; main's 12px bottom padding would add
document scroll. Drop it ONLY while the quiz screen is shown — intro/results untouched. */
main:has(#scr-quiz:not([hidden])){padding-bottom:0}
}
@media print{
.hdr,.whatif,.btn-row,#scr-quiz{display:none!important}
body{background:#fff}