-
Notifications
You must be signed in to change notification settings - Fork 27
Expand file tree
/
Copy pathscript.js
More file actions
591 lines (513 loc) · 26.7 KB
/
Copy pathscript.js
File metadata and controls
591 lines (513 loc) · 26.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
import { ACTIVE_NAV_SECTIONS, applySectionTheme, SITE_SECTIONS } from "./site-sections.js"
import { initMarginTabNav, swapTabPanels } from "./shared/tab-slide.js"
import { onScroll } from "./shared/scroll-loop.js"
import { INITIAL_ROUTE, landOnLoad, matchRoute, onRoute, readRoute, revealSection, writeRoute } from "./shared/deep-link.js"
/* Imported for its side effect: the module hooks the route and keeps the tab
title in step. Here as well as in the two sections that register resolvers,
so a section failing to load cannot leave the title frozen. */
import "./shared/page-meta.js"
/* script.js
* Entry-point for the landing page interaction.
* Handles the door-screen open animation: listens for click / keyboard
* events to trigger the CSS opening class, then hides the overlay once
* the opacity transition completes so it is removed from the a11y tree.
*/
const doorScreen = document.getElementById("door-screen")
const mainPage = document.getElementById("main-page")
/* The inline bootstrap in index.html has already opened the door if the page
was loaded on a hash — it runs before this module and before the first paint,
which is the only place the skip can happen without the door flashing. Start
from where it left off rather than offering to open something already open. */
let hasOpened = Boolean(doorScreen && doorScreen.hidden)
function openDoors() {
if (!doorScreen || !mainPage || hasOpened) return
hasOpened = true
doorScreen.classList.add("opening")
doorScreen.setAttribute("aria-hidden", "true")
mainPage.classList.add("visible")
}
if (doorScreen && mainPage) {
doorScreen.addEventListener("click", openDoors)
doorScreen.addEventListener("pointerdown", openDoors)
doorScreen.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
openDoors()
})
doorScreen.addEventListener("transitionend", (event) => {
if (event.target !== doorScreen || event.propertyName !== "opacity") return
doorScreen.hidden = true
})
}
// Theming runs after the door listeners are wired so a failure here can never
// leave the landing screen unclickable.
try {
applySectionTheme()
} catch (error) {
console.error("applySectionTheme failed", error)
}
// ── Active nav section highlighting ──
const sectionNavMap = ACTIVE_NAV_SECTIONS
/* The bar's own links, which are static markup in index.html — re-querying
them on every scroll frame was a document-wide selector run for a set that
cannot change. */
let navLinks = null
/* What the bar is currently showing, so a frame that finds the same section
does no DOM work at all. This runs on every painted frame of a 13,000px page
and the answer changes perhaps six times in a whole visit; without the
early-out it cleared and re-set `.active` across every link each time. */
let shownSectionId
function updateActiveNav() {
const mid = window.innerHeight / 2
let activeSectionId = null
for (const { sectionId, pageSectionId } of sectionNavMap) {
const el = document.getElementById(pageSectionId)
if (!el) continue
const r = el.getBoundingClientRect()
if (r.top <= mid && r.bottom >= mid) {
activeSectionId = sectionId
break
}
}
if (activeSectionId === shownSectionId) return
shownSectionId = activeSectionId
if (!navLinks) navLinks = document.querySelectorAll("nav a")
navLinks.forEach((a) => a.classList.toggle("active", Boolean(activeSectionId) && a.dataset.sectionId === activeSectionId))
}
// ── Nav visibility: hidden over the hero, revealed once scrolled past it ──
const navBar = document.querySelector("nav")
const heroSection = document.querySelector(".hero")
// Maps the tail of the hero's exit onto a 0 → 1 reveal the stylesheet reads as
// --nav-reveal, so the bar eases in with the scroll instead of snapping.
const NAV_REVEAL_END = 90
// The last value written. The reveal is pinned at 1 for everything below the
// hero, which is the whole page bar the first screenful, so most frames have
// nothing to say — and a custom property written with the value it already has
// still costs a style invalidation.
let shownReveal
function updateNavVisibility() {
if (!navBar || !heroSection) return
const heroBottom = heroSection.getBoundingClientRect().bottom
const revealStart = Math.max(NAV_REVEAL_END + 120, window.innerHeight * 0.45)
const reveal = Math.min(1, Math.max(0, (revealStart - heroBottom) / (revealStart - NAV_REVEAL_END))).toFixed(3)
if (reveal === shownReveal) return
shownReveal = reveal
navBar.style.setProperty("--nav-reveal", reveal)
navBar.classList.toggle("nav--hidden", Number(reveal) <= 0.02)
}
function initContactTabs() {
const buttons = Array.from(document.querySelectorAll(".contact-tab-btn"))
const panels = Array.from(document.querySelectorAll(".contact-tab-panel"))
if (!buttons.length || !panels.length) return
// The backdrop is a sibling of the panels, so it cannot select on which one
// is showing — and it has to, because the tabs differ enormously in height
// and the banner is sized against the section. See the [data-active-tab]
// rules in css/15-contact.css.
const section = document.querySelector(".contact-full")
const TABS = ["contact", "join", "services"]
/* `write` is false for the two calls that are not a reader pressing
something — the initial render, and applying a route that came out of the
URL in the first place. Writing on those would be this section claiming
the hash off whatever section the reader actually linked to. */
function activateContactTab(tab, write) {
if (section) section.dataset.activeTab = tab
buttons.forEach((button) => {
const isActive = button.dataset.tab === tab
button.classList.toggle("contact-tab-btn--active", isActive)
button.setAttribute("aria-selected", isActive ? "true" : "false")
})
swapTabPanels(panels, "contact-" + tab)
if (write !== false) writeRoute("contact-" + tab)
}
buttons.forEach((button) => {
button.addEventListener("click", () => {
activateContactTab(button.dataset.tab || "contact")
})
})
/* The FABs and the two nav entries. They are plain `#sec-contact-full`
anchors, so the browser does the scrolling and the hash it leaves behind
is the section's — which is why these do not write one of their own. */
document.querySelectorAll("[data-contact-tab-target]").forEach((link) => {
link.addEventListener("click", () => {
activateContactTab(link.getAttribute("data-contact-tab-target") || "contact", false)
})
})
/* Landing is part of it: a `#contact-join` a reader was sent is a link to
the tab, and every other section's route handler scrolls to itself. The
one on the initial route is re-run on `load` for the reason
`landOnInitialSection` is — this section is the last one on the page, so
its offset is the sum of every manifest still being fetched. */
function applyRoute(route) {
const tab = matchRoute(route, "contact")
if (tab === null || !TABS.includes(tab)) return
activateContactTab(tab, false)
revealSection("sec-contact-full")
}
onRoute(applyRoute)
const initialTab = matchRoute(INITIAL_ROUTE, "contact")
activateContactTab(TABS.includes(initialTab) ? initialTab : "contact", false)
if (TABS.includes(initialTab)) {
revealSection("sec-contact-full")
landOnLoad("sec-contact-full")
}
initMarginTabNav(section, ".contact-tab-btn")
}
/* ── Section backdrop parallax ──
* Shared by the People video and the Information image. Travel is clamped to
* however much the layer and its section differ in height, which covers both
* arrangements: a layer shorter than its section (the video, sized so the whole
* frame stays visible) moves within the letterbox gap, and a layer taller than
* its section (the image, deliberately overhanging) moves within the overhang.
* Either way the layer never pulls away from an edge.
*
* In-view is derived from the same rect rather than a separate
* IntersectionObserver: one mechanism, on a path guaranteed to run whenever the
* section can be seen.
*/
function trackSectionParallax(section, layer, customProperty, options) {
const settings = options || {}
const maxTravel = settings.maxTravel || 160
const viewMargin = settings.viewMargin || 200
let inView = false
// The last offset written. Both of these layers spend long stretches at the
// end of their travel — the People video is clamped for the whole roster
// below the fold — and re-writing a custom property with the value it
// already holds still invalidates style on the subtree that reads it.
let shownOffset
function update() {
const rect = section.getBoundingClientRect()
const viewport = window.innerHeight
const nowInView = rect.bottom > -viewMargin && rect.top < viewport + viewMargin
if (nowInView !== inView) {
inView = nowInView
if (settings.onViewChange) settings.onViewChange(inView)
}
if (!nowInView) return
const slack = Math.abs(rect.height - layer.offsetHeight) / 2
const travel = Math.min(slack * 0.9, maxTravel)
// Progress is mapped onto the range the box can actually be scrolled
// through, not onto a full crossing of the viewport. The Information
// banner sits at the end of the page and never reaches the top of it,
// so against a full crossing it only ever used a quarter of its travel
// — and it got weaker every time the section grew. For a box that does
// cross fully this is the same ratio as before.
const maxScroll = mainPage.scrollHeight - mainPage.clientHeight
const boxTop = rect.top + mainPage.scrollTop
const enter = Math.min(viewport, boxTop)
const exit = Math.max(-rect.height, boxTop - maxScroll)
const span = enter - exit
// Clamped because viewMargin runs update() while the layer is still
// outside that range, where the ratio overshoots and pushes the layer
// past its own overhang — which uncovers a strip at the far edge.
const progress = span > 0 ? Math.min(1, Math.max(0, (enter - rect.top) / span)) : 0.5
/* Where the swing sits inside the slack. Centred by default, which is
what a layer taller than its box wants — the overhang is symmetric
and either edge is as far from view as the other.
`bottom` offsets it so that full progress lands the layer's bottom
edge exactly on the box's. That is for the People video, which is
*shorter* than its box: centred, its feathered bottom edge floats at
whatever height the roster happens to give the section, and it used
to finish below the Alumni bar. Anchored, the band always ends on the
line the content ends on, whoever joins or leaves the lab. */
const offset = settings.anchor === "bottom" ? slack - travel : 0
const value = (offset + (progress - 0.5) * 2 * travel).toFixed(1) + "px"
if (value === shownOffset) return
shownOffset = value
section.style.setProperty(customProperty, value)
}
onScroll(mainPage, update)
window.addEventListener("resize", update)
// Section height is not settled at startup and changes with the tabs; both
// move the slack the travel is clamped to.
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(update).observe(section)
}
update()
return { update, isInView: () => inView }
}
// ── Information: parallaxed Sussex banner, starting at the tab line ──
function initInformationBackdrop() {
const section = document.querySelector(".contact-full")
const backdrop = section && section.querySelector(".contact-full__backdrop")
const layer = backdrop && backdrop.querySelector(".contact-full__backdrop-img")
if (!section || !backdrop || !layer || !mainPage) return
// Where the banner starts. Measured rather than hardcoded because the tab
// row wraps to a second line on narrow viewports.
const tabsNav = section.querySelector(".contact-tabs-nav")
function syncTop() {
if (!tabsNav) return
const top = tabsNav.getBoundingClientRect().bottom - section.getBoundingClientRect().top
backdrop.style.setProperty("--contact-backdrop-top", Math.round(top) + "px")
}
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(syncTop).observe(section)
}
window.addEventListener("resize", syncTop)
syncTop()
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
// Measured against the banner, not the section: the travel comes from the
// image's overhang inside the banner, and the banner is the shorter of the
// two now that it starts at the tab line.
/* The Services tab is deliberately not driven from here: it holds its
photograph still with `position: sticky` and no script at all. A scroll
handler was tried and is the thing to not go back to — the content is
moved by the compositor and the picture would be moved by JS, so the
picture arrives a frame late and visibly drags behind at speed. */
trackSectionParallax(backdrop, layer, "--contact-parallax")
}
// ── People: dimmed backdrop loop, fetched and played only while in view ──
function initPeopleVideo() {
const section = document.querySelector(".people-full")
const video = section && section.querySelector(".people-full__video-el")
// The box the parallax measures against is the backdrop, not the section:
// it stops at the section's bottom padding — the line the Alumni bar sits
// on — so "as low as the band goes" and "where the roster ends" are the
// same place. Measured against the section they differ by that padding,
// which is what put the fade below the bar.
const videoBox = section && section.querySelector(".people-full__video")
if (!section || !video || !videoBox || !mainPage) return
// Under reduced motion the poster stays put and nothing is ever fetched.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
// Only the team roster gets the backdrop; the other two tabs stay plain.
const labPanel = document.getElementById("people-tab-lab")
const onLabTab = () => !labPanel || !labPanel.hidden
let inView = false
let loadStarted = false
function sync() {
section.classList.toggle("people-full--video-on", onLabTab())
if (!inView || !onLabTab()) {
video.pause()
return
}
if (!loadStarted) {
loadStarted = true
// preload="none" in the markup keeps the video from competing with
// the hero for bandwidth, but it leaves the fetch suspended — and
// raising preload on its own does not reliably restart it. load()
// re-runs resource selection and actually pulls the bytes.
video.preload = "auto"
video.load()
}
// play() before any data has arrived is fine — playback starts once
// there is enough. Never swallow the rejection: it is the only thing
// that names a blocked autoplay.
video.play().catch((error) => {
console.warn("People backdrop video did not start:", error && error.name, error && error.message)
})
}
if (labPanel) {
new MutationObserver(sync).observe(labPanel, { attributes: true, attributeFilter: ["hidden"] })
}
// Chrome pauses backdrop loops in a backgrounded tab — it reports them as
// "video-only background media" and refuses play() outright while hidden,
// silent audio track or not. Coming back to the tab fires nothing else, so
// without this the backdrop stays frozen on a frame for the rest of the
// visit. One event, no polling.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") sync()
})
// Parallax also tells us when the section is near enough to start fetching.
const tracker = trackSectionParallax(videoBox, video, "--people-video-parallax", {
anchor: "bottom",
onViewChange: (visible) => {
inView = visible
sync()
},
})
inView = tracker.isInView()
sync()
}
// ── Hero: soft glow that trails the pointer across the dark half ──
function initHeroGlow() {
const hero = document.querySelector(".hero")
if (!hero || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
hero.addEventListener(
"pointermove",
(event) => {
const rect = hero.getBoundingClientRect()
if (!rect.width || !rect.height) return
hero.style.setProperty("--hero-pointer-x", (((event.clientX - rect.left) / rect.width) * 100).toFixed(2) + "%")
hero.style.setProperty("--hero-pointer-y", (((event.clientY - rect.top) / rect.height) * 100).toFixed(2) + "%")
},
{ passive: true },
)
}
/* ── "Like this website?" ──
* A third floating button that slides up under the other two once a visitor is
* far enough in to have formed an opinion of the site. It points at the
* Services tab, which the FAB group's own handler in initContactTabs already
* knows how to open — this only decides *when* the button exists.
*
* It withdraws again on the way back up, and — the part that keeps it from
* being a nuisance — it withdraws once the Information section is actually on
* screen. A button whose whole job is "go over there" has nothing to say while
* you are standing in front of "there", and leaving it floating over the
* section it advertises is exactly the behaviour that makes these obnoxious.
*
* Both ends are section boundaries, deliberately: it lives for exactly News and
* Publications. Nothing here may be a fraction of the scroll height — the
* Research zoom's gate changes that height by ~700vh (see the note at `gate`).
*/
function initSitePromoFab() {
const fab = document.getElementById("fab-site-promo")
// Every other init in this file guards its own element and this one did
// not: measureCollapse() reads `fab.parentElement` unconditionally, so a
// shell without the button threw a TypeError at module evaluation rather
// than simply going without a FAB.
if (!fab || !mainPage) return
const target = document.getElementById("sec-contact-full")
/* What "far enough in" is measured against. It used to be a fraction of the
page — half way down, on the theory that People and Research are behind
you by then — and that was wrong twice over. Research is the tallest
section on the page even with its zoom shut, so half of the scroll height
already landed inside it; and opening the zoom adds ~700vh to the
document, which drags the halfway mark right into the middle of the dive.
Either way the button arrived over the section it was meant to come
after, and its position moved depending on whether the reader had opened
something. A section boundary does not have that problem. */
const gate = document.getElementById("sec-research-full")
// Fallback only, for a page without that section: half way down.
const SHOW_PAST = 0.5
/* How far the button has to pull itself out of the column to leave no trace
of itself in it: its own height, plus the gap that would sit above it.
Measured rather than written down because the second line wraps at narrow
widths and the height follows. Safe to read at any time — the button is
collapsed with a margin, so it keeps its natural height throughout. */
function measureCollapse() {
const group = fab.parentElement
if (!group) return
const gap = parseFloat(getComputedStyle(group).rowGap) || 0
fab.style.setProperty("--fab-promo-collapse", fab.offsetHeight + gap + "px")
}
function update() {
const scrollable = mainPage.scrollHeight - mainPage.clientHeight
if (scrollable <= 0) return
// Research entirely off the top, which is the moment News fills the
// screen — so the button's whole life is News and Publications, whether
// or not the reader took the long way through the zoom.
const scrolled = gate ? gate.getBoundingClientRect().bottom <= 0 : mainPage.scrollTop / scrollable >= SHOW_PAST
// Three quarters of a viewport of the destination is "you are here".
const arrived = target ? target.getBoundingClientRect().top < mainPage.clientHeight * 0.75 : false
fab.classList.toggle("is-shown", scrolled && !arrived)
}
onScroll(mainPage, update)
window.addEventListener("resize", () => {
measureCollapse()
update()
})
measureCollapse()
update()
}
if (mainPage) {
onScroll(mainPage, updateActiveNav)
onScroll(mainPage, updateNavVisibility)
window.addEventListener("resize", updateNavVisibility)
updateActiveNav()
updateNavVisibility()
}
/* Landing on a plain section anchor — `#sec-news-full`, which is what the nav's
own links leave in the URL and so what a reader is most likely to copy. The
browser does resolve these itself, but it resolves them once, against a page
whose sections are still empty: every manifest here is fetched, so the
offsets move afterwards. Doing it again on `load` is what makes a shared
section link land where it says. Routes that name something inside a section
are that section's own job, since only it knows when its content exists. */
function landOnInitialSection() {
if (!INITIAL_ROUTE.startsWith("sec-")) return
revealSection(INITIAL_ROUTE)
}
if (INITIAL_ROUTE) {
landOnInitialSection()
window.addEventListener("load", landOnInitialSection)
}
/* ── The section anchors have to be scrolled by hand now ──
* `<base href="/">` in index.html is what keeps every site-relative URL on the
* page resolving against the site root rather than against whatever path
* `writeRoute` last wrote (see the note in index.html). It has one cost: a bare
* `href="#sec-people-full"` resolves to `/#sec-people-full`, which stops being
* a same-document fragment the moment the URL is `/people/memories/` — the
* browser would treat it as a navigation and reload the page.
*
* So these nine anchors are handled here instead. The behaviour is the one they
* always had, only smooth on purpose: a click inside the page is a move the
* reader initiated and reads better animated, which is the same distinction
* `revealSection` already makes between arriving and navigating.
*
* Delegated from the document so it covers the nav, the FABs and anything
* added later, and registered without `capture` so the existing
* `[data-contact-tab-target]` listeners still run — they open the tab, this
* moves the page, and neither knows about the other.
*
* Modifier-clicks and middle-clicks fall through untouched: those mean "open
* this somewhere else", and a preventDefault would silently break them.
*/
document.addEventListener("click", (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
const anchor = event.target.closest && event.target.closest('a[href*="#sec-"]')
if (!anchor) return
const sectionId = (anchor.getAttribute("href") || "").split("#")[1]
if (!sectionId || !document.getElementById(sectionId)) return
event.preventDefault()
revealSection(sectionId, { smooth: true })
})
/* ── The hero's six menu buttons ──
* They belonged to brain.js, which is loaded only where `.hero__brain` is
* displayed — 901px and up, so that a phone does not build a WebGL renderer
* against a 0×0 container and pull 5.7 MB of brain.glb to draw nothing. The
* menu is not gated that way: it is on screen at every width. So below 900px
* the whole hero menu was inert, and tapping People, Research or any of the
* others left the reader exactly where they were, on the hero.
*
* Here instead, in the module that always loads. brain.js keeps the *hover* —
* that one really is about the brain, since it flies the camera to the region —
* and keeps the click on the mesh itself, easter egg included.
*
* `revealSection` rather than the `scrollIntoView` brain.js used, so a button
* and the nav link for the same section now do the identical thing; #main-page
* is the scroll container, not the window, and revealSection is what knows it.
*
* The buttons are <button>, not anchors, so the `a[href*="#sec-"]` handler
* below was never going to catch them. Join carries its own
* data-contact-tab-target, whose listener opens the tab — that one runs
* alongside this and neither knows about the other, the same arrangement the
* two Information links in the nav already have.
*/
const MENU_TARGETS = new Map(SITE_SECTIONS.map((section) => [section.id, section.scrollTargetId]))
document.addEventListener("click", (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
const button = event.target.closest && event.target.closest(".menu-button")
if (!button) return
const target = MENU_TARGETS.get(button.id)
if (!target || !document.getElementById(target)) return
revealSection(target, { smooth: true })
})
/* ── The mark in the bar goes home ──
* The same bargain the section anchors above strike, for the same reason: it
* is a real `<a href=".">` so that middle-click, "copy link address" and a
* crawler all get the site root, and the plain left click is handled here
* instead — the browser would otherwise reload the entire site to reach a
* place that is one smooth scroll away.
*
* `writeRoute("")` is the other half of it, and not decoration. The reader can
* be at `/people/memories/2025-beach/` when they press this; leaving that in
* the address bar would have the URL naming a photograph nobody is looking at,
* and page-meta.js — which follows every write — would keep its title in the
* tab. "" is the route with no name, and routes.js maps it to `/`.
*
* Not delegated, unlike the section anchors: there is exactly one of these and
* it is in the markup at parse time.
*/
const navHome = document.querySelector(".nav-home")
if (navHome && mainPage) {
navHome.addEventListener("click", (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
mainPage.scrollTo({ top: 0, behavior: "smooth" })
writeRoute("")
})
}
initContactTabs()
initHeroGlow()
initPeopleVideo()
initInformationBackdrop()
initSitePromoFab()