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
No files matched your search
@@ -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`**
|
||||||
@@ -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`.
|
||||||
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 409 KiB |
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -87,10 +87,6 @@ button,input{touch-action:manipulation}
|
|||||||
}
|
}
|
||||||
@media(max-width:700px){
|
@media(max-width:700px){
|
||||||
.hdr .pill{display:none} /* privacy pill is repeated on the intro screen and the footer */
|
.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{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}
|
.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}
|
.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{
|
@media print{
|
||||||
.hdr,.whatif,.btn-row,#scr-quiz{display:none!important}
|
.hdr,.whatif,.btn-row,#scr-quiz{display:none!important}
|
||||||
body{background:#fff}
|
body{background:#fff}
|
||||||
|
|||||||