` 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}