Round 2 mobile pass: CTA pinned above bottom sheet, swipe hint in chart header, anchor scroll-margin
- Issue 1: --sheet-h CSS var (live sheet height via syncSheetH: load/toggle/resize/
panel-content), .scr clearance and sticky #btn-next/.step-nav above the sheet;
income short step pixel-identical to round 1 (CTA top 485).
- Issue 2: chart() helper renders .chart-h (h3+p + .swipe span) into #g1-#g4;
.chartbox::before overlay removed; hint is flex sibling in header, sticky left.
- Issue 3: [id]{scroll-margin-top:70px} so #whatif anchor lands below sticky hdr.
- G2 tick clip and Four-futures scroll documented as non-issues in report.
- verify2.py: 90/90 PASS live; before/after evidence in audit/mobile-{before,after}2/.
Spec: MOBILE-FIX-2.md. Report: MOBILE-FIX-2-REPORT.md.
No files matched your search
@@ -0,0 +1,143 @@
|
|||||||
|
# MOBILE-FIX-2 — Report
|
||||||
|
|
||||||
|
Round 2 of the mobile pass on https://retire.krisforbes.ca, per `MOBILE-FIX-2.md`.
|
||||||
|
All three issues fixed, deployed, and verified on the live site at 320/360/390px (iPhone SE,
|
||||||
|
Pixel, iPhone 14) + 1280px desktop. Evidence: `audit/mobile-before2/` (pre-fix) and
|
||||||
|
`audit/mobile-after2/` (post-fix) — geometry logs, verification transcripts, viewport and
|
||||||
|
full-page screenshots (`full-*.png`).
|
||||||
|
|
||||||
|
**Result: 90/90 live verification assertions PASS (`VERIFY2-OK`), tests 36/36 + SMOKE-OK,
|
||||||
|
`DEPLOY-OK`, marker count 1, off-LAN (Midhurst) HTTP 200 + marker 1 (`OFFLAN-OK`).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 1 — Primary CTA hidden behind the bottom sheet (FIXED)
|
||||||
|
|
||||||
|
**Root cause.** `.scr` clearance was a fixed `185px` while the sheet is 218px tall, and the
|
||||||
|
Back/Next rows (`position:static`) scrolled behind the sheet. The live sheet height also changes
|
||||||
|
when the panel is expanded, so no constant works.
|
||||||
|
|
||||||
|
**Fix (spec as written, + one necessary addition).**
|
||||||
|
- `:root{--sheet-h:218px}`; `syncSheetH()` in `app.js` writes
|
||||||
|
`document.documentElement.style.setProperty('--sheet-h', panel.offsetHeight+'px')` — shim-guarded
|
||||||
|
(`var h = panel && panel.offsetHeight; if (h)`), called on init (final statement), on `.panel-h`
|
||||||
|
toggle, on scrim toggle, on `resize` (`if (window.addEventListener)` guarded), and at the end of
|
||||||
|
`updatePanel()` (the panel's content lands ~120ms after load; without this the first paint
|
||||||
|
measured the empty 60px header).
|
||||||
|
- Mobile block: `.scr{padding-bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom))}`,
|
||||||
|
`.step-nav{position:sticky;bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom));
|
||||||
|
background:var(--bg);z-index:20;padding:6px 0}` (spec-verbatim, + see deviation D5), and
|
||||||
|
**`#btn-next{position:sticky;bottom:calc(var(--sheet-h) + 12px + env(safe-area-inset-bottom));z-index:21}`**
|
||||||
|
— the CTA itself carries the pin (see deviation D4: in this codebase `.step-nav` is the Back row
|
||||||
|
*above* `#step-body`, so a bottom-sticky `.step-nav` alone cannot lift the CTA that lives after
|
||||||
|
the step content).
|
||||||
|
|
||||||
|
**Measured (live, `verify2.py`; sheet 218px, `--sheet-h` live at `218px`/`274px` expanded):**
|
||||||
|
|
||||||
|
| Check | Before | After |
|
||||||
|
|---|---|---|
|
||||||
|
| step 0, @0: `#btn-next` bottom vs panel top (390) | 717 vs 618 — behind, untappable | **614 ≤ 616** ✓ tappable ✓ |
|
||||||
|
| step 0, @0 (360) | 717 vs 574 — behind | **570 ≤ 572** ✓ tappable ✓ |
|
||||||
|
| step 0, @0 (320) | 751 vs 474 — behind | **470 ≤ 472** ✓ tappable ✓ |
|
||||||
|
| step 13 (interests), @0: next bottom / tappable | 390: 606 ✓; **360: 665, ✗; 320: 693, ✗** | 390: 606 ✓; **360: 570 ✓; 320: 470 ✓** (all tappable) |
|
||||||
|
| step 0, @mid: nav bottom ≤ panelTop−2 / next pinned | 141 (static, mid-scroll unreachable) | nav 88/66/−1, next **614/570/470** ✓ |
|
||||||
|
| step 0, @max: last `.opt` (Nunavut) bottom ≤ panelTop−2 | 630/630/664 (mid-scroll unreachable) | **513/469/369** ✓, next tappable ✓ |
|
||||||
|
| `.scr` padding-bottom | 185px | **232px** = 218+14 |
|
||||||
|
| short step (income): nav top (doc) / CTA top | 97 / 485 | **97 / 485** (390 & 360 — not stuck, pixel-identical to round 1) |
|
||||||
|
| income at 320 (step taller than viewport) | CTA 485–535, behind sheet top 474 | **CTA pinned, bottom 470 ≤ 472** ✓ (the fix working) |
|
||||||
|
| desktop 1280: `.step-nav` position / `.scr` padding | static / 80px | **static / 80px** ✓ (navTop 107 unchanged) |
|
||||||
|
|
||||||
|
`sheetVar` was `""` before; now `218px` collapsed, `274px` while the panel is expanded —
|
||||||
|
clearance tracks the sheet live.
|
||||||
|
|
||||||
|
## Issue 2 — "swipe →" overlay painted over chart labels (FIXED)
|
||||||
|
|
||||||
|
**Root cause.** `.chartbox::before{content:"swipe →";position:absolute;top:…;left:…}` was an
|
||||||
|
overlay on top of the SVG — it sat over the tick labels.
|
||||||
|
|
||||||
|
**Fix (spec as written).** Removed the `::before` rule entirely; the chart cards' h3+p blocks moved
|
||||||
|
from `index.html` into `app.js` so every render writes the card via one `chart(sel, inner, legend)`
|
||||||
|
helper (title + caption + `<span class="swipe">swipe →</span>` in `.chart-h`, SVG, legend — all as
|
||||||
|
one innerHTML string, shim-safe). Base: `.chart-h{display:flex;align-items:flex-start;
|
||||||
|
justify-content:space-between;gap:10px}`, `.swipe{display:none}`; ≤860px: `.swipe` shown with the
|
||||||
|
spec chip style (`11.5px`, `var(--muted)` on `var(--chip)`, radius 99, `padding:3px 9px`,
|
||||||
|
nowrap, `flex:none`) + `.chart-h{position:sticky;left:0}` so the hint stays pinned while the SVG
|
||||||
|
scrolls sideways.
|
||||||
|
|
||||||
|
**Measured.** At 320/360/390, on all 4 charts: `.swipe` present, inside `.chart-h`,
|
||||||
|
`offsetParent ≠ null` (visible), inside card bounds, and **intersects 0 of 11–13 `<text>` rects**.
|
||||||
|
Desktop 1280: present, `display:none` (hidden) ✓.
|
||||||
|
|
||||||
|
## Issue 3 — Anchor jumps land under the sticky header (FIXED)
|
||||||
|
|
||||||
|
**Fix (spec as written).** Global `[id]{scroll-margin-top:70px}` (right after `html{scroll-behavior}`).
|
||||||
|
|
||||||
|
**Measured (live, 390 results → `#whatif`, 2000 ms):** `scrollY 2662` (>0 ✓), target top **70**
|
||||||
|
vs header bottom 55 → clears by 15px ✓. 360: `2771 / 70 / 55` ✓. 320: `2896 / 70 / 55` ✓.
|
||||||
|
(Before: 390 top 56 — passed by 1px; 360/320 top **0.0** — covered, FAIL.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documented NON-issues (no changes made, per spec)
|
||||||
|
|
||||||
|
1. **G2 "$18k" y-tick looked clipped.** Artifact of the round-1 screenshot: the chart card is
|
||||||
|
taller than it is wide and the screenshot cropped at the card edge. Measured live before *and*
|
||||||
|
after: `svgFitsVertically: true`, `svgBottomMinusBoxBottom: −35` (SVG bottom sits 35px **inside**
|
||||||
|
the card), axis tick set renders clean (`["80","85","90","age"]` bottom row, 11 text nodes).
|
||||||
|
Nothing is clipped.
|
||||||
|
2. **"Four futures" bar cut at the card edge.** Intentional: `.chartbox{overflow-x:auto}` +
|
||||||
|
`.chartbox svg{min-width:560px}` (round-1 Fix 2) give charts horizontal scroll on phones —
|
||||||
|
desktop scale is preserved and the new in-header "swipe →" hint (Issue 2) now explains it.
|
||||||
|
|
||||||
|
## Tests / deploy / off-LAN
|
||||||
|
|
||||||
|
- `node tests/engine.test.js` → **36 pass, 0 fail**; `node tests/ui.smoke.js` → **SMOKE-OK**
|
||||||
|
(all round-2 JS is shim-safe: `chart()` writes one innerHTML string directly to `#g1–#g4`;
|
||||||
|
`syncSheetH` null/guard-checked; `window.addEventListener` guarded; `#lifeexp-lbl` assignment
|
||||||
|
guarded since the label is now created by the g4 header).
|
||||||
|
- `bash scripts/deploy.sh` → **DEPLOY-OK**; `curl … | grep -c 'retire-planner v1'` → **1**.
|
||||||
|
- Off-LAN via Midhurst troublebox (paramiko, password harvested by regex, never printed):
|
||||||
|
login OK (`trouble@100.96.20.61:2222`), `curl https://retire.krisforbes.ca/` → **200** (attempt 1),
|
||||||
|
marker count from box **1** → `OFFLAN-OK`.
|
||||||
|
|
||||||
|
## Deviations & notes (all forced by the codebase, all inside the hard constraints)
|
||||||
|
|
||||||
|
- **D1** `--muted:#4c5b74; --chip:#eef2f8` added to `:root` (the spec's `.swipe` style references
|
||||||
|
them; they existed only as literal colors in round 1 — same values, so nothing re-colored).
|
||||||
|
- **D2** `.chart-h>div{min-width:0}` wrapper: keeps the h3+p stack rendering pixel-equivalent to
|
||||||
|
the old block on desktop (flex `min-width:auto` would stretch the caption; desktop is unchanged
|
||||||
|
per the constraint).
|
||||||
|
- **D3** Chart h3/captions moved from `index.html` to `CHART_HDR` in `app.js` verbatim — required
|
||||||
|
by the spec's own design ("render the span … via app.js chart() helper"; every re-render must
|
||||||
|
keep the header). `#lifeexp-lbl` is now regenerated by the g4 header from `state.a.lifeExp`
|
||||||
|
(verified live: renders the answer value).
|
||||||
|
- **D4** `#btn-next` sticky pin added alongside the spec's `.step-nav` pin. The spec's premise
|
||||||
|
("the nav row is the last row on screen") doesn't match this codebase: `.step-nav` (Back + step
|
||||||
|
count) sits *above* `#step-body` and the CTA is the last element of the flow — `.step-nav`-only
|
||||||
|
sticky leaves the CTA behind the sheet (staging-measured: `nextBottom 729 > panelTop 618`,
|
||||||
|
untappable). The added rule is mobile-scoped, z-ordered under scrim(35)/sheet(40), and is what
|
||||||
|
makes the spec's Issue-1 assertions pass.
|
||||||
|
- **D5** `.quiz-main .step-nav{margin-bottom:-6px}` (mobile): the spec's `padding:6px 0` grows the
|
||||||
|
nav box by 12px and pushes short-step content down; netting the base 6px margin makes the
|
||||||
|
non-stuck short steps **pixel-identical to round 1** (income CTA top 485 = the spec's "~485",
|
||||||
|
nav box top 97 = true round-1 value measured from the git HEAD copy at all 3 widths).
|
||||||
|
Specificity raised (`.quiz-main .step-nav`) because the base `.step-nav` rule sits later in the
|
||||||
|
file (cascade order).
|
||||||
|
- **D6** Spec's "income nav top ~485": the round-1 **nav box** top in doc coords is 97 (measured on
|
||||||
|
the HEAD copy; 485 is the round-1 CTA-row top). Both are asserted unchanged after the fix.
|
||||||
|
- **Verification note:** `scrollTo` + rect reads must settle (`scroll-behavior:smooth`) —
|
||||||
|
`verify2.py` scrolls with `behavior:'instant'` + 350 ms settle before measuring; the pre-fix
|
||||||
|
transcript's `@mid/@max` rows (scrollY 0) are the artifact this fixed.
|
||||||
|
- Hard constraints honored: `site/engine/*.js` and constants untouched; `<!-- retire-planner v1 -->`
|
||||||
|
first line in `<head>`; privacy copy untouched; no frameworks/build/CDN; all new CSS in the
|
||||||
|
≤860px block (plus the two global rules the spec prescribes: `[id]{scroll-margin-top}` and
|
||||||
|
`.chart-h`/`.swipe` base, which are layout-neutral on desktop and measured unchanged
|
||||||
|
— desktop nav static, padding 80px, swipe hidden).
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
`site/style.css` (+27/−2) · `site/app.js` (+50/−8) · `site/index.html` (+1/−12).
|
||||||
|
Evidence/verifiers added: `audit/verify2.py`, `audit/measure2_run.py`, `audit/mobile_audit_run2.py`,
|
||||||
|
`audit/mobile-before2/` (6 shots + `geo-round2.txt` + pre-fix `verify-round2.txt`),
|
||||||
|
`audit/mobile-after2/` (6 viewport + 12 `full-*.png` shots, `geo-round2.txt`, `verify-round2.txt`
|
||||||
|
90 PASS / 0 FAIL, `audit-run.txt`).
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# MOBILE PASS ROUND 2 — fix spec for https://retire.krisforbes.ca
|
||||||
|
|
||||||
|
**Spec v1 — written 2026-10-09 by Hermes (GLM). Execution: DSH (local 27B), all steps, zero user interaction.**
|
||||||
|
Round 1 (commit 666b6c5) shipped 8 fixes. Hermes then re-audited the live site with vision-checked
|
||||||
|
screenshots + DOM geometry and found **3 remaining issues** (below). Fix exactly these; do not
|
||||||
|
re-litigate round-1 decisions.
|
||||||
|
|
||||||
|
## Evidence (measured by Hermes against the LIVE site, 2026-10-09)
|
||||||
|
|
||||||
|
Geometry script: `/home/ubuntu/.hermes/cache/scratch/measure2.py` (Playwright, mobile emulation,
|
||||||
|
390×844 / 360×800 / 320×700). Screenshots: `/home/ubuntu/.hermes/cache/scratch/mobile-audit2/`.
|
||||||
|
|
||||||
|
### Issue 1 — Primary CTA hidden behind the bottom sheet on tall steps (all 3 widths)
|
||||||
|
Step 0 (province grid, 13 options) at initial scroll (`scrollY=0`):
|
||||||
|
|
||||||
|
| Device | Next button (viewport y) | Sheet top | Next behind sheet? | Next tappable? | Nunavut bottom vs sheet top |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 390×844 | 667–717 | 618 | **yes, fully** | **false** | 630 > 618 (behind) |
|
||||||
|
| 360×800 | 667–717 | 574 | **yes, fully** | **false** | 630 > 574 (behind) |
|
||||||
|
| 320×700 | 701–751 | 474 | **yes, fully** | **false** | 664 > 474 (behind) |
|
||||||
|
|
||||||
|
Root cause, two parts:
|
||||||
|
- `.scr{padding-bottom:185px}` (mobile) is **smaller than the sheet obstruction** (sheet 218px tall +
|
||||||
|
8px bottom offset + safe-area = 226px+). At max scroll (70px on 390w) the Next bottom lands at
|
||||||
|
viewport y=647, still 29px **below** the sheet top (618). The padding must be ≥ sheet obstruction + gap.
|
||||||
|
- On tall steps the Back/Next row sits below the fold at load — the primary CTA of step 1 of 14 is
|
||||||
|
invisible until the user scrolls.
|
||||||
|
|
||||||
|
**Fix (all rules scoped to `@media(max-width:860px)`):**
|
||||||
|
1. Maintain a CSS var with the live sheet height: on load, on `resize`, and whenever the panel
|
||||||
|
toggles `.open`, run `document.documentElement.style.setProperty('--sheet-h', panel.offsetHeight + 'px')`
|
||||||
|
(guard for the smoke-test shim: `const h = panel && panel.offsetHeight; if (h) …`). Default
|
||||||
|
`--sheet-h:218px` on `:root` so the first paint is correct.
|
||||||
|
2. `.scr{padding-bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom))}` (replaces the 185px).
|
||||||
|
3. `.step-nav{position:sticky;bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom));background:var(--bg);z-index:20;padding:6px 0}`
|
||||||
|
— Back/Next pin above the sheet on tall steps; on short steps (e.g. income) the nav is naturally
|
||||||
|
above the stick point so nothing changes visually. Keep it below the scrim (z 35) and sheet (z 40).
|
||||||
|
Give the stuck bar a solid background so content scrolling under it doesn't show through.
|
||||||
|
|
||||||
|
**Verify (programmatic, all 3 widths, step 0 AND step 12/interests):**
|
||||||
|
- At `scrollY=0`: `#btn-next` bottom ≤ panel top − 2, and `document.elementFromPoint(nextCenter)`
|
||||||
|
is inside `#btn-next` (tappable).
|
||||||
|
- While scrolling (scrollY = maxScroll/2): nav bottom stays ≤ panel top − 2 (pinned).
|
||||||
|
- At max scroll: last `.opt` bottom ≤ panel top − 2 (every option reachable above the sheet).
|
||||||
|
- Short step (income, step 3): nav top unchanged vs round 1 (~485), i.e. no visual shift when not stuck.
|
||||||
|
- Desktop (1280px): `.step-nav` computed `position` is `static`, `.scr` padding-bottom back to 80px.
|
||||||
|
|
||||||
|
### Issue 2 — "swipe →" pill overlaps the chart's top y-axis tick
|
||||||
|
Vision-confirmed at 320px: the pill (`.chartbox::before` overlay, top-left of the card) sits on top of
|
||||||
|
the topmost y-tick ("$1.05M"). At 390px it grazes the tick area too.
|
||||||
|
|
||||||
|
**Fix:** remove the `.chartbox::before` overlay entirely. Instead render the hint in the chart card
|
||||||
|
header: in `app.js`'s `chart()` helper, append `<span class="swipe">swipe →</span>` inside the
|
||||||
|
`.chart-h` div (which already holds the `<h3>`+`<p>`). CSS: `.chart-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}`
|
||||||
|
and `.swipe{display:none}` by default; ≤860px: `.swipe{display:inline-block;font-size:11.5px;color:var(--muted);background:var(--chip);border-radius:99px;padding:3px 9px;white-space:nowrap;flex:none}`.
|
||||||
|
The hint then lives in the header row and can never overlap SVG content. (The `.chart-h` flex change
|
||||||
|
is safe on desktop — the span is `display:none` there.)
|
||||||
|
|
||||||
|
**Verify:** at 320/360/390, for each of #g0–#g3: the `.swipe` rect does not intersect any `<text>`
|
||||||
|
rect of that chart's SVG; `.swipe` is visible (offsetParent ≠ null); hint present on all 4 charts.
|
||||||
|
|
||||||
|
### Issue 3 — anchor targets can hide behind the sticky header
|
||||||
|
The results page has in-page anchors (`#whatif`, `#years` details). The header is `position:sticky`,
|
||||||
|
so a native anchor jump can land the target underneath it. (Hermes' measurement was inconclusive —
|
||||||
|
re-measure with a 2s wait; regardless, the fix is one line and correct on desktop too, where the
|
||||||
|
header is also sticky.)
|
||||||
|
|
||||||
|
**Fix:** `style.css` (global, not media-scoped): `[id]{scroll-margin-top:70px}`.
|
||||||
|
|
||||||
|
**Verify:** on results at 390px: `location.hash='#whatif'`, wait **2000ms** (the page has
|
||||||
|
`scroll-behavior:smooth`), assert `window.scrollY > 0` AND
|
||||||
|
`document.querySelector('#whatif').getBoundingClientRect().top >= document.querySelector('.hdr').getBoundingClientRect().bottom - 1`.
|
||||||
|
|
||||||
|
### Verified NON-issues (document in the report, do not "fix")
|
||||||
|
- G2's bottom y-tick "$18k" looked half-clipped in a viewport screenshot — measured: the SVG fits
|
||||||
|
vertically (`svgFitsVertically:true`, svg bottom 35px above the box bottom). It was a screenshot
|
||||||
|
crop artifact.
|
||||||
|
- "Four futures" bar chart's third bar is cut at the card's right edge — that is the intentional
|
||||||
|
horizontal scroll container working as designed (swipe hint present).
|
||||||
|
|
||||||
|
## Hard constraints (same as MOBILE-FIX.md)
|
||||||
|
- Do not touch `site/engine/*.js` or any constants. UI/CSS/JS-flow only.
|
||||||
|
- Keep `<!-- retire-planner v1 -->` as first line in `<head>`; keep privacy text untouched.
|
||||||
|
- No frameworks/build/CDN. Desktop (≥861px) visually unchanged (sticky nav + bigger padding are
|
||||||
|
≤860px-only; `scroll-margin-top` is a global improvement, not a regression).
|
||||||
|
- `node tests/engine.test.js` → 36/36; `node tests/ui.smoke.js` → SMOKE-OK. Shim-safe JS only
|
||||||
|
(guard `panel.offsetHeight` etc.).
|
||||||
|
|
||||||
|
## Verification + delivery steps
|
||||||
|
1. Tests green (both suites).
|
||||||
|
2. `mkdir -p audit/mobile-before2 && cp /home/ubuntu/.hermes/cache/scratch/mobile-audit2/*.png audit/mobile-before2/`
|
||||||
|
(round-2 before evidence; also copy `audit/mobile-before2/geo-round2.txt` — write the measure2
|
||||||
|
output there: run `python3 /home/ubuntu/.hermes/cache/scratch/measure2.py > audit/mobile-before2/geo-round2.txt 2>&1`).
|
||||||
|
3. Deploy: `bash scripts/deploy.sh` → `DEPLOY-OK`; origin grep marker → 1.
|
||||||
|
4. Re-run `python3 /home/ubuntu/.hermes/cache/scratch/measure2.py` (after state) and save output +
|
||||||
|
screenshots: extend measure2 usage — copy its output to `audit/mobile-after2/geo-round2.txt` and
|
||||||
|
its PNGs to `audit/mobile-after2/`. Also re-run `python3 /home/ubuntu/.hermes/cache/scratch/mobile_audit.py`
|
||||||
|
and copy those PNGs to `audit/mobile-after2/full-*.png` (prefix them so they don't collide).
|
||||||
|
5. All Issue-1/2/3 verification assertions above must pass; record the numbers.
|
||||||
|
6. Off-LAN (Midhurst troublebox paramiko, `trouble@100.96.20.61:2222`, password candidates in
|
||||||
|
`~/projects/*.py` lines containing `b1tvvt`): `https://retire.krisforbes.ca/` → 200.
|
||||||
|
7. Commit + push to `krisf/retire-planner` main: site changes + `MOBILE-FIX-2.md` +
|
||||||
|
`MOBILE-FIX-2-REPORT.md` (issue → fix → measured evidence, incl. the non-issues section) +
|
||||||
|
`audit/mobile-before2/` + `audit/mobile-after2/`.
|
||||||
|
8. Your FINAL output line must be exactly: `MOBILE_PASS_2_DONE`
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Round-2 mobile measurements: step-0 CTA vs sheet, g2 tick clip, anchor scroll, at 390/360/320."""
|
||||||
|
import json, os
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
import sys
|
||||||
|
OUT = sys.argv[1] if len(sys.argv) > 1 else '/home/ubuntu/.hermes/cache/scratch/mobile-audit2'
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
|
||||||
|
BASE = 'https://retire.krisforbes.ca/'
|
||||||
|
LS = 'retire-planner-v1'
|
||||||
|
ANSWERS = {
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
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'),
|
||||||
|
]
|
||||||
|
|
||||||
|
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, 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')
|
||||||
|
# --- step 0 (province grid, tallest step) at initial scroll ---
|
||||||
|
page.evaluate("""([ls, a]) => localStorage.setItem(ls, JSON.stringify({a: a, step: 0, screen: 'quiz'}))""", [LS, ANSWERS])
|
||||||
|
page.reload(wait_until='networkidle'); page.wait_for_timeout(600)
|
||||||
|
page.evaluate("window.scrollTo(0,0)")
|
||||||
|
g = page.evaluate("""() => {
|
||||||
|
const rc = e => { const b = e.getBoundingClientRect(); return {top: Math.round(b.top), bottom: Math.round(b.bottom), h: Math.round(b.height)}; };
|
||||||
|
const panel = document.querySelector('.panel');
|
||||||
|
const next = document.querySelector('#btn-next');
|
||||||
|
const opts = [...document.querySelectorAll('.opt')];
|
||||||
|
const last = opts[opts.length-1];
|
||||||
|
const nc = next.getBoundingClientRect();
|
||||||
|
const elAt = document.elementFromPoint(nc.left + nc.width/2, nc.top + nc.height/2);
|
||||||
|
return {
|
||||||
|
innerH: window.innerHeight,
|
||||||
|
docH: document.documentElement.scrollHeight,
|
||||||
|
maxScroll: document.documentElement.scrollHeight - window.innerHeight,
|
||||||
|
panelTop: Math.round(panel.getBoundingClientRect().top),
|
||||||
|
sheetH: Math.round(panel.getBoundingClientRect().height),
|
||||||
|
next: rc(next),
|
||||||
|
nextBehindSheet: Math.round(nc.bottom) > Math.round(panel.getBoundingClientRect().top),
|
||||||
|
nextTappable: !!(elAt && elAt.closest && elAt.closest('#btn-next')),
|
||||||
|
lastOptText: last.textContent.trim().slice(0, 20),
|
||||||
|
lastOpt: rc(last),
|
||||||
|
lastOptBehindSheet: Math.round(last.getBoundingClientRect().bottom) > Math.round(panel.getBoundingClientRect().top)
|
||||||
|
};
|
||||||
|
}""")
|
||||||
|
print(f'{dev} STEP0:', json.dumps(g))
|
||||||
|
page.screenshot(path=f'{OUT}/{dev}-step0-viewport.png')
|
||||||
|
# --- results: g2 bottom tick clip check + anchor scroll check ---
|
||||||
|
page.evaluate("""([ls, a]) => localStorage.setItem(ls, JSON.stringify({a: a, step: 0, screen: 'results'}))""", [LS, ANSWERS])
|
||||||
|
page.reload(wait_until='networkidle'); page.wait_for_timeout(1200)
|
||||||
|
g2 = page.evaluate("""() => {
|
||||||
|
const box = document.querySelector('#g2');
|
||||||
|
const svg = box ? box.querySelector('svg') : null;
|
||||||
|
if (!box || !svg) return {missing: true};
|
||||||
|
const bb = box.getBoundingClientRect(), sb = svg.getBoundingClientRect();
|
||||||
|
const ticks = [...svg.querySelectorAll('text')].map(t => t.textContent);
|
||||||
|
return {
|
||||||
|
boxH: Math.round(bb.height), svgH: Math.round(sb.height),
|
||||||
|
svgFitsVertically: sb.height <= bb.height + 1,
|
||||||
|
svgBottomMinusBoxBottom: Math.round(sb.bottom - bb.bottom),
|
||||||
|
bottomTick: ticks.slice(-4)
|
||||||
|
};
|
||||||
|
}""")
|
||||||
|
print(f'{dev} G2:', json.dumps(g2))
|
||||||
|
page.evaluate("document.querySelector('#g2').scrollIntoView({block:'center'})")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
page.screenshot(path=f'{OUT}/{dev}-g2-viewport.png')
|
||||||
|
# --- anchor scroll under sticky header ---
|
||||||
|
ha = page.evaluate("""() => {
|
||||||
|
location.hash = '#whatif';
|
||||||
|
return new Promise(res => setTimeout(() => {
|
||||||
|
const t = document.querySelector('#whatif');
|
||||||
|
const hdr = document.querySelector('.hdr');
|
||||||
|
if (!t) return res({missing: true});
|
||||||
|
const tr = t.getBoundingClientRect(), hr = hdr.getBoundingClientRect();
|
||||||
|
res({targetTop: Math.round(tr.top), hdrBottom: Math.round(hr.bottom), covered: tr.top < hr.bottom});
|
||||||
|
}, 500));
|
||||||
|
}""")
|
||||||
|
print(f'{dev} HASH-ANCHOR:', json.dumps(ha))
|
||||||
|
ctx.close()
|
||||||
|
browser.close()
|
||||||
|
print('MEASURE2-DONE')
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-intro.png
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-quiz-province.png
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-quiz-income.png
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-quiz-slider.png
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-quiz-sheet-open.png
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-results-top.png
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-results-charts.png
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-results-table.png
|
||||||
|
GEO iphone14 quiz: {"docH": 844, "innerH": 844, "panel": {"w": 374, "h": 218, "top": 618, "bottom": 836}, "panelH": {"w": 344, "h": 22, "top": 633, "bottom": 655}, "chips": {"w": 344, "h": 158, "top": 663, "bottom": 821}, "stepBody": {"w": 358, "h": 320, "top": 147, "bottom": 467}, "btnNext": {"w": 358, "h": 50, "top": 485, "bottom": 535}, "btnBack": {"w": 75, "h": 44, "top": 103, "bottom": 147}, "hdr": {"w": 390, "h": 55, "top": 0, "bottom": 55}, "g1": {"w": 0, "h": 0, "top": 0, "bottom": 0}, "tblWrap": {"w": 0, "h": 0, "top": 0, "bottom": 0}, "minTouch": 0}
|
||||||
|
GEO iphone14 results: {"g1": {"w": 358, "h": 409}, "g1svg": {"w": 560, "h": 236}, "firstSvgTextPx": 13, "tblScrollW": {"clientW": 326, "scrollW": 508}, "visionImg": {"w": 356, "h": 250}}
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/pixel-quiz-province.png
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/pixel-results-charts.png
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/se-quiz-income.png
|
||||||
|
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/se-results-charts.png
|
||||||
|
AUDIT-DONE
|
||||||
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
iphone14 STEP0: {"innerH": 844, "docH": 961, "maxScroll": 117, "panelTop": 618, "sheetH": 218, "next": {"top": 564, "bottom": 614, "h": 50}, "nextBehindSheet": false, "nextTappable": true, "lastOptText": "Nunavut", "lastOpt": {"top": 587, "bottom": 630, "h": 43}, "lastOptBehindSheet": true}
|
||||||
|
iphone14 G2: {"boxH": 437, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -35, "bottomTick": ["80", "85", "90", "age"]}
|
||||||
|
iphone14 HASH-ANCHOR: {"targetTop": 1537, "hdrBottom": 55, "covered": false}
|
||||||
|
pixel STEP0: {"innerH": 800, "docH": 961, "maxScroll": 161, "panelTop": 574, "sheetH": 218, "next": {"top": 520, "bottom": 570, "h": 50}, "nextBehindSheet": false, "nextTappable": true, "lastOptText": "Nunavut", "lastOpt": {"top": 587, "bottom": 630, "h": 43}, "lastOptBehindSheet": true}
|
||||||
|
pixel G2: {"boxH": 437, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -35, "bottomTick": ["80", "85", "90", "age"]}
|
||||||
|
pixel HASH-ANCHOR: {"targetTop": 1547, "hdrBottom": 55, "covered": false}
|
||||||
|
se STEP0: {"innerH": 700, "docH": 995, "maxScroll": 295, "panelTop": 474, "sheetH": 218, "next": {"top": 420, "bottom": 470, "h": 50}, "nextBehindSheet": false, "nextTappable": true, "lastOptText": "Nunavut", "lastOpt": {"top": 621, "bottom": 664, "h": 43}, "lastOptBehindSheet": true}
|
||||||
|
se G2: {"boxH": 469, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -67, "bottomTick": ["80", "85", "90", "age"]}
|
||||||
|
se HASH-ANCHOR: {"targetTop": 1562, "hdrBottom": 55, "covered": false}
|
||||||
|
MEASURE2-DONE
|
||||||
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,121 @@
|
|||||||
|
iphone14 step0 @scrollY=0: {"scrollY": 0, "maxScroll": 117, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 564, "bottom": 614}, "nextTappable": true, "lastOptBottom": 630}
|
||||||
|
PASS iphone14 step0: next bottom <= panelTop-2 @0 {"nextBottom": 614, "panelTop": 618}
|
||||||
|
PASS iphone14 step0: next tappable @0
|
||||||
|
iphone14 step0 @mid: {"scrollY": 59, "maxScroll": 117, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 38, "bottom": 94}, "next": {"top": 564, "bottom": 614}, "nextTappable": true, "lastOptBottom": 571}
|
||||||
|
PASS iphone14 step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 94, "panelTop": 618}
|
||||||
|
PASS iphone14 step0: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 614, "panelTop": 618}
|
||||||
|
iphone14 step0 @max: {"scrollY": 117, "maxScroll": 117, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -20, "bottom": 36}, "next": {"top": 550, "bottom": 600}, "nextTappable": true, "lastOptBottom": 513}
|
||||||
|
PASS iphone14 step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 513, "panelTop": 618}
|
||||||
|
PASS iphone14 step0: next tappable @max
|
||||||
|
iphone14 step13-interests @scrollY=0: {"scrollY": 0, "maxScroll": 6, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 556, "bottom": 606}, "nextTappable": true, "lastOptBottom": 519}
|
||||||
|
PASS iphone14 step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 606, "panelTop": 618}
|
||||||
|
PASS iphone14 step13-interests: next tappable @0
|
||||||
|
iphone14 step13-interests @mid: {"scrollY": 3, "maxScroll": 6, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 94, "bottom": 150}, "next": {"top": 553, "bottom": 603}, "nextTappable": true, "lastOptBottom": 516}
|
||||||
|
PASS iphone14 step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 150, "panelTop": 618}
|
||||||
|
PASS iphone14 step13-interests: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 603, "panelTop": 618}
|
||||||
|
iphone14 step13-interests @max: {"scrollY": 6, "maxScroll": 6, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 91, "bottom": 147}, "next": {"top": 550, "bottom": 600}, "nextTappable": true, "lastOptBottom": 513}
|
||||||
|
PASS iphone14 step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 513, "panelTop": 618}
|
||||||
|
PASS iphone14 step13-interests: next tappable @max
|
||||||
|
iphone14 income-step3 (doc coords): {"navTop": 97, "navBottom": 153, "navPos": "sticky", "nextTop": 485, "nextBottom": 535, "panelTop": 618, "maxScroll": 0, "scrollY": 0}
|
||||||
|
PASS iphone14: income nav top unchanged (97, round-1) {"navTop": 97}
|
||||||
|
PASS iphone14: income CTA at round-1 (485) when not stuck, pinned when tall {"nextTop": 485, "nextBottom": 535, "panelTop": 618, "maxScroll": 0}
|
||||||
|
iphone14 SWIPE: [{"sel": " #g1", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1036, "bottom": 1060, "left": 301, "right": 363}}, {"sel": "#g2", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1445, "bottom": 1469, "left": 301, "right": 363}}, {"sel": "#g3", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1882, "bottom": 1906, "left": 301, "right": 363}}, {"sel": "#g4", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 13, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 2319, "bottom": 2343, "left": 301, "right": 363}}]
|
||||||
|
PASS iphone14 #g1: swipe hint present+visible
|
||||||
|
PASS iphone14 #g1: swipe does not intersect <text> {"texts": 12}
|
||||||
|
PASS iphone14 #g1: swipe inside card bounds
|
||||||
|
PASS iphone14 #g2: swipe hint present+visible
|
||||||
|
PASS iphone14 #g2: swipe does not intersect <text> {"texts": 12}
|
||||||
|
PASS iphone14 #g2: swipe inside card bounds
|
||||||
|
PASS iphone14 #g3: swipe hint present+visible
|
||||||
|
PASS iphone14 #g3: swipe does not intersect <text> {"texts": 12}
|
||||||
|
PASS iphone14 #g3: swipe inside card bounds
|
||||||
|
PASS iphone14 #g4: swipe hint present+visible
|
||||||
|
PASS iphone14 #g4: swipe does not intersect <text> {"texts": 13}
|
||||||
|
PASS iphone14 #g4: swipe inside card bounds
|
||||||
|
PASS iphone14: swipe on all 4 charts
|
||||||
|
iphone14 HASH-ANCHOR: {"scrollY": 2662, "targetTop": 70, "hdrBottom": 55}
|
||||||
|
PASS iphone14: anchor scrollY > 0 {"scrollY": 2662}
|
||||||
|
PASS iphone14: #whatif clears sticky header {"targetTop": 70, "hdrBottom": 55}
|
||||||
|
pixel step0 @scrollY=0: {"scrollY": 0, "maxScroll": 161, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 520, "bottom": 570}, "nextTappable": true, "lastOptBottom": 630}
|
||||||
|
PASS pixel step0: next bottom <= panelTop-2 @0 {"nextBottom": 570, "panelTop": 574}
|
||||||
|
PASS pixel step0: next tappable @0
|
||||||
|
pixel step0 @mid: {"scrollY": 81, "maxScroll": 161, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 16, "bottom": 72}, "next": {"top": 520, "bottom": 570}, "nextTappable": true, "lastOptBottom": 549}
|
||||||
|
PASS pixel step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 72, "panelTop": 574}
|
||||||
|
PASS pixel step0: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 570, "panelTop": 574}
|
||||||
|
pixel step0 @max: {"scrollY": 161, "maxScroll": 161, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -64, "bottom": -8}, "next": {"top": 506, "bottom": 556}, "nextTappable": true, "lastOptBottom": 469}
|
||||||
|
PASS pixel step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 469, "panelTop": 574}
|
||||||
|
PASS pixel step0: next tappable @max
|
||||||
|
pixel step13-interests @scrollY=0: {"scrollY": 0, "maxScroll": 109, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 520, "bottom": 570}, "nextTappable": true, "lastOptBottom": 578}
|
||||||
|
PASS pixel step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 570, "panelTop": 574}
|
||||||
|
PASS pixel step13-interests: next tappable @0
|
||||||
|
pixel step13-interests @mid: {"scrollY": 55, "maxScroll": 109, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 42, "bottom": 98}, "next": {"top": 520, "bottom": 570}, "nextTappable": true, "lastOptBottom": 523}
|
||||||
|
PASS pixel step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 98, "panelTop": 574}
|
||||||
|
PASS pixel step13-interests: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 570, "panelTop": 574}
|
||||||
|
pixel step13-interests @max: {"scrollY": 109, "maxScroll": 109, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -12, "bottom": 44}, "next": {"top": 506, "bottom": 556}, "nextTappable": true, "lastOptBottom": 469}
|
||||||
|
PASS pixel step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 469, "panelTop": 574}
|
||||||
|
PASS pixel step13-interests: next tappable @max
|
||||||
|
pixel income-step3 (doc coords): {"navTop": 97, "navBottom": 153, "navPos": "sticky", "nextTop": 485, "nextBottom": 535, "panelTop": 574, "maxScroll": 0, "scrollY": 0}
|
||||||
|
PASS pixel: income nav top unchanged (97, round-1) {"navTop": 97}
|
||||||
|
PASS pixel: income CTA at round-1 (485) when not stuck, pinned when tall {"nextTop": 485, "nextBottom": 535, "panelTop": 574, "maxScroll": 0}
|
||||||
|
pixel SWIPE: [{"sel": " #g1", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1085, "bottom": 1109, "left": 271, "right": 333}}, {"sel": "#g2", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1522, "bottom": 1545, "left": 271, "right": 333}}, {"sel": "#g3", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1959, "bottom": 1982, "left": 271, "right": 333}}, {"sel": "#g4", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 13, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 2428, "bottom": 2451, "left": 271, "right": 333}}]
|
||||||
|
PASS pixel #g1: swipe hint present+visible
|
||||||
|
PASS pixel #g1: swipe does not intersect <text> {"texts": 12}
|
||||||
|
PASS pixel #g1: swipe inside card bounds
|
||||||
|
PASS pixel #g2: swipe hint present+visible
|
||||||
|
PASS pixel #g2: swipe does not intersect <text> {"texts": 12}
|
||||||
|
PASS pixel #g2: swipe inside card bounds
|
||||||
|
PASS pixel #g3: swipe hint present+visible
|
||||||
|
PASS pixel #g3: swipe does not intersect <text> {"texts": 12}
|
||||||
|
PASS pixel #g3: swipe inside card bounds
|
||||||
|
PASS pixel #g4: swipe hint present+visible
|
||||||
|
PASS pixel #g4: swipe does not intersect <text> {"texts": 13}
|
||||||
|
PASS pixel #g4: swipe inside card bounds
|
||||||
|
PASS pixel: swipe on all 4 charts
|
||||||
|
pixel HASH-ANCHOR: {"scrollY": 2771, "targetTop": 70, "hdrBottom": 55}
|
||||||
|
PASS pixel: anchor scrollY > 0 {"scrollY": 2771}
|
||||||
|
PASS pixel: #whatif clears sticky header {"targetTop": 70, "hdrBottom": 55}
|
||||||
|
se step0 @scrollY=0: {"scrollY": 0, "maxScroll": 295, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 420, "bottom": 470}, "nextTappable": true, "lastOptBottom": 664}
|
||||||
|
PASS se step0: next bottom <= panelTop-2 @0 {"nextBottom": 470, "panelTop": 474}
|
||||||
|
PASS se step0: next tappable @0
|
||||||
|
se step0 @mid: {"scrollY": 148, "maxScroll": 295, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -51, "bottom": 5}, "next": {"top": 420, "bottom": 470}, "nextTappable": true, "lastOptBottom": 516}
|
||||||
|
PASS se step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 5, "panelTop": 474}
|
||||||
|
PASS se step0: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 470, "panelTop": 474}
|
||||||
|
se step0 @max: {"scrollY": 295, "maxScroll": 295, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -198, "bottom": -142}, "next": {"top": 406, "bottom": 456}, "nextTappable": true, "lastOptBottom": 369}
|
||||||
|
PASS se step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 369, "panelTop": 474}
|
||||||
|
PASS se step0: next tappable @max
|
||||||
|
se step13-interests @scrollY=0: {"scrollY": 0, "maxScroll": 240, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 420, "bottom": 470}, "nextTappable": true, "lastOptBottom": 609}
|
||||||
|
PASS se step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 470, "panelTop": 474}
|
||||||
|
PASS se step13-interests: next tappable @0
|
||||||
|
se step13-interests @mid: {"scrollY": 120, "maxScroll": 240, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -23, "bottom": 33}, "next": {"top": 420, "bottom": 470}, "nextTappable": true, "lastOptBottom": 489}
|
||||||
|
PASS se step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 33, "panelTop": 474}
|
||||||
|
PASS se step13-interests: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 470, "panelTop": 474}
|
||||||
|
se step13-interests @max: {"scrollY": 240, "maxScroll": 240, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -143, "bottom": -87}, "next": {"top": 406, "bottom": 456}, "nextTappable": true, "lastOptBottom": 369}
|
||||||
|
PASS se step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 369, "panelTop": 474}
|
||||||
|
PASS se step13-interests: next tappable @max
|
||||||
|
se income-step3 (doc coords): {"navTop": 97, "navBottom": 153, "navPos": "sticky", "nextTop": 420, "nextBottom": 470, "panelTop": 474, "maxScroll": 79, "scrollY": 0}
|
||||||
|
PASS se: income nav top unchanged (97, round-1) {"navTop": 97}
|
||||||
|
PASS se: income CTA at round-1 (485) when not stuck, pinned when tall {"nextTop": 420, "nextBottom": 470, "panelTop": 474, "maxScroll": 79}
|
||||||
|
se SWIPE: [{"sel": " #g1", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1108, "bottom": 1132, "left": 231, "right": 293}}, {"sel": "#g2", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1566, "bottom": 1590, "left": 231, "right": 293}}, {"sel": "#g3", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 2035, "bottom": 2059, "left": 231, "right": 293}}, {"sel": "#g4", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 13, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 2504, "bottom": 2528, "left": 231, "right": 293}}]
|
||||||
|
PASS se #g1: swipe hint present+visible
|
||||||
|
PASS se #g1: swipe does not intersect <text> {"texts": 12}
|
||||||
|
PASS se #g1: swipe inside card bounds
|
||||||
|
PASS se #g2: swipe hint present+visible
|
||||||
|
PASS se #g2: swipe does not intersect <text> {"texts": 12}
|
||||||
|
PASS se #g2: swipe inside card bounds
|
||||||
|
PASS se #g3: swipe hint present+visible
|
||||||
|
PASS se #g3: swipe does not intersect <text> {"texts": 12}
|
||||||
|
PASS se #g3: swipe inside card bounds
|
||||||
|
PASS se #g4: swipe hint present+visible
|
||||||
|
PASS se #g4: swipe does not intersect <text> {"texts": 13}
|
||||||
|
PASS se #g4: swipe inside card bounds
|
||||||
|
PASS se: swipe on all 4 charts
|
||||||
|
se HASH-ANCHOR: {"scrollY": 2896, "targetTop": 70, "hdrBottom": 55}
|
||||||
|
PASS se: anchor scrollY > 0 {"scrollY": 2896}
|
||||||
|
PASS se: #whatif clears sticky header {"targetTop": 70, "hdrBottom": 55}
|
||||||
|
desk1280 DESKTOP-QUIZ: {"navPos": "static", "scrPadBottom": "80px", "navTop": 107}
|
||||||
|
PASS desk1280: .step-nav position static {"navPos": "static"}
|
||||||
|
PASS desk1280: .scr padding-bottom 80px {"pad": "80px"}
|
||||||
|
desk1280 DESKTOP-SWIPE: [{"sel": "#g1", "present": true, "visible": false}, {"sel": "#g2", "present": true, "visible": false}, {"sel": "#g3", "present": true, "visible": false}, {"sel": "#g4", "present": true, "visible": false}]
|
||||||
|
PASS desk1280: desktop swipe hidden [{"sel": "#g1", "present": true, "visible": false}, {"sel": "#g2", "present": true, "visible": false}, {"sel": "#g3", "present": true, "visible": false}, {"sel": "#g4", "present": true, "visible": false}]
|
||||||
|
|
||||||
|
VERIFY2-OK
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
iphone14 STEP0: {"innerH": 844, "docH": 914, "maxScroll": 70, "panelTop": 618, "sheetH": 218, "next": {"top": 667, "bottom": 717, "h": 50}, "nextBehindSheet": true, "nextTappable": false, "lastOptText": "Nunavut", "lastOpt": {"top": 587, "bottom": 630, "h": 43}, "lastOptBehindSheet": true}
|
||||||
|
iphone14 G2: {"boxH": 285, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -35, "bottomTick": ["80", "85", "90", "age"]}
|
||||||
|
iphone14 HASH-ANCHOR: {"targetTop": 1382, "hdrBottom": 55, "covered": false}
|
||||||
|
pixel STEP0: {"innerH": 800, "docH": 914, "maxScroll": 114, "panelTop": 574, "sheetH": 218, "next": {"top": 667, "bottom": 717, "h": 50}, "nextBehindSheet": true, "nextTappable": false, "lastOptText": "Nunavut", "lastOpt": {"top": 587, "bottom": 630, "h": 43}, "lastOptBehindSheet": true}
|
||||||
|
pixel G2: {"boxH": 285, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -35, "bottomTick": ["80", "85", "90", "age"]}
|
||||||
|
pixel HASH-ANCHOR: {"targetTop": 1393, "hdrBottom": 55, "covered": false}
|
||||||
|
se STEP0: {"innerH": 700, "docH": 948, "maxScroll": 248, "panelTop": 474, "sheetH": 218, "next": {"top": 701, "bottom": 751, "h": 50}, "nextBehindSheet": true, "nextTappable": false, "lastOptText": "Nunavut", "lastOpt": {"top": 621, "bottom": 664, "h": 43}, "lastOptBehindSheet": true}
|
||||||
|
se G2: {"boxH": 317, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -67, "bottomTick": ["80", "85", "90", "age"]}
|
||||||
|
se HASH-ANCHOR: {"targetTop": 1386, "hdrBottom": 55, "covered": false}
|
||||||
|
MEASURE2-DONE
|
||||||
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 101 KiB |
@@ -0,0 +1,103 @@
|
|||||||
|
iphone14 step0 @scrollY=0: {"scrollY": 0, "maxScroll": 70, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
|
||||||
|
FAIL iphone14 step0: next bottom <= panelTop-2 @0 {"nextBottom": 717, "panelTop": 618}
|
||||||
|
FAIL iphone14 step0: next tappable @0
|
||||||
|
iphone14 step0 @mid: {"scrollY": 0, "maxScroll": 70, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
|
||||||
|
PASS iphone14 step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 141, "panelTop": 618}
|
||||||
|
iphone14 step0 @max: {"scrollY": 0, "maxScroll": 70, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
|
||||||
|
FAIL iphone14 step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 630, "panelTop": 618}
|
||||||
|
iphone14 step13-interests @scrollY=0: {"scrollY": 0, "maxScroll": 0, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 556, "bottom": 606}, "nextTappable": true, "lastOptBottom": 519}
|
||||||
|
PASS iphone14 step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 606, "panelTop": 618}
|
||||||
|
PASS iphone14 step13-interests: next tappable @0
|
||||||
|
iphone14 step13-interests @mid: {"scrollY": 0, "maxScroll": 0, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 556, "bottom": 606}, "nextTappable": true, "lastOptBottom": 519}
|
||||||
|
PASS iphone14 step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 141, "panelTop": 618}
|
||||||
|
iphone14 step13-interests @max: {"scrollY": 0, "maxScroll": 0, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 556, "bottom": 606}, "nextTappable": true, "lastOptBottom": 519}
|
||||||
|
PASS iphone14 step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 519, "panelTop": 618}
|
||||||
|
iphone14 income-step3 (doc coords): {"navTop": 97, "navBottom": 141, "navPos": "static", "scrollY": 0}
|
||||||
|
iphone14 SWIPE: [{"sel": " #g1", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g2", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g3", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g4", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 13, "svg": true}]
|
||||||
|
FAIL iphone14 #g1: swipe hint present+visible
|
||||||
|
FAIL iphone14 #g1: swipe does not intersect <text> {"texts": 12}
|
||||||
|
FAIL iphone14 #g1: swipe inside card bounds
|
||||||
|
FAIL iphone14 #g2: swipe hint present+visible
|
||||||
|
FAIL iphone14 #g2: swipe does not intersect <text> {"texts": 12}
|
||||||
|
FAIL iphone14 #g2: swipe inside card bounds
|
||||||
|
FAIL iphone14 #g3: swipe hint present+visible
|
||||||
|
FAIL iphone14 #g3: swipe does not intersect <text> {"texts": 12}
|
||||||
|
FAIL iphone14 #g3: swipe inside card bounds
|
||||||
|
FAIL iphone14 #g4: swipe hint present+visible
|
||||||
|
FAIL iphone14 #g4: swipe does not intersect <text> {"texts": 13}
|
||||||
|
FAIL iphone14 #g4: swipe inside card bounds
|
||||||
|
FAIL iphone14: swipe on all 4 charts
|
||||||
|
iphone14 HASH-ANCHOR: {"scrollY": 2562, "targetTop": 56, "hdrBottom": 55}
|
||||||
|
PASS iphone14: anchor scrollY > 0 {"scrollY": 2562}
|
||||||
|
PASS iphone14: #whatif clears sticky header {"targetTop": 56, "hdrBottom": 55}
|
||||||
|
pixel step0 @scrollY=0: {"scrollY": 0, "maxScroll": 114, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
|
||||||
|
FAIL pixel step0: next bottom <= panelTop-2 @0 {"nextBottom": 717, "panelTop": 574}
|
||||||
|
FAIL pixel step0: next tappable @0
|
||||||
|
pixel step0 @mid: {"scrollY": 0, "maxScroll": 114, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
|
||||||
|
PASS pixel step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 141, "panelTop": 574}
|
||||||
|
pixel step0 @max: {"scrollY": 0, "maxScroll": 114, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
|
||||||
|
FAIL pixel step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 630, "panelTop": 574}
|
||||||
|
pixel step13-interests @scrollY=0: {"scrollY": 0, "maxScroll": 62, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 615, "bottom": 665}, "nextTappable": false, "lastOptBottom": 578}
|
||||||
|
FAIL pixel step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 665, "panelTop": 574}
|
||||||
|
FAIL pixel step13-interests: next tappable @0
|
||||||
|
pixel step13-interests @mid: {"scrollY": 0, "maxScroll": 62, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 615, "bottom": 665}, "nextTappable": false, "lastOptBottom": 578}
|
||||||
|
PASS pixel step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 141, "panelTop": 574}
|
||||||
|
pixel step13-interests @max: {"scrollY": 0, "maxScroll": 62, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 615, "bottom": 665}, "nextTappable": false, "lastOptBottom": 578}
|
||||||
|
FAIL pixel step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 578, "panelTop": 574}
|
||||||
|
pixel income-step3 (doc coords): {"navTop": 97, "navBottom": 141, "navPos": "static", "scrollY": 0}
|
||||||
|
pixel SWIPE: [{"sel": " #g1", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g2", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g3", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g4", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 13, "svg": true}]
|
||||||
|
FAIL pixel #g1: swipe hint present+visible
|
||||||
|
FAIL pixel #g1: swipe does not intersect <text> {"texts": 12}
|
||||||
|
FAIL pixel #g1: swipe inside card bounds
|
||||||
|
FAIL pixel #g2: swipe hint present+visible
|
||||||
|
FAIL pixel #g2: swipe does not intersect <text> {"texts": 12}
|
||||||
|
FAIL pixel #g2: swipe inside card bounds
|
||||||
|
FAIL pixel #g3: swipe hint present+visible
|
||||||
|
FAIL pixel #g3: swipe does not intersect <text> {"texts": 12}
|
||||||
|
FAIL pixel #g3: swipe inside card bounds
|
||||||
|
FAIL pixel #g4: swipe hint present+visible
|
||||||
|
FAIL pixel #g4: swipe does not intersect <text> {"texts": 13}
|
||||||
|
FAIL pixel #g4: swipe inside card bounds
|
||||||
|
FAIL pixel: swipe on all 4 charts
|
||||||
|
pixel HASH-ANCHOR: {"scrollY": 2699, "targetTop": -0.0, "hdrBottom": 55}
|
||||||
|
PASS pixel: anchor scrollY > 0 {"scrollY": 2699}
|
||||||
|
FAIL pixel: #whatif clears sticky header {"targetTop": -0.0, "hdrBottom": 55}
|
||||||
|
se step0 @scrollY=0: {"scrollY": 0, "maxScroll": 248, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 701, "bottom": 751}, "nextTappable": false, "lastOptBottom": 664}
|
||||||
|
FAIL se step0: next bottom <= panelTop-2 @0 {"nextBottom": 751, "panelTop": 474}
|
||||||
|
FAIL se step0: next tappable @0
|
||||||
|
se step0 @mid: {"scrollY": 0, "maxScroll": 248, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 701, "bottom": 751}, "nextTappable": false, "lastOptBottom": 664}
|
||||||
|
PASS se step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 141, "panelTop": 474}
|
||||||
|
se step0 @max: {"scrollY": 0, "maxScroll": 248, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 701, "bottom": 751}, "nextTappable": false, "lastOptBottom": 664}
|
||||||
|
FAIL se step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 664, "panelTop": 474}
|
||||||
|
se step13-interests @scrollY=0: {"scrollY": 3, "maxScroll": 193, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 94, "bottom": 138}, "next": {"top": 643, "bottom": 693}, "nextTappable": false, "lastOptBottom": 606}
|
||||||
|
FAIL se step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 693, "panelTop": 474}
|
||||||
|
FAIL se step13-interests: next tappable @0
|
||||||
|
se step13-interests @mid: {"scrollY": 3, "maxScroll": 193, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 94, "bottom": 138}, "next": {"top": 643, "bottom": 693}, "nextTappable": false, "lastOptBottom": 606}
|
||||||
|
PASS se step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 138, "panelTop": 474}
|
||||||
|
se step13-interests @max: {"scrollY": 3, "maxScroll": 193, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 94, "bottom": 138}, "next": {"top": 643, "bottom": 693}, "nextTappable": false, "lastOptBottom": 606}
|
||||||
|
FAIL se step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 606, "panelTop": 474}
|
||||||
|
se income-step3 (doc coords): {"navTop": 97, "navBottom": 141, "navPos": "static", "scrollY": 9}
|
||||||
|
se SWIPE: [{"sel": " #g1", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g2", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g3", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g4", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 13, "svg": true}]
|
||||||
|
FAIL se #g1: swipe hint present+visible
|
||||||
|
FAIL se #g1: swipe does not intersect <text> {"texts": 12}
|
||||||
|
FAIL se #g1: swipe inside card bounds
|
||||||
|
FAIL se #g2: swipe hint present+visible
|
||||||
|
FAIL se #g2: swipe does not intersect <text> {"texts": 12}
|
||||||
|
FAIL se #g2: swipe inside card bounds
|
||||||
|
FAIL se #g3: swipe hint present+visible
|
||||||
|
FAIL se #g3: swipe does not intersect <text> {"texts": 12}
|
||||||
|
FAIL se #g3: swipe inside card bounds
|
||||||
|
FAIL se #g4: swipe hint present+visible
|
||||||
|
FAIL se #g4: swipe does not intersect <text> {"texts": 13}
|
||||||
|
FAIL se #g4: swipe inside card bounds
|
||||||
|
FAIL se: swipe on all 4 charts
|
||||||
|
se HASH-ANCHOR: {"scrollY": 2810, "targetTop": -0.0, "hdrBottom": 55}
|
||||||
|
PASS se: anchor scrollY > 0 {"scrollY": 2810}
|
||||||
|
FAIL se: #whatif clears sticky header {"targetTop": -0.0, "hdrBottom": 55}
|
||||||
|
desk1280 DESKTOP-QUIZ: {"navPos": "static", "scrPadBottom": "80px", "navTop": 107}
|
||||||
|
PASS desk1280: .step-nav position static {"navPos": "static"}
|
||||||
|
PASS desk1280: .scr padding-bottom 80px {"pad": "80px"}
|
||||||
|
desk1280 DESKTOP-SWIPE: [{"sel": "#g1", "present": false, "visible": false}, {"sel": "#g2", "present": false, "visible": false}, {"sel": "#g3", "present": false, "visible": false}, {"sel": "#g4", "present": false, "visible": false}]
|
||||||
|
PASS desk1280: desktop swipe hidden [{"sel": "#g1", "present": false, "visible": false}, {"sel": "#g2", "present": false, "visible": false}, {"sel": "#g3", "present": false, "visible": false}, {"sel": "#g4", "present": false, "visible": false}]
|
||||||
|
|
||||||
|
VERIFY2-FAIL (56): iphone14 step0: next bottom <= panelTop-2 @0; iphone14 step0: next tappable @0; iphone14 step0: last opt bottom <= panelTop-2 @max; iphone14 #g1: swipe hint present+visible; iphone14 #g1: swipe does not intersect <text>; iphone14 #g1: swipe inside card bounds; iphone14 #g2: swipe hint present+visible; iphone14 #g2: swipe does not intersect <text>; iphone14 #g2: swipe inside card bounds; iphone14 #g3: swipe hint present+visible; iphone14 #g3: swipe does not intersect <text>; iphone14 #g3: swipe inside card bounds; iphone14 #g4: swipe hint present+visible; iphone14 #g4: swipe does not intersect <text>; iphone14 #g4: swipe inside card bounds; iphone14: swipe on all 4 charts; pixel step0: next bottom <= panelTop-2 @0; pixel step0: next tappable @0; pixel step0: last opt bottom <= panelTop-2 @max; pixel step13-interests: next bottom <= panelTop-2 @0; pixel step13-interests: next tappable @0; pixel step13-interests: last opt bottom <= panelTop-2 @max; pixel #g1: swipe hint present+visible; pixel #g1: swipe does not intersect <text>; pixel #g1: swipe inside card bounds; pixel #g2: swipe hint present+visible; pixel #g2: swipe does not intersect <text>; pixel #g2: swipe inside card bounds; pixel #g3: swipe hint present+visible; pixel #g3: swipe does not intersect <text>; pixel #g3: swipe inside card bounds; pixel #g4: swipe hint present+visible; pixel #g4: swipe does not intersect <text>; pixel #g4: swipe inside card bounds; pixel: swipe on all 4 charts; pixel: #whatif clears sticky header; se step0: next bottom <= panelTop-2 @0; se step0: next tappable @0; se step0: last opt bottom <= panelTop-2 @max; se step13-interests: next bottom <= panelTop-2 @0; se step13-interests: next tappable @0; se step13-interests: last opt bottom <= panelTop-2 @max; se #g1: swipe hint present+visible; se #g1: swipe does not intersect <text>; se #g1: swipe inside card bounds; se #g2: swipe hint present+visible; se #g2: swipe does not intersect <text>; se #g2: swipe inside card bounds; se #g3: swipe hint present+visible; se #g3: swipe does not intersect <text>; se #g3: swipe inside card bounds; se #g4: swipe hint present+visible; se #g4: swipe does not intersect <text>; se #g4: swipe inside card bounds; se: swipe on all 4 charts; se: #whatif clears sticky header
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Mobile UX audit for https://retire.krisforbes.ca — screenshots at phone viewports.
|
||||||
|
Sets localStorage answers, reloads, captures key screens. Output: scratch/mobile-audit/*.png
|
||||||
|
"""
|
||||||
|
import json, os, sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
import sys
|
||||||
|
OUT = sys.argv[1] if len(sys.argv) > 1 else '/home/ubuntu/.hermes/cache/scratch/mobile-audit'
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
|
||||||
|
BASE = 'https://retire.krisforbes.ca/'
|
||||||
|
LS = 'retire-planner-v1'
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"province": None, "age": 45, "household": "single",
|
||||||
|
"partner": {"age": 45, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0},
|
||||||
|
"income": 60000, "rrsp": 100000, "tfsa": 30000, "nonreg": 20000,
|
||||||
|
"monthlyContribution": 500, "employerMatchMonthly": 0,
|
||||||
|
"retireAge": 65, "spendingAnnual": 50000,
|
||||||
|
"cppStartAge": 65, "oasStartAge": 65,
|
||||||
|
"pensionAnnual": 0, "rentalAnnual": 0, "otherAnnual": 0,
|
||||||
|
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
|
||||||
|
"interests": []
|
||||||
|
}
|
||||||
|
ANSWERS = dict(DEFAULTS)
|
||||||
|
ANSWERS.update({
|
||||||
|
"province": "ON", "age": 45, "income": 85000, "rrsp": 250000, "tfsa": 60000,
|
||||||
|
"nonreg": 40000, "monthlyContribution": 800, "retireAge": 65, "spendingAnnual": 60000,
|
||||||
|
"pensionAnnual": 12000, "interests": ["dock", "travel"]
|
||||||
|
})
|
||||||
|
|
||||||
|
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'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# (device, tag, screen, step, extra_action)
|
||||||
|
SHOTS = [
|
||||||
|
('iphone14', 'intro', 'intro', 0, None),
|
||||||
|
('iphone14', 'quiz-province','quiz', 0, None),
|
||||||
|
('iphone14', 'quiz-income', 'quiz', 3, None),
|
||||||
|
('iphone14', 'quiz-slider', 'quiz', 7, None),
|
||||||
|
('iphone14', 'quiz-sheet-open','quiz', 3, 'open_sheet'),
|
||||||
|
('iphone14', 'results-top', 'results', 0, None),
|
||||||
|
('iphone14', 'results-charts','results', 0, 'charts'),
|
||||||
|
('iphone14', 'results-table','results', 0, 'table'),
|
||||||
|
('pixel', 'quiz-province','quiz', 0, None),
|
||||||
|
('pixel', 'results-charts','results', 0, 'charts'),
|
||||||
|
('se', 'quiz-income', 'quiz', 3, None),
|
||||||
|
('se', 'results-charts','results', 0, 'charts'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def set_state(page, screen, step):
|
||||||
|
page.evaluate("""([ls, a, screen, step]) => {
|
||||||
|
const st = {a: a, step: step, screen: screen};
|
||||||
|
localStorage.setItem(ls, JSON.stringify(st));
|
||||||
|
}""", [LS, ANSWERS, screen, step])
|
||||||
|
|
||||||
|
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, 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 d, tag, screen, step, action in [s for s in SHOTS if s[0] == dev]:
|
||||||
|
set_state(page, screen, step)
|
||||||
|
page.reload(wait_until='networkidle')
|
||||||
|
page.wait_for_timeout(700)
|
||||||
|
if action == 'open_sheet':
|
||||||
|
try:
|
||||||
|
page.click('.panel-h', timeout=3000)
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
except Exception as e:
|
||||||
|
print('sheet open failed:', e)
|
||||||
|
elif action == 'charts':
|
||||||
|
el = page.locator('#g1')
|
||||||
|
if el.count():
|
||||||
|
el.scroll_into_view_if_needed()
|
||||||
|
page.wait_for_timeout(600) # let G4 async render
|
||||||
|
elif action == 'table':
|
||||||
|
el = page.locator('#yr-table')
|
||||||
|
if el.count():
|
||||||
|
el.scroll_into_view_if_needed()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
path = os.path.join(OUT, f'{dev}-{tag}.png')
|
||||||
|
page.screenshot(path=path, full_page=(action is None))
|
||||||
|
print('saved', path)
|
||||||
|
# also measure key geometry on the widest phone for the report
|
||||||
|
if dev == 'iphone14':
|
||||||
|
set_state(page, 'quiz', 3)
|
||||||
|
page.reload(wait_until='networkidle')
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
geo = page.evaluate("""() => {
|
||||||
|
const r = s => { const e = document.querySelector(s); if (!e) return null; const b = e.getBoundingClientRect(); return {w: Math.round(b.width), h: Math.round(b.height), top: Math.round(b.top + window.scrollY), bottom: Math.round(b.bottom + window.scrollY)}; };
|
||||||
|
return {
|
||||||
|
docH: document.documentElement.scrollHeight,
|
||||||
|
innerH: window.innerHeight,
|
||||||
|
panel: r('.panel'), panelH: r('.panel-h'), chips: r('#panel-chips'),
|
||||||
|
stepBody: r('#step-body'), btnNext: r('#btn-next'), btnBack: r('#btn-back'),
|
||||||
|
hdr: r('.hdr'), g1: r('#g1'), tblWrap: r('.tbl-wrap'),
|
||||||
|
minTouch: Math.min(...[...document.querySelectorAll('.ghost,.opt,.cta')].map(e => Math.round(e.getBoundingClientRect().height)))
|
||||||
|
};
|
||||||
|
}""")
|
||||||
|
print('GEO iphone14 quiz:', json.dumps(geo))
|
||||||
|
set_state(page, 'results', 0)
|
||||||
|
page.reload(wait_until='networkidle')
|
||||||
|
page.wait_for_timeout(1200)
|
||||||
|
geo2 = page.evaluate("""() => {
|
||||||
|
const r = s => { const e = document.querySelector(s); if (!e) return null; const b = e.getBoundingClientRect(); return {w: Math.round(b.width), h: Math.round(b.height)}; };
|
||||||
|
const svg = document.querySelector('#g1 svg');
|
||||||
|
const txt = svg ? svg.querySelector('text') : null;
|
||||||
|
return {
|
||||||
|
g1: r('#g1'), g1svg: svg ? {w: Math.round(svg.getBoundingClientRect().width), h: Math.round(svg.getBoundingClientRect().height)} : null,
|
||||||
|
firstSvgTextPx: txt ? Math.round(640 / svg.getBoundingClientRect().width * 11) : null,
|
||||||
|
tblScrollW: (() => { const t = document.querySelector('.tbl-wrap'); return t ? {clientW: t.clientWidth, scrollW: t.scrollWidth} : null; })(),
|
||||||
|
visionImg: r('.vision img')
|
||||||
|
};
|
||||||
|
}""")
|
||||||
|
print('GEO iphone14 results:', json.dumps(geo2))
|
||||||
|
ctx.close()
|
||||||
|
browser.close()
|
||||||
|
print('AUDIT-DONE')
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Round-2 verification: MOBILE-FIX-2.md Issue 1/2/3 assertions, measured on the LIVE site.
|
||||||
|
Runs step-0 + tall-step checks (Issue 1), swipe-vs-ticks (Issue 2), anchor scroll (Issue 3),
|
||||||
|
short-step nav-top baseline, and a desktop 1280 spot-check. Prints VERIFY2-OK / VERIFY2-FAIL.
|
||||||
|
Usage: python3 audit/verify2.py > audit/mobile-<before|after>2/verify-round2.txt 2>&1
|
||||||
|
"""
|
||||||
|
import json, os, sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
|
||||||
|
BASE = os.environ.get('RP_BASE', 'https://retire.krisforbes.ca/')
|
||||||
|
LS = 'retire-planner-v1'
|
||||||
|
ANSWERS = {
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
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'),
|
||||||
|
('desk1280', 1280, 900, 1.0, 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'),
|
||||||
|
]
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
def chk(name, cond, extra=None):
|
||||||
|
print((' PASS ' if cond else ' FAIL ') + name + (' ' + json.dumps(extra) if extra is not None else ''))
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
def set_state(page, screen, step):
|
||||||
|
page.evaluate("([ls, a, screen, step]) => localStorage.setItem(ls, JSON.stringify({a: a, step: step, screen: screen}))",
|
||||||
|
[LS, ANSWERS, screen, step])
|
||||||
|
|
||||||
|
def scroll_to(page, frac):
|
||||||
|
page.evaluate("""(frac) => {
|
||||||
|
const max = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
window.scrollTo({top: Math.round(max * frac), behavior: 'instant'});
|
||||||
|
}""", frac)
|
||||||
|
page.wait_for_timeout(350) # scroll-behavior:smooth — let it settle before measuring
|
||||||
|
|
||||||
|
MEASURE_JS = """() => {
|
||||||
|
const rc = e => { const b = e.getBoundingClientRect(); return {top: Math.round(b.top), bottom: Math.round(b.bottom)}; };
|
||||||
|
const panel = document.querySelector('.panel');
|
||||||
|
const nav = document.querySelector('.step-nav');
|
||||||
|
const next = document.querySelector('#btn-next');
|
||||||
|
const opts = [...document.querySelectorAll('.opt')];
|
||||||
|
const last = opts[opts.length - 1];
|
||||||
|
const pTop = Math.round(panel.getBoundingClientRect().top);
|
||||||
|
const nc = next.getBoundingClientRect();
|
||||||
|
const elAt = document.elementFromPoint(nc.left + nc.width / 2, nc.top + nc.height / 2);
|
||||||
|
return {
|
||||||
|
scrollY: Math.round(window.scrollY),
|
||||||
|
maxScroll: Math.round(document.documentElement.scrollHeight - window.innerHeight),
|
||||||
|
panelTop: pTop,
|
||||||
|
sheetH: Math.round(panel.getBoundingClientRect().height),
|
||||||
|
sheetVar: getComputedStyle(document.documentElement).getPropertyValue('--sheet-h').trim(),
|
||||||
|
scrPadBottom: getComputedStyle(document.querySelector('#scr-quiz')).paddingBottom,
|
||||||
|
navPos: getComputedStyle(nav).position,
|
||||||
|
nav: rc(nav), next: rc(next),
|
||||||
|
nextTappable: !!(elAt && elAt.closest && elAt.closest('#btn-next')),
|
||||||
|
lastOptBottom: last ? Math.round(last.getBoundingClientRect().bottom) : null
|
||||||
|
};
|
||||||
|
}"""
|
||||||
|
|
||||||
|
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, ua in DEVICES:
|
||||||
|
mobile = dev != 'desk1280'
|
||||||
|
ctx = browser.new_context(viewport={'width': w, 'height': h}, device_scale_factor=dsf,
|
||||||
|
is_mobile=mobile, has_touch=mobile, user_agent=ua)
|
||||||
|
page = ctx.new_page()
|
||||||
|
page.goto(BASE, wait_until='networkidle')
|
||||||
|
|
||||||
|
# ---------------- Issue 1: quiz, tall steps (step 0 = province grid, step 13 = interests) ----------
|
||||||
|
for step, tag in ((0, 'step0'), (13, 'step13-interests')):
|
||||||
|
if not mobile:
|
||||||
|
break # Issue 1 assertions are mobile; desktop checked separately below
|
||||||
|
set_state(page, 'quiz', step)
|
||||||
|
page.reload(wait_until='networkidle'); page.wait_for_timeout(700)
|
||||||
|
scroll_to(page, 0.0)
|
||||||
|
g0 = page.evaluate(MEASURE_JS)
|
||||||
|
print(f'{dev} {tag} @scrollY=0:', json.dumps(g0))
|
||||||
|
chk(f'{dev} {tag}: next bottom <= panelTop-2 @0', g0['next']['bottom'] <= g0['panelTop'] - 2,
|
||||||
|
{'nextBottom': g0['next']['bottom'], 'panelTop': g0['panelTop']})
|
||||||
|
chk(f'{dev} {tag}: next tappable @0', g0['nextTappable'])
|
||||||
|
scroll_to(page, 0.5)
|
||||||
|
gm = page.evaluate(MEASURE_JS)
|
||||||
|
print(f'{dev} {tag} @mid:', json.dumps(gm))
|
||||||
|
chk(f'{dev} {tag}: nav pinned mid (navBottom <= panelTop-2)',
|
||||||
|
gm['nav']['bottom'] <= gm['panelTop'] - 2, {'navBottom': gm['nav']['bottom'], 'panelTop': gm['panelTop']})
|
||||||
|
chk(f'{dev} {tag}: next still pinned mid (nextBottom <= panelTop-2)',
|
||||||
|
gm['next']['bottom'] <= gm['panelTop'] - 2, {'nextBottom': gm['next']['bottom'], 'panelTop': gm['panelTop']})
|
||||||
|
scroll_to(page, 1.0)
|
||||||
|
gmax = page.evaluate(MEASURE_JS)
|
||||||
|
print(f'{dev} {tag} @max:', json.dumps(gmax))
|
||||||
|
chk(f'{dev} {tag}: last opt bottom <= panelTop-2 @max',
|
||||||
|
gmax['lastOptBottom'] <= gmax['panelTop'] - 2, {'lastOptBottom': gmax['lastOptBottom'], 'panelTop': gmax['panelTop']})
|
||||||
|
chk(f'{dev} {tag}: next tappable @max', gmax['nextTappable'])
|
||||||
|
|
||||||
|
# ---------------- Issue 1 (short step): income (quiz step 3 single) — no shift when not stuck --------
|
||||||
|
# Round-1 (pre-fix, audit/mobile-before2/verify-round2.txt): nav doc-top = 97,
|
||||||
|
# #btn-next top = 460 / center = 485 on all 3 widths. Round 2 must reproduce that exactly.
|
||||||
|
if mobile:
|
||||||
|
set_state(page, 'quiz', 3)
|
||||||
|
page.reload(wait_until='networkidle'); page.wait_for_timeout(600)
|
||||||
|
gi = page.evaluate("""() => {
|
||||||
|
const nav = document.querySelector('.step-nav');
|
||||||
|
const nx = document.querySelector('#btn-next');
|
||||||
|
const b = nav.getBoundingClientRect(), n = nx.getBoundingClientRect();
|
||||||
|
return {navTop: Math.round(b.top + window.scrollY), navBottom: Math.round(b.bottom + window.scrollY),
|
||||||
|
navPos: getComputedStyle(nav).position,
|
||||||
|
nextTop: Math.round(n.top + window.scrollY), nextBottom: Math.round(n.bottom),
|
||||||
|
panelTop: Math.round(document.querySelector('.panel').getBoundingClientRect().top),
|
||||||
|
maxScroll: Math.round(document.documentElement.scrollHeight - window.innerHeight),
|
||||||
|
scrollY: Math.round(window.scrollY)};
|
||||||
|
}""")
|
||||||
|
print(f'{dev} income-step3 (doc coords):', json.dumps(gi))
|
||||||
|
chk(f'{dev}: income nav top unchanged (97, round-1)', gi['navTop'] == 97, {'navTop': gi['navTop']})
|
||||||
|
# Spec: "nav top ~485 unchanged … visually nothing changes on short steps". True round-1
|
||||||
|
# CTA-row top (measured on git HEAD copy at 390/360/320) = 485. Not-stuck rendering must
|
||||||
|
# reproduce it exactly. At 320 the income step is TALL (docH > vh), so round-2 pins the
|
||||||
|
# CTA above the sheet (that IS the issue-1 fix) — accept the pinned position there.
|
||||||
|
unstuck = gi['nextTop'] == 485
|
||||||
|
pinned = gi['panelTop'] - 16 <= gi['nextBottom'] <= gi['panelTop'] - 2
|
||||||
|
chk(f'{dev}: income CTA at round-1 (485) when not stuck, pinned when tall',
|
||||||
|
unstuck or pinned, {'nextTop': gi['nextTop'], 'nextBottom': gi['nextBottom'],
|
||||||
|
'panelTop': gi['panelTop'], 'maxScroll': gi['maxScroll']})
|
||||||
|
|
||||||
|
# ---------------- Issue 2: swipe hint in chart header, no text intersection ------------------------
|
||||||
|
if mobile:
|
||||||
|
set_state(page, 'results', 0)
|
||||||
|
page.reload(wait_until='networkidle'); page.wait_for_timeout(1500)
|
||||||
|
sw = page.evaluate("""() => [' #g1','#g2','#g3','#g4'].map(sel => {
|
||||||
|
const box = document.querySelector(sel.trim());
|
||||||
|
if (!box) return {sel, missing: true};
|
||||||
|
const span = box.querySelector('.swipe');
|
||||||
|
const hdr = box.querySelector('.chart-h');
|
||||||
|
const svg = box.querySelector('svg');
|
||||||
|
const texts = svg ? [...svg.querySelectorAll('text')] : [];
|
||||||
|
const out = {sel, hasSwipe: !!span, inHeader: !!(span && hdr && hdr.contains(span)),
|
||||||
|
swipeVisible: !!(span && span.offsetParent),
|
||||||
|
swipeText: span ? span.textContent.trim() : null,
|
||||||
|
texts: texts.length, svg: !!svg};
|
||||||
|
if (span && texts.length) {
|
||||||
|
const r = span.getBoundingClientRect();
|
||||||
|
out.intersectsText = texts.some(t => { const b = t.getBoundingClientRect();
|
||||||
|
return !(r.right <= b.left || r.left >= b.right || r.bottom <= b.top || r.top >= b.bottom); });
|
||||||
|
const br = box.getBoundingClientRect();
|
||||||
|
out.swipeInsideCard = r.left >= br.left - 1 && r.right <= br.right + 1 && r.top >= br.top - 1 && r.bottom <= br.bottom + 1;
|
||||||
|
out.swipe = {top: Math.round(r.top), bottom: Math.round(r.bottom), left: Math.round(r.left), right: Math.round(r.right)};
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})""")
|
||||||
|
print(f'{dev} SWIPE:', json.dumps(sw))
|
||||||
|
for o in sw:
|
||||||
|
chk(f'{dev} {o["sel"]}: swipe hint present+visible', o.get('hasSwipe') and o.get('swipeVisible') and o.get('inHeader'))
|
||||||
|
chk(f'{dev} {o["sel"]}: swipe does not intersect <text>', o.get('intersectsText') is False,
|
||||||
|
{'texts': o.get('texts')})
|
||||||
|
chk(f'{dev} {o["sel"]}: swipe inside card bounds', o.get('swipeInsideCard') is True)
|
||||||
|
chk(f'{dev}: swipe on all 4 charts', all(o.get('hasSwipe') for o in sw))
|
||||||
|
|
||||||
|
# ---------------- Issue 3: anchor under sticky header ------------------------------------------
|
||||||
|
ha = page.evaluate("""() => {
|
||||||
|
location.hash = '#whatif';
|
||||||
|
return new Promise(res => setTimeout(() => {
|
||||||
|
const t = document.querySelector('#whatif'), hdr = document.querySelector('.hdr');
|
||||||
|
if (!t) return res({missing: true});
|
||||||
|
const tr = t.getBoundingClientRect(), hr = hdr.getBoundingClientRect();
|
||||||
|
res({scrollY: Math.round(window.scrollY), targetTop: Math.round(tr.top), hdrBottom: Math.round(hr.bottom)});
|
||||||
|
}, 2000));
|
||||||
|
}""")
|
||||||
|
print(f'{dev} HASH-ANCHOR:', json.dumps(ha))
|
||||||
|
chk(f'{dev}: anchor scrollY > 0', ha.get('scrollY', 0) > 0, {'scrollY': ha.get('scrollY')})
|
||||||
|
chk(f'{dev}: #whatif clears sticky header', ha.get('targetTop', -99) >= ha.get('hdrBottom', 999) - 1,
|
||||||
|
{'targetTop': ha.get('targetTop'), 'hdrBottom': ha.get('hdrBottom')})
|
||||||
|
else:
|
||||||
|
# ---------------- Desktop spot-check (1280): static nav, 80px scr padding, hidden hint ----------
|
||||||
|
set_state(page, 'quiz', 3)
|
||||||
|
page.reload(wait_until='networkidle'); page.wait_for_timeout(600)
|
||||||
|
d = page.evaluate("""() => ({
|
||||||
|
navPos: getComputedStyle(document.querySelector('.step-nav')).position,
|
||||||
|
scrPadBottom: getComputedStyle(document.querySelector('#scr-quiz')).paddingBottom,
|
||||||
|
navTop: Math.round(document.querySelector('.step-nav').getBoundingClientRect().top + window.scrollY)
|
||||||
|
})""")
|
||||||
|
print(f'{dev} DESKTOP-QUIZ:', json.dumps(d))
|
||||||
|
chk(f'{dev}: .step-nav position static', d['navPos'] == 'static', {'navPos': d['navPos']})
|
||||||
|
chk(f'{dev}: .scr padding-bottom 80px', d['scrPadBottom'] == '80px', {'pad': d['scrPadBottom']})
|
||||||
|
set_state(page, 'results', 0)
|
||||||
|
page.reload(wait_until='networkidle'); page.wait_for_timeout(1500)
|
||||||
|
d2 = page.evaluate("""() => [' #g1','#g2','#g3','#g4'].map(s => {
|
||||||
|
const sp = document.querySelector(s.trim()).querySelector('.swipe');
|
||||||
|
return {sel: s.trim(), present: !!sp, visible: !!(sp && sp.offsetParent)};
|
||||||
|
})""")
|
||||||
|
print(f'{dev} DESKTOP-SWIPE:', json.dumps(d2))
|
||||||
|
chk(f'{dev}: desktop swipe hidden', all((not o['visible']) for o in d2), d2)
|
||||||
|
ctx.close()
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print('\nVERIFY2-' + ('OK' if not fails else f'FAIL ({len(fails)}): ' + '; '.join(fails)))
|
||||||
|
sys.exit(0 if not fails else 1)
|
||||||
@@ -322,6 +322,7 @@
|
|||||||
else if (p.household === 'couple') ins = 'With pension splitting + household GIS, your joint taxes drop — the panel shows the joint total.';
|
else if (p.household === 'couple') ins = 'With pension splitting + household GIS, your joint taxes drop — the panel shows the joint total.';
|
||||||
else ins = 'Live numbers — change any answer and this panel moves.';
|
else ins = 'Live numbers — change any answer and this panel moves.';
|
||||||
$('#panel-insight').innerHTML = ins;
|
$('#panel-insight').innerHTML = ins;
|
||||||
|
syncSheetH(); /* panel content just changed height — keep --sheet-h live (round 2 Fix 1) */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- charts (hand-rolled inline SVG) ---------- */
|
/* ---------- charts (hand-rolled inline SVG) ---------- */
|
||||||
@@ -367,6 +368,25 @@
|
|||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Round 2 Fix 2: one chart() helper renders each card = header (.chart-h: h3 + caption)
|
||||||
|
+ SVG (+ legend). The "swipe →" hint is appended inside .chart-h — in the header row it
|
||||||
|
can never overlap SVG content (the old .chartbox::before overlay sat on the top y-tick).
|
||||||
|
Card titles/captions moved here from index.html so every render keeps the header. */
|
||||||
|
var CHART_SWIPE = '<span class="swipe">swipe →</span>';
|
||||||
|
var CHART_HDR = {
|
||||||
|
'#g1': '<h3>Will it last? Projected savings</h3><p class="cap">Total portfolio (RRSP + TFSA + non-registered), in today\'s dollars. Shaded: money running out.</p>',
|
||||||
|
'#g2': '<h3>Where retirement income comes from</h3><p class="cap">CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.</p>',
|
||||||
|
'#g3': '<h3>Spending vs. what you\'ll actually have</h3><p class="cap">Annual cash in (income after tax & clawback, plus withdrawals) vs. planned spending.</p>',
|
||||||
|
'#g4': function () { return '<h3>Four futures, one number</h3><p class="cap">Sustainable annual spending that lasts to <span id="lifeexp-lbl">' + state.a.lifeExp + '</span> under each scenario.</p>'; }
|
||||||
|
};
|
||||||
|
function chartH(sel) {
|
||||||
|
var hd = typeof CHART_HDR[sel] === 'function' ? CHART_HDR[sel]() : (CHART_HDR[sel] || '');
|
||||||
|
return '<div class="chart-h"><div>' + hd + '</div>' + CHART_SWIPE + '</div>';
|
||||||
|
}
|
||||||
|
function chart(sel, inner, legendItems) {
|
||||||
|
$(sel).innerHTML = chartH(sel) + (inner || '') + (legendItems ? legend(legendItems) : '');
|
||||||
|
}
|
||||||
|
|
||||||
var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626' };
|
var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626' };
|
||||||
|
|
||||||
function g1_balance(proj) {
|
function g1_balance(proj) {
|
||||||
@@ -385,12 +405,12 @@
|
|||||||
var ret = Math.max(a0, inputs().retireAge);
|
var ret = Math.max(a0, inputs().retireAge);
|
||||||
inner += '<line x1="' + X(ret) + '" x2="' + X(ret) + '" y1="' + T + '" y2="' + (H - B) + '" stroke="#94a3b8" stroke-dasharray="2 4"/>' +
|
inner += '<line x1="' + X(ret) + '" x2="' + X(ret) + '" y1="' + T + '" y2="' + (H - B) + '" stroke="#94a3b8" stroke-dasharray="2 4"/>' +
|
||||||
'<text x="' + (X(ret) + 4) + '" y="' + (H - B - 6) + '" font-size="12" fill="#94a3b8">retire</text>';
|
'<text x="' + (X(ret) + 4) + '" y="' + (H - B - 6) + '" font-size="12" fill="#94a3b8">retire</text>';
|
||||||
$('#g1').innerHTML = svgWrap(inner) + legend([['Total savings (today’s $)', COL.bal]]);
|
chart('#g1', svgWrap(inner), [['Total savings (today’s $)', COL.bal]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function g2_income(proj, p) {
|
function g2_income(proj, p) {
|
||||||
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
|
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
|
||||||
if (!yrs.length) { $('#g2').innerHTML = '<p class="cap">Retirement after your plan horizon.</p>'; return; }
|
if (!yrs.length) { chart('#g2', '<p class="cap">Retirement after your plan horizon.</p>'); return; }
|
||||||
var ages = yrs.map(function (r) { return r.age; });
|
var ages = yrs.map(function (r) { return r.age; });
|
||||||
var X = sc(ages, ages[0], ages[ages.length - 1]);
|
var X = sc(ages, ages[0], ages[ages.length - 1]);
|
||||||
var series = [
|
var series = [
|
||||||
@@ -409,12 +429,12 @@
|
|||||||
inner += '<path d="' + stackPath(ages, lower, upper, X, Y) + '" fill="' + se[1] + '33" stroke="' + se[1] + '" stroke-width="1"/>';
|
inner += '<path d="' + stackPath(ages, lower, upper, X, Y) + '" fill="' + se[1] + '33" stroke="' + se[1] + '" stroke-width="1"/>';
|
||||||
lower = upper;
|
lower = upper;
|
||||||
});
|
});
|
||||||
$('#g2').innerHTML = svgWrap(inner) + legend(series.map(function (s) { return [s[0], s[1]]; }));
|
chart('#g2', svgWrap(inner), series.map(function (s) { return [s[0], s[1]]; }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function g3_cash(proj) {
|
function g3_cash(proj) {
|
||||||
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
|
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
|
||||||
if (!yrs.length) { $('#g3').innerHTML = ''; return; }
|
if (!yrs.length) { chart('#g3', ''); return; }
|
||||||
var ages = yrs.map(function (r) { return r.age; });
|
var ages = yrs.map(function (r) { return r.age; });
|
||||||
var X = sc(ages, ages[0], ages[ages.length - 1]);
|
var X = sc(ages, ages[0], ages[ages.length - 1]);
|
||||||
var inc = yrs.map(function (r) { return r.cash + r.unfunded; });
|
var inc = yrs.map(function (r) { return r.cash + r.unfunded; });
|
||||||
@@ -428,13 +448,13 @@
|
|||||||
inner += '<circle cx="' + X(first) + '" cy="' + Y(need[ages.indexOf(first)]) + '" r="4.5" fill="#dc2626"/>' +
|
inner += '<circle cx="' + X(first) + '" cy="' + Y(need[ages.indexOf(first)]) + '" r="4.5" fill="#dc2626"/>' +
|
||||||
'<text x="' + (X(first) + 6) + '" y="' + (Y(need[ages.indexOf(first)]) - 6) + '" font-size="13" fill="#dc2626" font-weight="700">shortfall from ~' + first + '</text>';
|
'<text x="' + (X(first) + 6) + '" y="' + (Y(need[ages.indexOf(first)]) - 6) + '" font-size="13" fill="#dc2626" font-weight="700">shortfall from ~' + first + '</text>';
|
||||||
}
|
}
|
||||||
$('#g3').innerHTML = svgWrap(inner) + legend([['Cash you’ll have (after tax)', COL.cash], ['Planned spending', COL.need]]);
|
chart('#g3', svgWrap(inner), [['Cash you’ll have (after tax)', COL.cash], ['Planned spending', COL.need]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var g4cache = null;
|
var g4cache = null;
|
||||||
function g4_bars(p, fast) {
|
function g4_bars(p, fast) {
|
||||||
if (g4cache && fast) { drawG4(g4cache); $('#g4-note').textContent = 'Computed from the current answers.'; return; }
|
if (g4cache && fast) { drawG4(g4cache); $('#g4-note').textContent = 'Computed from the current answers.'; return; }
|
||||||
$('#g4').innerHTML = '<p class="cap">Calculating scenarios…</p>';
|
chart('#g4', '<p class="cap">Calculating scenarios…</p>');
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
var scen = [
|
var scen = [
|
||||||
['Current plan', p],
|
['Current plan', p],
|
||||||
@@ -463,7 +483,7 @@
|
|||||||
'<text x="' + (x + bw * 0.34) + '" y="' + (y - 6) + '" text-anchor="middle" font-size="13.5" font-weight="700" fill="#16233b">' + k(r[1]) + '</text>' +
|
'<text x="' + (x + bw * 0.34) + '" y="' + (y - 6) + '" text-anchor="middle" font-size="13.5" font-weight="700" fill="#16233b">' + k(r[1]) + '</text>' +
|
||||||
'<text x="' + (x + bw * 0.34) + '" y="' + (H - B + 14) + '" text-anchor="middle" font-size="12" fill="#8a97ad">' + r[0] + '</text>';
|
'<text x="' + (x + bw * 0.34) + '" y="' + (H - B + 14) + '" text-anchor="middle" font-size="12" fill="#8a97ad">' + r[0] + '</text>';
|
||||||
});
|
});
|
||||||
$('#g4').innerHTML = svgWrap(inner);
|
chart('#g4', svgWrap(inner));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- vision ---------- */
|
/* ---------- vision ---------- */
|
||||||
@@ -531,7 +551,9 @@
|
|||||||
v.innerHTML = 'This plan survives to <b>95</b> with ' + k(proj.finalBal) + ' left over — you can sustain about <b>' + money(ss) + '/yr</b>. ' +
|
v.innerHTML = 'This plan survives to <b>95</b> with ' + k(proj.finalBal) + ' left over — you can sustain about <b>' + money(ss) + '/yr</b>. ' +
|
||||||
'That’s ' + money(ss - p.spendingAnnual) + '/yr of headroom above your plan.';
|
'That’s ' + money(ss - p.spendingAnnual) + '/yr of headroom above your plan.';
|
||||||
}
|
}
|
||||||
$('#lifeexp-lbl').textContent = p.lifeExp;
|
/* lifeexp-lbl now lives inside the g4 card header (rendered below via chart('#g4'));
|
||||||
|
the header bakes in state.a.lifeExp, so just sync if the node exists (re-renders). */
|
||||||
|
var le = $('#lifeexp-lbl'); if (le) le.textContent = p.lifeExp;
|
||||||
|
|
||||||
var cards = '';
|
var cards = '';
|
||||||
if (rec.gapAnnual > 0) {
|
if (rec.gapAnnual > 0) {
|
||||||
@@ -625,6 +647,7 @@
|
|||||||
if (h && h.contains(e.target)) {
|
if (h && h.contains(e.target)) {
|
||||||
document.getElementById('panel').classList.toggle('open');
|
document.getElementById('panel').classList.toggle('open');
|
||||||
syncScrim();
|
syncScrim();
|
||||||
|
syncSheetH();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
/* Fix 3: scrim behind the open sheet on mobile; tapping it closes the sheet. */
|
/* Fix 3: scrim behind the open sheet on mobile; tapping it closes the sheet. */
|
||||||
@@ -635,13 +658,24 @@
|
|||||||
scrim.hidden = !open;
|
scrim.hidden = !open;
|
||||||
scrim.classList.toggle('on', open);
|
scrim.classList.toggle('on', open);
|
||||||
}
|
}
|
||||||
|
/* Round 2 Fix 1: publish the live sheet height as --sheet-h so .scr padding and the sticky
|
||||||
|
step-nav always clear the sheet (default 218px on :root keeps the first paint right).
|
||||||
|
Shim-safe: smoke-test stubs have no offsetHeight — the guard skips them. */
|
||||||
|
function syncSheetH() {
|
||||||
|
var panel = document.getElementById('panel');
|
||||||
|
var h = panel && panel.offsetHeight;
|
||||||
|
if (h) document.documentElement.style.setProperty('--sheet-h', h + 'px');
|
||||||
|
}
|
||||||
|
if (window.addEventListener) window.addEventListener('resize', syncSheetH);
|
||||||
var scrimEl = document.getElementById('scrim');
|
var scrimEl = document.getElementById('scrim');
|
||||||
if (scrimEl) scrimEl.addEventListener('click', function () {
|
if (scrimEl) scrimEl.addEventListener('click', function () {
|
||||||
document.getElementById('panel').classList.remove('open');
|
document.getElementById('panel').classList.remove('open');
|
||||||
syncScrim();
|
syncScrim();
|
||||||
|
syncSheetH();
|
||||||
});
|
});
|
||||||
var savedScreen = state.screen, savedStep = state.step;
|
var savedScreen = state.screen, savedStep = state.step;
|
||||||
showScreen(savedScreen);
|
showScreen(savedScreen);
|
||||||
if (savedScreen === 'quiz') { renderStep(); liveUpdate(); }
|
if (savedScreen === 'quiz') { renderStep(); liveUpdate(); }
|
||||||
else if (savedScreen === 'results') { renderResults(false); liveUpdate(); }
|
else if (savedScreen === 'results') { renderResults(false); liveUpdate(); }
|
||||||
|
syncSheetH();
|
||||||
})();
|
})();
|
||||||
@@ -64,20 +64,10 @@
|
|||||||
|
|
||||||
<div class="cards" id="gap-cards"></div>
|
<div class="cards" id="gap-cards"></div>
|
||||||
|
|
||||||
<h3>Will it last? Projected savings</h3>
|
<!-- Chart titles/captions render inside each card's .chart-h via app.js chart() (round 2 Fix 2) -->
|
||||||
<p class="cap">Total portfolio (RRSP + TFSA + non-registered), in today's dollars. Shaded: money running out.</p>
|
|
||||||
<div id="g1" class="chartbox"></div>
|
<div id="g1" class="chartbox"></div>
|
||||||
|
|
||||||
<h3>Where retirement income comes from</h3>
|
|
||||||
<p class="cap">CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.</p>
|
|
||||||
<div id="g2" class="chartbox"></div>
|
<div id="g2" class="chartbox"></div>
|
||||||
|
|
||||||
<h3>Spending vs. what you'll actually have</h3>
|
|
||||||
<p class="cap">Annual cash in (income after tax & clawback, plus withdrawals) vs. planned spending.</p>
|
|
||||||
<div id="g3" class="chartbox"></div>
|
<div id="g3" class="chartbox"></div>
|
||||||
|
|
||||||
<h3>Four futures, one number</h3>
|
|
||||||
<p class="cap">Sustainable annual spending that lasts to <span id="lifeexp-lbl">95</span> under each scenario.</p>
|
|
||||||
<div id="g4" class="chartbox"></div>
|
<div id="g4" class="chartbox"></div>
|
||||||
<p id="g4-note" class="cap"></p>
|
<p id="g4-note" class="cap"></p>
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
--ink:#16233b; --ink2:#4c5b74; --line:#e3e8f0; --bg:#f7f9fc; --card:#ffffff;
|
--ink:#16233b; --ink2:#4c5b74; --line:#e3e8f0; --bg:#f7f9fc; --card:#ffffff;
|
||||||
--blue:#2563eb; --red:#dc2626; --amber:#d97706; --green:#059669; --violet:#7c3aed;
|
--blue:#2563eb; --red:#dc2626; --amber:#d97706; --green:#059669; --violet:#7c3aed;
|
||||||
--r:14px;
|
--r:14px;
|
||||||
|
--muted:#4c5b74; --chip:#eef2f8;
|
||||||
|
--sheet-h:218px; /* round 2 Fix 1: live bottom-sheet height; app.js keeps it current */
|
||||||
}
|
}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
html{scroll-behavior:smooth}
|
html{scroll-behavior:smooth}
|
||||||
|
/* Round 2 Fix 3: in-page anchors must land below the sticky header (global; header is sticky on desktop too) */
|
||||||
|
[id]{scroll-margin-top:70px}
|
||||||
body{margin:0;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
|
body{margin:0;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
|
||||||
main{max-width:1060px;margin:0 auto;padding:12px}
|
main{max-width:1060px;margin:0 auto;padding:12px}
|
||||||
h1{font-size:clamp(26px,6vw,40px);line-height:1.15;margin:.4em 0 .3em}
|
h1{font-size:clamp(26px,6vw,40px);line-height:1.15;margin:.4em 0 .3em}
|
||||||
@@ -25,6 +29,7 @@ button,input{touch-action:manipulation}
|
|||||||
|
|
||||||
/* screens */
|
/* screens */
|
||||||
.scr{padding:8px 4px 80px}
|
.scr{padding:8px 4px 80px}
|
||||||
|
.swipe{display:none} /* round 2 Fix 2: hint hidden on desktop; shown ≤860px (rule in the mobile block below — keep it after this line) */
|
||||||
.lede{font-size:18px}
|
.lede{font-size:18px}
|
||||||
.ticks{list-style:none;padding:0;margin:14px 0}
|
.ticks{list-style:none;padding:0;margin:14px 0}
|
||||||
.ticks li{padding:7px 0 7px 30px;position:relative;color:var(--ink2)}
|
.ticks li{padding:7px 0 7px 30px;position:relative;color:var(--ink2)}
|
||||||
@@ -48,8 +53,11 @@ button,input{touch-action:manipulation}
|
|||||||
.hdr #btn-print{display:none}
|
.hdr #btn-print{display:none}
|
||||||
/* Fix 2: charts keep desktop scale; scroll sideways on phones */
|
/* Fix 2: charts keep desktop scale; scroll sideways on phones */
|
||||||
.chartbox{overflow-x:auto;overflow-y:hidden}
|
.chartbox{overflow-x:auto;overflow-y:hidden}
|
||||||
.chartbox::before{content:"swipe →";position:sticky;right:0;display:block;width:max-content;margin-bottom:-17px;padding:1px 8px;font-size:10px;color:var(--ink2);background:#ffffffcc;border:1px solid var(--line);border-radius:99px;pointer-events:none}
|
|
||||||
.chartbox svg{min-width:560px}
|
.chartbox svg{min-width:560px}
|
||||||
|
/* Round 2 Fix 2: "swipe →" lives in the chart card header (can never overlap SVG ticks);
|
||||||
|
sticky left:0 keeps it pinned while the card scrolls horizontally */
|
||||||
|
.chart-h{position:sticky;left:0}
|
||||||
|
.swipe{display:inline-block;font-size:11.5px;color:var(--muted);background:var(--chip);border-radius:99px;padding:3px 9px;white-space:nowrap;flex:none}
|
||||||
/* Fix 3: sheet height = its content; chips always visible; no transform peek */
|
/* Fix 3: sheet height = its content; chips always visible; no transform peek */
|
||||||
.panel{position:fixed;left:8px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom));z-index:40;max-height:44vh;overflow:auto;box-shadow:0 -6px 24px #16233b22}
|
.panel{position:fixed;left:8px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom));z-index:40;max-height:44vh;overflow:auto;box-shadow:0 -6px 24px #16233b22}
|
||||||
.panel .insight{display:none}
|
.panel .insight{display:none}
|
||||||
@@ -59,7 +67,18 @@ button,input{touch-action:manipulation}
|
|||||||
.panel.open .panel-h::after{content:" ▼"}
|
.panel.open .panel-h::after{content:" ▼"}
|
||||||
#scrim{position:fixed;inset:0;background:#16233b33;z-index:35}
|
#scrim{position:fixed;inset:0;background:#16233b33;z-index:35}
|
||||||
#scrim.on{display:block}
|
#scrim.on{display:block}
|
||||||
.scr{padding-bottom:calc(185px + env(safe-area-inset-bottom))}
|
/* Round 2 Fix 1: clearance = live sheet height (app.js sets --sheet-h) + gap + safe area */
|
||||||
|
.scr{padding-bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom))}
|
||||||
|
/* Round 2 Fix 1: Back/Next rows stick above the sheet on tall steps; solid bg, below scrim(35)/sheet(40).
|
||||||
|
#btn-next (the primary CTA, last element of the flow) carries the pin at sheet+12px so it is on
|
||||||
|
screen at every scroll position of every step; .step-nav is pinned per spec and stays in flow
|
||||||
|
above it. margin-bottom:-6px nets out the base margin so the spec's 6px padding box does not
|
||||||
|
shift short steps at all (income step stays pixel-identical to round 1: CTA top 485).
|
||||||
|
At max scroll the doc's own padding + the CTA's 18px margin + 50px height keep the
|
||||||
|
last option's bottom ≈507 — clear of the CTA top (≈544) and the sheet (≥616). */
|
||||||
|
.step-nav{position:sticky;bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom));background:var(--bg);z-index:20;padding:6px 0}
|
||||||
|
.quiz-main .step-nav{margin-bottom:-6px} /* specificity: base `.step-nav{margin-bottom:6px}` is later in file; net the padding box so round-1 flow is pixel-identical */
|
||||||
|
#btn-next{position:sticky;bottom:calc(var(--sheet-h) + 12px + env(safe-area-inset-bottom));z-index:21}
|
||||||
}
|
}
|
||||||
@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 */
|
||||||
@@ -106,6 +125,10 @@ input[type=range]{width:100%;accent-color:var(--blue)}
|
|||||||
.cap{font-size:13.5px;margin:.2em 0 .6em}
|
.cap{font-size:13.5px;margin:.2em 0 .6em}
|
||||||
.chartbox{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:10px;overflow:hidden}
|
.chartbox{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:10px;overflow:hidden}
|
||||||
.chartbox svg{display:block;width:100%;height:auto}
|
.chartbox svg{display:block;width:100%;height:auto}
|
||||||
|
/* Round 2 Fix 2: chart card header = title+caption block with the swipe hint on its right.
|
||||||
|
.swipe is display:none on desktop (base rule above), so the flex row renders exactly like the old stacked block. */
|
||||||
|
.chart-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
|
||||||
|
.chart-h>div{min-width:0}
|
||||||
.legend{display:flex;flex-wrap:wrap;gap:12px;font-size:13px;color:var(--ink2);padding:4px 6px 0}
|
.legend{display:flex;flex-wrap:wrap;gap:12px;font-size:13px;color:var(--ink2);padding:4px 6px 0}
|
||||||
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}
|
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}
|
||||||
|
|
||||||
|
|||||||