/**
* @file Home-page interactions for `index.html`.
*
* No framework — plain DOM code. Everything here is a progressive
* enhancement: with JavaScript off, the page still reads and works, just
* without the extras. Each feature checks that its elements exist first, so
* the file is safe to load on a page that lacks some of them.
*
* Features, in file order:
* 1. Mobile navigation disclosure (hamburger menu).
* 2. In-page anchor links: smooth scroll + move keyboard focus to the target.
* 3. Sticky header: shadow once scrolled; hides near the end of the page.
* 4. "How it works": connector line draws in when the section is visible.
* 5. Scroll-reveal entrance animations for `[data-reveal]` elements.
* 6. Scrollspy: highlights the nav link for the section in view.
* 7. "Get Started" drawer tab: shown only in the middle stretch of the page.
* 8. Booking-band maze: route draws itself once the footer comes into view.
* 9. "Fine print" sample statement: hovering a note highlights its line, and
* each marked line's highlighter swipes across the row as one stroke.
*
* The FAQ needs nothing here: each question is a native `<details>`.
*
* Loaded as `<script type="module">`, which is deferred by default, so the
* whole document has been parsed by the time this runs.
*
* Several features throttle scroll handling with `requestAnimationFrame` and
* a `ticking` flag: however many scroll events fire, the work runs at most
* once per frame.
*
* @module main
*/
/**
* Live media query for the device's reduced-motion setting.
* @type {MediaQueryList}
*/
const osReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); // watch the reduced-motion setting
/**
* Whether motion should be reduced right now: the device asks for it, or the
* visitor turned Animations off in Site preferences (`data-motion="off"` on
* `<html>`, see `theme.js` / `prefs.js`). Checked by every animated feature;
* under reduced motion, content is simply shown.
* @type {{matches: boolean}}
*/
const prefersReducedMotion = { // same shape as a MediaQueryList, for the checks below
get matches() { return osReducedMotion.matches || document.documentElement.dataset.motion === 'off'; }, // OS setting or site preference
};
/* -------------------------------------------------------------------------
Mobile navigation disclosure
---------------------------------------------------------------------- */
// The button's aria-expanded attribute is the single source of truth for
// whether the menu is open.
/** The hamburger button. @type {HTMLElement|null} */
const navToggle = document.getElementById('nav-toggle'); // find the hamburger button
/** The mobile nav panel it controls. @type {HTMLElement|null} */
const mobileNav = document.getElementById('mobile-nav'); // find the mobile nav panel
if (navToggle && mobileNav) { // only wire the menu if both exist
/**
* Opens or closes the mobile menu and updates the button to match
* (`aria-expanded` for assistive tech, `aria-label` for its name).
*
* @param {boolean} open `true` to open, `false` to close.
* @returns {void}
*/
const setNav = (open) => { // define setNav(open)
navToggle.setAttribute('aria-expanded', String(open)); // tell assistive tech whether the menu is open
navToggle.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); // update the button's accessible name
navToggle.setAttribute('title', open ? 'Close menu' : 'Open menu'); // and its tooltip
if (open) { // opening:
mobileNav.hidden = false; // un-hide the panel
mobileNav.classList.remove('hidden'); // remove the utility class that hides it
// next frame, so the reveal transition actually runs from its start state
requestAnimationFrame(() => mobileNav.classList.add('is-open')); // add is-open to start the reveal
} else { // closing:
mobileNav.classList.remove('is-open'); // drop the open state
mobileNav.hidden = true; // hide the panel
mobileNav.classList.add('hidden'); // re-add the utility class that hides it
}
};
// Button toggles the menu.
navToggle.addEventListener('click', () => { // when the menu button is clicked:
setNav(navToggle.getAttribute('aria-expanded') !== 'true'); // flip the menu to the opposite state
});
// Opening by keyboard moves focus to the first link. (keydown fires before
// the click that opens the menu, hence the "not yet expanded" check and the
// one-frame wait for the panel to become visible.)
navToggle.addEventListener('keydown', (e) => { // when a key is pressed on the menu button:
if ((e.key === 'Enter' || e.key === ' ') && navToggle.getAttribute('aria-expanded') !== 'true') { // Enter or Space while the menu is still closed?
requestAnimationFrame(() => mobileNav.querySelector('a')?.focus()); // next frame, focus the first link in the menu
}
});
// Close after choosing a destination, on Escape, or on a click outside.
mobileNav.addEventListener('click', (e) => { // when anything in the menu is clicked:
if (e.target.closest('a')) setNav(false); // if it was a link, close the menu
});
document.addEventListener('click', (e) => { // when anything in the page is clicked:
if (navToggle.getAttribute('aria-expanded') !== 'true') return; // menu already closed: nothing to do
if (mobileNav.contains(e.target) || navToggle.contains(e.target)) return; // click was inside the menu or on its button: ignore
setNav(false); // click was outside: close the menu
});
document.addEventListener('keydown', (e) => { // on any key press in the page:
if (e.key === 'Escape' && navToggle.getAttribute('aria-expanded') === 'true') { // if it's Escape and the menu is open:
setNav(false); // close the menu
navToggle.focus(); // put focus back on the menu button
}
});
// Reset when the desktop nav takes over: leaving the mobile breakpoint
// (930px = the Tailwind `nav:` screen), or leaving the Larger / Largest
// text size on a wide window (those keep the mobile menu at any width).
const wide = window.matchMedia('(min-width: 930px)'); // the desktop-nav width
const closeIfDesktop = () => { // close the menu if the desktop nav is showing
if (wide.matches && !/^(xl|xxl)$/.test(document.documentElement.dataset.text || '')) setNav(false);
};
wide.addEventListener('change', closeIfDesktop); // window crossed 930px
document.addEventListener('cha:prefs', closeIfDesktop); // text size changed
}
/* -------------------------------------------------------------------------
Move focus to the target region after in-page navigation, so keyboard
and screen-reader users land where sighted users' eyes go.
---------------------------------------------------------------------- */
/**
* Moves keyboard focus to the element an in-page link points at.
*
* Most section elements aren't normally focusable, so a temporary
* `tabindex="-1"` is added (focusable by script, but not added to the Tab
* order) and removed again once focus leaves. Scrolling is left to the
* caller (`preventScroll`), so focus doesn't fight the smooth scroll.
*
* @param {string} hash A fragment such as `'#services'`.
* @returns {void}
*/
function focusTarget(hash) { // define focusTarget(hash)
if (!hash || hash === '#') return; // empty or bare "#": nothing to focus
const el = document.querySelector(hash); // find the element the hash names
if (!el) return; // no such element: stop
// Only add (and later remove) tabindex if the element didn't have one.
const hadTabindex = el.hasAttribute('tabindex'); // did it already have a tabindex?
if (!hadTabindex) el.setAttribute('tabindex', '-1'); // if not, make it focusable by script
el.focus({ preventScroll: true }); // focus it without jumping the scroll position
if (!hadTabindex) { // if we added the tabindex…
el.addEventListener('blur', () => el.removeAttribute('tabindex'), { once: true }); // …remove it again the first time focus leaves
}
}
// Take over every same-page link ("#…"): scroll smoothly (or instantly under
// reduced motion), update the URL, then move focus to the target.
document.querySelectorAll('a[href^="#"]').forEach((link) => { // for each link whose href starts with #:
link.addEventListener('click', (e) => { // when it's clicked:
const hash = link.getAttribute('href'); // read the target, e.g. "#services"
if (hash.length < 2) return; // a bare "#" — leave it alone
const el = document.querySelector(hash); // find the target element
if (!el) return; // no such target — let the browser handle it
e.preventDefault(); // stop the browser's instant jump
el.scrollIntoView({ behavior: prefersReducedMotion.matches ? 'auto' : 'smooth', block: 'start' }); // scroll there (smoothly unless reduced motion is on)
// Update the address bar (and history) without the browser's own jump.
history.pushState(null, '', hash); // add the hash to the URL and history
// Wait a frame so scroll starts before focus (avoids a focus-jump fight).
requestAnimationFrame(() => focusTarget(hash)); // next frame, move focus to the target
});
});
/* -------------------------------------------------------------------------
Slide the sticky header out of the way as the visitor nears the end of
the page — the closing CTA and footer become the focus, and the nav has
nothing left to scroll to. Reveal it again on any upward scroll.
---------------------------------------------------------------------- */
/** The sticky site header. @type {HTMLElement|null} */
const header = document.querySelector('.site-header'); // find the site header
/**
* The closing region of the page; the header hides as this approaches.
* Falls back to the footer if there is no #schedule section.
* @type {HTMLElement|null}
*/
const tail = document.querySelector('#schedule') || document.querySelector('footer'); // find the schedule section, or the footer if it's missing
if (header && tail) { // only if both exist
/** Scroll position at the previous update, to detect direction. */
let lastY = window.scrollY; // starting scroll position
/** True while an update is already queued for the next frame. */
let ticking = false; // no update queued yet
/**
* Recomputes the header's classes from the current scroll position:
* - `scrolled` once the page has moved more than 8px from the top;
* - `header-hidden` when the closing region is near and the visitor
* isn't scrolling up.
*
* @returns {void}
*/
const update = () => { // define update()
ticking = false; // this queued update is now running
const y = window.scrollY; // current scroll position
// A 2px tolerance ignores tiny jitter (e.g. momentum scrolling).
const goingUp = y < lastY - 2; // did the page move up since last time?
lastY = y; // remember this position for next time
// Read the closing region's position before any writes below, so the
// browser doesn't have to run a forced synchronous layout to answer it.
const gap = tail.getBoundingClientRect().top - window.innerHeight; // how far the closing region is below the viewport
// "Near" = the closing region is less than 45% of a screen below the fold.
const nearTail = gap < window.innerHeight * 0.45; // is it within 45% of a screen?
// Soft elevation once the page has left the very top.
header.classList.toggle('scrolled', y > 8); // add a shadow once scrolled past 8px
header.classList.toggle('header-hidden', nearTail && !goingUp); // hide the header when near the end and not scrolling up
};
// Keyboard focus moving into the header always brings it back into view.
header.addEventListener('focusin', () => header.classList.remove('header-hidden')); // show the header whenever something inside it gets focus
// While hidden it is also `visibility: hidden` (not focusable), so Shift-Tab from
// below would skip it; reveal it when focus heads above the fold of the page.
document.addEventListener('keydown', (e) => { // on any key press in the page:
if (e.key === 'Tab' && e.shiftKey) header.classList.remove('header-hidden'); // Shift+Tab (moving focus backwards): show the header
});
// Set the initial state (the page may load already scrolled), then keep it
// in step with scrolling — at most once per animation frame.
update(); // set the header state right away
window.addEventListener( // listen for…
'scroll', // …scroll events…
() => { // …with a handler that:
if (ticking) return; // skips if an update is already queued
ticking = true; // marks an update as queued
requestAnimationFrame(update); // runs update() on the next frame
},
{ passive: true } // passive: the handler never blocks scrolling
);
}
/* -------------------------------------------------------------------------
"How it works" — draw the connector and lift the step cards in once the
section scrolls into view. Progressive enhancement: with no JS, or with
reduced motion, every element is already shown in its finished state.
---------------------------------------------------------------------- */
/** The "How it works" steps container. @type {HTMLElement|null} */
const howFlow = document.querySelector('.how-flow'); // find the How it works container
// `js-draw` arms the hidden start state in CSS; `is-drawn` plays the entrance.
if (howFlow && 'IntersectionObserver' in window && !prefersReducedMotion.matches) { // only with IntersectionObserver support and motion allowed
howFlow.classList.add('js-draw'); // arm the hidden, not-yet-drawn state
// is-drawn is a one-shot (the thread draws itself in once); is-onscreen tracks
// visibility so the marching-ants loop can pause whenever the flow scrolls away.
const howObserver = new IntersectionObserver( // create an observer that…
(entries) => { // …receives visibility changes…
entries.forEach((entry) => { // …and for each one:
howFlow.classList.toggle('is-onscreen', entry.isIntersecting); // marks whether the flow is currently on screen
if (entry.isIntersecting) howFlow.classList.add('is-drawn'); // draws it in the first time it appears
});
},
{ threshold: 0.2 } // fire once 20% of the flow is visible
);
howObserver.observe(howFlow); // start watching the flow
}
/* -------------------------------------------------------------------------
Scroll-reveal — "coming into the light". Each [data-reveal] element plays
its entrance once, on its own, as its top edge crosses into the lower part
of the viewport; the value (heading, list, pair, checks, rows, photo) picks
the treatment in the CSS. `.reveal-pending` arms the hidden start state; it
is swapped for `.reveal-in` to play, then cleared. Staggered items get their
index as --reveal-i (capped, so long lists don't trail). An element flung
past before it could play (fast fling, anchor jump) is simply shown, and
everything left is shown once the page bottoms out. A position sweep on a
throttled scroll listener is used rather than IntersectionObserver so an
element can never be skipped and left hidden. Nothing is armed under
reduced motion or without JS, so nothing is ever hidden.
---------------------------------------------------------------------- */
if (!prefersReducedMotion.matches) { // skip scroll-reveal entirely under reduced motion
const revealEls = Array.from(document.querySelectorAll('[data-reveal]')); // collect every element to reveal
if (revealEls.length) { // only if there are any
/** Highest stagger index; items beyond this all share the last delay. */
const STAGGER_CAP = 8; // stagger index cap
/** How long to wait before clearing `reveal-in` (longest entrance, in ms). */
const REVEAL_MS = 1400 + STAGGER_CAP * 75 + 200; // slowest (photo zoom / last icon) + stagger + slack
/**
* For each `data-reveal` value whose children enter one after another,
* the selector for those children (relative to the element).
* @type {Object<string, string>}
*/
const STAGGERED = { list: ':scope > *', pair: ':scope > *', checks: ':scope > li', rows: 'tbody tr' }; // which children stagger, by reveal type
// Arm every element: give staggered children their index (the CSS turns
// --reveal-i into a delay), and apply the hidden start state.
revealEls.forEach((el) => { // for each reveal element:
const items = STAGGERED[el.dataset.reveal]; // its staggered-children selector, if any
if (items) { // this type staggers:
el.querySelectorAll(items).forEach((item, i) => { // for each child, with its position i:
item.style.setProperty('--reveal-i', String(Math.min(i, STAGGER_CAP))); // set --reveal-i to i (at most 8)
});
}
el.classList.add('reveal-pending'); // hide the element until it's revealed
});
/** Elements that haven't been revealed yet. Shrinks as they play. */
let pending = revealEls; // start with every element pending
/**
* Checks every still-pending element's position and reveals the ones
* that are due. An element is due once its top is above 92% of the
* viewport height — or unconditionally once the page is scrolled to the
* bottom (short final sections might never reach that line).
*
* Elements already above the top of the viewport are shown without an
* entrance (the visitor jumped past them).
*
* @returns {void}
*/
const sweep = () => { // define sweep()
if (!pending.length) return; // nothing left to reveal: stop
const vh = window.innerHeight; // viewport height
const root = document.documentElement; // the <html> element
// Within 4px of the end counts as the bottom (sub-pixel rounding).
const atBottom = window.scrollY + vh >= root.scrollHeight - 4;
// Read every still-pending element's position first, then apply all
// the writes below — interleaving the two forces a synchronous layout
// per element instead of one for the whole batch.
const tops = pending.map((el) => el.getBoundingClientRect().top); // each element's top edge, relative to the viewport
pending = pending.filter((el, i) => { // keep only the elements still waiting; for each:
const top = tops[i]; // its top edge, read above
if (top > vh * 0.92 && !atBottom) return true; // not yet in view — keep waiting
el.classList.remove('reveal-pending'); // stop hiding it
if (top > 0) { // top is still on screen (entering from below):
el.classList.add('reveal-in'); // entering from below — play the entrance
setTimeout(() => el.classList.remove('reveal-in'), REVEAL_MS); // remove the entrance class once it has finished
}
return false; // already scrolled past — just shown, no entrance
});
};
/** True while a sweep is already queued for the next frame. */
let ticking = false; // no sweep queued yet
/**
* Scroll/resize handler: queues one {@link sweep} per animation frame.
* @returns {void}
*/
const onScroll = () => { // define onScroll()
if (ticking) return; // a sweep is already queued: skip
ticking = true; // mark a sweep as queued
requestAnimationFrame(() => { // on the next frame:
ticking = false; // clear the queued flag
sweep(); // run the sweep
});
};
window.addEventListener('scroll', onScroll, { passive: true }); // sweep on scroll (without blocking scrolling)
window.addEventListener('resize', onScroll, { passive: true }); // sweep on resize
window.addEventListener('load', onScroll); // late images can shift things into view
sweep(); // whatever is already at or above the fold on load
}
}
/* -------------------------------------------------------------------------
Scrollspy — mark the nav link whose section sits under the middle of the
viewport with aria-current="true" (styled as a held underline). Applies
to both the desktop and mobile nav. No-JS: nothing is marked, which is
the correct resting state.
---------------------------------------------------------------------- */
/** Every in-page nav link (desktop and mobile). @type {HTMLAnchorElement[]} */
const navLinks = Array.from(document.querySelectorAll('.nav-link[href^="#"]')); // collect nav links that point within the page
if (navLinks.length && 'IntersectionObserver' in window) { // only with links and IntersectionObserver support
// href -> [links], and the sections those links point at.
/** Section id → the nav links pointing at it. @type {Map<string, HTMLAnchorElement[]>} */
const linksById = new Map(); // empty id → links map
/** The distinct sections to observe. @type {HTMLElement[]} */
const sections = []; // empty section list
navLinks.forEach((link) => { // for each nav link:
const id = link.getAttribute('href').slice(1); // its target id, without the #
if (!id) return; // bare "#": skip
if (!linksById.has(id)) { // first link seen for this id:
const section = document.getElementById(id); // find the section
if (!section) return; // section missing: skip
linksById.set(id, []); // start a link list for it
sections.push(section); // add the section to the watch list
}
linksById.get(id).push(link); // add this link to the section's list
});
/** Sections currently crossing the viewport's midline. @type {Set<Element>} */
const visible = new Set(); // no sections visible yet
/** Id of the section currently marked, or null. @type {string|null} */
let currentId = null; // nothing marked yet
/**
* Marks the nav links for section `id` with `aria-current="true"` and
* clears the mark from all others. No-op if `id` is already current.
*
* @param {string|null} id Section id to mark, or `null` to clear all.
* @returns {void}
*/
const setCurrent = (id) => { // define setCurrent(id)
if (id === currentId) return; // already marked: nothing to change
currentId = id; // remember the new current section
linksById.forEach((links, sectionId) => { // for each section and its links:
const on = sectionId === id; // is this the section to mark?
links.forEach((link) => { // for each of its links:
if (on) link.setAttribute('aria-current', 'true'); // mark it as current
else link.removeAttribute('aria-current'); // or clear the mark
});
});
};
// Keep `visible` up to date as sections cross the midline, then mark the
// topmost visible one.
const spy = new IntersectionObserver( // create an observer that…
(entries) => { // …receives visibility changes and:
entries.forEach((entry) => { // for each change:
if (entry.isIntersecting) visible.add(entry.target); // adds a section that crossed into the midline
else visible.delete(entry.target); // removes one that left it
});
if (!visible.size) { // no section at the midline:
setCurrent(null); // clear every mark
return; // and stop
}
// Topmost section in document order that's currently crossing the midline.
let top = null; // best candidate so far
visible.forEach((section) => { // for each visible section:
if (!top || section.getBoundingClientRect().top < top.getBoundingClientRect().top) { // if it's the first, or higher up than the current best…
top = section; // …make it the best
}
});
setCurrent(top.id); // mark the winning section's links
},
// A 1px band across the vertical center of the viewport: a section is
// "current" while that line is inside it.
{ rootMargin: '-50% 0px -50% 0px' } // shrink the observed area to the center line
);
sections.forEach((section) => spy.observe(section)); // watch every section
}
/* -------------------------------------------------------------------------
"Get Started" drawer tab — hidden over the hero until the "What is a
healthcare advocate?" section comes into view, then tucked
off-screen again once the in-page intake form (or the closing schedule /
footer region) is visible, where a second consultation CTA only repeats
what's already on screen. No JS: the tab just stays, which is harmless.
---------------------------------------------------------------------- */
/** The fixed "Get Started" tab. @type {HTMLElement|null} */
const drawerTab = document.querySelector('.drawer-tab'); // find the drawer tab
/** The tab appears once this section reaches the viewport. @type {HTMLElement|null} */
const tabShowAt = document.getElementById('what'); // find the section where it appears
/** The tab tucks away once this section is well into view. @type {HTMLElement|null} */
const tabTuckAt = document.getElementById('intake'); // find the section where it tucks away
if (drawerTab && tabTuckAt) { // only if the tab and the intake section exist
/** True while an update is already queued for the next frame. */
let tabTicking = false; // no update queued yet
let tabArrived = false; // the first appearance leans out and settles (CSS .is-arriving), once
/**
* Shows or tucks away the drawer tab based on scroll position.
*
* - `is-tucked` hides it (before #what is reached, or once #intake is in
* the lower quarter of the viewport or above).
* - `tab-wait` (the initial authored state) is removed on first run.
* - The first time the tab appears, `is-arriving` plays a one-off
* attention animation (skipped under reduced motion).
*
* @returns {void}
*/
const syncDrawerTab = () => { // define syncDrawerTab()
tabTicking = false; // this queued update is now running
// Not yet: the top of the "What is a healthcare advocate?" section is still below the fold.
const early = tabShowAt ? tabShowAt.getBoundingClientRect().top > window.innerHeight : false; // true while #what is still below the viewport
// Tuck the tab away once the top of the intake section has scrolled up
// into the lower part of the viewport — from here down (form, schedule,
// footer) the page carries its own consultation CTAs.
const late = tabTuckAt.getBoundingClientRect().top < window.innerHeight * 0.75; // true once #intake's top is above 75% of the viewport
const tucked = early || late; // tuck if too early or too late
drawerTab.classList.toggle('is-tucked', tucked); // apply or remove the tucked state
drawerTab.classList.remove('tab-wait'); // clear the initial pre-script state
if (!tucked && !tabArrived) { // visible for the first time:
tabArrived = true; // remember that it has arrived
if (!prefersReducedMotion.matches) { // motion allowed?
drawerTab.classList.add('is-arriving'); // play the arrival animation
drawerTab.addEventListener('animationend', () => drawerTab.classList.remove('is-arriving'), { once: true }); // remove the class once the animation ends
}
} else if (tucked) { // tucked:
drawerTab.classList.remove('is-arriving'); // cancel any arrival animation
}
};
// Initial state, then keep in step with scrolling (once per frame) and resizing.
syncDrawerTab(); // set the tab state right away
window.addEventListener( // listen for…
'scroll', // …scroll events…
() => { // …with a handler that:
if (tabTicking) return; // skips if an update is already queued
tabTicking = true; // marks an update as queued
requestAnimationFrame(syncDrawerTab); // runs syncDrawerTab() on the next frame
},
{ passive: true } // passive: the handler never blocks scrolling
);
window.addEventListener('resize', syncDrawerTab, { passive: true }); // re-check when the window is resized
}
/* -------------------------------------------------------------------------
Booking band maze — once the footer comes into view, the route draws itself
across the maze, from the entrance in its left wall to the exit in its right.
Both mazes (wide, and the phone one) are armed; only one is displayed. Once
per page view. No JS: the route is simply shown as drawn. A position check on
a throttled scroll listener (like the scroll-reveal above), so a fast fling
or an anchor jump to the footer can't skip it.
---------------------------------------------------------------------- */
/** The maze graphics (wide and phone versions). @type {NodeListOf<Element>} */
const routeMazes = document.querySelectorAll('.maze-cross'); // find the maze graphics
/** The footer whose arrival triggers the route drawing. @type {HTMLElement|null} */
const siteFooter = document.querySelector('.site-footer'); // find the site footer
if (routeMazes.length && siteFooter) { // only if both exist
// `route-pending` hides the route until it's time to draw it.
routeMazes.forEach((maze) => maze.classList.add('route-pending')); // hide every route for now
/** True while a check is already queued for the next frame. */
let routeTicking = false; // no check queued yet
/**
* Once the footer's top is above 92% of the viewport height, adds
* `is-routed` to every maze (the CSS draws the route) and stops listening —
* it only happens once per page view.
*
* @returns {void}
*/
const checkRoute = () => { // define checkRoute()
routeTicking = false; // this queued check is now running
if (siteFooter.getBoundingClientRect().top > window.innerHeight * 0.92) return; // footer not near yet: stop
routeMazes.forEach((maze) => maze.classList.add('is-routed')); // draw the route in every maze
window.removeEventListener('scroll', onRouteScroll); // stop listening to scroll…
window.removeEventListener('resize', onRouteScroll); // …and to resize
};
/**
* Scroll/resize handler: queues one {@link checkRoute} per animation frame.
* Declared with `const` after checkRoute, which is fine: checkRoute only
* reads it when it runs, by which point it exists.
* @returns {void}
*/
const onRouteScroll = () => { // define onRouteScroll()
if (routeTicking) return; // a check is already queued: skip
routeTicking = true; // mark a check as queued
requestAnimationFrame(checkRoute); // run checkRoute() on the next frame
};
window.addEventListener('scroll', onRouteScroll, { passive: true }); // check on scroll (without blocking scrolling)
window.addEventListener('resize', onRouteScroll, { passive: true }); // check on resize
checkRoute(); // check right away (the page may load at the footer)
}
/* -------------------------------------------------------------------------
Fine print, read — pointing at a note lights its marked line on the sample
statement (and pointing at a line lights its note). Pointer only and purely
additive: the notes, numbers and marks all read without it.
---------------------------------------------------------------------- */
/** The sample-statement container. @type {HTMLElement|null} */
const finePrint = document.querySelector('[data-fineprint]'); // find the sample statement
// Notes and statement lines share a `data-note` value. Pointing at either
// copies that value to the container's `data-active`, and the CSS highlights
// every element whose `data-note` matches.
if (finePrint) { // only if it's on this page
finePrint.querySelectorAll('[data-note]').forEach((el) => { // for each note or marked line:
el.addEventListener('pointerenter', () => { // when the pointer moves onto it:
finePrint.dataset.active = el.dataset.note; // highlight everything with the same note number
});
el.addEventListener('pointerleave', () => { // when the pointer moves off it:
// Only clear it if another element hasn't already taken over.
if (finePrint.dataset.active === el.dataset.note) delete finePrint.dataset.active; // remove the highlight if it's still this note's
});
});
// The highlighter wash is one gradient the width of the row, and each cell
// shows its own slice of it, so the swipe runs across the row as one stroke.
// The CSS needs the row's width and each cell's offset inside the row.
const flaggedRows = finePrint.querySelectorAll('tr.is-flagged'); // the highlighted lines
const measureRows = () => { // record the sizes the CSS needs
// Read every row's and cell's geometry first, then write the custom
// properties below — interleaving reads and writes across rows/cells
// would force a synchronous layout for each one instead of one for
// the whole table.
const measurements = Array.from(flaggedRows).map((row) => ({ // for each highlighted line, record:
row, // the row itself
width: row.offsetWidth, // the wash is as wide as the row
// Offsets, not bounding boxes, so the reveal's transform can't skew them.
cells: Array.from(row.cells).map((cell) => [cell, cell.offsetLeft - row.offsetLeft]), // each cell, and how far in from the row's left edge it sits
}));
measurements.forEach(({ row, width, cells }) => { // for each measured row:
row.style.setProperty('--fp-row-w', `${width}px`); // apply its width
cells.forEach(([cell, x]) => cell.style.setProperty('--fp-x', `${x}px`)); // and each cell's offset
});
};
if (flaggedRows.length) { // only if there are highlighted lines
measureRows(); // measure now
if ('ResizeObserver' in window) new ResizeObserver(measureRows).observe(finePrint.querySelector('table')); // and again whenever the table changes size
}
}