initCards and initRoadmap now use a pin-with-scrub-release pattern instead of a simple scroll-tied fade. Each section fades in over ~50vh as it approaches viewport centre, locks in place for 100vh of scroll input (cards extends to 150vh and fades out while still pinned; roadmap stays visible as the page ends), then releases. Scroll itself is never blocked — wheel/keyboard/touch all advance scroll normally against the pin budget. platform-cards is removed from bifrost's sticky-damping list since the new pin handles the dwell. Dot-nav fixes for the new pins: - activatePage now also calls __platform.init() in the same tick as __bifrost.init(), so pin spacers exist before scrollTo reads target offsets. Previously platform's MutationObserver-driven init fired ~80ms after scrollTo, leaving roadmap.offsetTop pointing at the pre-spacer position (empty space between cards and roadmap). - scrollTo walks the offsetParent chain via offsetTopWithin() instead of reading target.offsetTop directly. ScrollTrigger's pinSpacing wraps pinned sections in a pin-spacer with position:relative, which becomes the section's offsetParent and makes target.offsetTop return ~0 — collapsing every dot click to scrollY=0 (hero). - getSceneAnchorOffset adds cases for platform-cards / platform-roadmap returning (section.height - vh) / 2, so the user lands exactly at the pin-engagement point with the full pin budget remaining. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
485 lines
23 KiB
JavaScript
485 lines
23 KiB
JavaScript
// ─────────────────────────────────────────────────────────────
|
||
// protected/timeline.js — timeline scroll, dot-nav, globe.
|
||
// This file also boots the Bifrost scenes (bifrost.js) the first
|
||
// time the Overview page is activated via the dot-nav or the
|
||
// "Read the editor's note" button, and fetches the user's first
|
||
// name from /auth/me so Scene 3 can personalise its sentence.
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
// Signal to the Bifrost CSS that JS is running (enables the scroll-
|
||
// triggered reveal baseline: .js .some-element { opacity: 0 }).
|
||
// Harmless on the timeline itself — nothing on the timeline uses .js.
|
||
document.documentElement.classList.add('js');
|
||
|
||
const EVENTS = [
|
||
// 2024
|
||
{ date: 'September 2024', kind: 'Editorial', accent: 'copper',
|
||
hed: 'Three American firms run 70% of Europe’s cloud — and almost all of its <em>AI.</em>',
|
||
body: 'Mario Draghi’s verdict to the European Parliament: only four of the world’s top fifty tech companies are European. “It is too late,” he writes, to challenge American cloud providers. Without radical reform, the EU faces “slow agony.”',
|
||
source: 'The Draghi Report · Brussels' },
|
||
{ date: 'December 2024', kind: 'Field Note', accent: 'copper',
|
||
hed: 'Denmark warns: digital society is now “extremely <em>vulnerable.</em>”',
|
||
body: 'The expert group on tech giants reports: dependence on a handful of foreign suppliers is no longer a procurement question. It is a national security one. Minister Bodskov: “we need to fence in the tech giants.”',
|
||
source: 'Danish Expert Group · Copenhagen' },
|
||
// 2025
|
||
{ date: 'January 2025', kind: 'Rupture', accent: 'crimson',
|
||
hed: 'Trump refuses to rule out military force against <em>Greenland.</em>',
|
||
body: 'Two weeks before inauguration, the president-elect threatens “very high tariffs” on Denmark. The shock in Copenhagen is total.',
|
||
source: 'Mar-a-Lago · Press Conference' },
|
||
{ date: 'May 2025', kind: 'Rupture', accent: 'crimson',
|
||
hed: 'Microsoft cuts off the ICC chief prosecutor\'s <em>email.</em>',
|
||
body: 'A US tech company, complying with a US executive order, disables the digital life of an officer of an international tribunal in the Netherlands. The “kill switch” stops being theoretical.',
|
||
source: 'Associated Press · The Hague' },
|
||
{ date: 'June 2025', kind: 'Regulation', accent: 'copper',
|
||
hed: 'Microsoft admits under oath: it cannot guarantee European <em>sovereignty.</em>',
|
||
body: 'Even data on European soil, with European staff, encrypted with European keys — US authorities can compel disclosure under the CLOUD Act. The legal fiction collapses.',
|
||
source: 'French Senate Hearing · Paris' },
|
||
{ date: 'June 2025', kind: 'Field Note', accent: 'ochre',
|
||
hed: 'Copenhagen’s Microsoft bill jumps <em>72% in five years.</em>',
|
||
body: 'From 313 to 538 million Danish kroner. Copenhagen and Aarhus announce they will leave Microsoft entirely. The minister of emergency tells companies: “create exit plans for cloud services.”',
|
||
source: 'Copenhagen Municipality · Finance Report' },
|
||
{ date: 'Summer 2025', kind: 'Regulation', accent: 'copper',
|
||
hed: 'A Danish minister tells industry: prepare your exit plans for <em>cloud services.</em>',
|
||
body: 'Caroline Stage Olsen begins moving her ministry off Microsoft 365. The minister of emergency preparedness urges every Danish company to do the same. Continued dependence is now classified as a vulnerability.',
|
||
source: 'Danish Ministry of Digital Affairs' },
|
||
{ date: 'August 2025', kind: 'Rupture', accent: 'crimson',
|
||
hed: 'Trump threatens tariffs against any country with digital <em>regulations.</em>',
|
||
body: '“American technology is not the world’s piggy bank.” The DSA, the DMA, the AI Act — all reframed as discriminatory trade barriers. Chip export restrictions are added to the list of consequences.',
|
||
source: 'Truth Social · Washington' },
|
||
// 2026
|
||
{ date: 'January 2026', kind: 'Rupture', accent: 'crimson',
|
||
hed: 'Trump imposes tariffs on Denmark and seven <em>European nations.</em>',
|
||
body: '10% in February. 25% from June — until Denmark cedes Greenland. Denmark, Norway, Sweden, Finland, France, Germany, Netherlands, UK. The post-war alliance, weaponised.',
|
||
source: 'Presidential Executive Order' },
|
||
{ date: 'January 2026', kind: 'Rupture', accent: 'crimson',
|
||
hed: 'Denmark names the United States as a national security <em>threat.</em>',
|
||
body: 'For the first time in history, the official Danish threat assessment lists the US alongside Russia and China. Defence committee chair Rasmus Jarlov tells Washington: “You are the threat. Not them.”',
|
||
source: 'Danish Defence Intelligence · FE' },
|
||
{ date: 'February 2026', kind: 'Product', accent: 'terracotta',
|
||
hed: 'The court that prosecutes war crimes can no longer use American <em>software.</em>',
|
||
body: 'The ICC migrates to OpenDesk — an open-source suite delivered by the German Centre for Digital Sovereignty. If a global tribunal cannot trust Microsoft, the implication for every other European institution is unavoidable.',
|
||
source: 'Handelsblatt · The Hague' },
|
||
{ date: 'Q1 2026', kind: 'Regulation', accent: 'copper',
|
||
hed: 'Europe drafts a sovereignty law as US firms still hold 70% of the <em>cloud.</em>',
|
||
body: 'Europe writes rules for infrastructure it does not own. US hyperscalers add €10 billion of European capacity every quarter — more than Gaia-X spent in a decade. The servers stay in Texas. The AI models stay in California. The law changes neither.',
|
||
source: 'European Commission · Brussels' },
|
||
];
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
Timeline layout
|
||
───────────────────────────────────────────────────────────── */
|
||
const CARD_PITCH = 760; // horizontal spacing between card centers (doubled to fit 640-wide cards)
|
||
const LEFT_PAD = 720; // extra room so first above-card clears the title/sub
|
||
const RIGHT_PAD = 420;
|
||
|
||
const track = document.getElementById('tl-track');
|
||
const viewport = document.getElementById('tl-viewport');
|
||
const spine = document.getElementById('spine');
|
||
|
||
function buildTimeline() {
|
||
// Year ticks
|
||
const years = [...new Set(EVENTS.map(e => e.date.slice(-4)))].sort();
|
||
// Build cards
|
||
EVENTS.forEach((e, i) => {
|
||
const x = LEFT_PAD + i * CARD_PITCH;
|
||
const above = i % 2 === 0;
|
||
const card = document.createElement('article');
|
||
card.className = 'evt ' + (above ? 'above' : 'below');
|
||
card.dataset.accent = e.accent;
|
||
card.style.left = (x - 320) + 'px';
|
||
card.innerHTML = `
|
||
<span class="node"></span>
|
||
<h3>${e.hed}</h3>
|
||
<p>${e.body}</p>
|
||
<div class="source">${e.source} · ${e.date}</div>
|
||
`;
|
||
track.appendChild(card);
|
||
});
|
||
|
||
// Year ticks
|
||
years.forEach(y => {
|
||
// place tick at first event of that year
|
||
const firstIdx = EVENTS.findIndex(e => e.date.endsWith(y));
|
||
const x = LEFT_PAD + firstIdx * CARD_PITCH - CARD_PITCH * 0.42;
|
||
const tick = document.createElement('div');
|
||
tick.className = 'year-tick';
|
||
tick.style.left = x + 'px';
|
||
tick.innerHTML = `<span class="y">${y}</span>`;
|
||
track.appendChild(tick);
|
||
});
|
||
|
||
// Set track width
|
||
const totalW = LEFT_PAD + EVENTS.length * CARD_PITCH + RIGHT_PAD;
|
||
track.style.width = totalW + 'px';
|
||
}
|
||
|
||
buildTimeline();
|
||
|
||
/* Horizontal scroll + inertia — mousewheel-driven */
|
||
const state = {
|
||
target: 0,
|
||
current: 0,
|
||
max: 0,
|
||
rafPending: false,
|
||
};
|
||
|
||
function recalcMax() {
|
||
state.max = Math.max(0, track.offsetWidth - window.innerWidth);
|
||
}
|
||
recalcMax();
|
||
window.addEventListener('resize', recalcMax);
|
||
|
||
function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
|
||
|
||
function onWheel(e) {
|
||
// Prefer vertical delta (mousewheel); allow horizontal from trackpads too.
|
||
const dy = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
||
state.target = clamp(state.target + dy * 1.1, 0, state.max);
|
||
e.preventDefault();
|
||
kick();
|
||
// The hint class is toggled in applyScroll() based on state.target, so
|
||
// it dismisses on the first wheel tick (target jumps instantly) AND
|
||
// re-appears if the reader scrolls all the way back to the start.
|
||
}
|
||
|
||
function kick() {
|
||
if (state.rafPending) return;
|
||
state.rafPending = true;
|
||
requestAnimationFrame(tick);
|
||
}
|
||
|
||
function tick() {
|
||
const diff = state.target - state.current;
|
||
state.current += diff * 0.12; // soft inertia
|
||
if (Math.abs(diff) < 0.3) {
|
||
state.current = state.target;
|
||
state.rafPending = false;
|
||
} else {
|
||
requestAnimationFrame(tick);
|
||
}
|
||
applyScroll();
|
||
}
|
||
|
||
function applyScroll() {
|
||
const vw = window.innerWidth;
|
||
|
||
// Timeline starts offscreen to the right. At scroll=0 we add one full
|
||
// viewport of extra offset so no cards are visible; the hero caption has
|
||
// the page to itself. As the reader scrolls, this intro offset decays to
|
||
// zero and then the track scrolls normally.
|
||
const introTravel = vw;
|
||
const introProg = Math.min(1, state.current / introTravel);
|
||
const extraOffset = introTravel * (1 - introProg);
|
||
track.style.transform = `translate3d(${-state.current + extraOffset}px,0,0)`;
|
||
|
||
// Front-matter fades once the user has begun scrolling
|
||
const pageEl = document.getElementById('page-timeline');
|
||
pageEl.classList.toggle('is-scrolled', state.current > 40);
|
||
|
||
// Scroll-to-begin hint: tied to state.target (not state.current) so
|
||
// it leaves the moment the reader commits to a wheel tick, and comes
|
||
// back if they scroll all the way back to the start. A small threshold
|
||
// keeps it from flickering on tiny trackpad noise.
|
||
pageEl.classList.toggle('hint-dismissed', state.target > 10);
|
||
|
||
// Progress across the entire catalog (0 → 1)
|
||
const p = state.max > 0 ? state.current / state.max : 0;
|
||
|
||
// Globe opacity — peaks around the 40% mark, where the globe is doing
|
||
// its most descriptive work (mid-rotation between the Atlantic and Europe).
|
||
// Baseline 0.50, peak 0.78 at p=0.40, then softens back to 0.52 by the end.
|
||
const wrapEl = document.getElementById('globe-wrap');
|
||
if (wrapEl) {
|
||
const peak = 1 - Math.min(1, Math.abs(p - 0.40) / 0.40);
|
||
const opacity = 0.50 + peak * 0.28;
|
||
wrapEl.style.opacity = opacity.toFixed(3);
|
||
}
|
||
|
||
// Reveal cards (fade + up, one-way once visible).
|
||
// NB: account for the intro offset so cards don't arm while offscreen.
|
||
document.querySelectorAll('.evt').forEach(el => {
|
||
const left = parseFloat(el.style.left);
|
||
const onscreen = left - state.current + extraOffset;
|
||
if (onscreen < vw + 200 && onscreen > -600) {
|
||
el.classList.add('is-near');
|
||
}
|
||
});
|
||
|
||
// Continue button — visible once we're near the end and past the front matter
|
||
const nearEnd = state.max > 0 && (state.max - state.current) < (vw * 1.1);
|
||
const btn = document.getElementById('continue-btn');
|
||
if (btn) btn.classList.toggle('is-visible', nearEnd && state.current > 40);
|
||
|
||
// Globe rotation — from North America toward Europe as we advance.
|
||
const rotDeg = 95 + (-10 - 95) * p;
|
||
if (globe.setRotation) globe.setRotation(rotDeg);
|
||
|
||
// Screen label for commenting context
|
||
const idx = Math.min(EVENTS.length - 1,
|
||
Math.round(state.current / CARD_PITCH));
|
||
const slug = '01 Timeline · ' + String(idx + 1).padStart(2, '0') + '/' + EVENTS.length;
|
||
pageEl.setAttribute('data-screen-label', slug);
|
||
}
|
||
|
||
/* Keyboard navigation for accessibility */
|
||
window.addEventListener('keydown', (e) => {
|
||
if (document.getElementById('page-timeline').classList.contains('is-active')) {
|
||
if (e.key === 'ArrowRight' || e.key === 'PageDown') {
|
||
state.target = clamp(state.target + CARD_PITCH, 0, state.max);
|
||
kick();
|
||
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
|
||
state.target = clamp(state.target - CARD_PITCH, 0, state.max);
|
||
kick();
|
||
} else if (e.key === 'Home') {
|
||
state.target = 0; kick();
|
||
} else if (e.key === 'End') {
|
||
state.target = state.max; kick();
|
||
}
|
||
}
|
||
});
|
||
|
||
viewport.addEventListener('wheel', onWheel, { passive: false });
|
||
|
||
/* Continue button — click routes to the Overview page (same dot-nav path) */
|
||
document.getElementById('continue-btn').addEventListener('click', () => {
|
||
const overBtn = document.querySelector('.dot-btn[data-target="page-overview"]');
|
||
if (overBtn) overBtn.click();
|
||
});
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
Globe — orthographic, paper-toned, top/bottom masked by CSS
|
||
───────────────────────────────────────────────────────────── */
|
||
const WORLD_URL = 'vendor/countries-110m.json';
|
||
const globe = { setRotation: null };
|
||
|
||
/* Kick once so initial cards reveal \u2014 deferred to next frame so layout settles
|
||
before transitions can arm. Then enable transitions on all cards. */
|
||
requestAnimationFrame(() => {
|
||
applyScroll();
|
||
requestAnimationFrame(() => {
|
||
document.querySelectorAll('.evt').forEach(el => el.classList.add('can-animate'));
|
||
});
|
||
});
|
||
|
||
(function initGlobe() {
|
||
const wrap = document.getElementById('globe-wrap');
|
||
buildGlobe(wrap, { rotateLambda: 95, rotatePhi: -10, setRotationTarget: globe });
|
||
|
||
// Second globe — sits behind the Overview page, centered on Europe.
|
||
// λ = -10° (central longitude over western Europe / Iberia)
|
||
// φ = -50° (in d3 this puts 50°N latitude at the SVG center; Germany / N. France)
|
||
const overviewWrap = document.getElementById('overview-globe');
|
||
if (overviewWrap) {
|
||
buildGlobe(overviewWrap, { rotateLambda: -10, rotatePhi: -50 });
|
||
}
|
||
})();
|
||
|
||
/* Build a single orthographic globe SVG inside `wrap`.
|
||
Options:
|
||
rotateLambda : initial longitude center (d3 convention: negate east)
|
||
rotatePhi : latitude tilt (d3: negate north latitude)
|
||
setRotationTarget : if supplied, exposes a setRotation(deg) on the object
|
||
*/
|
||
function buildGlobe(wrap, opts) {
|
||
const W = 1400, H = 1400;
|
||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||
|
||
const INK = '#383831';
|
||
const PAPER_LOW = '#ece5d2';
|
||
|
||
// Ocean
|
||
const ocean = document.createElementNS(svg.namespaceURI, 'circle');
|
||
ocean.setAttribute('cx', W/2); ocean.setAttribute('cy', H/2);
|
||
ocean.setAttribute('r', 560);
|
||
ocean.setAttribute('fill', PAPER_LOW);
|
||
ocean.setAttribute('opacity', '0.55');
|
||
svg.appendChild(ocean);
|
||
|
||
// defs clip
|
||
const defs = document.createElementNS(svg.namespaceURI, 'defs');
|
||
const clip = document.createElementNS(svg.namespaceURI, 'clipPath');
|
||
const clipId = 'globeClip-' + Math.random().toString(36).slice(2, 8);
|
||
clip.setAttribute('id', clipId);
|
||
const cc = document.createElementNS(svg.namespaceURI, 'circle');
|
||
cc.setAttribute('cx', W/2); cc.setAttribute('cy', H/2); cc.setAttribute('r', 560);
|
||
clip.appendChild(cc); defs.appendChild(clip); svg.appendChild(defs);
|
||
|
||
const g = document.createElementNS(svg.namespaceURI, 'g');
|
||
g.setAttribute('clip-path', `url(#${clipId})`);
|
||
svg.appendChild(g);
|
||
|
||
const gratPath = document.createElementNS(svg.namespaceURI, 'path');
|
||
gratPath.setAttribute('fill', 'none');
|
||
gratPath.setAttribute('stroke', INK);
|
||
gratPath.setAttribute('stroke-width', '0.8');
|
||
gratPath.setAttribute('opacity', '0.18');
|
||
g.appendChild(gratPath);
|
||
|
||
const countriesPath = document.createElementNS(svg.namespaceURI, 'path');
|
||
countriesPath.setAttribute('fill', '#faf6ee');
|
||
countriesPath.setAttribute('fill-opacity', '0.85');
|
||
countriesPath.setAttribute('stroke', 'none');
|
||
g.appendChild(countriesPath);
|
||
|
||
const bordersPath = document.createElementNS(svg.namespaceURI, 'path');
|
||
bordersPath.setAttribute('fill', 'none');
|
||
bordersPath.setAttribute('stroke', INK);
|
||
bordersPath.setAttribute('stroke-width', '0.8');
|
||
bordersPath.setAttribute('stroke-linejoin', 'round');
|
||
bordersPath.setAttribute('opacity', '0.55');
|
||
g.appendChild(bordersPath);
|
||
|
||
// Faint outer rim — tonal, not hard
|
||
const rim = document.createElementNS(svg.namespaceURI, 'circle');
|
||
rim.setAttribute('cx', W/2); rim.setAttribute('cy', H/2); rim.setAttribute('r', 560);
|
||
rim.setAttribute('fill', 'none');
|
||
rim.setAttribute('stroke', INK);
|
||
rim.setAttribute('stroke-width', '1');
|
||
rim.setAttribute('opacity', '0.22');
|
||
svg.appendChild(rim);
|
||
|
||
wrap.appendChild(svg);
|
||
|
||
fetch(WORLD_URL).then(r => r.json()).then(topo => {
|
||
const countries = window.topojson.feature(topo, topo.objects.countries);
|
||
const borders = window.topojson.mesh(topo, topo.objects.countries, (a,b) => a !== b);
|
||
const graticule = window.d3.geoGraticule().step([20, 20]);
|
||
const proj = window.d3.geoOrthographic()
|
||
.scale(560).translate([W/2, H/2])
|
||
.rotate([opts.rotateLambda, opts.rotatePhi ?? -10, 0]).clipAngle(90);
|
||
const pathFn = window.d3.geoPath(proj);
|
||
|
||
function render() {
|
||
gratPath.setAttribute('d', pathFn(graticule()) || '');
|
||
countriesPath.setAttribute('d', pathFn(countries) || '');
|
||
bordersPath.setAttribute('d', pathFn(borders) || '');
|
||
}
|
||
render();
|
||
if (opts.setRotationTarget) {
|
||
const phi = opts.rotatePhi ?? -10;
|
||
opts.setRotationTarget.setRotation = function(deg) {
|
||
proj.rotate([deg, phi, 0]);
|
||
render();
|
||
};
|
||
}
|
||
}).catch(err => {
|
||
console.warn('globe topology failed to load', err);
|
||
});
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
Dot-nav + Bifrost lazy-boot on Overview activation
|
||
───────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Switch active page. When Overview becomes active, boot Bifrost (once)
|
||
* and optionally scroll the Overview's internal scroller to a target.
|
||
*
|
||
* @param {string} targetId e.g. "page-timeline" or "page-overview"
|
||
* @param {string?} scrollToId (Overview only) id of a scene to land on:
|
||
* "hero", "bifrost", "bifrost-meaning"
|
||
*/
|
||
function activatePage(targetId, scrollToId) {
|
||
document.querySelectorAll('.page').forEach(p => {
|
||
p.classList.toggle('is-active', p.id === targetId);
|
||
});
|
||
|
||
if (targetId === 'page-overview') {
|
||
if (window.__bifrost && typeof window.__bifrost.init === 'function') {
|
||
// Let the page transition start painting first, then init.
|
||
// 60ms is about 1.5 frames at 120Hz — plenty for the
|
||
// .page-overview.is-active class to flip, short enough that
|
||
// the user can't scroll before ScrollTriggers are wired up.
|
||
setTimeout(() => {
|
||
window.__bifrost.init();
|
||
// Run platform.js's init in the SAME tick. Without this,
|
||
// platform's own MutationObserver fires it ~140ms after
|
||
// page activation — too late for the scrollTo below, which
|
||
// reads target.offsetTop. Platform installs pin spacers for
|
||
// #platform-cards and #platform-roadmap; those spacers shift
|
||
// sections downstream of cards by 150vh, so scrolling to
|
||
// pre-spacer offsetTop lands the user in empty space.
|
||
// __platform.init is idempotent (guarded by `initialized`),
|
||
// so calling it here is a no-op if it already ran.
|
||
if (window.__platform && typeof window.__platform.init === 'function') {
|
||
window.__platform.init();
|
||
}
|
||
// After init resolves, scroll to the requested scene (or top
|
||
// if none specified). Bifrost exposes scrollTo() which drives
|
||
// Lenis on the overview's internal scroller.
|
||
if (window.__bifrost.scrollTo) {
|
||
// One more frame so Lenis has picked up its first measurements.
|
||
requestAnimationFrame(() => window.__bifrost.scrollTo(scrollToId || 'hero'));
|
||
}
|
||
}, 60);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update the .dot-btn.is-active highlight. Called on nav clicks (forward
|
||
* activation) and will be called from bifrost.js on scroll to track which
|
||
* Overview scene is currently visible.
|
||
*/
|
||
function setActiveDot(targetId, scrollToId) {
|
||
document.querySelectorAll('.dot-btn').forEach(b => {
|
||
// External-target dots (e.g. Welcome) never show as active — they're
|
||
// navigation links, not section markers.
|
||
if ((b.dataset.target || '').startsWith('external-')) {
|
||
b.classList.remove('is-active');
|
||
return;
|
||
}
|
||
const pageMatch = b.dataset.target === targetId;
|
||
const scrollMatch = (b.dataset.scrollTo || '') === (scrollToId || '');
|
||
// Timeline dot has no data-scroll-to — it's active when page-timeline
|
||
// is active. Overview dots only match their specific scrollToId.
|
||
const isActive = targetId === 'page-overview'
|
||
? pageMatch && scrollMatch
|
||
: pageMatch;
|
||
b.classList.toggle('is-active', isActive);
|
||
});
|
||
}
|
||
// Expose so bifrost.js can call it from its scroll-spy. Reads happen from
|
||
// non-module code, so the global is the simplest integration surface.
|
||
window.__setActiveDot = setActiveDot;
|
||
|
||
document.querySelectorAll('.dot-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const targetId = btn.dataset.target;
|
||
const scrollToId = btn.dataset.scrollTo || null;
|
||
|
||
// External targets navigate away rather than switching pages.
|
||
// Used by the "Welcome" dot (routes back to the entrance page at /),
|
||
// where the user sees the welcome step if their session is valid.
|
||
if (targetId && targetId.startsWith('external-')) {
|
||
const href = btn.dataset.href || '/';
|
||
window.location.href = href;
|
||
return;
|
||
}
|
||
|
||
setActiveDot(targetId, scrollToId);
|
||
activatePage(targetId, scrollToId);
|
||
});
|
||
});
|
||
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
First name propagation — fetched from /auth/me on load.
|
||
Used by Scene 3 ("This is why we've invited you, [Name].").
|
||
Bifrost.js looks for window.__fenjaFirstName at init time and
|
||
rewrites the .words sentence before priming the fly-in animation.
|
||
───────────────────────────────────────────────────────────── */
|
||
(async function fetchFirstName() {
|
||
try {
|
||
const res = await fetch('/auth/me', { credentials: 'same-origin' });
|
||
if (res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
window.__fenjaFirstName = data.firstName || null;
|
||
}
|
||
} catch {
|
||
// Offline — leave undefined; bifrost.js falls back to the
|
||
// no-name variant of the sentence.
|
||
}
|
||
})();
|