Mobile legend sheet: drags follow the finger, every state is reachable, and the Mapbox attribution lifts above the sheet
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

- No height transition while dragging; the resize grab zone is the top 44px (and, in detail
  mode, the content when it is scrolled to the top); one geometry source for the JS clamp and
  the CSS max-height; release snaps with hysteresis (open ↔ fullyOpen ↔ collapsed)
- The sheet publishes its height as --legend-sheet-h on the element host; the Mapbox/MapLibre
  bottom controls translate up by it, so the logo and attribution stay visible in every state
- New gate: internal/mobile-sheet-probe.mjs (Firefox, touch) taps a pin, drags the detail
  sheet up/down and checks heights, states and control overlaps
This commit is contained in:
swift-river542 2026-09-16 16:26:10 -05:00
parent 424d302064
commit 271ba0849c
2 changed files with 522 additions and 99 deletions

View file

@ -0,0 +1,331 @@
#!/usr/bin/env node
/**
* mobile-sheet-probe.mjs — REPRODUCE, with numbers, the two driver-reported mobile bugs
* of the legend bottom sheet (library/components/legends/LegendMobile.vue), on a phone
* viewport, through the real touch pipeline:
*
* BUG A "when you click on a people group … moving the informational page up and
* down in size has bugs" — the sheet must FOLLOW the finger during a resize
* drag (no lag / snap-back mid-drag), snap to the right ladder step on release
* (detail mode: open 30% ↔ fullyOpen), never climb over the top reserve where
* the search pill lives, keep the detail close X reachable, and re-expand from
* the collapsed footer on a tap.
* BUG B the sheet covers Mapbox's bottom-left logo / attribution — the audit here is
* a rect×rect overlap of the sheet against .mapboxgl-ctrl-bottom-left/-right
* (their VISIBLE children) in every sheet state.
*
* WHY: "it feels wrong on my phone" is not evidence a fix can be checked against. This
* probe turns the gesture into a per-step table (finger delta vs measured sheet height),
* the release into an expected/actual state, and the attribution into overlap px² —
* run it BEFORE a fix (baseline json) and AFTER (same json shape) and diff.
*
* HOW: Firefox (house rule: never Google Chrome) via internal/lib/browser.mjs, a MOBILE
* context (390×844, isMobile, hasTouch, dpr 2). The page is any host page carrying the
* custom element (--tag): the website dev server (the BUILT drop-in) or the bundler's
* standalone dev page (live edits, Vite HMR). The element is scrolled into view, its
* shadow root is polled for a WebGL canvas AND the mounted sheet.
* Selecting a people group — least hacky path found, in order:
* 1. a REAL TAP on a rendered pin: Vue's custom element keeps its root component at
* host._instance (Vue 3.5 VueElement); ProfileLoader provide()s the instance-scoped
* stores there, so host._instance.provides.mapStore.maps[*].instance is the live
* Mapbox map (both profiles registerMap). queryRenderedFeatures on the pin layer →
* map.project → page.touchscreen.tap → Mapbox 'click' → useMapEvents
* attachPinClickHandler → uiStore.selectPeopleGroup. The genuine user path.
* 2. fallback (no pin in the clear middle of the map, or the tap did not select):
* uiStore.selectPeopleGroup(feature) with a feature from dataStore.sources — the
* exact call the click handler makes (useMapEvents.js:234).
* The drag is synthetic TouchEvents (Touch/TouchEvent constructors work in Firefox with
* hasTouch) dispatched on the deepest element under the finger (shadow-aware
* elementFromPoint), so they bubble exactly like a real touch: touchstart on the drag
* strip, N touchmoves of --step px, touchend. After every move the sheet's
* offsetHeight is read on the next frame and compared with finger position.
* A second drag is started on the TITLE region (strip bottom + 18px) to show whether
* a finger that misses the 12px strip resizes the sheet or scrolls the content.
*
* Usage: node internal/mobile-sheet-probe.mjs [--url <page>] [--tag doxa-map]
* [--nth 0 | --instance <instanceId substring>] [--browser firefox|chromium]
* [--wait 30000] [--steps 10] [--step 20] [--json <out.json>] [--shots <dir>]
* [--no-select] skip the people-group selection and check the LEGEND-TAB
* free-drag contract instead (sheet stays where released,
* pull-to-bottom collapses, strip tap re-opens) + overlap audit
* Defaults: --url http://localhost:5174/app-profiles/doxa-simple-map/index.html
* --tag doxa-map --nth 0. NOTE the standalone dev page ignores ?profile= (its tabs
* are per-bundle, read from localStorage) and renders three stacked <doxa-map>
* slots (engagement / prayer / adoption): pick one with --instance dev-prayer.
* Website (built drop-in): --url http://localhost:3001/pray --tag doxa-map ·
* --url http://localhost:3001/research/ --tag doxa-research-map
* Exit: 0 every check passed · 1 any check failed (list printed) · 2 setup failure
* (no element / no canvas / no sheet / could not select a people group)
* Never prints token values; --shots saves PNGs only (nothing is read back).
*/
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { launch, TELEMETRY } from './lib/browser.mjs'
const TOOL = 'mobile-sheet-probe'
const args = process.argv.slice(2)
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
const URL = String(argOf('--url', 'http://localhost:5174/app-profiles/doxa-simple-map/index.html'))
const TAG = argOf('--tag', 'doxa-map')
const NTH = Number(argOf('--nth', 0))
const INSTANCE = argOf('--instance', '')
const BROWSER = argOf('--browser', 'firefox')
const WAIT = Number(argOf('--wait', 30000))
const STEPS = Math.max(1, Number(argOf('--steps', 10)))
const STEP = Number(argOf('--step', 20))
const JSON_OUT = argOf('--json', '')
const SHOTS = argOf('--shots', '')
const NO_SELECT = args.includes('--no-select') // legend-tab free-drag contract instead of the detail ladder
const FOLLOW_TOL = 8 // px — the sheet must track the finger within this
const SETTLE_MS = 450 // > the sheet's 0.3s height transition
const VIEWPORT = { width: 390, height: 844 }
// ── page-side helpers (composed in Node — no eval inside the page) ───────────
// host(): the probed custom element. sheet(): its bottom sheet. Shadow-aware
// elementFromPoint descends through nested open shadow roots.
const HELPERS = `
const host = () => window.__msp_host
const sr = () => host() && host().shadowRoot
// The VISIBLE sheet. In detail mode doxa-simple-map also mounts LegendDesktop (in the
// display:none desktop slot), which mounts a HIDDEN twin LegendMobile that precedes
// the real one in DOM order — a bare querySelector returns that 0×0 twin.
const sheets = () => sr() ? [...sr().querySelectorAll('.legend-mobile-sheet')] : []
const sheet = () => sheets().find(s => s.getClientRects().length && s.offsetHeight > 0) || sheets()[0] || null
const q = (sel) => { const s = sheet(); return s ? s.querySelector(sel) : null }
const provides = () => (host() && host()._instance && host()._instance.provides) || null
const ui = () => (provides() || {}).uiStore || null
const deepFromPoint = (x, y) => { let el = document.elementFromPoint(x, y); while (el && el.shadowRoot) { const n = el.shadowRoot.elementFromPoint(x, y); if (!n || n === el) break; el = n } return el }
const rect = (el) => { if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.x * 100) / 100, y: Math.round(r.y * 100) / 100, w: Math.round(r.width * 100) / 100, h: Math.round(r.height * 100) / 100, top: r.top, bottom: r.bottom, left: r.left, right: r.right } }
const overlap = (a, b) => { if (!a || !b) return 0; const w = Math.min(a.right, b.right) - Math.max(a.left, b.left); const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); return (w > 0 && h > 0) ? Math.round(w * h) : 0 }
// bottom control corner: the union of its VISIBLE children (the logo / attribution
// boxes), falling back to the container's own box. An empty corner → null.
const ctrlRect = (sel) => { const c = sr() && sr().querySelector(sel); if (!c) return null; let u = null; for (const ch of c.children) { const cs = getComputedStyle(ch); if (cs.display === 'none' || cs.visibility === 'hidden') continue; const r = ch.getBoundingClientRect(); if (!r.width || !r.height) continue; u = u ? { left: Math.min(u.left, r.left), top: Math.min(u.top, r.top), right: Math.max(u.right, r.right), bottom: Math.max(u.bottom, r.bottom) } : { left: r.left, top: r.top, right: r.right, bottom: r.bottom } } if (!u) return null; return { x: Math.round(u.left * 100) / 100, y: Math.round(u.top * 100) / 100, w: Math.round((u.right - u.left) * 100) / 100, h: Math.round((u.bottom - u.top) * 100) / 100, top: u.top, bottom: u.bottom, left: u.left, right: u.right, children: c.childElementCount } }
const touch = (type, target, x, y, id) => { const t = new Touch({ identifier: id, target, clientX: x, clientY: y, pageX: x + window.scrollX, pageY: y + window.scrollY, screenX: x, screenY: y }); const list = type === 'touchend' || type === 'touchcancel' ? [] : [t]; const ev = new TouchEvent(type, { touches: list, targetTouches: list, changedTouches: [t], bubbles: true, cancelable: true, composed: true }); const notCancelled = target.dispatchEvent(ev); return notCancelled }
const snap = () => { const s = sheet(); const u = ui(); const root = s && s.parentElement; return { sheetCount: sheets().length, legendState: u && u.legendState, legendMode: u && u.legendMode, legendHeight: u && u.legendHeight, selected: !!(u && u.selectedPeopleGroup), sheetH: s ? s.offsetHeight : null, sheetRect: rect(s), containerH: root ? root.offsetHeight : null, containerRect: rect(root), hostRect: rect(host()), transition: s ? getComputedStyle(s).transitionProperty + ' ' + getComputedStyle(s).transitionDuration : null, maxHeight: s ? getComputedStyle(s).maxHeight : null, classes: s ? s.className : null, ctrlBL: ctrlRect('.mapboxgl-ctrl-bottom-left, .maplibregl-ctrl-bottom-left'), ctrlBR: ctrlRect('.mapboxgl-ctrl-bottom-right, .maplibregl-ctrl-bottom-right'), miniImage: rect(sr() && sr().querySelector('.floating-mini-image')), hostVar: host() ? host().style.getPropertyValue('--legend-sheet-h') : null } }
`
const ev = (page, body, arg) => page.evaluate(new Function('arg', `${HELPERS}\nreturn (async () => { ${body} })()`), arg)
const raf = 'await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));'
// ── main ─────────────────────────────────────────────────────────────────────
const browser = await launch(BROWSER, { label: TOOL })
const ctx = await browser.newContext({ viewport: VIEWPORT, isMobile: true, hasTouch: true, deviceScaleFactor: 2 })
const page = await ctx.newPage()
const consoleErrors = [], pageErrors = []
page.on('console', m => { if (m.type() === 'error' && !TELEMETRY.test(m.text())) consoleErrors.push(m.text().slice(0, 240)) })
page.on('pageerror', e => pageErrors.push(String(e.message || e).slice(0, 240)))
if (SHOTS) mkdirSync(SHOTS, { recursive: true })
const shot = async (name) => { if (SHOTS) await page.screenshot({ path: join(SHOTS, `${name}.png`) }).catch(() => {}) }
const report = { tool: TOOL, generatedAt: new Date().toISOString(), url: URL, tag: TAG, nth: NTH, instance: INSTANCE || null, browser: BROWSER, viewport: VIEWPORT, steps: STEPS, stepPx: STEP, followTolPx: FOLLOW_TOL, setupError: null, selection: null, states: {}, drags: [], checks: [], consoleErrors, pageErrors }
const checks = report.checks
const check = (id, pass, detail) => { checks.push({ id, pass: !!pass, detail }); console.log(` ${pass ? '✓' : '✗'} ${id}${detail ? ` — ${detail}` : ''}`); return !!pass }
const fmtRect = (r) => r ? `${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.w)}×${Math.round(r.h)}` : 'none'
async function fail(msg) {
report.setupError = msg
console.log(`✗ ${TOOL} SETUP: ${msg}`)
if (JSON_OUT) { mkdirSync(dirname(JSON_OUT), { recursive: true }); writeFileSync(JSON_OUT, JSON.stringify(report, null, 2)) }
await browser.close(); process.exit(2)
}
// Records the sheet + bottom-control geometry for one named state (BUG B audit).
async function audit(name) {
const s = await ev(page, `${raf} return snap()`)
const blOverlap = overlap2(s.sheetRect, s.ctrlBL), brOverlap = overlap2(s.sheetRect, s.ctrlBR)
report.states[name] = { ...s, overlapBL: blOverlap, overlapBR: brOverlap }
console.log(` [${name}] sheets=${s.sheetCount} state=${s.legendState} mode=${s.legendMode} sheet=${fmtRect(s.sheetRect)} (h ${s.sheetH}, legendHeight '${s.legendHeight}') ctrlBL=${fmtRect(s.ctrlBL)} ctrlBR=${fmtRect(s.ctrlBR)} overlapBL=${blOverlap}px² overlapBR=${brOverlap}px² hostVar='${s.hostVar}'`)
return report.states[name]
}
function overlap2(a, b) { if (!a || !b) return 0; const w = Math.min(a.right, b.right) - Math.max(a.left, b.left); const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); return (w > 0 && h > 0) ? Math.round(w * h) : 0 }
// One resize drag: touchstart at (x, y0), STEPS touchmoves of dy each (negative = up),
// touchend. Per step: expected height = clamp(start + finger delta), measured = offsetHeight
// on the next frame. `label` names it; `expectRelease` is the legend state expected after.
async function drag({ label, y0, dy, steps = STEPS, expectRelease, expectHeightPx, offsetX = 0 }) {
const before = await ev(page, `${raf} return snap()`)
const x = before.sheetRect.x + before.sheetRect.w / 2 + offsetX
const startEl = await ev(page, `const el = deepFromPoint(arg.x, arg.y); window.__msp_finger = el; return el ? (el.className && String(el.className).slice(0, 60) || el.tagName) : null`, { x, y: y0 })
const started = await ev(page, `const el = window.__msp_finger; if (!el) return false; return touch('touchstart', el, arg.x, arg.y, 7)`, { x, y: y0 })
const rows = []
let y = y0, maxH = before.sheetH, minTop = before.sheetRect.top
for (let i = 1; i <= steps; i++) {
y += dy
const r = await ev(page, `touch('touchmove', window.__msp_finger, arg.x, arg.y, 7); ${raf} const s = snap(); return { sheetH: s.sheetH, top: s.sheetRect ? s.sheetRect.top : null, legendHeight: s.legendHeight, legendState: s.legendState, transition: s.transition, classes: s.classes }`, { x, y })
await page.waitForTimeout(40) // ~real finger cadence; also lets a CSS transition show its lag
const fingerDelta = -(y - y0)
const expected = Math.min(Math.max(before.sheetH + fingerDelta, 48), before.containerH)
rows.push({ step: i, fingerY: y, fingerDelta, sheetH: r.sheetH, deviation: r.sheetH - expected, legendHeight: r.legendHeight, legendState: r.legendState, transition: r.transition })
if (r.sheetH > maxH) maxH = r.sheetH
if (r.top < minTop) minTop = r.top
}
// read once more after the last move settles (a lagging transition shows here as a jump)
await page.waitForTimeout(SETTLE_MS)
const preRelease = await ev(page, `return snap()`)
await ev(page, `touch('touchend', window.__msp_finger, arg.x, arg.y, 7)`, { x, y })
await page.waitForTimeout(SETTLE_MS)
const after = await ev(page, `${raf} return snap()`)
const d = { label, startEl, started, x, y0, dy, steps, before: { sheetH: before.sheetH, legendState: before.legendState, containerH: before.containerH, transition: before.transition, maxHeight: before.maxHeight }, rows, preRelease: { sheetH: preRelease.sheetH, legendHeight: preRelease.legendHeight }, after: { sheetH: after.sheetH, legendState: after.legendState, legendHeight: after.legendHeight, sheetRect: after.sheetRect }, maxSheetH: maxH, minSheetTop: minTop, expectRelease, expectHeightPx }
report.drags.push(d)
console.log(` drag "${label}" from ${startEl} @y${y0} ${dy > 0 ? '+' : ''}${dy}px×${steps}: ${before.legendState} h${before.sheetH} → per-step h [${rows.map(r => r.sheetH).join(',')}] (deviation [${rows.map(r => r.deviation).join(',')}]) → release: ${after.legendState} h${after.sheetH}`)
return d
}
try {
console.log(`[${TOOL}] ${URL} <${TAG}>${INSTANCE ? ` instance~"${INSTANCE}"` : ` nth ${NTH}`} · ${BROWSER} · ${VIEWPORT.width}×${VIEWPORT.height} touch`)
await page.goto(URL, { waitUntil: 'load', timeout: 60000 })
// ── 1. the element ──
const found = await ev(page, `const els = [...document.querySelectorAll(arg.tag)]; let el = null; if (arg.instance) el = els.find(e => (e.getAttribute('profile-config') || '').includes(arg.instance)) || null; else el = els[arg.nth] || null; if (!el) return { count: els.length }; window.__msp_host = el; el.scrollIntoView({ block: 'start' }); return { count: els.length, defined: !!customElements.get(arg.tag) }`, { tag: TAG, nth: NTH, instance: INSTANCE })
if (!found.defined && found.count === 0) await fail(`no <${TAG}> on the page (defined: ${found.defined})`)
if (!(await ev(page, `return !!host()`))) await fail(`<${TAG}> #${INSTANCE || NTH} not found (${found.count} on page)`)
// ── 2. canvas + sheet + stores + rendered pins ──
const deadline = Date.now() + WAIT
let ready = null
while (Date.now() < deadline) {
ready = await ev(page, `const p = provides(); const maps = p && p.mapStore && p.mapStore.maps ? Object.values(p.mapStore.maps) : []; const m = maps.find(x => x && x.instance) ? maps.find(x => x && x.instance).instance : null; let pins = 0, layer = null; try { for (const id of ['language-family-pins', 'language-family-pins-hitbox', 'pins']) { if (m && m.getLayer && m.getLayer(id)) { pins = m.queryRenderedFeatures({ layers: [id] }).length; layer = id; if (pins) break } } } catch (_) {} return { canvas: !!(sr() && sr().querySelector('canvas')), sheet: !!sheet(), stores: !!(p && p.uiStore), map: !!m, mapLoaded: !!(m && m.loaded && m.loaded()), pins, layer, breakpoint: p && p.uiStore ? p.uiStore.breakpoint : null }`)
if (ready.canvas && ready.sheet && ready.stores && ready.map && ready.pins > 0) break
await page.waitForTimeout(300)
}
console.log(` ready: canvas=${ready.canvas} sheet=${ready.sheet} stores=${ready.stores} map=${ready.map} loaded=${ready.mapLoaded} renderedPins=${ready.pins} (${ready.layer}) breakpoint=${ready.breakpoint}`)
if (!ready.canvas) await fail(`no WebGL canvas in <${TAG}> within ${WAIT}ms`)
if (!ready.sheet) await fail(`no .legend-mobile-sheet mounted within ${WAIT}ms (viewport ${VIEWPORT.width}px should hit the mobile @media slot)`)
if (!ready.stores) await fail('host._instance.provides.uiStore missing — Vue custom element internals changed?')
await page.waitForTimeout(800) // let the pins' first paint + any boot transitions settle
await shot('01-initial')
const s0 = await audit('collapsed-initial')
// ── 3'. --no-select: the NON-detail (legend tab) free-drag contract instead ──
// qa 2026-05-03: a legend tab is free-drag — the sheet stays where it is released
// (no snap to fullyOpen); only a pull to the bottom collapses it; a strip tap re-opens.
if (NO_SELECT) {
if (s0.legendState === 'collapsed') { const st = await ev(page, `return rect(q('.sheet-drag-strip'))`); await page.touchscreen.tap(st.x + st.w / 2, st.y + st.h / 2); await page.waitForTimeout(SETTLE_MS) }
const sO = await audit('legend-open')
check('L0.legend-open', sO.legendState === 'open' && sO.legendMode !== 'detail', `legendState=${sO.legendState} mode=${sO.legendMode} h=${sO.sheetH}`)
const st1 = await ev(page, `const st = q('.sheet-drag-strip'); return { rect: rect(st), h: st.offsetHeight }`)
const dU = await drag({ label: 'legend-up-200-from-strip', y0: st1.rect.y + st1.h / 2, dy: -STEP, expectRelease: 'open' })
const worst = Math.max(...dU.rows.map(r => Math.abs(r.deviation)))
check('L1.follows-finger-up', worst <= FOLLOW_TOL, `worst deviation ${worst}px; per-step h [${dU.rows.map(r => r.sheetH).join(',')}]`)
check('L2.free-drag-stays', dU.after.legendState === 'open' && Math.abs(dU.after.sheetH - dU.rows[dU.rows.length - 1].sheetH) <= FOLLOW_TOL, `released at h${dU.rows[dU.rows.length - 1].sheetH} → state ${dU.after.legendState} h${dU.after.sheetH} (must stay, not snap to fullyOpen)`)
const sL = await audit('legend-custom')
const st2 = await ev(page, `const st = q('.sheet-drag-strip'); return { rect: rect(st), h: st.offsetHeight }`)
const downPx = Math.ceil((sL.sheetH - sL.containerH * 0.3 * 0.5 + 20) / 10)
const dD = await drag({ label: 'legend-down-to-bottom', y0: st2.rect.y + st2.h / 2, dy: +downPx, steps: 10, expectRelease: 'collapsed' })
check('L3.pull-to-bottom-collapses', dD.after.legendState === 'collapsed' && dD.after.sheetH === 48, `state ${dD.after.legendState} h${dD.after.sheetH}`)
const sC = await audit('legend-collapsed')
const st3 = await ev(page, `return rect(q('.sheet-drag-strip'))`)
await page.touchscreen.tap(st3.x + st3.w / 2, st3.y + st3.h / 2); await page.waitForTimeout(SETTLE_MS)
const sR = await ev(page, `${raf} return snap()`)
check('L4.strip-tap-reopens', sR.legendState === 'open', `state ${sR.legendState} h${sR.sheetH}`)
for (const [name, s] of [['collapsed-initial', s0], ['legend-open', sO], ['legend-custom', sL], ['legend-collapsed', sC]]) {
if (s.ctrlBL) check(`B.no-overlap-BL@${name}`, s.overlapBL === 0, `sheet ${fmtRect(s.sheetRect)} × ctrlBL ${fmtRect(s.ctrlBL)} = ${s.overlapBL}px² (host --legend-sheet-h ${s.hostVar || 'unset'})`)
}
throw { done: true }
}
// ── 3. select a people group ──
const sel = { method: null, tapPoint: null, name: null, attempts: [] }
// 3a. real tap on a pin sitting in the CLEAR part of the map (not under the search
// pill / toolbar / sheet / edge strips). Try a few, nearest to the centre first.
const cands = await ev(page, `const p = provides(); const m = Object.values(p.mapStore.maps).map(x => x.instance).find(Boolean); if (!m) return []; const c = sr().querySelector('canvas').getBoundingClientRect(); const sh = sheet().getBoundingClientRect(); const seen = new Set(); const out = []; for (const id of ['language-family-pins-hitbox', 'language-family-pins']) { if (!m.getLayer(id)) continue; for (const f of m.queryRenderedFeatures({ layers: [id] })) { const uid = f.properties && f.properties.uniqueId; if (!uid || seen.has(uid)) continue; seen.add(uid); const pt = m.project(f.geometry.coordinates); const x = c.left + pt.x, y = c.top + pt.y; if (x < c.left + 60 || x > c.right - 60 || y < c.top + 130 || y > sh.top - 40) continue; out.push({ x, y, uid, name: f.properties.name || f.properties.peopleGroup || '' }) } } const cx = c.left + c.width / 2, cy = (c.top + 130 + sh.top - 40) / 2; out.sort((a, b) => Math.hypot(a.x - cx, a.y - cy) - Math.hypot(b.x - cx, b.y - cy)); return out.slice(0, 6)`)
for (const cnd of cands) {
const under = await ev(page, `const el = deepFromPoint(arg.x, arg.y); return el ? (el.tagName + (el.className ? '.' + String(el.className).split(' ')[0] : '')) : null`, cnd)
if (!/canvas/i.test(under || '')) { sel.attempts.push({ ...cnd, skipped: `covered by ${under}` }); continue }
await page.touchscreen.tap(cnd.x, cnd.y)
await page.waitForTimeout(700)
const st = await ev(page, `return snap()`)
sel.attempts.push({ ...cnd, under, legendMode: st.legendMode, legendState: st.legendState })
if (st.legendMode === 'detail' && st.selected) { sel.method = 'real-tap-on-pin (page.touchscreen.tap → Mapbox click → attachPinClickHandler)'; sel.tapPoint = { x: Math.round(cnd.x), y: Math.round(cnd.y) }; sel.name = cnd.name; break }
}
// 3b. fallback: the store call the click handler makes
if (!sel.method) {
const r = await ev(page, `const p = provides(); let f = null; for (const id in (p.dataStore.sources || {})) { const feats = p.dataStore.sources[id].features || []; f = feats.find(x => x && x.geometry && x.properties && x.properties.uniqueId) || null; if (f) break } if (!f) return { ok: false }; p.uiStore.selectPeopleGroup(f); if (p.uiStore.isMobile && p.uiStore.legendState === 'collapsed') p.uiStore.setLegendState('open'); return { ok: true, name: f.properties.name || '' }`)
if (r.ok) { await page.waitForTimeout(SETTLE_MS); sel.method = 'store-call uiStore.selectPeopleGroup(feature) (fallback — same call as useMapEvents.js:234)'; sel.name = r.name }
}
report.selection = sel
if (!sel.method) await fail(`could not select a people group (${sel.attempts.length} pin taps tried, no dataStore feature)`)
console.log(` selected "${sel.name}" via ${sel.method}`)
await page.waitForTimeout(SETTLE_MS)
await shot('02-detail-open')
const sOpen = await audit('detail-open')
check('A0.detail-mode-open', sOpen.legendMode === 'detail' && sOpen.legendState === 'open', `legendMode=${sOpen.legendMode} legendState=${sOpen.legendState} h=${sOpen.sheetH} (30% of ${sOpen.containerH} = ${Math.round(sOpen.containerH * 0.3)})`)
const strip = await ev(page, `const st = q('.sheet-drag-strip'); return { rect: rect(st), h: st ? st.offsetHeight : null }`)
report.dragStrip = strip
const closeOpen = await ev(page, `const b = q('.detail-close-btn'); if (!b) return { present: false }; const r = b.getBoundingClientRect(); const hit = deepFromPoint(r.left + r.width / 2, r.top + r.height / 2); return { present: true, rect: rect(b), hit: hit ? (hit === b || b.contains(hit)) : false, inHost: r.top >= host().getBoundingClientRect().top && r.bottom <= host().getBoundingClientRect().bottom }`)
report.states['detail-open'].closeX = closeOpen
check('A1.closeX-reachable-open', closeOpen.present && closeOpen.hit && closeOpen.inHost, `rect ${fmtRect(closeOpen.rect)} hit=${closeOpen.hit}`)
// ── 4. BUG A — drag UP from the drag strip ──
console.log(` drag strip: ${fmtRect(strip.rect)} (height ${strip.h}px) · sheet transition: ${sOpen.transition} · CSS max-height: ${sOpen.maxHeight} · JS clamp (container−56): ${sOpen.containerH - 56} vs CSS (container−80): ${sOpen.containerH - 80}`)
const topReserve = 80
const dUp = await drag({ label: 'up-200-from-strip', y0: strip.rect.y + strip.h / 2, dy: -STEP, expectRelease: 'fullyOpen' })
const worstUp = Math.max(...dUp.rows.map(r => Math.abs(r.deviation)))
check('A2.follows-finger-up', worstUp <= FOLLOW_TOL, `worst deviation ${worstUp}px (tol ±${FOLLOW_TOL}); per-step h [${dUp.rows.map(r => r.sheetH).join(',')}]`)
const monotonic = dUp.rows.every((r, i) => i === 0 || r.sheetH >= dUp.rows[i - 1].sheetH - 1)
check('A3.no-snap-back-mid-drag', monotonic, monotonic ? 'height never decreased during the upward drag' : `heights [${dUp.rows.map(r => r.sheetH).join(',')}]`)
check('A4.release-up→fullyOpen', dUp.after.legendState === 'fullyOpen', `legendState=${dUp.after.legendState} h=${dUp.after.sheetH} (expected ${sOpen.containerH - topReserve})`)
check('A5.sheet-top-reserve', dUp.minSheetTop >= sOpen.containerRect.top + 56 - 0.5, `min sheet top ${Math.round(dUp.minSheetTop - sOpen.containerRect.top)}px below container top (search pill occupies 10..45px)`)
await shot('03-fullyOpen')
const sFull = await audit('detail-fullyOpen')
const closeFull = await ev(page, `const b = q('.detail-close-btn'); if (!b) return { present: false }; const r = b.getBoundingClientRect(); const hit = deepFromPoint(r.left + r.width / 2, r.top + r.height / 2); return { present: true, rect: rect(b), hit: hit ? (hit === b || b.contains(hit)) : false }`)
sFull.closeX = closeFull
check('A6.closeX-reachable-fullyOpen', closeFull.present && closeFull.hit, `rect ${fmtRect(closeFull.rect)} hit=${closeFull.hit}`)
// ── 5. drag DOWN 150 from fullyOpen → open ──
const strip2 = await ev(page, `return rect(q('.sheet-drag-strip'))`)
const dDown = await drag({ label: 'down-150-from-strip', y0: strip2.y + strip.h / 2, dy: +15, steps: 10, expectRelease: 'open' })
const worstDown = Math.max(...dDown.rows.map(r => Math.abs(r.deviation)))
check('A7.follows-finger-down', worstDown <= FOLLOW_TOL, `worst deviation ${worstDown}px; per-step h [${dDown.rows.map(r => r.sheetH).join(',')}]`)
check('A8.release-down→open', dDown.after.legendState === 'open', `legendState=${dDown.after.legendState} h=${dDown.after.sheetH} (30% = ${Math.round(sOpen.containerH * 0.3)})`)
await shot('04-open-again')
// ── 6. drag DOWN again from open → collapsed ──
const strip3 = await ev(page, `return rect(q('.sheet-drag-strip'))`)
const dDown2 = await drag({ label: 'down-100-from-open', y0: strip3.y + strip.h / 2, dy: +10, steps: 10, expectRelease: 'collapsed' })
check('A9.release-down→collapsed', dDown2.after.legendState === 'collapsed', `legendState=${dDown2.after.legendState} h=${dDown2.after.sheetH}`)
await shot('05-collapsed-detail')
const sCol = await audit('detail-collapsed')
// ── 7. tap the collapsed footer → open ──
const footer = await ev(page, `return rect(q('.collapsed-detail-footer'))`)
if (footer) { await page.touchscreen.tap(footer.x + footer.w / 2, footer.y + footer.h / 2); await page.waitForTimeout(SETTLE_MS) }
const sReopen = await ev(page, `${raf} return snap()`)
check('A10.footer-tap-reexpands', !!footer && sReopen.legendState === 'open', `footer ${fmtRect(footer)} → legendState=${sReopen.legendState} h=${sReopen.sheetH}`)
await shot('06-reopened')
// ── 8. a finger that misses the 12px strip: drag from the TITLE region ──
// (strip bottom + 18px — where a thumb naturally lands on the header). Before the
// fix this scrolls the content instead of resizing; the row is informational
// (A11) and becomes a hard check once the grab surface is widened.
const strip4 = await ev(page, `return rect(q('.sheet-drag-strip'))`)
const dTitle = await drag({ label: 'up-200-from-title-region', y0: strip4.y + strip4.h + 18, dy: -STEP, expectRelease: 'fullyOpen' })
const titleResized = dTitle.after.sheetH > sReopen.sheetH + FOLLOW_TOL
check('A11.title-region-drag-resizes', titleResized, `start element ${dTitle.startEl} → ${dTitle.after.legendState} h${dTitle.after.sheetH} (was h${sReopen.sheetH})`)
const sFull2 = await audit('detail-fullyOpen-2')
// restore to open for the final overlap audit at 30%
await ev(page, `ui().openLegend()`); await page.waitForTimeout(SETTLE_MS)
const sOpen2 = await audit('detail-open-2')
// ── 9. BUG B — bottom controls must never sit under the sheet ──
const audits = [['collapsed-initial', s0], ['detail-open', sOpen], ['detail-fullyOpen', sFull], ['detail-collapsed', sCol], ['detail-fullyOpen-2', sFull2], ['detail-open-2', sOpen2]]
const hasBL = audits.some(([, s]) => s.ctrlBL), hasBR = audits.some(([, s]) => s.ctrlBR)
for (const [name, s] of audits) {
if (s.ctrlBL) check(`B.no-overlap-BL@${name}`, s.overlapBL === 0, `sheet ${fmtRect(s.sheetRect)} × ctrlBL ${fmtRect(s.ctrlBL)} = ${s.overlapBL}px²${s.hostVar ? ` (host --legend-sheet-h ${s.hostVar})` : ''}`)
if (s.ctrlBR) check(`B.no-overlap-BR@${name}`, s.overlapBR === 0, `sheet ${fmtRect(s.sheetRect)} × ctrlBR ${fmtRect(s.ctrlBR)} = ${s.overlapBR}px²`)
if (s.ctrlBL) check(`B.ctrlBL-inside-map@${name}`, s.ctrlBL.top >= s.containerRect.top && s.ctrlBL.bottom <= s.containerRect.bottom, `ctrlBL y ${Math.round(s.ctrlBL.y)}..${Math.round(s.ctrlBL.bottom)} within container ${Math.round(s.containerRect.top)}..${Math.round(s.containerRect.bottom)}`)
// the floating mini photo (detail mode at 30%) must not cover a lifted logo either
if (s.ctrlBL && s.miniImage && s.miniImage.h > 0) check(`B.no-overlap-miniImage×BL@${name}`, overlap2(s.miniImage, s.ctrlBL) === 0, `mini ${fmtRect(s.miniImage)} × ctrlBL ${fmtRect(s.ctrlBL)} = ${overlap2(s.miniImage, s.ctrlBL)}px²`)
}
if (!hasBL && !hasBR) console.log(' (no visible bottom-left/right control children on this instance — a 2nd+ map on the page hides its logo via claimLogo; probe the FIRST map for the attribution audit)')
report.bottomControls = { visibleBL: hasBL, visibleBR: hasBR }
} catch (e) {
if (!(e && e.done)) await fail('exception: ' + String(e.stack || e.message || e).slice(0, 600))
}
await browser.close()
const failed = checks.filter(c => !c.pass)
report.pass = failed.length === 0
console.log(`\n[${TOOL}] ${checks.length - failed.length}/${checks.length} checks pass · selection: ${NO_SELECT ? 'n/a (--no-select)' : report.selection?.method}${consoleErrors.length ? ` · ${consoleErrors.length} console error(s)` : ''}${pageErrors.length ? ` · ${pageErrors.length} page error(s)` : ''}`)
if (JSON_OUT) { mkdirSync(dirname(JSON_OUT), { recursive: true }); writeFileSync(JSON_OUT, JSON.stringify(report, null, 2)); console.log(`[${TOOL}] report → ${JSON_OUT}`) }
if (failed.length) { console.log('FAILED:'); failed.forEach(f => console.log(` ✗ ${f.id} — ${f.detail}`)); process.exit(1) }

View file

@ -19,17 +19,41 @@ useShadowStyles(`
the SAME component desktop uses, with all the same default CSS. Mobile
no longer overrides any .lrg-* rule — the bottom sheet is purely
mobile chrome (pull tab, collapse/expand caret, touch drag). ── */
.floating-mini-image{position:absolute;left:16px;bottom:calc(30% + 16px);z-index:999;pointer-events:auto;cursor:pointer;}
/* bottom = 30% (the sheet's open height) + 16px gap + a 28px band that keeps the
Mapbox logo visible: the bottom-left control is lifted by the sheet height (see
the --legend-sheet-h rule below) and would otherwise sit under this image. */
.floating-mini-image{position:absolute;left:16px;bottom:calc(30% + 44px);z-index:999;pointer-events:auto;cursor:pointer;}
.mini-image{width:120px;height:150px;object-fit:cover;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.25);display:block;}
/* Containment root: clamps absolute children to the map area. */
.legend-sheet-root{position:absolute;inset:0;pointer-events:none;overflow:hidden;}
.legend-sheet-root>*{pointer-events:auto;}
/* ── Attribution stays visible (driver bug 2026-09-16) ──
The sheet is a bottom overlay, so the engine's bottom-left logo / bottom-right
attribution corners ended up UNDER it in every state (even the 48px collapsed
footer fully covered the 19px logo). The sheet observes its own height
(ResizeObserver, script below) and writes --legend-sheet-h on the custom-element
HOST; custom properties inherit across the shadow boundary, so this one rule
lifts both corners by exactly the sheet height, following drags and the 0.3s
height transition frame by frame. Desktop: the sheet slot is display:none, the
observer sees 0 and never writes the var, so nothing moves. Every profile that
renders this sheet loads this stylesheet — no per-profile wiring. */
.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,
.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right{transform:translateY(calc(-1 * var(--legend-sheet-h,0px)));}
/* Sheet shell — no padding; drag strip and caret are absolutely
positioned overlays. Legend content owns the top padding, matching
desktop's arrangement so caret+title align on the same visual baseline. */
.legend-mobile-sheet{position:absolute;bottom:0;left:0;right:0;z-index:1000;background:white;border-top-left-radius:var(--map-radius,16px);border-top-right-radius:var(--map-radius,16px);box-shadow:0 -4px 20px rgba(0,0,0,0.2);transition:height 0.3s ease-out;display:flex;flex-direction:column;overflow:hidden;max-height:calc(100% - 80px);}
desktop's arrangement so caret+title align on the same visual baseline.
max-height reads --legend-sheet-top-reserve, set on this element from the
TOP_RESERVE_PX constant in the script — the SAME number the touch-drag clamp
uses, so CSS and JS can never disagree about how tall the sheet may get. */
.legend-mobile-sheet{position:absolute;bottom:0;left:0;right:0;z-index:1000;background:white;border-top-left-radius:var(--map-radius,16px);border-top-right-radius:var(--map-radius,16px);box-shadow:0 -4px 20px rgba(0,0,0,0.2);transition:height 0.3s ease-out;display:flex;flex-direction:column;overflow:hidden;max-height:calc(100% - var(--legend-sheet-top-reserve,80px));}
/* While a finger is resizing the sheet the height must be applied INSTANTLY —
with the 0.3s transition left on, the measured height lagged the finger by
14-30px (mobile-sheet-probe baseline 2026-09-16) and the release logic read a
mid-animation value. Snapping on release re-enables the transition. */
.legend-mobile-sheet.dragging{transition:none!important;}
/* Drag strip — thin top overlay holding the pull-tab handle + touch
gestures. z-index puts it above content. */
@ -402,11 +426,31 @@ const treeColumnLabelForMobile = computed(() => {
return ''
})
// Touch gesture state
const touchStartY = ref(0)
const touchStartHeight = ref(0)
const containerHeight = ref(0) // actual container height (not window height)
// ── Sheet geometry — ONE set of numbers for CSS and JS ─────────────────────
// TOP_RESERVE_PX: room kept free above a fully-open sheet for the search pill
// (top 10 + 35 tall) and a gap. Bound to --legend-sheet-top-reserve on the sheet
// (CSS max-height) AND used as the drag clamp below; it equals the 80 in
// uiStore.legendHeight's fullyOpen value 'calc(100% - 80px)'.
const TOP_RESERVE_PX = 80
// COLLAPSED_PX: the footer height — uiStore's collapsed '48px' / the .collapsed rule.
const COLLAPSED_PX = 48
// GRAB_ZONE_PX: a finger-sized handle measured from the sheet's top edge — the
// 12px drag strip plus the title row / detail header. A touch starting here
// resizes the sheet; below it the content scrolls as usual (Apple HIG 44pt).
const GRAB_ZONE_PX = 44
// SNAP_MIN_PX: hysteresis. A release after less travel than this snaps back to
// the state the drag started from, so a tap or thumb jitter never changes state.
const SNAP_MIN_PX = 24
// Touch gesture state (plain object — nothing here needs to be reactive except
// the .dragging class flag).
const sheetRef = ref(null)
const isDragging = ref(false)
const drag = { pending: null, active: false, startY: 0, startH: 0, startState: '', containerH: 0, maxH: 0, lastH: 0, contentEl: null }
let lastDragEndAt = 0
// A drag that starts on a button (caret / close X / strip) can still be followed
// by a synthesized click on release in some browsers — ignore it.
const justDragged = () => Date.now() - lastDragEndAt < 400
// Computed properties from stores
const legendState = computed(() => uiStore.legendState)
@ -438,10 +482,12 @@ function handleRowFilterClick(filterKey) {
// Methods
function handleCaretClick() {
if (justDragged()) return
uiStore.toggleLegend()
}
function handleCloseDetail() {
if (justDragged()) return
uiStore.selectPeopleGroup(null) // Return to data-view mode
uiStore.openLegend() // Snap back to 30% so map is visible
}
@ -455,101 +501,140 @@ const miniImageUrl = computed(() => {
return props_.imageUrl || props_._raw?.ImageURL || null
})
// Touch gesture handlers for pull tab
function handlePullTabTouchStart(event) {
if (legendState.value !== 'collapsed') {
touchStartY.value = event.touches[0].clientY
const sheet = event.currentTarget.parentElement
touchStartHeight.value = sheet.offsetHeight
// Store the map container height (parent of the sheet) for clamping
containerHeight.value = sheet.parentElement?.offsetHeight || window.innerHeight
isDragging.value = true
}
// ── Touch resize — handlers live on the SHEET, not the 12px strip ──────────
// Driver bug 2026-09-16 ("moving the informational page up and down has bugs"),
// reproduced by internal/mobile-sheet-probe.mjs. Three real causes, all fixed
// here: (1) the only grab surface was the 12px strip, so a thumb on the title
// row scrolled the content instead of resizing; (2) the 0.3s height transition
// stayed on during touchmove, so the sheet lagged the finger by 14-30px and the
// release logic read a mid-animation height; (3) touchmove clamped at 30%, so a
// downward drag from 'open' could not shrink the sheet at all — the
// open → collapsed ladder step (and the free-drag collapse) was dead code and a
// stale custom px height was left behind.
//
// Gesture model:
// touchstart in the top GRAB_ZONE_PX of the sheet → this gesture is a resize.
// Below the zone, in DETAIL mode, the gesture is provisional: the
// FIRST touchmove decides (that is the only cancelable one once the
// browser starts a native scroll) — a pull UP while the sheet can
// still grow, or a pull DOWN with the content scrolled to its top,
// resizes the sheet; anything else is a normal content scroll.
// Non-detail tabs keep content scrolling untouched (qa 2026-05-03).
// touchmove .dragging (transition:none) + an explicit px height every move,
// clamped to [COLLAPSED_PX, container - TOP_RESERVE_PX].
// touchend snap. Detail mode = ladder open ↔ fullyOpen with hysteresis
// (< SNAP_MIN_PX travel → back to the start state); pulled below
// half the open height → collapsed. Non-detail = free-drag: stay
// where released, collapse only below half the open height.
// The collapsed footer is tap-only (its 48px height is a CSS !important rule).
function onSheetTouchStart(event) {
if (legendState.value === 'collapsed') return
const t = event.touches[0]
const sheet = sheetRef.value
if (!t || !sheet) return
const inGrabZone = (t.clientY - sheet.getBoundingClientRect().top) <= GRAB_ZONE_PX
const mode = inGrabZone ? 'grab' : (legendMode.value === 'detail' ? 'content' : null)
if (!mode) return
drag.pending = mode
drag.active = false
drag.startY = t.clientY
// offsetHeight is the CURRENT visual height even mid-transition, so a drag
// that starts while the sheet is still animating continues from where it is.
drag.startH = sheet.offsetHeight
drag.lastH = drag.startH
drag.startState = legendState.value
drag.containerH = sheet.parentElement?.offsetHeight || window.innerHeight
drag.maxH = drag.containerH - TOP_RESERVE_PX
drag.contentEl = sheet.querySelector('.legend-content')
}
function handlePullTabTouchMove(event) {
if (!isDragging.value) return
event.preventDefault() // Prevent scrolling while dragging
const currentY = event.touches[0].clientY
const deltaY = touchStartY.value - currentY // Positive = drag up, Negative = drag down
// Calculate new height
const newHeight = touchStartHeight.value + deltaY
const availHeight = containerHeight.value || window.innerHeight
// Clamp between 30% and (container - toolbar)
const minHeight = availHeight * 0.3
const maxHeight = availHeight - 56 // 56px = map toolbar height
const clampedHeight = Math.max(minHeight, Math.min(maxHeight, newHeight))
// Set height dynamically (smooth dragging)
uiStore.setLegendHeight(`${clampedHeight}px`)
}
function handlePullTabTouchEnd(event) {
isDragging.value = false
// Read actual pixel height of the sheet — DO NOT parse legendHeight.value
// because it's a mixed-unit CSS string ('48px' / '30%' / 'calc(100% - 80px)').
const sheet = event?.currentTarget?.parentElement
const currentHeight = sheet ? sheet.offsetHeight : touchStartHeight.value
const startHeight = touchStartHeight.value
const availHeight = containerHeight.value || window.innerHeight
// Gesture direction — ignore a few pixels of noise from tap vs true drag.
const JITTER = 8
const movedUp = currentHeight > startHeight + JITTER
const movedDown = currentHeight < startHeight - JITTER
// Pure tap (no movement) — let the @click handler own the cascade.
if (!movedUp && !movedDown) return
// ── Mode-aware release behavior (qa: 2026-05-03 user feedback) ────────────
// Any non-detail legend tab → free-drag: leave the sheet wherever the user
// released, no snap to fullyOpen. They want to interact with the legend
// AND the map at the same time, not have the legend take over the screen.
// 'detail' mode → ladder-snap: a slight upward pull jumps to fullyOpen so
// the people-group photo + content fills the screen for reading.
// (uiStore default legendMode is 'prayer'; the previous iter-13 check
// against 'data' never matched, which is why the snap kept firing.)
if (legendMode.value !== 'detail') {
// Only snap if dragged down past the collapse threshold (small height).
// Otherwise keep the custom height already set during touchmove. State
// stays 'open' (or 'fullyOpen' if it started there) so showPullTab
// remains true and the sheet keeps its expanded chrome.
if (currentHeight < availHeight * 0.12) {
uiStore.collapseLegend()
function onSheetTouchMove(event) {
if (!drag.pending && !drag.active) return
const t = event.touches[0]
if (!t) return
const dy = drag.startY - t.clientY // + = finger moved up = sheet grows
if (!drag.active) {
if (dy === 0) return
let take = drag.pending === 'grab'
if (drag.pending === 'content') {
const atTop = !drag.contentEl || drag.contentEl.scrollTop <= 0
take = dy > 0 ? drag.startH < drag.maxH - 1 : atTop
}
drag.pending = null
if (!take) return // native content scroll owns this gesture
drag.active = true
isDragging.value = true // .dragging → transition:none, same flush as the height
}
event.preventDefault() // no page/content scroll while resizing
const h = Math.round(Math.min(drag.maxH, Math.max(COLLAPSED_PX, drag.startH + dy)))
drag.lastH = h
uiStore.setLegendHeight(`${h}px`)
}
function onSheetTouchEnd() {
drag.pending = null
if (!drag.active) return // a tap — the @click handlers own it
drag.active = false
isDragging.value = false // transition back on BEFORE the snap so it animates
lastDragEndAt = Date.now()
const h = drag.lastH
const travel = h - drag.startH
const openH = drag.containerH * 0.3 // the 'open' resting height (uiStore '30%')
if (legendMode.value !== 'detail') {
// Free-drag tabs: leave the sheet where it was released (custom px stays,
// state unchanged) — the user reads the legend and the map together.
if (h < openH * 0.5) uiStore.collapseLegend()
return
}
const startedFullyOpen = startHeight >= availHeight * 0.55
const startedOpen = startHeight >= availHeight * 0.2 && !startedFullyOpen
if (movedDown && startedFullyOpen) {
// Any noticeable downward drag from fullyOpen → 30% (one step down the ladder).
uiStore.openLegend()
} else if (movedDown && startedOpen) {
// Dragged down from 30% → collapse.
uiStore.collapseLegend()
} else if (movedUp) {
// Upward drag → fully open.
uiStore.fullyOpenLegend()
} else {
// Fallback — shouldn't normally hit given the early return above.
uiStore.openLegend()
}
if (Math.abs(travel) < SNAP_MIN_PX) { snapTo(drag.startState); return } // hysteresis
if (h < openH * 0.5) { uiStore.collapseLegend(); return } // pulled to the bottom
if (travel > 0) uiStore.fullyOpenLegend() // up → full
else if (drag.startState === 'fullyOpen') uiStore.openLegend() // down one step
else uiStore.collapseLegend() // down from open
}
// Lifecycle hooks
function snapTo(state) {
if (state === 'fullyOpen') uiStore.fullyOpenLegend()
else if (state === 'collapsed') uiStore.collapseLegend()
else uiStore.openLegend()
}
// ── Sheet height → host CSS var (keeps the engine's attribution visible) ───
// Writes --legend-sheet-h on the custom-element host (the CSS rule near the top
// of the stylesheet lifts the bottom control corners by it). Only a sheet with a
// real height writes: the hidden twin that LegendDesktop mounts inside the
// display:none desktop slot in detail mode (both profiles) observes 0 and must
// never zero the var under the visible sheet. The owner clears it on unmount.
const SHEET_H_VAR = '--legend-sheet-h'
let _sheetResizeObserver = null
let _sheetVarTarget = null
let _observedSheet = null
onMounted(() => {
// mounted
const sheet = sheetRef.value
if (!sheet || typeof ResizeObserver === 'undefined') return
const root = sheet.getRootNode()
_sheetVarTarget = (root instanceof ShadowRoot) ? root.host : (root.documentElement || null)
if (!_sheetVarTarget) return
_observedSheet = sheet
_sheetResizeObserver = new ResizeObserver(() => {
const h = sheet.offsetHeight
if (h <= 0) return
_sheetVarTarget.style.setProperty(SHEET_H_VAR, `${h}px`)
_sheetVarTarget.__legendSheetVarOwner = sheet
})
_sheetResizeObserver.observe(sheet)
})
onBeforeUnmount(() => {
// cleanup
if (_sheetResizeObserver) { _sheetResizeObserver.disconnect(); _sheetResizeObserver = null }
if (_sheetVarTarget && _sheetVarTarget.__legendSheetVarOwner === _observedSheet) {
_sheetVarTarget.style.removeProperty(SHEET_H_VAR)
_sheetVarTarget.__legendSheetVarOwner = null
}
_sheetVarTarget = null
_observedSheet = null
})
</script>
@ -571,14 +656,21 @@ onBeforeUnmount(() => {
/>
</div>
<div class="legend-mobile-sheet" :class="[legendState, { 'sheet-dark': isDark }]" :style="{ height: legendHeight }">
<!-- Thin drag strip at the very top: pull-tab visual + touch gestures. -->
<div class="sheet-drag-strip"
@click="handleCaretClick"
@touchstart="showPullTab ? handlePullTabTouchStart($event) : null"
@touchmove="showPullTab ? handlePullTabTouchMove($event) : null"
@touchend="showPullTab ? handlePullTabTouchEnd($event) : null"
>
<!-- Touch resize handlers sit on the SHEET: a drag may start anywhere in the
top GRAB_ZONE_PX (strip + title row) — and, in detail mode, on the content
when it can't scroll that way. See onSheetTouchStart for the model. -->
<div
ref="sheetRef"
class="legend-mobile-sheet"
:class="[legendState, { 'sheet-dark': isDark, dragging: isDragging }]"
:style="{ height: legendHeight, '--legend-sheet-top-reserve': TOP_RESERVE_PX + 'px' }"
@touchstart="onSheetTouchStart"
@touchmove="onSheetTouchMove"
@touchend="onSheetTouchEnd"
@touchcancel="onSheetTouchEnd"
>
<!-- Thin drag strip at the very top: pull-tab visual + tap-to-toggle. -->
<div class="sheet-drag-strip" @click="handleCaretClick">
<div v-if="showPullTab" class="pull-tab-handle"></div>
</div>