Deep Research: the header leaves while the gallery is scrolled; the Deeper Research line is small

The Driver, reviewing on a phone: on /deep-research the site header stayed while every other
page lets it scroll away, and the "Want Deeper Research?" line under the research map was far
too big.

/deep-research keeps the verified layout at rest: the page does not scroll, the header shows,
and the gallery fills the rest of the screen, its bottom menu button included. The gallery
scrolls inside its own main pane, so a finger or a wheel over it never moves the page. Once
that pane is scrolled down a little, <html> gets rg-header-hidden: the header hides and the
gallery takes the whole screen (100svh). Back at the pane's top, the header returns. One
scroller, so nothing fights its momentum. A capture-phase scroll listener on the gallery's
shadow root sees the pane; only a scroller at least half the gallery's height counts. Measured
with a real finger at 390: the map (703 px) and the open sheet (211 px) never resize, and the
header hides once and returns once.

/research: the line is 1.2rem, the size of the homepage's "Explore our research maps" link.
This commit is contained in:
driver 2026-09-30 03:25:12 +00:00
parent 90fc8ffd19
commit 03c02a1f18
2 changed files with 54 additions and 4 deletions

View file

@ -17,6 +17,8 @@
import { mapboxTokenIfChosen } from '~/utils/mapProvider'
// ONE scrollbar: no footer here, and the page itself never scrolls; the gallery's own content does.
// The header still leaves as on every other page: it hides while the gallery is scrolled down
// (see watchPaneForHeader) and the gallery then takes the whole screen.
definePageMeta({ hideFooter: true })
const { t, locale } = useI18n()
@ -36,6 +38,7 @@ useHead({
const host = ref<HTMLElement | null>(null)
let gallery: HTMLElement | null = null
let resizeObs: ResizeObserver | null = null
let unwatchPane: (() => void) | null = null
// The gallery fills the viewport below the header. The header's height differs between desktop
// and phone (and with the language), so it is measured, not assumed.
@ -45,6 +48,31 @@ function fitBelowHeader() {
document.documentElement.style.setProperty('--rg-header-h', h + 'px')
}
// The gallery scrolls inside its own main pane, so a finger (or a wheel) over it scrolls that
// pane and never the page. The header leaves the way it does on every other page: once the pane
// is scrolled down a little, <html> gets rg-header-hidden (the header hides and the gallery takes
// the full screen); back at the pane's top, the header returns. One scroller, so nothing fights
// the pane's own momentum. Only a scroller at least half the gallery's height counts (its main
// pane), not a small list such as a legend sheet's.
function watchPaneForHeader(el: HTMLElement, root: ShadowRoot) {
const onScroll = (e: Event) => {
const pane = e.target
if (!(pane instanceof HTMLElement) || pane.clientHeight < el.clientHeight / 2) return
const cls = document.documentElement.classList
if (pane.scrollTop > 8) cls.add('rg-header-hidden')
else if (pane.scrollTop <= 0) cls.remove('rg-header-hidden')
}
// Scroll events don't bubble, but they do pass the shadow root in the capture phase.
root.addEventListener('scroll', onScroll, { capture: true, passive: true })
unwatchPane = () => root.removeEventListener('scroll', onScroll, true)
}
// The gallery attaches its shadow root once its script has defined the element.
function whenShadowRoot(el: HTMLElement, done: (root: ShadowRoot) => void, tries = 50) {
if (el.shadowRoot) done(el.shadowRoot)
else if (tries > 0) setTimeout(() => whenShadowRoot(el, done, tries - 1), 100)
}
onMounted(() => {
fitBelowHeader()
// The gallery is sized to the viewport below the header; a sub-pixel header height must not add a
@ -76,6 +104,10 @@ onMounted(() => {
gallery.setAttribute('provider', provider)
gallery.setAttribute('profile-config', JSON.stringify(galleryConfig))
host.value?.appendChild(gallery)
customElements.whenDefined('doxa-maps-spa').then(() => {
const el = gallery
if (el) whenShadowRoot(el, root => watchPaneForHeader(el, root))
})
})
watch(locale, (lang) => {
@ -83,9 +115,10 @@ watch(locale, (lang) => {
})
onBeforeUnmount(() => {
document.documentElement.classList.remove('rg-no-page-scroll')
document.documentElement.classList.remove('rg-gallery-mounted')
document.documentElement.classList.remove('rg-no-page-scroll', 'rg-gallery-mounted', 'rg-header-hidden')
resizeObs?.disconnect()
unwatchPane?.()
unwatchPane = null
gallery?.remove()
gallery = null
})
@ -131,6 +164,15 @@ html.rg-no-page-scroll body {
overflow: hidden;
}
/* While the gallery is scrolled down, the header is away and the gallery takes the whole screen,
as the header leaves on every other page. Back at the gallery's top, the header returns. */
html.rg-header-hidden #masthead {
display: none;
}
html.rg-header-hidden .research-gallery-page {
height: 100svh;
}
/* The site header is z-index 1500 (blocks/_header.scss); the gallery's info dialog is a full-viewport
layer at z-index 1000, full-screen on a phone, so the header covered the dialog's title and close
button there. While the gallery is shown, the header sits just under the dialog: 999 still keeps it,

View file

@ -51,8 +51,9 @@ useTextHighlight()
:profile-config="researchProfileConfig"
class="rounded-xlg"
/>
<!-- Under the map: the way to the full set of research maps, the hidden Deep Research page. -->
<p class="text-center">
<!-- Under the map: the way to the full set of research maps, the hidden Deep Research page.
Small, like the homepage's "Explore our research maps" line under its map. -->
<p class="text-center deep-research-line">
{{ t('Want Deeper Research?') }}
<NuxtLink
class="light-link"
@ -70,3 +71,10 @@ useTextHighlight()
/>
</div>
</template>
<style scoped>
/* The same size as the homepage's link under its map (.research-map-link in pages/index.vue). */
.deep-research-line {
font-size: 1.2rem;
}
</style>