Merge Project Bifrost scenes into the Overview page
Six scroll-bound scenes (hero, architecture stack, words fly-in,
aurora arc, treasure-map, join CTA) now live inside page-overview,
above the existing 23-headline timeline. The Europe map stays as a
static background that fades with scroll.
- protected/index.html: rewrote #page-overview only; timeline and
archive sections unchanged. Site-2 palette re-mapped to site-1
Nordic Editorial tokens, Fraunces to Newsreader, tokens scoped
to #page-overview.
- protected/timeline.js: dot-nav boots window.__bifrost.init()
on first Overview activation. Added .js class on documentElement.
- protected/bifrost.js (new): Lenis + ScrollTrigger wired to the
overview's internal scroller via scrollerProxy; drives Europe
map opacity on scroll.
- protected/vendor/{lenis,gsap,scrolltrigger}.min.js (new):
extracted from site-2's inlined vendor blobs; CSP-compliant.
- protected/fenja/illustrations/{community,council,pilot}.svg
(new): treasure-map stop images.
No changes to src/, server.js, deploy/, or public/. CSP stays
strict (script-src 'self'); zero inline scripts added. Auth gate
and session model untouched.
This commit is contained in:
parent
dc545b0776
commit
f2f0f8a43e
10 changed files with 3061 additions and 72 deletions
189
MERGE_NOTES.md
Normal file
189
MERGE_NOTES.md
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
# project-bifrost — merge notes
|
||||||
|
|
||||||
|
Site 2 (the 6-scene editorial scroll) has been merged into the Timeline's
|
||||||
|
Overview page. Timeline and Archive pages are **untouched**. Auth, CSP, and
|
||||||
|
all of `src/`, `server.js`, `deploy/` are untouched.
|
||||||
|
|
||||||
|
## Deploy — what to rsync where
|
||||||
|
|
||||||
|
From the project root on your laptop, copy the contents of this `protected/`
|
||||||
|
folder into `project-bifrost/protected/` on the VPS, preserving structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
protected/
|
||||||
|
├── index.html ← REPLACED (timeline + 6 Bifrost scenes)
|
||||||
|
├── timeline.js ← REPLACED (adds `.js` class + bifrost lazy-boot)
|
||||||
|
├── bifrost.js ← NEW
|
||||||
|
├── vendor/
|
||||||
|
│ ├── lenis.min.js ← NEW
|
||||||
|
│ ├── gsap.min.js ← NEW
|
||||||
|
│ └── scrolltrigger.min.js ← NEW
|
||||||
|
│ (d3-array, d3-geo, topojson-client, countries-110m are UNCHANGED; do not overwrite)
|
||||||
|
└── fenja/
|
||||||
|
└── illustrations/ ← NEW FOLDER
|
||||||
|
├── community.svg
|
||||||
|
├── council.svg
|
||||||
|
└── pilot.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
After `rsync`, run the usual:
|
||||||
|
```
|
||||||
|
sudo chown -R fenja:fenja /opt/fenja
|
||||||
|
sudo systemctl restart fenja
|
||||||
|
sudo journalctl -u fenja -n 20
|
||||||
|
```
|
||||||
|
|
||||||
|
## What changed, concretely
|
||||||
|
|
||||||
|
1. **`protected/index.html`** — rewrote `#page-overview` only.
|
||||||
|
- Europe map (`.overview-globe`) stays as absolute-positioned background.
|
||||||
|
- Added `<div id="overview-scroll">` — the new internal scroller. All six
|
||||||
|
Bifrost scenes live inside it. Lenis + ScrollTrigger are wired to this
|
||||||
|
element, never `window`.
|
||||||
|
- Removed the four editorial paragraphs + meta-strip ("Notes on a quiet
|
||||||
|
inheritance" / entries / period / editor) — replaced by the Bifrost
|
||||||
|
scenes in scroll order.
|
||||||
|
- Site-1 palette applied via `#page-overview { --ink: #383831; --paper:
|
||||||
|
#faf6ee; ...; --aurora-*: site-1 Archival Pigments; }`. Tokens are
|
||||||
|
scoped to `#page-overview` only — they do **not** leak to the
|
||||||
|
timeline or archive pages.
|
||||||
|
- All `Fraunces` references replaced with `Newsreader`.
|
||||||
|
- Site-2's top-left brand mark, top-right meta chip, and right-edge
|
||||||
|
progress rail removed (would clash with site 1's site-mark + dot-nav).
|
||||||
|
- Footer centre logo: swapped inlined Fenja SVG for
|
||||||
|
`<img src="/fenja/fenja-wordmark-black.svg">`.
|
||||||
|
- Innovationsfonden remains the redrawn placeholder — swap when the
|
||||||
|
real asset arrives.
|
||||||
|
- Illustration references: the big PNG data URIs in site 2's CSS were
|
||||||
|
replaced with three `url("/fenja/illustrations/*.svg")` references
|
||||||
|
pointing to the new illustration files.
|
||||||
|
- Timeline section (`#page-timeline`) and Archive section
|
||||||
|
(`#page-archive`) are byte-identical to the previous version.
|
||||||
|
|
||||||
|
2. **`protected/timeline.js`** — two changes only.
|
||||||
|
- Added `document.documentElement.classList.add('js')` at the top so
|
||||||
|
site-2's `.js .some-element { opacity: 0 }` hide-before-reveal rules
|
||||||
|
work. Harmless to timeline (nothing there uses `.js`).
|
||||||
|
- Dot-nav click handler wrapped into `activatePage(targetId)`. When
|
||||||
|
`targetId === 'page-overview'`, it calls `window.__bifrost.init()`
|
||||||
|
after a 60ms delay (lets the page-activation transition start).
|
||||||
|
`init()` is idempotent — subsequent activations just trigger a
|
||||||
|
`ScrollTrigger.refresh()`.
|
||||||
|
- The "Read the editor's note" button's existing behaviour
|
||||||
|
(`document.querySelector('.dot-btn[data-target="page-overview"]').click()`)
|
||||||
|
routes through `activatePage` now, so clicking the button both
|
||||||
|
activates the overview **and** boots the bifrost scenes.
|
||||||
|
|
||||||
|
3. **`protected/bifrost.js`** (NEW) — single-file CSP-compliant module.
|
||||||
|
- Exposes `window.__bifrost.init()`. No auto-exec.
|
||||||
|
- Creates a `new Lenis({ wrapper: scroller, content: scroller.firstElementChild, ... })`
|
||||||
|
scoped to `#overview-scroll`.
|
||||||
|
- Registers a `ScrollTrigger.scrollerProxy(scroller, ...)` and sets
|
||||||
|
`ScrollTrigger.defaults({ scroller })` so every ScrollTrigger the
|
||||||
|
site-2 code registers targets the overview's internal scroll.
|
||||||
|
- Europe-map opacity is driven from Lenis's scroll event:
|
||||||
|
- `scrollTop = 0` → 0.42 opacity (site-1's original "active" value)
|
||||||
|
- between `0.2 × vh` and `0.8 × vh` → ramps to 0
|
||||||
|
- scrolling back up → fades back in
|
||||||
|
- The map does **not** rotate with scroll — it's static.
|
||||||
|
- Reduced-motion: short-circuits to "content visible, map at 0.42, no
|
||||||
|
animations" and bails before registering any ScrollTriggers.
|
||||||
|
- All site-2 scene logic (HERO reveal, pinned 4-card stack, word
|
||||||
|
fly-ins, aurora arc draw-in, treasure-map path draw-in + per-stop
|
||||||
|
reveals, Join CTA + confirmation crossfade) transplanted verbatim.
|
||||||
|
|
||||||
|
4. **`protected/vendor/{lenis,gsap,scrolltrigger}.min.js`** (NEW) — the
|
||||||
|
three libraries extracted verbatim from site 2's inlined vendor blobs.
|
||||||
|
Served with `defer` so they load and execute before `bifrost.js` and
|
||||||
|
`timeline.js`. (Script order at the bottom of `index.html`:
|
||||||
|
lenis → gsap → scrolltrigger → bifrost → timeline.)
|
||||||
|
|
||||||
|
5. **`protected/fenja/illustrations/{community,council,pilot}.svg`** (NEW)
|
||||||
|
— the three illustrations you uploaded, renamed for site-2's slot
|
||||||
|
names. ~1.7 MB each (SVG wrappers around embedded PNGs).
|
||||||
|
|
||||||
|
## What I did NOT touch
|
||||||
|
|
||||||
|
- `src/` (auth, db, mail, middleware, sessions) — byte-identical.
|
||||||
|
- `server.js` — byte-identical. The request-routing order, CSP headers,
|
||||||
|
and `requireAuth` gate are preserved exactly.
|
||||||
|
- `deploy/` — byte-identical.
|
||||||
|
- `public/entrance.html`, `public/entrance.js` — byte-identical.
|
||||||
|
- `protected/fenja/colors_and_type.css` — byte-identical. (The Bifrost
|
||||||
|
scenes' tokens live in `index.html`'s own `<style>` block, scoped
|
||||||
|
to `#page-overview`.)
|
||||||
|
- `protected/fenja/fonts/*` — byte-identical.
|
||||||
|
- `protected/archive.html`, `protected/archive.js` — byte-identical.
|
||||||
|
- `protected/vendor/{d3-array,d3-geo,topojson-client,countries-110m}*` —
|
||||||
|
byte-identical. The new vendor libs are added **alongside** these.
|
||||||
|
|
||||||
|
## Security posture
|
||||||
|
|
||||||
|
CSP is `script-src 'self'` per `server.js`. The merged page contains **zero**
|
||||||
|
inline `<script>` tags — verified. All JS is in separate `.js` files served
|
||||||
|
from `/vendor/...` or `/`.
|
||||||
|
|
||||||
|
Auth gate is unchanged: `requireAuth` runs before `express.static(protected)`,
|
||||||
|
so the merged page is fully gated by the session cookie. The protected
|
||||||
|
directory's structure didn't change.
|
||||||
|
|
||||||
|
## What to spot-check after deploy (from `CHECKLIST.md` section B + D)
|
||||||
|
|
||||||
|
Primary smoke test:
|
||||||
|
- [ ] Login flow still works: `/` → email → code → welcome → click "Learn
|
||||||
|
more" button → timeline loads as before.
|
||||||
|
- [ ] Timeline page unchanged: 23 headlines, globe rotates with wheel
|
||||||
|
scroll, "Read the editor's note" button appears when scrolled to
|
||||||
|
the end.
|
||||||
|
- [ ] Click "Read the editor's note" → overview activates. Europe map
|
||||||
|
fades in on the right. Left column shows the new site-2 hero
|
||||||
|
headline: "Secure & **Sovereign** AI, hosted where it **belongs**."
|
||||||
|
- [ ] Scroll inside the overview page — Europe map fades out between
|
||||||
|
~20 % and ~80 % of the first viewport scroll.
|
||||||
|
- [ ] Scroll back up — Europe map fades back in.
|
||||||
|
- [ ] Keep scrolling past the hero — the 4 architecture layer cards fall
|
||||||
|
in, stack, then rearrange into a 2×2 grid (pinned scrub).
|
||||||
|
- [ ] Words fly in one by one.
|
||||||
|
- [ ] Project Bifrost aurora arc draws across, "Project Bifrost" headline
|
||||||
|
appears.
|
||||||
|
- [ ] Treasure-map with 3 stops: Community / Advisory Council / Pilot
|
||||||
|
Projects. Path draws in as you scroll. Each stop's illustration
|
||||||
|
fades in on reach.
|
||||||
|
- [ ] Join CTA visible. Clicking "Join Project Bifrost" crossfades to
|
||||||
|
the confirmation panel with 4 list items.
|
||||||
|
- [ ] Three-column footer at the very bottom: "Project Bifrost" (left),
|
||||||
|
Fenja AI logo (centre), Innovationsfonden placeholder (right).
|
||||||
|
- [ ] Click dot-nav "Archive" — the 23-row archive table still works.
|
||||||
|
- [ ] Click dot-nav "Timeline" — back to the horizontal-scroll timeline.
|
||||||
|
- [ ] DevTools console: no CSP violations, no 404s. Check that
|
||||||
|
`/bifrost.js`, `/vendor/lenis.min.js`, `/vendor/gsap.min.js`,
|
||||||
|
`/vendor/scrolltrigger.min.js`, and all three illustration SVGs
|
||||||
|
return 200.
|
||||||
|
- [ ] Response headers on `/timeline` still include the full six security
|
||||||
|
headers (CSP with `script-src 'self'`, etc.). `curl -I
|
||||||
|
https://project-bifrost.fenja.ai/timeline` should look identical
|
||||||
|
to before.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **Innovationsfonden logo** is still a hand-drawn placeholder. Swap when
|
||||||
|
the real asset is ready — replace the SVG + span block in the
|
||||||
|
`.foot-innov` div in `index.html` (search for `foot-innov`).
|
||||||
|
- **Illustration file sizes** are large (~1.7 MB each — SVG wrappers
|
||||||
|
around embedded PNGs). The gated page is slow to first-paint the
|
||||||
|
treasure-map if bandwidth is constrained. Consider exporting flat
|
||||||
|
PNGs later and swapping the `url(...)` in the `#page-overview
|
||||||
|
{ --illust-*: ... }` block in `index.html`.
|
||||||
|
- **Scene 2 (architecture stack)** uses `pin` with `pinType: 'transform'`
|
||||||
|
to work inside the nested scroller. This is the correct setting for
|
||||||
|
overflow-auto containers but means the pinned card's `position` during
|
||||||
|
pinning is transform-based. If layout looks off on Firefox or Safari
|
||||||
|
specifically, the first thing to investigate is the pin behaviour.
|
||||||
|
- **Resize behaviour**: we consolidated site-2's two separate resize
|
||||||
|
listeners into one debounced `ScrollTrigger.refresh()` 220ms after the
|
||||||
|
last resize event. If you see ScrollTrigger positions drifting on
|
||||||
|
fast/rapid resize, the de-duper can be tuned in `bifrost.js`
|
||||||
|
(`setTimeout(scheduleRefresh, 220)`).
|
||||||
|
- **Performance**: inlining vendor libs in site 2 was ~135 KB. They're now
|
||||||
|
served as three separate cacheable files (~135 KB uncompressed, ~50 KB
|
||||||
|
gzipped). Nginx `gzip` should already cover them.
|
||||||
946
protected/bifrost.js
Normal file
946
protected/bifrost.js
Normal file
|
|
@ -0,0 +1,946 @@
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// protected/bifrost.js — Project Bifrost scenes inside the
|
||||||
|
// Overview page of the timeline.
|
||||||
|
//
|
||||||
|
// This file:
|
||||||
|
// 1. Wraps site-2's six scroll-bound scenes (hero → architecture
|
||||||
|
// stack → words → aurora arc → treasure-map → join CTA) so
|
||||||
|
// they run inside the Overview page, not as a standalone site.
|
||||||
|
// 2. Rewires Lenis smooth scroll + GSAP ScrollTrigger so the
|
||||||
|
// scroller is the Overview's internal scrolling container —
|
||||||
|
// never the window — so the three-page Timeline/Overview/
|
||||||
|
// Archive model (each fixed-viewport) keeps working.
|
||||||
|
// 3. Drives the Europe map's opacity from the scroller's scroll
|
||||||
|
// position: fully visible at the top, fades to 0 as the user
|
||||||
|
// scrolls into the hero, fades back in on scrolling up.
|
||||||
|
// 4. Is a lazy-init module. Nothing happens at page load; the
|
||||||
|
// dot-nav handler in timeline.js calls window.__bifrost.init()
|
||||||
|
// the first time the Overview page becomes active.
|
||||||
|
//
|
||||||
|
// CSP: 'script-src self'. No inline scripts anywhere.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Lazy single-shot init flag — nav can click the Overview pip
|
||||||
|
// multiple times; we only wire everything up once.
|
||||||
|
let initialized = false;
|
||||||
|
let refreshScheduled = false;
|
||||||
|
|
||||||
|
// A tiny helper: schedule a ScrollTrigger.refresh() on the next
|
||||||
|
// animation frame, de-duplicating calls within the same frame.
|
||||||
|
function scheduleRefresh() {
|
||||||
|
if (refreshScheduled || !window.ScrollTrigger) return;
|
||||||
|
refreshScheduled = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
refreshScheduled = false;
|
||||||
|
window.ScrollTrigger.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (initialized) {
|
||||||
|
// Already booted — just re-measure, in case layout shifted while
|
||||||
|
// the page was inactive (e.g. user resized the window on Timeline).
|
||||||
|
scheduleRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
// Guard: vendor libs must have loaded.
|
||||||
|
if (typeof window.gsap === 'undefined' ||
|
||||||
|
typeof window.ScrollTrigger === 'undefined' ||
|
||||||
|
typeof window.Lenis === 'undefined') {
|
||||||
|
console.warn('[bifrost] Vendor libraries (gsap/ScrollTrigger/Lenis) missing; skipping init.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
// Reveal-on-load elements: if reduced motion, just make them visible
|
||||||
|
// and bail before registering any ScrollTrigger-bound tweens.
|
||||||
|
if (reduceMotion) {
|
||||||
|
document.querySelectorAll('#page-overview [data-reveal], #page-overview [data-reveal-lines]').forEach(el => {
|
||||||
|
el.style.opacity = '1';
|
||||||
|
el.style.transform = 'none';
|
||||||
|
});
|
||||||
|
// Still fade the Europe map fully in — it's the scene background.
|
||||||
|
const mapEl = document.getElementById('overview-globe');
|
||||||
|
if (mapEl) mapEl.style.opacity = '1';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scroller setup ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The Overview is a fixed-position .page that contains one
|
||||||
|
// scrollable child: `#overview-scroll`. All six scenes live
|
||||||
|
// inside it. Lenis drives wheel input on that element;
|
||||||
|
// ScrollTrigger reads scroll from the same element.
|
||||||
|
const scroller = document.getElementById('overview-scroll');
|
||||||
|
if (!scroller) {
|
||||||
|
console.error('[bifrost] #overview-scroll not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gsap = window.gsap;
|
||||||
|
const ScrollTrigger = window.ScrollTrigger;
|
||||||
|
const Lenis = window.Lenis;
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
// Lenis wired to the Overview's internal scroller, NOT the window.
|
||||||
|
const lenis = new Lenis({
|
||||||
|
wrapper: scroller,
|
||||||
|
content: scroller.firstElementChild,
|
||||||
|
duration: 1.15,
|
||||||
|
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||||
|
smoothWheel: true,
|
||||||
|
wheelMultiplier: 1,
|
||||||
|
touchMultiplier: 1.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tell ScrollTrigger how to read/write scroll on this scroller.
|
||||||
|
ScrollTrigger.scrollerProxy(scroller, {
|
||||||
|
scrollTop(value) {
|
||||||
|
if (arguments.length) {
|
||||||
|
scroller.scrollTop = value;
|
||||||
|
}
|
||||||
|
return scroller.scrollTop;
|
||||||
|
},
|
||||||
|
getBoundingClientRect() {
|
||||||
|
// The scroller occupies the full viewport (inset: 0 on its parent).
|
||||||
|
return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight };
|
||||||
|
},
|
||||||
|
// Scrollbars are hidden via CSS; pinType is 'transform' for nested scrollers.
|
||||||
|
pinType: 'transform',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every ScrollTrigger below implicitly targets our scroller.
|
||||||
|
ScrollTrigger.defaults({ scroller });
|
||||||
|
|
||||||
|
// Pump Lenis via GSAP's ticker; notify ScrollTrigger on each scroll.
|
||||||
|
lenis.on('scroll', ScrollTrigger.update);
|
||||||
|
gsap.ticker.add((time) => lenis.raf(time * 1000));
|
||||||
|
gsap.ticker.lagSmoothing(0);
|
||||||
|
|
||||||
|
// ─── Europe map fade ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Fully visible at scrollTop=0. Fades to 0 between 20% and 80%
|
||||||
|
// of viewport height. Fades back in on scrolling up. Opacity
|
||||||
|
// ceiling is 0.42 — matches the original .page-overview.is-active
|
||||||
|
// .overview-globe svg look.
|
||||||
|
const mapSvg = document.querySelector('#overview-globe svg');
|
||||||
|
const MAP_MAX_OPACITY = 0.42;
|
||||||
|
if (mapSvg) {
|
||||||
|
// Initial: visible (map is the hero backdrop). We'll drive opacity
|
||||||
|
// directly on every scroll tick, so kill any CSS opacity transition
|
||||||
|
// (the existing rule animates opacity over 900ms — would be janky).
|
||||||
|
mapSvg.style.transition = 'none';
|
||||||
|
mapSvg.style.opacity = MAP_MAX_OPACITY.toFixed(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMapOpacity() {
|
||||||
|
if (!mapSvg) return;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const y = scroller.scrollTop;
|
||||||
|
// Between 0.20 × vh and 0.80 × vh, ramp from full to zero.
|
||||||
|
const startFade = vh * 0.20;
|
||||||
|
const endFade = vh * 0.80;
|
||||||
|
let t;
|
||||||
|
if (y <= startFade) {
|
||||||
|
t = 0; // fully visible
|
||||||
|
} else if (y >= endFade) {
|
||||||
|
t = 1; // fully hidden
|
||||||
|
} else {
|
||||||
|
t = (y - startFade) / (endFade - startFade);
|
||||||
|
}
|
||||||
|
mapSvg.style.opacity = (MAP_MAX_OPACITY * (1 - t)).toFixed(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
lenis.on('scroll', updateMapOpacity);
|
||||||
|
// Initial paint
|
||||||
|
updateMapOpacity();
|
||||||
|
|
||||||
|
// ─── Site-2 scene animations ─────────────────────────────────
|
||||||
|
// (transplanted verbatim; all ScrollTriggers below automatically
|
||||||
|
// use the Overview scroller via ScrollTrigger.defaults above.)
|
||||||
|
|
||||||
|
// Script 1 body — HERO + SCENE 2 (architecture stack) + SCENE 3 (words) + SCENE 4 (bifrost arc)
|
||||||
|
/* -------------------------------------------------------------
|
||||||
|
HERO — staggered intro on load
|
||||||
|
------------------------------------------------------------- */
|
||||||
|
const heroTl = gsap.timeline({ defaults: { ease: 'power3.out' } });
|
||||||
|
|
||||||
|
// Split the hero title into lines-ish spans for a nicer reveal
|
||||||
|
const heroTitle = document.querySelector('.hero-title');
|
||||||
|
if (heroTitle) {
|
||||||
|
// preserve <br>, wrap visible text chunks in spans
|
||||||
|
const walk = (node) => {
|
||||||
|
const kids = [...node.childNodes];
|
||||||
|
kids.forEach(k => {
|
||||||
|
if (k.nodeType === Node.TEXT_NODE && k.textContent.trim()) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
k.textContent.split(/(\s+)/).forEach(tok => {
|
||||||
|
if (tok.trim()) {
|
||||||
|
const w = document.createElement('span');
|
||||||
|
w.className = 'htw';
|
||||||
|
w.style.display = 'inline-block';
|
||||||
|
w.style.overflow = 'hidden';
|
||||||
|
const inner = document.createElement('span');
|
||||||
|
inner.style.display = 'inline-block';
|
||||||
|
inner.style.transform = 'translateY(110%)';
|
||||||
|
inner.style.willChange = 'transform';
|
||||||
|
inner.textContent = tok;
|
||||||
|
w.appendChild(inner);
|
||||||
|
frag.appendChild(w);
|
||||||
|
} else {
|
||||||
|
frag.appendChild(document.createTextNode(tok));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
node.replaceChild(frag, k);
|
||||||
|
} else if (k.nodeType === Node.ELEMENT_NODE && k.tagName !== 'BR') {
|
||||||
|
walk(k);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
walk(heroTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
heroTl
|
||||||
|
.from('.eyebrow', { opacity: 0, y: 14, duration: 0.7 }, 0.15)
|
||||||
|
.to('.hero-title .htw > span', { y: '0%', duration: 1.05, stagger: 0.045, ease: 'power4.out' }, 0.2)
|
||||||
|
.from('.hero-lede', { opacity: 0, y: 20, duration: 0.9 }, 0.7)
|
||||||
|
.from('.hero-foot', { opacity: 0, y: 14, duration: 0.8 }, 0.9);
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------
|
||||||
|
ARCHITECTURE — two-phase scrubbed sequence
|
||||||
|
Phase A (0.00 – 0.45): each of 4 layer-cards falls from above
|
||||||
|
and lands at a progressively higher Y offset so the previous
|
||||||
|
card's bottom strip peeks out below. Only the topmost card's
|
||||||
|
eyebrow is visible at any time.
|
||||||
|
Phase B (0.50 – 1.00): the stack rearranges into a 2x2 grid on
|
||||||
|
the right side. Body text in each card fades out; eyebrow
|
||||||
|
stays. Explanatory copy crossfades on the LEFT, three panels:
|
||||||
|
~0.55 "All the capabilities to solve business use cases"
|
||||||
|
~0.70 "Full client control / Complete sovereignty"
|
||||||
|
~0.85 "Built in Denmark / For Europe"
|
||||||
|
------------------------------------------------------------- */
|
||||||
|
const theatre = document.querySelector('.layer-theatre');
|
||||||
|
const cards = gsap.utils.toArray('.layer-card');
|
||||||
|
const copyLayers = gsap.utils.toArray('.copy-layer');
|
||||||
|
|
||||||
|
// Each card lands N pixels higher than the previous — previous's
|
||||||
|
// bottom strip is visible below.
|
||||||
|
const STACK_OFFSET_PER_CARD = 22; // px, upward
|
||||||
|
|
||||||
|
// Compute grid target positions. In .in-grid mode, each card-box is
|
||||||
|
// 20vw square and centered (via margin:auto) inside its full-width
|
||||||
|
// parent .layer-card. We translate the parent card so the box lands
|
||||||
|
// at the correct grid-cell position.
|
||||||
|
function computeGridPlan() {
|
||||||
|
const W = theatre.offsetWidth;
|
||||||
|
const H = theatre.offsetHeight;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
|
||||||
|
const cellSize = vw * 0.20; // matches .in-grid .card-box width (20vw)
|
||||||
|
const gap = Math.max(14, vw * 0.014);
|
||||||
|
|
||||||
|
const totalW = 2 * cellSize + gap;
|
||||||
|
const totalH = 2 * cellSize + gap;
|
||||||
|
|
||||||
|
// Right-anchor grid so it sits flush with the right side of the theatre
|
||||||
|
const gridRight = W * 0.99;
|
||||||
|
const gridStartX = gridRight - totalW;
|
||||||
|
const gridStartY = (H - totalH) / 2;
|
||||||
|
|
||||||
|
// Grid cell centers (in theatre coordinates), reading order: TL, TR, BL, BR
|
||||||
|
const centers = [
|
||||||
|
{ cx: gridStartX + cellSize / 2, cy: gridStartY + cellSize / 2 },
|
||||||
|
{ cx: gridStartX + cellSize / 2 + cellSize + gap, cy: gridStartY + cellSize / 2 },
|
||||||
|
{ cx: gridStartX + cellSize / 2, cy: gridStartY + cellSize / 2 + cellSize + gap },
|
||||||
|
{ cx: gridStartX + cellSize / 2 + cellSize + gap, cy: gridStartY + cellSize / 2 + cellSize + gap },
|
||||||
|
];
|
||||||
|
|
||||||
|
// In grid mode the card-box's horizontal center is the theatre horizontal
|
||||||
|
// center (via margin:auto). That's our anchor for dx computations.
|
||||||
|
const theatreCx = W / 2;
|
||||||
|
const theatreCy = H / 2;
|
||||||
|
|
||||||
|
return { cellSize, theatreCx, theatreCy, centers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial state — hide everything, set card translations.
|
||||||
|
// Cards are positioned via left:0/right:0 + top:50% in CSS; we use
|
||||||
|
// yPercent:-50 to center vertically (so `y` animations remain additive).
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
gsap.set(card, { xPercent: 0, yPercent: -50, opacity: 0, x: 0, y: 0, rotation: 0, scale: 1 });
|
||||||
|
gsap.set(card.querySelector('.card-eyebrow'), { opacity: 0 });
|
||||||
|
});
|
||||||
|
// Copy layers vertically centered in copy-stage via yPercent: -50.
|
||||||
|
// The animation uses `y` for the little drop-in offset (which is additive
|
||||||
|
// to yPercent, so centering is preserved).
|
||||||
|
copyLayers.forEach(el => gsap.set(el, { yPercent: -50, opacity: 0, y: 20 }));
|
||||||
|
|
||||||
|
const stackTl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#stack-scene',
|
||||||
|
start: 'top top',
|
||||||
|
end: '+=5000', // 5.5 viewports — more scroll for the new sequence
|
||||||
|
scrub: 0.6,
|
||||||
|
pin: '.stack-pin',
|
||||||
|
pinSpacing: true,
|
||||||
|
anticipatePin: 1,
|
||||||
|
invalidateOnRefresh: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------- Phase A: card landings --------
|
||||||
|
// Card i lands at y = -i * STACK_OFFSET_PER_CARD (above baseline).
|
||||||
|
// Its eyebrow fades IN on landing; the previous card's eyebrow fades OUT.
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
const landingY = -i * STACK_OFFSET_PER_CARD;
|
||||||
|
const t = i * 0.105; // each card gets ~10.5% of timeline
|
||||||
|
|
||||||
|
// Y motion — starts above viewport. Distance reduced to -900 so the
|
||||||
|
// visible portion of the fall (from viewport top down to landing) is
|
||||||
|
// a meaningful share of the animation rather than being swallowed by
|
||||||
|
// off-screen travel that the user never sees.
|
||||||
|
stackTl
|
||||||
|
.fromTo(card,
|
||||||
|
{ y: -900, rotation: (i % 2 === 0 ? -4 : 4), scale: 0.97 },
|
||||||
|
{ y: landingY, rotation: 0, scale: 1, duration: 0.09, ease: 'power3.out' },
|
||||||
|
t);
|
||||||
|
|
||||||
|
// Opacity ramps up across most of the fall so the user sees the card
|
||||||
|
// traveling rather than just popping in at the end.
|
||||||
|
stackTl.fromTo(card,
|
||||||
|
{ opacity: 0 },
|
||||||
|
{ opacity: 1, duration: 0.065, ease: 'power2.out' },
|
||||||
|
t + 0.015);
|
||||||
|
|
||||||
|
// Settle bounce
|
||||||
|
stackTl
|
||||||
|
.to(card, { y: landingY + 4, duration: 0.012, ease: 'power1.out' }, t + 0.092)
|
||||||
|
.to(card, { y: landingY, duration: 0.02, ease: 'power2.inOut' }, t + 0.105);
|
||||||
|
|
||||||
|
// This card's eyebrow fades in
|
||||||
|
stackTl.to(card.querySelector('.card-eyebrow'),
|
||||||
|
{ opacity: 1, duration: 0.025, ease: 'power2.out' },
|
||||||
|
t + 0.06);
|
||||||
|
|
||||||
|
// Previous card's eyebrow fades out (it's now covered)
|
||||||
|
if (i > 0) {
|
||||||
|
stackTl.to(cards[i - 1].querySelector('.card-eyebrow'),
|
||||||
|
{ opacity: 0, duration: 0.02, ease: 'power2.in' },
|
||||||
|
t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Short hold after all 4 have landed (0.42 to 0.50)
|
||||||
|
|
||||||
|
// -------- Phase B: rearrange to grid + fade copy --------
|
||||||
|
// Phase A's 4th card (Agents) finishes its fade-in around timeline 0.42,
|
||||||
|
// but Lenis + scrub:0.6 adds smoothing so visually cards settle around
|
||||||
|
// 0.55 of scroll progress. Starting Phase B at 0.58 ensures the user
|
||||||
|
// sees the complete stack briefly before the grid morph begins.
|
||||||
|
const PHASE_B_START = 0.58;
|
||||||
|
|
||||||
|
// Transition each card to its grid cell. The .in-grid class
|
||||||
|
// (applied via a separate ScrollTrigger at Phase B start) restructures
|
||||||
|
// each card-box into a 30vw square centered within its full-width card.
|
||||||
|
// GSAP only needs to translate — scale stays 1.
|
||||||
|
//
|
||||||
|
// The card's effective visual center in grid phase is the card-box's
|
||||||
|
// center, which is the theatre horizontal center (margin:auto). So
|
||||||
|
// dx = targetCellCenterX − theatreCenterX, dy = same for Y.
|
||||||
|
function scheduleGridTransition() {
|
||||||
|
const plan = computeGridPlan();
|
||||||
|
|
||||||
|
// Target scales for the morph. Cards start as wide rectangles
|
||||||
|
// (~1324×526 at 1440vw) and need to morph to squares (~288×288).
|
||||||
|
// Using independent scaleX/scaleY lets the rectangle SHAPE-CHANGE
|
||||||
|
// into a square as it shrinks — so at morph-end the pre-snap and
|
||||||
|
// post-snap aspect ratios match and the .in-grid CSS handoff is
|
||||||
|
// imperceptible. Without this, ending at uniform scale would leave
|
||||||
|
// a flat 2.5:1 rectangle that pops to a 1:1 square on snap.
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const cardRect = cards[0].getBoundingClientRect();
|
||||||
|
const cardW = cardRect.width || vw;
|
||||||
|
const cardH = cardRect.height || 600;
|
||||||
|
const targetW = vw * 0.20;
|
||||||
|
const targetH = targetW; // square
|
||||||
|
const targetScaleX = targetW / cardW;
|
||||||
|
const targetScaleY = targetH / cardH;
|
||||||
|
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
const target = plan.centers[i];
|
||||||
|
const dx = target.cx - plan.theatreCx;
|
||||||
|
const dy = target.cy - plan.theatreCy;
|
||||||
|
const content = card.querySelector('.card-content');
|
||||||
|
const gridLabel = card.querySelector('.card-grid-label');
|
||||||
|
const brain = card.querySelector('.card-brain');
|
||||||
|
|
||||||
|
// Translate card to grid-cell position AND morph its SHAPE from
|
||||||
|
// wide rectangle to square via independent scaleX/scaleY. Ending
|
||||||
|
// at the exact target aspect ratio means the CSS .in-grid snap
|
||||||
|
// (where card-box becomes aspect-ratio 1:1) produces no visual
|
||||||
|
// change — the user sees a continuous morph.
|
||||||
|
stackTl.to(card,
|
||||||
|
{ x: dx, y: dy,
|
||||||
|
scaleX: targetScaleX, scaleY: targetScaleY,
|
||||||
|
rotation: 0,
|
||||||
|
duration: 0.14, ease: 'power2.inOut',
|
||||||
|
transformOrigin: 'center center' },
|
||||||
|
PHASE_B_START);
|
||||||
|
|
||||||
|
// COUNTER-SCALE the brain to prevent it being visually squeezed
|
||||||
|
// by the card's non-uniform scale. Without this, the brain would
|
||||||
|
// appear horizontally compressed (stretched tall/narrow) during
|
||||||
|
// the morph because scaleX (0.22) is 2.5× more compressed than
|
||||||
|
// scaleY (0.55).
|
||||||
|
//
|
||||||
|
// Applying additional scaleX = targetScaleY / targetScaleX (~2.5)
|
||||||
|
// to the brain combines with the card's scale multiplicatively:
|
||||||
|
// brain.visual.scaleX = card.scaleX × brain.scaleX
|
||||||
|
// = 0.22 × 2.5 = 0.55 = card.scaleY
|
||||||
|
// giving the brain UNIFORM visual scaling (both axes reduced by
|
||||||
|
// card.scaleY factor), preserving its natural aspect ratio.
|
||||||
|
//
|
||||||
|
// Using transformOrigin: 'right center' on the brain keeps its
|
||||||
|
// right edge anchored and expands the scale LEFTWARD into the
|
||||||
|
// card's interior — not rightward into blank space or adjacent
|
||||||
|
// cards. The brain already sits on the right side of the card
|
||||||
|
// (grid column), so this keeps it where the user expects it.
|
||||||
|
//
|
||||||
|
// Content (title+body) and grid-label are NOT counter-scaled —
|
||||||
|
// content fades to 0 opacity early in the morph, masking any
|
||||||
|
// distortion; grid-label is tiny text, distortion barely visible.
|
||||||
|
const counterScaleX = targetScaleY / targetScaleX;
|
||||||
|
stackTl.to(brain,
|
||||||
|
{ scaleX: counterScaleX,
|
||||||
|
duration: 0.14, ease: 'power2.inOut',
|
||||||
|
transformOrigin: 'right center',
|
||||||
|
immediateRender: false },
|
||||||
|
PHASE_B_START);
|
||||||
|
|
||||||
|
// INSTANT scale reset at the end of the morph window. Using a
|
||||||
|
// tiny duration (0.00001) with immediateRender:false means scale
|
||||||
|
// jumps from targetScale to 1 essentially in a single scrub frame
|
||||||
|
// — no visible ramp (0.00001 of a 1-second timeline is far below
|
||||||
|
// one render frame). Piggy-back the .in-grid CSS class toggle on
|
||||||
|
// the FIRST card's scale-reset tween via onStart (forward) and
|
||||||
|
// onReverseComplete (backward), so the scale snap and the class
|
||||||
|
// apply happen in the same GSAP render pass. Previously the class
|
||||||
|
// toggle was a separate tween or a separate ScrollTrigger; either
|
||||||
|
// way GSAP and ScrollTrigger didn't guarantee same-frame
|
||||||
|
// execution, producing a visible moment where scale=1 but
|
||||||
|
// box=1324 (the "becomes large briefly" glitch the user saw).
|
||||||
|
const resetVars = {
|
||||||
|
scaleX: 1, scaleY: 1,
|
||||||
|
duration: 0.00001,
|
||||||
|
immediateRender: false,
|
||||||
|
};
|
||||||
|
if (i === 0) {
|
||||||
|
resetVars.onStart = function() {
|
||||||
|
theatre.classList.add('in-grid');
|
||||||
|
};
|
||||||
|
resetVars.onReverseComplete = function() {
|
||||||
|
theatre.classList.remove('in-grid');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
stackTl.to(card, resetVars, PHASE_B_START + 0.14);
|
||||||
|
|
||||||
|
// Reset brain counter-scale atomically with the card's scale
|
||||||
|
// snap. After this, CSS .in-grid takes over layout (brain fills
|
||||||
|
// the square flex-column centered, with no inline scaleX).
|
||||||
|
stackTl.to(brain,
|
||||||
|
{ scaleX: 1, duration: 0.00001, immediateRender: false },
|
||||||
|
PHASE_B_START + 0.14);
|
||||||
|
|
||||||
|
// Crossfade: the old text content fades out while the grid label
|
||||||
|
// fades in. Both run alongside the scale/translate so all changes
|
||||||
|
// happen simultaneously as a single coherent morph.
|
||||||
|
stackTl.to(content,
|
||||||
|
{ opacity: 0, duration: 0.08, ease: 'power2.in' },
|
||||||
|
PHASE_B_START);
|
||||||
|
stackTl.to(gridLabel,
|
||||||
|
{ opacity: 0.88, duration: 0.08, ease: 'power2.out' },
|
||||||
|
PHASE_B_START + 0.06);
|
||||||
|
|
||||||
|
// Fade the outside-box eyebrow out as we transition to grid.
|
||||||
|
stackTl.to(card.querySelector('.card-eyebrow'),
|
||||||
|
{ opacity: 0, duration: 0.06, ease: 'power2.in' },
|
||||||
|
PHASE_B_START);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
scheduleGridTransition();
|
||||||
|
|
||||||
|
// (Class-toggle is now piggy-backed on card[0]'s scale-reset tween
|
||||||
|
// above — see the i === 0 branch. Keeping them on the same tween
|
||||||
|
// guarantees they fire in the same GSAP render pass.)
|
||||||
|
|
||||||
|
// On resize we need to recompute. ScrollTrigger.invalidateOnRefresh
|
||||||
|
// only rebuilds positions if our tweens use function-based values or
|
||||||
|
// we kill/rebuild. Simplest: rebuild timeline entirely on resize.
|
||||||
|
let resizeTimer;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => ScrollTrigger.refresh(), 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------- Copy layer crossfade on the LEFT (during grid phase) --------
|
||||||
|
const FADE = 0.025;
|
||||||
|
const swap = (fromIdx, toIdx, pos) => {
|
||||||
|
if (fromIdx !== null) {
|
||||||
|
stackTl.to(copyLayers[fromIdx], { opacity: 0, y: -14, duration: FADE, ease: 'power2.in' }, pos);
|
||||||
|
}
|
||||||
|
stackTl.fromTo(copyLayers[toIdx],
|
||||||
|
{ opacity: 0, y: 16 },
|
||||||
|
{ opacity: 1, y: 0, duration: FADE, ease: 'power2.out' },
|
||||||
|
pos + FADE + 0.002);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3 panels: capabilities → sovereignty → Denmark
|
||||||
|
stackTl.fromTo(copyLayers[0],
|
||||||
|
{ opacity: 0, y: 16 },
|
||||||
|
{ opacity: 1, y: 0, duration: FADE * 1.5, ease: 'power2.out' },
|
||||||
|
PHASE_B_START + 0.08);
|
||||||
|
|
||||||
|
swap(0, 1, 0.77); // sovereignty
|
||||||
|
swap(1, 2, 0.90); // Denmark
|
||||||
|
|
||||||
|
// Clean exit: fade the whole stack-pin contents just before the pin
|
||||||
|
// releases, so the scroll gap before #words-scene shows clean paper
|
||||||
|
// rather than stack content receding away.
|
||||||
|
stackTl.to('.layer-theatre', { opacity: 0, duration: 0.03, ease: 'power2.in' }, 0.97);
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------
|
||||||
|
SCENE 3 — WORDS fly in one at a time, driven by scroll
|
||||||
|
------------------------------------------------------------- */
|
||||||
|
const wordEls = gsap.utils.toArray('.words .w');
|
||||||
|
|
||||||
|
// Give each word a random fly-in vector (stable per word), and a scale pop.
|
||||||
|
// The "with them" words (marked .hi) come in from center with more weight.
|
||||||
|
const rnd = (i, seed) => {
|
||||||
|
// simple deterministic pseudo-random so layout is stable per word
|
||||||
|
const s = Math.sin((i + 1) * seed) * 10000;
|
||||||
|
return s - Math.floor(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
wordEls.forEach((w, i) => {
|
||||||
|
const hi = w.classList.contains('hi');
|
||||||
|
const fromX = hi ? 0 : (rnd(i, 12.9898) - 0.5) * 220;
|
||||||
|
const fromY = hi ? 80 : (rnd(i, 78.233) - 0.5) * 160;
|
||||||
|
const rot = hi ? 0 : (rnd(i, 37.719) - 0.5) * 16;
|
||||||
|
gsap.set(w, {
|
||||||
|
opacity: 0,
|
||||||
|
x: fromX,
|
||||||
|
y: fromY,
|
||||||
|
rotate: rot,
|
||||||
|
scale: hi ? 1.05 : 0.9,
|
||||||
|
filter: 'blur(6px)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const wordsTl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#words-scene',
|
||||||
|
start: 'top top',
|
||||||
|
end: 'bottom bottom',
|
||||||
|
scrub: 0.4,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wordEls.forEach((w, i) => {
|
||||||
|
const hi = w.classList.contains('hi');
|
||||||
|
const dur = hi ? 0.14 : 0.1;
|
||||||
|
wordsTl.to(w, {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0, y: 0, rotate: 0,
|
||||||
|
scale: 1,
|
||||||
|
filter: 'blur(0px)',
|
||||||
|
duration: dur,
|
||||||
|
ease: 'power3.out',
|
||||||
|
}, i * 0.055);
|
||||||
|
if (hi) {
|
||||||
|
wordsTl.to(w, { scale: 1.0, duration: 0.05 }, '>-0.02');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------
|
||||||
|
SCENE 4 — PROJECT BIFROST REVEAL
|
||||||
|
Arc draws in, then the words settle.
|
||||||
|
------------------------------------------------------------- */
|
||||||
|
const arcMain = document.getElementById('arcMain');
|
||||||
|
const arcThin = document.getElementById('arcThin');
|
||||||
|
const arcHalo = document.getElementById('arcHalo');
|
||||||
|
[arcMain, arcThin, arcHalo].forEach(el => {
|
||||||
|
if (!el) return;
|
||||||
|
const len = el.getTotalLength();
|
||||||
|
el.style.strokeDasharray = len;
|
||||||
|
el.style.strokeDashoffset = len;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = gsap.utils.toArray('.bifrost-name .token');
|
||||||
|
tokens.forEach((t, i) => {
|
||||||
|
gsap.set(t, { opacity: 0, y: 40, filter: 'blur(10px)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const bifrostTl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#bifrost',
|
||||||
|
start: 'top top',
|
||||||
|
end: 'bottom bottom',
|
||||||
|
scrub: 0.6,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bifrostTl
|
||||||
|
.to('.arc-wrap', { opacity: 1, duration: 0.1, ease: 'power2.out' }, 0.02)
|
||||||
|
.to(arcHalo, { strokeDashoffset: 0, duration: 0.45, ease: 'power2.inOut' }, 0.02)
|
||||||
|
.to(arcMain, { strokeDashoffset: 0, duration: 0.5, ease: 'power2.inOut' }, 0.05)
|
||||||
|
.to(arcThin, { strokeDashoffset: 0, duration: 0.45, ease: 'power2.inOut' }, 0.12)
|
||||||
|
.to('.bifrost-eyebrow', { opacity: 1, y: 0, duration: 0.1, ease: 'power2.out' }, 0.30)
|
||||||
|
.fromTo('.bifrost-eyebrow', { y: 20 }, { y: 0, duration: 0.1 }, 0.30)
|
||||||
|
.to(tokens[0], { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.15, ease: 'power3.out' }, 0.40)
|
||||||
|
.to(tokens[1], { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.2, ease: 'power3.out' }, 0.50)
|
||||||
|
.to('.bifrost-sub', { opacity: 1, y: 0, duration: 0.15, ease: 'power2.out' }, 0.68)
|
||||||
|
.fromTo('.bifrost-sub', { y: 20 }, { y: 0, duration: 0.15 }, 0.68);
|
||||||
|
|
||||||
|
// Slight parallax on the arc while the user continues to scroll
|
||||||
|
bifrostTl.to('.arc-wrap', { y: -40, duration: 0.3, ease: 'none' }, 0.5);
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------
|
||||||
|
On load — kick hero animation
|
||||||
|
------------------------------------------------------------- */
|
||||||
|
// Ensure ScrollTrigger measures correctly after fonts load.
|
||||||
|
if (document.fonts && document.fonts.ready) {
|
||||||
|
document.fonts.ready.then(() => ScrollTrigger.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script 2 body — SCENE 5 (treasure-map) + SCENE 6 (join CTA + footer)
|
||||||
|
/* =================================================================
|
||||||
|
PROJECT BIFROST — "What it means" treasure-map + summary cards.
|
||||||
|
|
||||||
|
Self-contained additive IIFE. Reads the global `gsap` and
|
||||||
|
`ScrollTrigger` registered by the existing scripts above; does
|
||||||
|
not touch any pre-existing timelines or DOM nodes.
|
||||||
|
================================================================= */
|
||||||
|
/* -------------------------------------------------------------
|
||||||
|
SCENE 5 — Treasure-map path draw + per-stop reveals
|
||||||
|
------------------------------------------------------------- */
|
||||||
|
|
||||||
|
// -------- Build the path geometry from actual dot positions --------
|
||||||
|
//
|
||||||
|
// The treasure-map path needs to thread cleanly through each dot.
|
||||||
|
// The dots are positioned by CSS grid/flex, so their Y positions in
|
||||||
|
// the canvas depend on the rendered height of each stop's content
|
||||||
|
// (which varies with viewport width and font metrics). We compute
|
||||||
|
// the path here AFTER layout, so it always passes through the dots.
|
||||||
|
//
|
||||||
|
// Coordinate system: the SVG viewBox is "0 0 100 200" with
|
||||||
|
// preserveAspectRatio="none", so X is normalised to canvas width
|
||||||
|
// and Y to canvas height. We measure each dot's centre in canvas
|
||||||
|
// coordinates, normalise, and emit a chain of cubic bezier segments
|
||||||
|
// — each one bowing out to alternating sides for a meandering feel.
|
||||||
|
const mapCanvasEl = document.querySelector('.map-canvas');
|
||||||
|
const pathBg = document.getElementById('mapPathBg');
|
||||||
|
const pathDraw = document.getElementById('mapPathDraw');
|
||||||
|
let drawScrollTrigger = null;
|
||||||
|
|
||||||
|
function buildMapPath() {
|
||||||
|
if (!mapCanvasEl || !pathDraw || !pathBg) return;
|
||||||
|
const dots = mapCanvasEl.querySelectorAll('.dot-anchor');
|
||||||
|
if (dots.length < 2) return;
|
||||||
|
const cb = mapCanvasEl.getBoundingClientRect();
|
||||||
|
if (cb.width === 0 || cb.height === 0) return;
|
||||||
|
|
||||||
|
// Normalise dot centres to viewBox units (0-100 X, 0-200 Y)
|
||||||
|
const pts = Array.from(dots).map((d) => {
|
||||||
|
const r = d.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: ((r.left + r.width / 2 - cb.left) / cb.width) * 100,
|
||||||
|
y: ((r.top + r.height / 2 - cb.top) / cb.height) * 200,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build path: M to first, then cubic beziers to each subsequent
|
||||||
|
// dot. Control points sit at the same X as the dots (so the path
|
||||||
|
// exits/enters each dot along the vertical axis) but bow out to
|
||||||
|
// alternating sides between them — gives a Nordic-river feel.
|
||||||
|
const segs = [`M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`];
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
const a = pts[i - 1];
|
||||||
|
const b = pts[i];
|
||||||
|
// Bow direction alternates by segment index. Amplitude is in
|
||||||
|
// viewBox X units (0-100) — clamped down on tall segments to
|
||||||
|
// avoid the path drifting outside the canvas.
|
||||||
|
const bowDir = (i % 2 === 1) ? 1 : -1;
|
||||||
|
const bowAmount = Math.min(20, (b.y - a.y) * 0.18);
|
||||||
|
const cx1 = 50 + bowAmount * bowDir;
|
||||||
|
const cy1 = a.y + (b.y - a.y) * 0.35;
|
||||||
|
const cx2 = 50 + bowAmount * bowDir;
|
||||||
|
const cy2 = a.y + (b.y - a.y) * 0.65;
|
||||||
|
segs.push(`C ${cx1.toFixed(2)} ${cy1.toFixed(2)}, ${cx2.toFixed(2)} ${cy2.toFixed(2)}, ${b.x.toFixed(2)} ${b.y.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
const d = segs.join(' ');
|
||||||
|
pathBg.setAttribute('d', d);
|
||||||
|
pathDraw.setAttribute('d', d);
|
||||||
|
|
||||||
|
// Re-measure the drawn path's length and reset the dash offset
|
||||||
|
// so the draw-in animation covers the full new geometry.
|
||||||
|
const len = pathDraw.getTotalLength();
|
||||||
|
pathDraw.style.strokeDasharray = len;
|
||||||
|
// If the scroll-trigger animation already played to completion,
|
||||||
|
// keep the path drawn; otherwise hide it pending scroll.
|
||||||
|
const trig = drawScrollTrigger;
|
||||||
|
if (trig && trig.progress >= 1) {
|
||||||
|
pathDraw.style.strokeDashoffset = 0;
|
||||||
|
} else {
|
||||||
|
pathDraw.style.strokeDashoffset = len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build initially after a paint
|
||||||
|
requestAnimationFrame(buildMapPath);
|
||||||
|
|
||||||
|
// Animate the accent path drawing in as the user scrolls down
|
||||||
|
// through the meaning section
|
||||||
|
if (pathDraw) {
|
||||||
|
const tween = gsap.to(pathDraw, {
|
||||||
|
strokeDashoffset: 0,
|
||||||
|
ease: 'none',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#bifrost-meaning',
|
||||||
|
start: 'top 65%',
|
||||||
|
end: 'bottom 75%',
|
||||||
|
scrub: 0.6,
|
||||||
|
invalidateOnRefresh: true,
|
||||||
|
onRefresh: () => {
|
||||||
|
// Re-measure dasharray on every ScrollTrigger refresh so
|
||||||
|
// the animation stays in sync with any path changes
|
||||||
|
const len = pathDraw.getTotalLength();
|
||||||
|
pathDraw.style.strokeDasharray = len;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
drawScrollTrigger = tween.scrollTrigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the path on resize (debounced) since dot positions move
|
||||||
|
let mapPathResizeTimer;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(mapPathResizeTimer);
|
||||||
|
mapPathResizeTimer = setTimeout(() => {
|
||||||
|
buildMapPath();
|
||||||
|
ScrollTrigger.refresh();
|
||||||
|
}, 220);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile fallback — the SVG path is hidden and replaced by a CSS
|
||||||
|
// pseudo-element rail. Drive its progress with a CSS custom prop
|
||||||
|
// so the same scroll range animates a vertical fill.
|
||||||
|
const mapCanvas = document.querySelector('.map-canvas');
|
||||||
|
if (mapCanvas) {
|
||||||
|
const railObj = { p: 0 };
|
||||||
|
gsap.to(railObj, {
|
||||||
|
p: 100,
|
||||||
|
ease: 'none',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#bifrost-meaning',
|
||||||
|
start: 'top 65%',
|
||||||
|
end: 'bottom 75%',
|
||||||
|
scrub: 0.6,
|
||||||
|
onUpdate: () => {
|
||||||
|
mapCanvas.style.setProperty('--rail-progress', railObj.p + '%');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-stop reveal — dot pops in first, then content + image rise
|
||||||
|
// and fade in alongside each other. ToggleActions: play forward
|
||||||
|
// when entering, reverse when scrolling back up past the trigger.
|
||||||
|
gsap.utils.toArray('.map-stop').forEach((stop) => {
|
||||||
|
const dot = stop.querySelector('.dot');
|
||||||
|
const contentBits = stop.querySelectorAll('.stop-content > *');
|
||||||
|
const image = stop.querySelector('.stop-image');
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: stop,
|
||||||
|
start: 'top 78%',
|
||||||
|
toggleActions: 'play none none reverse',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dot pops in with a subtle back ease — feels like a pin
|
||||||
|
// dropping into the map
|
||||||
|
if (dot) {
|
||||||
|
tl.to(dot, {
|
||||||
|
scale: 1, opacity: 1,
|
||||||
|
duration: 0.55, ease: 'back.out(2)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text bits stagger in
|
||||||
|
if (contentBits.length) {
|
||||||
|
tl.to(contentBits, {
|
||||||
|
opacity: 1, y: 0,
|
||||||
|
duration: 0.7, stagger: 0.08,
|
||||||
|
ease: 'power3.out',
|
||||||
|
}, '-=0.35');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image animates in alongside the text (overlapping for unity)
|
||||||
|
if (image) {
|
||||||
|
tl.to(image, {
|
||||||
|
opacity: 1, y: 0,
|
||||||
|
duration: 0.9,
|
||||||
|
ease: 'power3.out',
|
||||||
|
}, '-=0.6');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------
|
||||||
|
SCENE 6 — Join section: scroll-triggered reveals + CTA click
|
||||||
|
------------------------------------------------------------- */
|
||||||
|
|
||||||
|
// Reveal the CTA panel when the section scrolls into view.
|
||||||
|
// Captured to a variable so the click handler can kill this
|
||||||
|
// ScrollTrigger once the user has joined — otherwise scrolling up
|
||||||
|
// and back down would re-play the reveal and the CTA would fade
|
||||||
|
// back in over the confirmation.
|
||||||
|
const ctaRevealTween = gsap.to('.join-cta', {
|
||||||
|
opacity: 1, y: 0,
|
||||||
|
duration: 0.9, ease: 'power3.out',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#bifrost-join',
|
||||||
|
start: 'top 70%',
|
||||||
|
toggleActions: 'play none none reverse',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reveal the three footer marks in sequence
|
||||||
|
gsap.to('.join-footer > *', {
|
||||||
|
opacity: 1, y: 0,
|
||||||
|
duration: 0.8, stagger: 0.14,
|
||||||
|
ease: 'power3.out',
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '.join-footer',
|
||||||
|
start: 'top 88%',
|
||||||
|
toggleActions: 'play none none reverse',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// CTA click handler — crossfade CTA out, confirmation in, then stagger
|
||||||
|
// the checkmarks on each list item so the list feels like it's
|
||||||
|
// filling in as the user reads it.
|
||||||
|
const joinBtn = document.getElementById('joinBtn');
|
||||||
|
const joinCTA = document.getElementById('joinCTA');
|
||||||
|
const joinConfirm = document.getElementById('joinConfirm');
|
||||||
|
|
||||||
|
if (joinBtn && joinCTA && joinConfirm) {
|
||||||
|
joinBtn.addEventListener('click', () => {
|
||||||
|
if (joinBtn.disabled) return;
|
||||||
|
joinBtn.disabled = true;
|
||||||
|
|
||||||
|
// Kill the CTA's scroll-reveal trigger so scrolling up + back
|
||||||
|
// down can't replay the reveal and bring the CTA back over the
|
||||||
|
// confirmation. After click, the CTA stays in whatever state
|
||||||
|
// the click-timeline puts it in (fading out, then hidden).
|
||||||
|
if (ctaRevealTween && ctaRevealTween.scrollTrigger) {
|
||||||
|
ctaRevealTween.scrollTrigger.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = joinConfirm.querySelectorAll('.confirm-list li');
|
||||||
|
|
||||||
|
const tl = gsap.timeline();
|
||||||
|
// Fade the CTA out
|
||||||
|
tl.to(joinCTA, {
|
||||||
|
opacity: 0, y: -16,
|
||||||
|
duration: 0.5, ease: 'power2.in',
|
||||||
|
onComplete: () => {
|
||||||
|
joinCTA.setAttribute('aria-hidden', 'true');
|
||||||
|
joinCTA.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Fade the confirmation in
|
||||||
|
tl.fromTo(joinConfirm,
|
||||||
|
{ opacity: 0, y: 16 },
|
||||||
|
{
|
||||||
|
opacity: 1, y: 0,
|
||||||
|
duration: 0.7, ease: 'power3.out',
|
||||||
|
onStart: () => {
|
||||||
|
joinConfirm.setAttribute('aria-hidden', 'false');
|
||||||
|
joinConfirm.style.pointerEvents = 'auto';
|
||||||
|
},
|
||||||
|
}, '-=0.1');
|
||||||
|
|
||||||
|
// Stagger the circle+check markers by toggling `.is-checked`
|
||||||
|
// on each list item — CSS handles the pop-in transition.
|
||||||
|
items.forEach((li, i) => {
|
||||||
|
gsap.delayedCall(0.45 + i * 0.16, () => {
|
||||||
|
li.classList.add('is-checked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------
|
||||||
|
Refresh ScrollTrigger after fonts and images load so positions
|
||||||
|
are accurate — large embedded illustrations affect layout.
|
||||||
|
------------------------------------------------------------- */
|
||||||
|
if (document.fonts && document.fonts.ready) {
|
||||||
|
document.fonts.ready.then(() => ScrollTrigger.refresh());
|
||||||
|
}
|
||||||
|
// Refresh once illustrations have laid out
|
||||||
|
const illustrations = document.querySelectorAll('#bifrost-meaning img');
|
||||||
|
let pending = illustrations.length;
|
||||||
|
if (pending === 0) ScrollTrigger.refresh();
|
||||||
|
illustrations.forEach((img) => {
|
||||||
|
if (img.complete) {
|
||||||
|
if (--pending === 0) ScrollTrigger.refresh();
|
||||||
|
} else {
|
||||||
|
img.addEventListener('load', () => { if (--pending === 0) ScrollTrigger.refresh(); });
|
||||||
|
img.addEventListener('error', () => { if (--pending === 0) ScrollTrigger.refresh(); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── One consolidated resize handler ─────────────────────────
|
||||||
|
// Site 2 had two separate resize listeners; we defer to ScrollTrigger's
|
||||||
|
// own handling + our de-duped refresh. Scroller-relative measurements
|
||||||
|
// get recalculated whenever ScrollTrigger.refresh fires.
|
||||||
|
let resizeT = null;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeT);
|
||||||
|
resizeT = setTimeout(scheduleRefresh, 220);
|
||||||
|
});
|
||||||
|
|
||||||
|
// After fonts load, re-measure (headline wrap can shift positions).
|
||||||
|
if (document.fonts && document.fonts.ready) {
|
||||||
|
document.fonts.ready.then(scheduleRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After all illustrations load, re-measure (treasure-map stops change height).
|
||||||
|
const bgIllustrations = document.querySelectorAll('#page-overview .stop-illust');
|
||||||
|
let pendingBg = bgIllustrations.length;
|
||||||
|
if (pendingBg === 0) scheduleRefresh();
|
||||||
|
bgIllustrations.forEach((el) => {
|
||||||
|
// Illustrations are CSS-background images; use an Image() to listen for load
|
||||||
|
const bg = getComputedStyle(el).backgroundImage;
|
||||||
|
const url = bg && bg.match(/url\("?([^")]+)"?\)/);
|
||||||
|
if (!url) { if (--pendingBg === 0) scheduleRefresh(); return; }
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = img.onerror = () => { if (--pendingBg === 0) scheduleRefresh(); };
|
||||||
|
img.src = url[1];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final refresh after everything wires up
|
||||||
|
scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public surface — timeline.js calls this when the Overview tab activates.
|
||||||
|
window.__bifrost = { init };
|
||||||
|
})();
|
||||||
3
protected/fenja/illustrations/community.svg
Normal file
3
protected/fenja/illustrations/community.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 523 KiB |
3
protected/fenja/illustrations/council.svg
Normal file
3
protected/fenja/illustrations/council.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 456 KiB |
3
protected/fenja/illustrations/pilot.svg
Normal file
3
protected/fenja/illustrations/pilot.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 457 KiB |
1903
protected/index.html
1903
protected/index.html
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,15 @@
|
||||||
/* ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
Data — 23 events, quietly editorial
|
// protected/timeline.js — timeline scroll, dot-nav, globe, archive.
|
||||||
───────────────────────────────────────────────────────────── */
|
// This file now 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.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 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 = [
|
const EVENTS = [
|
||||||
// 2022
|
// 2022
|
||||||
{ date: '17 Mar 2022', kind: 'Infrastructure', accent: 'ochre',
|
{ date: '17 Mar 2022', kind: 'Infrastructure', accent: 'ochre',
|
||||||
|
|
@ -420,16 +429,29 @@ function buildGlobe(wrap, opts) {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────────────────────
|
/* ─────────────────────────────────────────────────────────────
|
||||||
Dot-nav
|
Dot-nav + Bifrost lazy-boot on Overview activation
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
document.querySelectorAll('.dot-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
// Switch active page. When Overview becomes active, fire
|
||||||
const target = btn.dataset.target;
|
// window.__bifrost.init() (idempotent) so the scenes wire up
|
||||||
|
// exactly once, on first visit. Subsequent activations just
|
||||||
|
// refresh ScrollTrigger in case the window was resized.
|
||||||
|
function activatePage(targetId) {
|
||||||
document.querySelectorAll('.page').forEach(p => {
|
document.querySelectorAll('.page').forEach(p => {
|
||||||
p.classList.toggle('is-active', p.id === target);
|
p.classList.toggle('is-active', p.id === targetId);
|
||||||
});
|
});
|
||||||
document.querySelectorAll('.dot-btn').forEach(b => {
|
document.querySelectorAll('.dot-btn').forEach(b => {
|
||||||
b.classList.toggle('is-active', b === btn);
|
b.classList.toggle('is-active', b.dataset.target === targetId);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
if (targetId === 'page-overview' && window.__bifrost && typeof window.__bifrost.init === 'function') {
|
||||||
|
// Let the page transition start painting first, then init.
|
||||||
|
// 60ms is about one-and-a-half 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(), 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.dot-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => activatePage(btn.dataset.target));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
12
protected/vendor/gsap.min.js
vendored
Normal file
12
protected/vendor/gsap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
protected/vendor/lenis.min.js
vendored
Normal file
2
protected/vendor/lenis.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
protected/vendor/scrolltrigger.min.js
vendored
Normal file
12
protected/vendor/scrolltrigger.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue