From 7ea7195646b69154530dfe32e1fc3ca72f318a16 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Thu, 1 Oct 2026 07:11:18 +0000 Subject: [PATCH] Pin hint: an inner scroller counts as a scrolling page, a map off the viewport is on one, and a stale lift is dropped DX Life's /pray at 1440 kept the pill at the viewport's bottom until the map's bottom came into view: the page scrolls a container, not the document, so the document-only test saw a full-screen page and kept the fold. pageScrolls now also walks out from the pill, across shadow roots, for an ancestor scroller whose content is taller than it; a map wholly below or above the viewport is treated as on a scrolling page; and a lift is cleared when the pill goes off screen, so a lift set while the map was half visible does not survive into the next view. check:pin-hint-floor adds the inner-scroller, shadow-crossing and map-below-the-fold fixtures; its self-test refuses 9 planted defects. --- internal/check-pin-hint-floor.mjs | 38 ++++++++++++++++++++++-- library/components/hints/PinHint.vue | 11 +++++-- library/components/hints/pinHintFloor.js | 23 ++++++++++++-- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/internal/check-pin-hint-floor.mjs b/internal/check-pin-hint-floor.mjs index bd220b960..5ff0792bc 100644 --- a/internal/check-pin-hint-floor.mjs +++ b/internal/check-pin-hint-floor.mjs @@ -8,11 +8,15 @@ * had its pill lifted to mid-map, re-set by the settle timers, and left there after a scroll. * * ASSERTS (headless, on library/components/hints/pinHintFloor.js and PinHint.vue's source): - * 1. scrolling page, map bottom below the fold → no lift (the pill stays at the map's bottom); + * 1. scrolling page, map bottom below the fold → no lift (the pill stays at the map's bottom); also a map + * ENTIRELY below the fold, at load, with its bottom in view and scrolled past → no lift at any scroll; * 2. full-screen page, pill under the fold → lifted above it (today's behaviour kept); * 3. the legend sheet and the licence credit still lift the pill on a scrolling page; * 4. pageScrolls() tells a longer page from a full-screen one, and fails safe (false) on bad input; - * 5. PinHint's clampIntoView takes its floor from pillFloor(), not from window.innerHeight. + * 4b. an INNER scroller around the map (html/body at 100%) counts as a scrolling page, across shadow roots; + * a scroller whose content fits does not; + * 5. PinHint's clampIntoView takes its floor from pillFloor(), not from window.innerHeight, treats a map wholly + * above or below the viewport as on a scrolling page, and drops a stale lift when the pill goes off screen. * --self-test plants each defect (the old fold-everywhere floor, a pageScrolls that never says yes, the * old source line) and must see each one refused. */ @@ -33,6 +37,12 @@ function audit({ pillFloor, pageScrolls }, src) { const scrolls = pageScrolls(doc(3200), { innerHeight: 900 }) const l1 = liftFor(1218, pillFloor({ viewportH: 900, scrolls })) if (l1 !== 0) bad.push(`a scrolling page lifts the pill ${l1}px off the map's bottom (the fold is not an obstacle there)`) + // 1b. DX Life /pray at 1440: the map ENTIRELY below the fold (map 1103..1867, fold 900). The pill's bottom + // (12px above the map's) must get NO lift from the fold at scrollY 0, with the map's bottom in view, or past it. + for (const [scrollY, label] of [[0, 'at load'], [967, 'with the map bottom in view'], [2067, 'scrolled past']]) { + const l = liftFor(1855 - scrollY, pillFloor({ viewportH: 900, scrolls })) + if (l !== 0) bad.push(`a map below the fold lifts the pill ${l}px ${label} (scrollY ${scrollY})`) + } // 2. A full-screen app page at 390×844: the pill 16px under the fold is lifted clear of it. const s2 = pageScrolls(doc(844), { innerHeight: 844 }) const l2 = liftFor(860, pillFloor({ viewportH: 844, scrolls: s2 })) @@ -42,6 +52,22 @@ function audit({ pillFloor, pageScrolls }, src) { if (l3 !== 76) bad.push(`the legend sheet lifts the pill ${l3}px on a scrolling page, expected 76`) const l3b = liftFor(1218, pillFloor({ viewportH: 900, scrolls: true, creditTop: 1200 })) if (l3b !== 26) bad.push(`the licence credit lifts the pill ${l3b}px on a scrolling page, expected 26`) + // 4b. An INNER scroller: the document does not scroll (html/body at 100%), a container around the map does. + const node = (oy, sh, ch, parent = null, host = null) => { const n = { nodeType: 1, oy, scrollHeight: sh, clientHeight: ch, parentElement: parent }; n.getRootNode = () => (host ? { host } : n); return n } + const winStyle = { innerHeight: 900, getComputedStyle: (n) => ({ overflowY: n.oy }) } + const scroller = node('auto', 3200, 900) + const mapBox = node('hidden', 764, 764, scroller) + const pillEl = node('visible', 19, 19, mapBox) + if (pageScrolls(doc(900), winStyle, pillEl) !== true) bad.push('an inner scroller around the map is not seen as a scrolling page') + const fits = node('auto', 900, 900); const pill2 = node('visible', 19, 19, node('hidden', 900, 900, fits)) + if (pageScrolls(doc(900), winStyle, pill2) !== false) bad.push('a scroller whose content fits is taken for a scrolling page') + const outerScroller = node('scroll', 2400, 900) + const shadowHost = node('visible', 900, 900, outerScroller) + const inShadow = node('visible', 19, 19, null, shadowHost) + if (pageScrolls(doc(900), winStyle, inShadow) !== true) bad.push('the walk does not cross a shadow root to the scroller outside it') + // The inner-scroller page at 1440 with the map below the fold: lift 0 at every scroll. + const s4 = pageScrolls(doc(900), winStyle, pillEl) + for (const y of [0, 967, 2067]) { const l = liftFor(1855 - y, pillFloor({ viewportH: 900, scrolls: s4 })); if (l !== 0) bad.push(`an inner-scroller page lifts the pill ${l}px at scroll ${y}`) } // 4. pageScrolls on its own. if (pageScrolls(doc(900), { innerHeight: 900 }) !== false) bad.push('pageScrolls says a full-screen page scrolls') if (pageScrolls(doc(901), { innerHeight: 900 }) !== false) bad.push('pageScrolls counts a 1px rounding overflow as a scroll') @@ -50,7 +76,9 @@ function audit({ pillFloor, pageScrolls }, src) { const body = (src.match(/function clampIntoView\(\)\s*\{([\s\S]*?)\n\}/) || [])[1] || '' if (!body) bad.push('PinHint.vue has no clampIntoView() to read') else { - if (!/pillFloor\(\{[^}]*scrolls:\s*pageScrolls\(\)/.test(body)) bad.push('clampIntoView does not take its floor from pillFloor({ … scrolls: pageScrolls() … })') + if (!/pillFloor\(\{[^}]*scrolls:\s*offPage \|\| pageScrolls\(document, window, el\)/.test(body)) bad.push('clampIntoView does not take its floor from pillFloor({ … scrolls: offPage || pageScrolls(document, window, el) … })') + if (!/const offPage = !!hr && \(hr\.top >= window\.innerHeight \|\| hr\.bottom <= 0\)/.test(body)) bad.push('clampIntoView does not treat a map wholly above or below the viewport as on a scrolling page') + if (!/if \(!onScreen\.value \|\| !paneActive\.value\) \{ el\.style\.removeProperty\('--pin-hint-lift'\); return \}/.test(body)) bad.push('clampIntoView keeps a stale lift when the pill goes off screen') if (/Math\.min\(\s*window\.innerHeight/.test(body)) bad.push('clampIntoView still floors on window.innerHeight directly') } return bad @@ -66,6 +94,10 @@ if (process.argv.includes('--self-test')) { ['a page that never scrolls', { ...real, pageScrolls: () => false }, SRC], ['a page that always scrolls', { ...real, pageScrolls: () => true }, SRC], ['obstacles dropped on a scrolling page', { ...real, pillFloor: (o) => (o.scrolls ? Infinity : F.pillFloor(o)) }, SRC], + ['an inner scroller ignored (document only)', { ...real, pageScrolls: (d, w) => F.pageScrolls(d, w) }, SRC], + ['no shadow-root crossing', { ...real, pageScrolls: (d, w, el) => { let e = el; while (e) { const oy = w && w.getComputedStyle ? w.getComputedStyle(e).overflowY : ''; if ((oy === 'auto' || oy === 'scroll') && e.scrollHeight > e.clientHeight + 1) return true; e = e.parentElement } return F.pageScrolls(d, w) } }, SRC], + ['a stale lift kept off screen', real, SRC.replace("if (!onScreen.value || !paneActive.value) { el.style.removeProperty('--pin-hint-lift'); return }", 'if (!onScreen.value || !paneActive.value) return')], + ['the off-page rule dropped', real, SRC.replace('scrolls: offPage || pageScrolls(document, window, el)', 'scrolls: pageScrolls(document, window, el)')], ['the old source line', real, SRC.replace(/const floor = pillFloor\([^\n]*\n/, 'const floor = Math.min(window.innerHeight, sheetFloor(r), creditFloor(r))\n')], ] let missed = 0 diff --git a/library/components/hints/PinHint.vue b/library/components/hints/PinHint.vue index 88ab9226e..1f1a71a7d 100644 --- a/library/components/hints/PinHint.vue +++ b/library/components/hints/PinHint.vue @@ -281,7 +281,10 @@ function creditFloor(pill) { function clampIntoView() { const el = hintEl.value - if (!el || !onScreen.value || !paneActive.value) return + if (!el) return + // Off screen or in a hidden pane: drop any lift first. A lift set while the map was partly visible must not + // survive into the next view; the clamp runs again when the pane or the map comes back. + if (!onScreen.value || !paneActive.value) { el.style.removeProperty('--pin-hint-lift'); return } try { // ★ ADJUST `bottom`, NEVER `transform`. My first attempt set el.style.transform and cleared it // between measurements — both wrong here, and the diagnosis took two failed builds: @@ -304,7 +307,11 @@ function clampIntoView() { // stayed at a stale 43px and the pill covered 36px of the 37px credit, while the same page, measured // later, sat at the cap. This check runs from the settle timers and the sheet and host observers, // so it reads where the credit IS, whatever the clearance says. - const floor = pillFloor({ viewportH: window.innerHeight, scrolls: pageScrolls(), sheetTop: sheetFloor(r), creditTop: creditFloor(r) }) + // A map that starts below the fold or ends above the top cannot be a full-screen page: it is a block in a + // page that scrolls, whatever scroller the page uses. + const hr = el.parentElement && el.parentElement.getBoundingClientRect ? el.parentElement.getBoundingClientRect() : null + const offPage = !!hr && (hr.top >= window.innerHeight || hr.bottom <= 0) + const floor = pillFloor({ viewportH: window.innerHeight, scrolls: offPage || pageScrolls(document, window, el), sheetTop: sheetFloor(r), creditTop: creditFloor(r) }) const overflow = Math.round(r.bottom - floor) if (overflow > 0) el.style.setProperty('--pin-hint-lift', (overflow + 8) + 'px') } catch (_) { /* detached — nothing to clamp */ } diff --git a/library/components/hints/pinHintFloor.js b/library/components/hints/pinHintFloor.js index f79910662..deafc46ee 100644 --- a/library/components/hints/pinHintFloor.js +++ b/library/components/hints/pinHintFloor.js @@ -16,12 +16,29 @@ * Pure (numbers in, a number out) so check:pin-hint-floor can prove both cases without a browser. */ -/** Does the page itself scroll (a map block in a longer page), as opposed to a full-screen app page? */ +/** + * Does the map sit on a page that SCROLLS (a block in a longer page), as opposed to a full-screen app page? + * Two ways a page scrolls: the document itself, or an INNER scroller around the map (a page that keeps html and + * body at 100% and scrolls a container instead). The second is found by walking out from the pill (`fromEl`), + * across shadow roots, for an ancestor with overflow-y auto/scroll/overlay whose content is taller than it. + * On DX Life's /pray at 1440 the pill rode at the viewport's bottom until the map's own bottom came into view, + * which is what a document-only test gives when the page scrolls an inner container. + */ export function pageScrolls(doc = typeof document !== 'undefined' ? document : null, - win = typeof window !== 'undefined' ? window : null) { + win = typeof window !== 'undefined' ? window : null, fromEl = null) { try { const se = doc && (doc.scrollingElement || doc.documentElement) - return !!(se && win && se.scrollHeight > win.innerHeight + 1) + if (se && win && se.scrollHeight > win.innerHeight + 1) return true + let el = fromEl + for (let i = 0; el && i < 80; i++) { + if (el.nodeType === 1 && win && typeof win.getComputedStyle === 'function') { + const oy = win.getComputedStyle(el).overflowY + if ((oy === 'auto' || oy === 'scroll' || oy === 'overlay') && el.scrollHeight > el.clientHeight + 1) return true + } + const root = typeof el.getRootNode === 'function' ? el.getRootNode() : null + el = el.parentElement || (root && root !== el && root.host) || null + } + return false } catch (_) { return false } }