514 lines
24 KiB
JavaScript
514 lines
24 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 = [
|
|
// 2022
|
|
{ date: '17 Mar 2022', kind: 'Infrastructure', accent: 'ochre',
|
|
hed: 'Europe begins buying a future in a language it does not <em>write.</em>',
|
|
body: 'Three national ministries sign framework agreements for cloud compute measured in dollars, datacenters located far from home.',
|
|
source: 'Le Monde, Archive' },
|
|
{ date: '02 Jun 2022', kind: 'Regulation', accent: 'copper',
|
|
hed: 'Brussels publishes the first draft of an act it calls quietly <em>historic.</em>',
|
|
body: 'Early text of what will become the AI Act is circulated to member states; no one yet uses the word sovereignty in public.',
|
|
source: 'EUR-Lex, COM(2022)206' },
|
|
{ date: '11 Oct 2022', kind: 'Field Note', accent: 'ochre',
|
|
hed: 'A Danish hospital lists twelve assistants, all hosted <em>elsewhere.</em>',
|
|
body: 'An internal audit at Rigshospitalet finds twelve pilot tools in use across radiology and oncology; none run on Danish soil.',
|
|
source: 'Ugeskrift for Læger' },
|
|
// 2023
|
|
{ date: '30 Jan 2023', kind: 'Product', accent: 'terracotta',
|
|
hed: 'A San Francisco chatbot quietly becomes the continent\u2019s unofficial <em>intern.</em>',
|
|
body: 'Ministries, law firms and universities report staff pasting confidential material into a free web tool. Legal teams issue circulars.',
|
|
source: 'Financial Times' },
|
|
{ date: '22 Apr 2023', kind: 'Rupture', accent: 'crimson',
|
|
hed: 'Italy switches off the assistant, then switches it back <em>on.</em>',
|
|
body: 'A four-week ban tests the limits of national action against a foreign-hosted model; a truce is brokered behind closed doors.',
|
|
source: 'Il Sole 24 Ore' },
|
|
{ date: '14 Jul 2023', kind: 'Regulation', accent: 'copper',
|
|
hed: 'The Act grows teeth, and so does the debate about who <em>sharpens them.</em>',
|
|
body: 'Trilogues yield real penalties for general-purpose models; lobbying intensifies around exemptions for “foundational” providers.',
|
|
source: 'Politico Europe' },
|
|
{ date: '06 Sep 2023', kind: 'Infrastructure', accent: 'ochre',
|
|
hed: 'A hyperscaler lays its fourth European campus, and calls it an act of <em>friendship.</em>',
|
|
body: 'Ground is broken in a small Irish town; the ribbon is cut by two prime ministers and, later, unofficially, the firm\u2019s lawyers.',
|
|
source: 'Reuters' },
|
|
{ date: '01 Dec 2023', kind: 'Field Note', accent: 'ochre',
|
|
hed: 'A European bank tallies its reliance and publishes only the <em>summary.</em>',
|
|
body: 'Internal mapping exercise counts 41 AI integrations, 38 of them contingent on a single non-EU cloud provider.',
|
|
source: 'Handelsblatt' },
|
|
// 2024
|
|
{ date: '19 Feb 2024', kind: 'Regulation', accent: 'copper',
|
|
hed: 'The Act is adopted, applauded, and then filed somewhere <em>safe.</em>',
|
|
body: 'Parliament passes the final text; implementation is deferred to 2026, giving incumbents eighteen months of grace.',
|
|
source: 'Official Journal' },
|
|
{ date: '08 Apr 2024', kind: 'Infrastructure', accent: 'ochre',
|
|
hed: 'A French lab pauses its rollout and asks, in writing, where its data <em>sleeps.</em>',
|
|
body: 'CNRS requests residency guarantees for a ministry-wide assistant; the provider replies with a thirty-page legal memorandum.',
|
|
source: 'CNRS Bulletin' },
|
|
{ date: '21 May 2024', kind: 'Rupture', accent: 'crimson',
|
|
hed: 'A foreign court compels disclosure, and a continent learns the meaning of <em>extraterritorial.</em>',
|
|
body: 'Under the CLOUD Act, records held on European servers are produced for a foreign proceeding; European data-protection authorities protest.',
|
|
source: 'New York Times' },
|
|
{ date: '02 Jul 2024', kind: 'Product', accent: 'terracotta',
|
|
hed: 'An open-source model is trained in Paris and called, hopefully, a <em>beginning.</em>',
|
|
body: 'A French laboratory releases a 22-billion-parameter model under a permissive license; adoption by ministries is briefly debated.',
|
|
source: 'Le Figaro' },
|
|
{ date: '15 Sep 2024', kind: 'Field Note', accent: 'ochre',
|
|
hed: 'A Nordic ministry asks for a sovereign option, and receives a <em>roadmap.</em>',
|
|
body: 'Danish procurement office publishes an RFI for on-premise AI platforms; fourteen vendors respond, four of them European.',
|
|
source: 'Digitaliseringsstyrelsen' },
|
|
{ date: '30 Nov 2024', kind: 'Rupture', accent: 'crimson',
|
|
hed: 'A new U.S. administration promises to review, renegotiate and in some cases <em>revoke.</em>',
|
|
body: 'Transition teams signal appetite to revisit the transatlantic data framework; European counsel spend December on contingency memos.',
|
|
source: 'Bloomberg' },
|
|
// 2025
|
|
{ date: '22 Jan 2025', kind: 'Field Note', accent: 'ochre',
|
|
hed: 'A quiet exodus begins, with no press release and no <em>timetable.</em>',
|
|
body: 'Several EU agencies move sensitive workloads off non-EU clouds and into operational hardware hosted by smaller, domestic providers.',
|
|
source: 'Politico Europe' },
|
|
{ date: '18 Mar 2025', kind: 'Infrastructure', accent: 'copper',
|
|
hed: 'Fenja AI is incorporated in Copenhagen, on a Monday, without <em>ceremony.</em>',
|
|
body: 'A small team begins work on an on-premise AI platform intended for critical sectors — health, finance, energy, public administration.',
|
|
source: 'Erhvervsstyrelsen' },
|
|
{ date: '04 May 2025', kind: 'Regulation', accent: 'copper',
|
|
hed: 'The Commission funds three “foundation-stone” initiatives, and Europe exhales <em>carefully.</em>',
|
|
body: 'Digital Europe program awards grants to sovereign-infrastructure consortia, including a Nordic group led out of Denmark.',
|
|
source: 'DG CNECT' },
|
|
{ date: '27 Jul 2025', kind: 'Product', accent: 'terracotta',
|
|
hed: 'A regulator issues a fine of nine figures, and a precedent of <em>ten.</em>',
|
|
body: 'The Irish Data Protection Commissioner fines a major U.S. provider over transfer mechanisms; the ruling is widely read as pivotal.',
|
|
source: 'DPC Ireland' },
|
|
{ date: '14 Sep 2025', kind: 'Rupture', accent: 'crimson',
|
|
hed: 'A multi-day outage reminds a continent that sovereignty is, among other things, <em>electrical.</em>',
|
|
body: 'A regional cloud incident takes portions of public services offline for thirty-one hours; emergency plans are reviewed everywhere.',
|
|
source: 'FAZ' },
|
|
{ date: '03 Nov 2025', kind: 'Field Note', accent: 'ochre',
|
|
hed: 'Procurement teams begin to ask a new question, and in <em>writing.</em>',
|
|
body: 'Tender documents across six member states add an explicit residency clause; three require open-source inference and local hosting.',
|
|
source: 'TED — Tenders Electronic Daily' },
|
|
// 2026
|
|
{ date: '12 Jan 2026', kind: 'Product', accent: 'copper',
|
|
hed: 'Fenja announces Project Bifrost, named after the bridge that crossed <em>everything.</em>',
|
|
body: 'A client-hosted, open-source AI platform designed for regulated European sectors; first deployments announced for Q2.',
|
|
source: 'Fenja AI — Field Notes III' },
|
|
{ date: '28 Feb 2026', kind: 'Field Note', accent: 'copper',
|
|
hed: 'The first hospital signs, and asks that the news be kept <em>small.</em>',
|
|
body: 'A regional Danish health authority adopts the platform for radiology triage; the announcement is a single sentence on page nine.',
|
|
source: 'Region Hovedstaden' },
|
|
{ date: '14 Apr 2026', kind: 'Editorial', accent: 'copper',
|
|
hed: 'A catalog is closed, and a second one is quietly <em>begun.</em>',
|
|
body: 'The editors mark this issue closed, pending the events of the coming year, which will arrive on their own schedule.',
|
|
source: 'Fenja AI — Field Notes IV' },
|
|
];
|
|
|
|
/* ─────────────────────────────────────────────────────────────
|
|
Timeline layout
|
|
───────────────────────────────────────────────────────────── */
|
|
const CARD_PITCH = 380; // horizontal spacing between card centers
|
|
const LEFT_PAD = 520; // extra room so first above-card clears the title/sub
|
|
const RIGHT_PAD = 280;
|
|
|
|
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 - 160) + 'px';
|
|
card.innerHTML = `
|
|
<span class="node"></span>
|
|
<div class="tag-row">
|
|
<span class="date">${e.date}</span>
|
|
<span class="kind">${e.kind}</span>
|
|
</div>
|
|
<h3>${e.hed}</h3>
|
|
<p>${e.body}</p>
|
|
<div class="source">${e.source}</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();
|
|
}
|
|
|
|
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);
|
|
|
|
// 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", "stack-scene", "words-scene",
|
|
* "bifrost", "bifrost-meaning", "bifrost-join"
|
|
*/
|
|
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();
|
|
// 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.
|
|
}
|
|
})();
|