diff --git a/MOBILE-FIX-2-REPORT.md b/MOBILE-FIX-2-REPORT.md new file mode 100644 index 0000000..042d023 --- /dev/null +++ b/MOBILE-FIX-2-REPORT.md @@ -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 + `swipe →` 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 `` 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; `` + first line in ``; 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`). diff --git a/MOBILE-FIX-2.md b/MOBILE-FIX-2.md new file mode 100644 index 0000000..02359eb --- /dev/null +++ b/MOBILE-FIX-2.md @@ -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 `swipe →` inside the +`.chart-h` div (which already holds the `

`+`

`). 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 `` +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 `` as first line in ``; 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` diff --git a/audit/measure2_run.py b/audit/measure2_run.py new file mode 100644 index 0000000..5849735 --- /dev/null +++ b/audit/measure2_run.py @@ -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') diff --git a/audit/mobile-after2/audit-run.txt b/audit/mobile-after2/audit-run.txt new file mode 100644 index 0000000..30a02ba --- /dev/null +++ b/audit/mobile-after2/audit-run.txt @@ -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 diff --git a/audit/mobile-after2/full-iphone14-intro.png b/audit/mobile-after2/full-iphone14-intro.png new file mode 100644 index 0000000..b27aead Binary files /dev/null and b/audit/mobile-after2/full-iphone14-intro.png differ diff --git a/audit/mobile-after2/full-iphone14-quiz-income.png b/audit/mobile-after2/full-iphone14-quiz-income.png new file mode 100644 index 0000000..d527dc7 Binary files /dev/null and b/audit/mobile-after2/full-iphone14-quiz-income.png differ diff --git a/audit/mobile-after2/full-iphone14-quiz-province.png b/audit/mobile-after2/full-iphone14-quiz-province.png new file mode 100644 index 0000000..58b15a5 Binary files /dev/null and b/audit/mobile-after2/full-iphone14-quiz-province.png differ diff --git a/audit/mobile-after2/full-iphone14-quiz-sheet-open.png b/audit/mobile-after2/full-iphone14-quiz-sheet-open.png new file mode 100644 index 0000000..8431a25 Binary files /dev/null and b/audit/mobile-after2/full-iphone14-quiz-sheet-open.png differ diff --git a/audit/mobile-after2/full-iphone14-quiz-slider.png b/audit/mobile-after2/full-iphone14-quiz-slider.png new file mode 100644 index 0000000..1a87e17 Binary files /dev/null and b/audit/mobile-after2/full-iphone14-quiz-slider.png differ diff --git a/audit/mobile-after2/full-iphone14-results-charts.png b/audit/mobile-after2/full-iphone14-results-charts.png new file mode 100644 index 0000000..2fecece Binary files /dev/null and b/audit/mobile-after2/full-iphone14-results-charts.png differ diff --git a/audit/mobile-after2/full-iphone14-results-table.png b/audit/mobile-after2/full-iphone14-results-table.png new file mode 100644 index 0000000..ddff4e6 Binary files /dev/null and b/audit/mobile-after2/full-iphone14-results-table.png differ diff --git a/audit/mobile-after2/full-iphone14-results-top.png b/audit/mobile-after2/full-iphone14-results-top.png new file mode 100644 index 0000000..53bfab4 Binary files /dev/null and b/audit/mobile-after2/full-iphone14-results-top.png differ diff --git a/audit/mobile-after2/full-pixel-quiz-province.png b/audit/mobile-after2/full-pixel-quiz-province.png new file mode 100644 index 0000000..4e931ff Binary files /dev/null and b/audit/mobile-after2/full-pixel-quiz-province.png differ diff --git a/audit/mobile-after2/full-pixel-results-charts.png b/audit/mobile-after2/full-pixel-results-charts.png new file mode 100644 index 0000000..65196f5 Binary files /dev/null and b/audit/mobile-after2/full-pixel-results-charts.png differ diff --git a/audit/mobile-after2/full-se-quiz-income.png b/audit/mobile-after2/full-se-quiz-income.png new file mode 100644 index 0000000..893b053 Binary files /dev/null and b/audit/mobile-after2/full-se-quiz-income.png differ diff --git a/audit/mobile-after2/full-se-results-charts.png b/audit/mobile-after2/full-se-results-charts.png new file mode 100644 index 0000000..a263893 Binary files /dev/null and b/audit/mobile-after2/full-se-results-charts.png differ diff --git a/audit/mobile-after2/geo-round2.txt b/audit/mobile-after2/geo-round2.txt new file mode 100644 index 0000000..94d1cda --- /dev/null +++ b/audit/mobile-after2/geo-round2.txt @@ -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 diff --git a/audit/mobile-after2/iphone14-g2-viewport.png b/audit/mobile-after2/iphone14-g2-viewport.png new file mode 100644 index 0000000..a73193c Binary files /dev/null and b/audit/mobile-after2/iphone14-g2-viewport.png differ diff --git a/audit/mobile-after2/iphone14-step0-viewport.png b/audit/mobile-after2/iphone14-step0-viewport.png new file mode 100644 index 0000000..f1c0851 Binary files /dev/null and b/audit/mobile-after2/iphone14-step0-viewport.png differ diff --git a/audit/mobile-after2/pixel-g2-viewport.png b/audit/mobile-after2/pixel-g2-viewport.png new file mode 100644 index 0000000..6ad363e Binary files /dev/null and b/audit/mobile-after2/pixel-g2-viewport.png differ diff --git a/audit/mobile-after2/pixel-step0-viewport.png b/audit/mobile-after2/pixel-step0-viewport.png new file mode 100644 index 0000000..33eda29 Binary files /dev/null and b/audit/mobile-after2/pixel-step0-viewport.png differ diff --git a/audit/mobile-after2/se-g2-viewport.png b/audit/mobile-after2/se-g2-viewport.png new file mode 100644 index 0000000..80fb963 Binary files /dev/null and b/audit/mobile-after2/se-g2-viewport.png differ diff --git a/audit/mobile-after2/se-step0-viewport.png b/audit/mobile-after2/se-step0-viewport.png new file mode 100644 index 0000000..d6460e3 Binary files /dev/null and b/audit/mobile-after2/se-step0-viewport.png differ diff --git a/audit/mobile-after2/verify-round2.txt b/audit/mobile-after2/verify-round2.txt new file mode 100644 index 0000000..4ab0fff --- /dev/null +++ b/audit/mobile-after2/verify-round2.txt @@ -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 {"texts": 12} + PASS iphone14 #g1: swipe inside card bounds + PASS iphone14 #g2: swipe hint present+visible + PASS iphone14 #g2: swipe does not intersect {"texts": 12} + PASS iphone14 #g2: swipe inside card bounds + PASS iphone14 #g3: swipe hint present+visible + PASS iphone14 #g3: swipe does not intersect {"texts": 12} + PASS iphone14 #g3: swipe inside card bounds + PASS iphone14 #g4: swipe hint present+visible + PASS iphone14 #g4: swipe does not intersect {"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 {"texts": 12} + PASS pixel #g1: swipe inside card bounds + PASS pixel #g2: swipe hint present+visible + PASS pixel #g2: swipe does not intersect {"texts": 12} + PASS pixel #g2: swipe inside card bounds + PASS pixel #g3: swipe hint present+visible + PASS pixel #g3: swipe does not intersect {"texts": 12} + PASS pixel #g3: swipe inside card bounds + PASS pixel #g4: swipe hint present+visible + PASS pixel #g4: swipe does not intersect {"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 {"texts": 12} + PASS se #g1: swipe inside card bounds + PASS se #g2: swipe hint present+visible + PASS se #g2: swipe does not intersect {"texts": 12} + PASS se #g2: swipe inside card bounds + PASS se #g3: swipe hint present+visible + PASS se #g3: swipe does not intersect {"texts": 12} + PASS se #g3: swipe inside card bounds + PASS se #g4: swipe hint present+visible + PASS se #g4: swipe does not intersect {"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 diff --git a/audit/mobile-before2/geo-round2.txt b/audit/mobile-before2/geo-round2.txt new file mode 100644 index 0000000..97ce13a --- /dev/null +++ b/audit/mobile-before2/geo-round2.txt @@ -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 diff --git a/audit/mobile-before2/iphone14-g2-viewport.png b/audit/mobile-before2/iphone14-g2-viewport.png new file mode 100644 index 0000000..ba6a593 Binary files /dev/null and b/audit/mobile-before2/iphone14-g2-viewport.png differ diff --git a/audit/mobile-before2/iphone14-step0-viewport.png b/audit/mobile-before2/iphone14-step0-viewport.png new file mode 100644 index 0000000..4452ab5 Binary files /dev/null and b/audit/mobile-before2/iphone14-step0-viewport.png differ diff --git a/audit/mobile-before2/pixel-g2-viewport.png b/audit/mobile-before2/pixel-g2-viewport.png new file mode 100644 index 0000000..b2943a7 Binary files /dev/null and b/audit/mobile-before2/pixel-g2-viewport.png differ diff --git a/audit/mobile-before2/pixel-step0-viewport.png b/audit/mobile-before2/pixel-step0-viewport.png new file mode 100644 index 0000000..016186f Binary files /dev/null and b/audit/mobile-before2/pixel-step0-viewport.png differ diff --git a/audit/mobile-before2/se-g2-viewport.png b/audit/mobile-before2/se-g2-viewport.png new file mode 100644 index 0000000..38017ba Binary files /dev/null and b/audit/mobile-before2/se-g2-viewport.png differ diff --git a/audit/mobile-before2/se-step0-viewport.png b/audit/mobile-before2/se-step0-viewport.png new file mode 100644 index 0000000..13af184 Binary files /dev/null and b/audit/mobile-before2/se-step0-viewport.png differ diff --git a/audit/mobile-before2/verify-round2.txt b/audit/mobile-before2/verify-round2.txt new file mode 100644 index 0000000..610f771 --- /dev/null +++ b/audit/mobile-before2/verify-round2.txt @@ -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 {"texts": 12} + FAIL iphone14 #g1: swipe inside card bounds + FAIL iphone14 #g2: swipe hint present+visible + FAIL iphone14 #g2: swipe does not intersect {"texts": 12} + FAIL iphone14 #g2: swipe inside card bounds + FAIL iphone14 #g3: swipe hint present+visible + FAIL iphone14 #g3: swipe does not intersect {"texts": 12} + FAIL iphone14 #g3: swipe inside card bounds + FAIL iphone14 #g4: swipe hint present+visible + FAIL iphone14 #g4: swipe does not intersect {"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 {"texts": 12} + FAIL pixel #g1: swipe inside card bounds + FAIL pixel #g2: swipe hint present+visible + FAIL pixel #g2: swipe does not intersect {"texts": 12} + FAIL pixel #g2: swipe inside card bounds + FAIL pixel #g3: swipe hint present+visible + FAIL pixel #g3: swipe does not intersect {"texts": 12} + FAIL pixel #g3: swipe inside card bounds + FAIL pixel #g4: swipe hint present+visible + FAIL pixel #g4: swipe does not intersect {"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 {"texts": 12} + FAIL se #g1: swipe inside card bounds + FAIL se #g2: swipe hint present+visible + FAIL se #g2: swipe does not intersect {"texts": 12} + FAIL se #g2: swipe inside card bounds + FAIL se #g3: swipe hint present+visible + FAIL se #g3: swipe does not intersect {"texts": 12} + FAIL se #g3: swipe inside card bounds + FAIL se #g4: swipe hint present+visible + FAIL se #g4: swipe does not intersect {"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 ; iphone14 #g1: swipe inside card bounds; iphone14 #g2: swipe hint present+visible; iphone14 #g2: swipe does not intersect ; iphone14 #g2: swipe inside card bounds; iphone14 #g3: swipe hint present+visible; iphone14 #g3: swipe does not intersect ; iphone14 #g3: swipe inside card bounds; iphone14 #g4: swipe hint present+visible; iphone14 #g4: swipe does not intersect ; 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 ; pixel #g1: swipe inside card bounds; pixel #g2: swipe hint present+visible; pixel #g2: swipe does not intersect ; pixel #g2: swipe inside card bounds; pixel #g3: swipe hint present+visible; pixel #g3: swipe does not intersect ; pixel #g3: swipe inside card bounds; pixel #g4: swipe hint present+visible; pixel #g4: swipe does not intersect ; 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 ; se #g1: swipe inside card bounds; se #g2: swipe hint present+visible; se #g2: swipe does not intersect ; se #g2: swipe inside card bounds; se #g3: swipe hint present+visible; se #g3: swipe does not intersect ; se #g3: swipe inside card bounds; se #g4: swipe hint present+visible; se #g4: swipe does not intersect ; se #g4: swipe inside card bounds; se: swipe on all 4 charts; se: #whatif clears sticky header diff --git a/audit/mobile_audit_run2.py b/audit/mobile_audit_run2.py new file mode 100644 index 0000000..a8f72b1 --- /dev/null +++ b/audit/mobile_audit_run2.py @@ -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') diff --git a/audit/verify2.py b/audit/verify2.py new file mode 100644 index 0000000..b6dc74a --- /dev/null +++ b/audit/verify2.py @@ -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-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 ', 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) diff --git a/site/app.js b/site/app.js index 99bc8be..93a002a 100644 --- a/site/app.js +++ b/site/app.js @@ -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 ins = 'Live numbers — change any answer and this panel moves.'; $('#panel-insight').innerHTML = ins; + syncSheetH(); /* panel content just changed height — keep --sheet-h live (round 2 Fix 1) */ } /* ---------- charts (hand-rolled inline SVG) ---------- */ @@ -367,6 +368,25 @@ }).join('') + ''; } + /* 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 = 'swipe →'; + var CHART_HDR = { + '#g1': '

Will it last? Projected savings

Total portfolio (RRSP + TFSA + non-registered), in today\'s dollars. Shaded: money running out.

', + '#g2': '

Where retirement income comes from

CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.

', + '#g3': '

Spending vs. what you\'ll actually have

Annual cash in (income after tax & clawback, plus withdrawals) vs. planned spending.

', + '#g4': function () { return '

Four futures, one number

Sustainable annual spending that lasts to ' + state.a.lifeExp + ' under each scenario.

'; } + }; + function chartH(sel) { + var hd = typeof CHART_HDR[sel] === 'function' ? CHART_HDR[sel]() : (CHART_HDR[sel] || ''); + return '
' + hd + '
' + CHART_SWIPE + '
'; + } + 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' }; function g1_balance(proj) { @@ -385,12 +405,12 @@ var ret = Math.max(a0, inputs().retireAge); inner += '' + 'retire'; - $('#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) { var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; }); - if (!yrs.length) { $('#g2').innerHTML = '

Retirement after your plan horizon.

'; return; } + if (!yrs.length) { chart('#g2', '

Retirement after your plan horizon.

'); return; } var ages = yrs.map(function (r) { return r.age; }); var X = sc(ages, ages[0], ages[ages.length - 1]); var series = [ @@ -409,12 +429,12 @@ inner += ''; 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) { 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 X = sc(ages, ages[0], ages[ages.length - 1]); var inc = yrs.map(function (r) { return r.cash + r.unfunded; }); @@ -428,13 +448,13 @@ inner += '' + 'shortfall from ~' + first + ''; } - $('#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; function g4_bars(p, fast) { if (g4cache && fast) { drawG4(g4cache); $('#g4-note').textContent = 'Computed from the current answers.'; return; } - $('#g4').innerHTML = '

Calculating scenarios…

'; + chart('#g4', '

Calculating scenarios…

'); setTimeout(function () { var scen = [ ['Current plan', p], @@ -463,7 +483,7 @@ '' + k(r[1]) + '' + '' + r[0] + ''; }); - $('#g4').innerHTML = svgWrap(inner); + chart('#g4', svgWrap(inner)); } /* ---------- vision ---------- */ @@ -531,7 +551,9 @@ v.innerHTML = 'This plan survives to 95 with ' + k(proj.finalBal) + ' left over — you can sustain about ' + money(ss) + '/yr. ' + '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 = ''; if (rec.gapAnnual > 0) { @@ -625,6 +647,7 @@ if (h && h.contains(e.target)) { document.getElementById('panel').classList.toggle('open'); syncScrim(); + syncSheetH(); } }); /* Fix 3: scrim behind the open sheet on mobile; tapping it closes the sheet. */ @@ -635,13 +658,24 @@ scrim.hidden = !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'); if (scrimEl) scrimEl.addEventListener('click', function () { document.getElementById('panel').classList.remove('open'); syncScrim(); + syncSheetH(); }); var savedScreen = state.screen, savedStep = state.step; showScreen(savedScreen); if (savedScreen === 'quiz') { renderStep(); liveUpdate(); } else if (savedScreen === 'results') { renderResults(false); liveUpdate(); } + syncSheetH(); })(); diff --git a/site/index.html b/site/index.html index 0cf8c6b..de71a95 100644 --- a/site/index.html +++ b/site/index.html @@ -64,20 +64,10 @@
-

Will it last? Projected savings

-

Total portfolio (RRSP + TFSA + non-registered), in today's dollars. Shaded: money running out.

+
- -

Where retirement income comes from

-

CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.

- -

Spending vs. what you'll actually have

-

Annual cash in (income after tax & clawback, plus withdrawals) vs. planned spending.

- -

Four futures, one number

-

Sustainable annual spending that lasts to 95 under each scenario.

diff --git a/site/style.css b/site/style.css index ae406e4..ff53476 100644 --- a/site/style.css +++ b/site/style.css @@ -3,9 +3,13 @@ --ink:#16233b; --ink2:#4c5b74; --line:#e3e8f0; --bg:#f7f9fc; --card:#ffffff; --blue:#2563eb; --red:#dc2626; --amber:#d97706; --green:#059669; --violet:#7c3aed; --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} 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)} main{max-width:1060px;margin:0 auto;padding:12px} 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 */ .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} .ticks{list-style:none;padding:0;margin:14px 0} .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} /* Fix 2: charts keep desktop scale; scroll sideways on phones */ .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} + /* 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 */ .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} @@ -59,7 +67,18 @@ button,input{touch-action:manipulation} .panel.open .panel-h::after{content:" ▼"} #scrim{position:fixed;inset:0;background:#16233b33;z-index:35} #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){ .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} .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} +/* 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 i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}