From 271ba0849c02361a886e3bfcc1c49693a8a2c5c9 Mon Sep 17 00:00:00 2001 From: swift-river542 Date: Wed, 16 Sep 2026 16:26:10 -0500 Subject: [PATCH] Mobile legend sheet: drags follow the finger, every state is reachable, and the Mapbox attribution lifts above the sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/mobile-sheet-probe.mjs | 331 ++++++++++++++++++++ library/components/legends/LegendMobile.vue | 290 +++++++++++------ 2 files changed, 522 insertions(+), 99 deletions(-) create mode 100644 internal/mobile-sheet-probe.mjs diff --git a/internal/mobile-sheet-probe.mjs b/internal/mobile-sheet-probe.mjs new file mode 100644 index 000000000..f2bc3e727 --- /dev/null +++ b/internal/mobile-sheet-probe.mjs @@ -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 ] [--tag doxa-map] + * [--nth 0 | --instance ] [--browser firefox|chromium] + * [--wait 30000] [--steps 10] [--step 20] [--json ] [--shots ] + * [--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 + * 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) } diff --git a/library/components/legends/LegendMobile.vue b/library/components/legends/LegendMobile.vue index 7288cc899..ee84c4df1 100644 --- a/library/components/legends/LegendMobile.vue +++ b/library/components/legends/LegendMobile.vue @@ -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 }) @@ -571,14 +656,21 @@ onBeforeUnmount(() => { /> -
- -
+ +
+ +