customer-presentation: convert deck from Bifrost invitation to customer-facing

Why: pivot the experience from a personal invitation for Project Bifrost
participants to a customer-facing presentation that can be shown to
prospects like Novo Nordisk while still mentioning Bifrost in context.

Major changes:
- Entrance: re-worded title/body away from "invitation" into "introduction"; kept Fenja AI / Project Bifrost definition blocks.
- Timeline: page-sub reworked to also speak to highly-regulated private orgs (data, IP, regulated workflows, US-vendor dependency) alongside public sector.
- "Backed by Innovationsfonden" pairs with new "Part of BioInnovation Institute AI Lab" line on entrance and Scene 1 hero.
- Removed: stack-scene (4 capabilities) and words-scene ("This is why we've invited you") — archived at protected/_archive/stack-scene.html for restore.
- Removed: bifrost-join CTA + Innovationsfonden footer section.
- Inlined the standalone /deepdive architecture explainer into #overview-scroll after #bifrost-meaning; platform.js detects scroller and skips its own Lenis setup when integrated.
- New: Wiki deep-dive section (#wiki-deepdive) — scattered knowledge cluster → Fenja AI Compiler → layered page stack with citations, plus pinned scrubbed beat-by-beat reveal.
- New: Implementation roadmap section (#platform-roadmap) — four stage cards + GOVERN & SCALE band + footer, with click-to-expand card-morph (FLIP-based; same DOM element grows into the featured panel).
- Dot-nav: 4 → 8 entries — Welcome / Timeline / Fenja introduction / Project Bifrost / Architecture / Wiki / Deployment / Roadmap.
- Deployment options: scroll-tied fade-in for the whole section + sticky-damping at centre for a subtle dwell stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-20 12:56:30 +02:00
parent e3439d6c8f
commit fb815768e2
8 changed files with 2958 additions and 829 deletions

View file

@ -0,0 +1,480 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Archive — stack-scene + words-scene (removed 2026-05-19)</title>
<style>
body { font-family: ui-monospace, Menlo, monospace; max-width: 1100px;
margin: 40px auto; padding: 0 24px; line-height: 1.45; color: #383831; }
h1 { font-size: 20px; }
h2 { font-size: 16px; margin-top: 32px; }
pre { background: #f4efe2; padding: 16px; border-radius: 6px;
overflow: auto; font-size: 12px; line-height: 1.5; }
code { font-family: inherit; }
</style>
</head>
<body>
<h1>Archive — 4-capabilities (#stack-scene) + "This is why we've invited you" (#words-scene)</h1>
<p>These two sections were removed from <code>protected/index.html</code> on 2026-05-19
when the experience was reframed from a Project Bifrost personal invitation into a
customer presentation. Kept here so they can be restored.</p>
<h2>To restore</h2>
<ol>
<li>Copy the <code>&lt;template id="stack-scene-html"&gt;</code> and
<code>&lt;template id="words-scene-html"&gt;</code> contents back into
<code>#overview-scroll</code> in <code>protected/index.html</code>, between
<code>#hero</code> and <code>#bifrost</code> (in that order).</li>
<li>Copy the JS in <code>&lt;script type="text/x-archived-js" id="stack-words-js"&gt;</code>
back into <code>protected/bifrost.js</code>, right after the HERO fade-in (the
<code>gsap.to('.hero-wrap', { opacity: 1, ... })</code> block).</li>
<li>In <code>bifrost.js</code>, add <code>'stack-scene'</code> and <code>'words-scene'</code>
back into <code>sceneOrder</code> (between <code>'hero'</code> and <code>'bifrost'</code>),
add their entries to <code>sceneToDot</code>, and add <code>'words-scene'</code> back to
the <code>sceneIds</code> array inside <code>collectStickyTargets()</code>.</li>
<li>Restore the &quot;Capabilities&quot; dot-nav button in <code>protected/index.html</code>:
<pre>&lt;button class="dot-btn" data-target="page-overview" data-scroll-to="stack-scene"&gt;
&lt;span class="dot"&gt;&lt;/span&gt;
&lt;span class="label"&gt;Capabilities&lt;/span&gt;
&lt;/button&gt;</pre></li>
</ol>
<h2>HTML — #stack-scene (Scene 2)</h2>
<template id="stack-scene-html">
<!-- ============================================================
SCENE 2 — ARCHITECTURE (pinned, scrubbed)
============================================================ -->
<section id="stack-scene" aria-label="The Fenja AI architecture">
<div class="stack-pin">
<!-- Title bar: rides along with the pin so it stays visible while
the reader scrolls through all 4 capability cards. The per-card
counter now lives inside each .layer-card (see .card-counter
below), so the title here is a standalone centered lockup. -->
<div class="stack-title-bar" aria-hidden="true">
<h2 class="stack-title">The Fenja AI platform in four steps</h2>
</div>
<div class="layer-theatre">
<!-- LEFT SIDE — explanatory copy, visible only during the grid phase. -->
<div class="copy-stage" aria-live="polite">
<div class="copy-layer" data-copy="0">
<span class="tag">One complete platform</span>
<h2>Everything you need <em>in one place.</em></h2>
<p>Fenja AI brings models, knowledge, tools, and agents together in one platform for using and scaling AI across your organisation.</p>
</div>
<div class="copy-layer" data-copy="1">
<span class="tag">Full control</span>
<h2>Your <strong>infrastructure.</strong><br/>Your <em>rules.</em></h2>
<p>Fenja AI is installed in your own client-managed environment, giving you full control over data, security, and governance.</p>
</div>
<div class="copy-layer" data-copy="2">
<span class="tag">Sovereignty</span>
<h2>Built in <strong>Denmark.</strong><br/>Ready for <em>Europe.</em></h2>
<p>Fenja AI is built in Denmark for European organisations that want trusted, sovereign AI on their own terms.</p>
</div>
</div>
<!-- LAYER CARDS — drop in, stack, then rearrange to grid -->
<article class="layer-card" data-layer="0" aria-label="Layer 1: the AI">
<span class="card-eyebrow">The AI</span>
<div class="card-box">
<span class="card-counter" aria-hidden="true">1 / 4</span>
<span class="card-grid-label" aria-hidden="true">The AI</span>
<div class="card-content">
<h3 class="card-title">An <b>open-source</b> model, running on your <em>own hardware.</em></h3>
<p class="card-body">A state-of-the-art open-source language model deployed directly in your environment. It gives you powerful AI capabilities with full control over data, performance, and security.</p>
</div>
<div class="card-brain" aria-hidden="true"></div>
</div>
</article>
<article class="layer-card" data-layer="1" aria-label="Layer 2: Knowledge">
<span class="card-eyebrow">The Knowledge</span>
<div class="card-box">
<span class="card-counter" aria-hidden="true">2 / 4</span>
<span class="card-grid-label" aria-hidden="true">The Knowledge</span>
<div class="card-content">
<h3 class="card-title">The business context that makes <em>AI understand your world.</em></h3>
<p class="card-body">A built-in knowledge layer that helps the platform understand your terminology, processes, and data. It retains what matters, improves over time, and gives the AI the context needed to deliver relevant and accurate results.</p>
</div>
<div class="card-brain" aria-hidden="true"></div>
</div>
</article>
<article class="layer-card" data-layer="2" aria-label="Layer 3: Tools">
<span class="card-eyebrow">The Tools</span>
<div class="card-box">
<span class="card-counter" aria-hidden="true">3 / 4</span>
<span class="card-grid-label" aria-hidden="true">The Tools</span>
<div class="card-content">
<h3 class="card-title">How AI <b>acts</b> &mdash; not just what it <em>knows.</em></h3>
<p class="card-body">The capabilities that let the platform do real work across your environment. From search and retrieval to data access, automation, and analysis, these are the tools the AI uses to solve tasks in practice.</p>
</div>
<div class="card-brain" aria-hidden="true"></div>
</div>
</article>
<article class="layer-card" data-layer="3" aria-label="Layer 4: Agents">
<span class="card-eyebrow">The Agents</span>
<div class="card-box">
<span class="card-counter" aria-hidden="true">4 / 4</span>
<span class="card-grid-label" aria-hidden="true">The Agents</span>
<div class="card-content">
<h3 class="card-title">Specialized AI agents <b>working together</b> around <em>real tasks.</em></h3>
<p class="card-body">Purpose-built agents designed to handle distinct roles and workflows. Fenja AI includes both ready-made agents and the framework to build new ones, so you can orchestrate AI the same way your organisation already works &mdash; through specialisation and coordination.</p>
</div>
<div class="card-brain" aria-hidden="true"></div>
</div>
</article>
</div>
</div>
</section>
</template>
<h2>HTML — #words-scene (Scene 3)</h2>
<template id="words-scene-html">
<!-- ============================================================
SCENE 3 — SLIDE 11 — WORDS FLY IN ONE AT A TIME
============================================================ -->
<section id="words-scene" aria-labelledby="words-head">
<h3 id="words-head" class="sr-only" style="position:absolute;left:-9999px;">
This is why we've invited you. To ensure Fenja AI is not just built for you, but with you.
</h3>
<div class="words-pin">
<p class="words" aria-hidden="true" id="words-sentence">
<span class="w">This</span>
<span class="w">is</span>
<span class="w">why</span>
<span class="w">we&rsquo;ve</span>
<span class="w">invited</span>
<span class="w hi">you.</span>
<span class="w">To</span>
<span class="w">ensure</span>
<span class="w">Fenja</span>
<span class="w">AI</span>
<span class="w">is</span>
<span class="w">not</span>
<span class="w">just</span>
<span class="w">built</span>
<span class="w">for</span>
<span class="w">you</span>
<span class="w">&mdash;</span>
<span class="w">but</span>
<span class="w hi">with</span>
<span class="w hi">you.</span>
</p>
</div>
</section>
</template>
<h2>JS — bifrost.js timelines (stack-scene + words-scene)</h2>
<script type="text/x-archived-js" id="stack-words-js">
/* -------------------------------------------------------------
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.17; // matches .in-grid .card-box width (17vw)
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.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 });
});
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',
scrub: 0.6,
pin: '.stack-pin',
pinSpacing: true,
anticipatePin: 1,
invalidateOnRefresh: true,
}
});
// -------- Phase A: card landings --------
cards.forEach((card, i) => {
const landingY = -i * STACK_OFFSET_PER_CARD;
const t = i * 0.105;
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);
stackTl.fromTo(card,
{ opacity: 0 },
{ opacity: 1, duration: 0.065, ease: 'power2.out' },
t + 0.015);
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);
stackTl.to(card.querySelector('.card-eyebrow'),
{ opacity: 1, duration: 0.025, ease: 'power2.out' },
t + 0.06);
if (i > 0) {
stackTl.to(cards[i - 1].querySelector('.card-eyebrow'),
{ opacity: 0, duration: 0.02, ease: 'power2.in' },
t);
}
});
// -------- Phase B: rearrange to grid + fade copy --------
const PHASE_B_START = 0.58;
function scheduleGridTransition() {
const plan = computeGridPlan();
const vw = window.innerWidth;
const cardRect = cards[0].getBoundingClientRect();
const cardW = cardRect.width || vw;
const cardH = cardRect.height || 600;
const targetW = vw * 0.17;
const targetH = targetW;
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');
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);
const counterScaleX = targetScaleY / targetScaleX;
stackTl.to(brain,
{ scaleX: counterScaleX,
duration: 0.14, ease: 'power2.inOut',
transformOrigin: 'right center',
immediateRender: false },
PHASE_B_START);
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);
stackTl.to(brain,
{ scaleX: 1, duration: 0.00001, immediateRender: false },
PHASE_B_START + 0.14);
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);
stackTl.to(card.querySelector('.card-eyebrow'),
{ opacity: 0, duration: 0.06, ease: 'power2.in' },
PHASE_B_START);
});
}
scheduleGridTransition();
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);
};
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);
swap(1, 2, 0.90);
// Clean exit
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
------------------------------------------------------------- */
(function rebuildWordsSentence() {
const wordsP = document.getElementById('words-sentence');
if (!wordsP) return;
const firstName = (typeof window.__fenjaFirstName === 'string')
? window.__fenjaFirstName.trim()
: null;
let tokens;
if (firstName) {
tokens = [
{ text: 'This' }, { text: 'is' }, { text: 'why' },
{ text: 'weve' }, { text: 'invited' }, { text: 'you,' },
{ text: firstName + '.', hi: true },
{ text: 'To' }, { text: 'ensure' }, { text: 'Fenja' },
{ text: 'AI' }, { text: 'is' }, { text: 'not' },
{ text: 'just' }, { text: 'built' }, { text: 'for' },
{ text: 'you' }, { text: '—' }, { text: 'but' },
{ text: 'with', hi: true }, { text: 'you.', hi: true },
];
} else {
tokens = [
{ text: 'This' }, { text: 'is' }, { text: 'why' },
{ text: 'weve' }, { text: 'invited' },
{ text: 'you.', hi: true },
{ text: 'To' }, { text: 'ensure' }, { text: 'Fenja' },
{ text: 'AI' }, { text: 'is' }, { text: 'not' },
{ text: 'just' }, { text: 'built' }, { text: 'for' },
{ text: 'you' }, { text: '—' }, { text: 'but' },
{ text: 'with', hi: true }, { text: 'you.', hi: true },
];
}
wordsP.textContent = '';
tokens.forEach((t, i) => {
const span = document.createElement('span');
span.className = t.hi ? 'w hi' : 'w';
span.textContent = t.text;
wordsP.appendChild(span);
if (i < tokens.length - 1) wordsP.appendChild(document.createTextNode(' '));
});
})();
const wordEls = gsap.utils.toArray('.words .w');
const rnd = (i, seed) => {
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');
}
});
</script>
</body>
</html>

View file

@ -3,9 +3,12 @@
// 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.
// 1. Wraps the Overview's scroll-bound scenes (hero → aurora arc
// → treasure-map). The 4-card architecture stack, the "This is
// why we've invited you" word fly-in, and the Project Bifrost
// Join CTA were removed in the 2026-05-19 customer-presentation
// conversion; the architecture explainer (formerly /deepdive)
// now follows the treasure-map inline — see protected/platform.js.
// 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/
@ -188,21 +191,36 @@
// midpoint AND its bottom is below it. For stacked pinned scenes
// (S2) the pin duration makes "bottom" go well past the viewport,
// so the first-match wins — scenes are checked top-to-bottom.
// Every scrollable scene in the Overview, top-to-bottom. The
// scroll-spy walks this list and decides which one is "in
// view" by the viewport midline rule below. Intermediate
// scenes (bifrost-meaning, platform-question) map to a
// neighbouring dot via sceneToDot so the nav stays highlighted
// through them.
const sceneOrder = [
'hero', 'stack-scene', 'words-scene',
'bifrost', 'bifrost-meaning', 'bifrost-join',
'hero',
'bifrost', 'bifrost-meaning',
'platform-question', 'platform-layers',
'wiki-deepdive',
'platform-cards',
'platform-roadmap',
];
// Not every scene has a dot in the nav — words-scene and bifrost-meaning
// are intermediate sections with no standalone dot. Map them to the
// nearest surviving upstream dot so the nav stays highlighted through
// those sections instead of going blank.
// Maps a scene's id to the data-scroll-to of the dot that
// should highlight when that scene is in view.
// bifrost-meaning → bifrost (treasure-map is a
// continuation of the
// Project Bifrost reveal)
// platform-question → platform-layers (framing lead-in to
// the architecture)
const sceneToDot = {
'hero': 'hero',
'stack-scene': 'stack-scene',
'words-scene': 'stack-scene',
'bifrost': 'bifrost',
'bifrost-meaning': 'bifrost',
'bifrost-join': 'bifrost-join',
'hero': 'hero',
'bifrost': 'bifrost',
'bifrost-meaning': 'bifrost',
'platform-question': 'platform-layers',
'platform-layers': 'platform-layers',
'wiki-deepdive': 'wiki-deepdive',
'platform-cards': 'platform-cards',
'platform-roadmap': 'platform-roadmap',
};
let lastActiveScene = null;
function updateActiveSceneDot() {
@ -316,13 +334,13 @@
// the multiplier is restored.
//
// Targets are:
// - Non-pinned scenes (hero, words-scene, bifrost, bifrost-join)
// - Non-pinned scenes (hero, bifrost)
// - The treasure map (bifrost-meaning) AND each of its three
// stops individually — previously the whole 300vh section was
// one target, so users flew through the individual stops.
//
// stack-scene (S2) is deliberately excluded — it's GSAP-pinned and
// scrubbed; damping on top makes its card-fall feel like a drag.
// platform-layers is GSAP-pinned and scrubbed; damping on top would
// make its beat-by-beat build feel like a drag, so we exclude it.
const BASE_WHEEL_MULT = 1.0;
const BASE_TOUCH_MULT = 1.5;
const STICKY_WHEEL_MULT = 0.35; // 65% reduction while in a sticky zone
@ -334,7 +352,12 @@
// change after init.
function collectStickyTargets() {
const targets = [];
const sceneIds = ['hero', 'words-scene', 'bifrost', 'bifrost-join'];
// platform-cards is included so once the Deployment Options
// section is centred, the wheel multiplier drops — the
// reader has to scroll a few extra ticks to continue, which
// pairs with the scroll-tied fade-in to give the section a
// subtle "stop" feel.
const sceneIds = ['hero', 'bifrost', 'platform-cards'];
sceneIds.forEach(id => {
const el = document.getElementById(id);
if (el) targets.push(el);
@ -395,427 +418,18 @@
});
/* -------------------------------------------------------------
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"
ARCHITECTURE STACK + "This is why we've invited you" words
REMOVED 2026-05-19 in the customer-presentation conversion.
The full HTML + JS is archived at
protected/_archive/stack-scene.html
so the 4-capabilities pinned-scrub sequence can be restored.
------------------------------------------------------------- */
const theatre = document.querySelector('.layer-theatre');
const cards = gsap.utils.toArray('.layer-card');
const copyLayers = gsap.utils.toArray('.copy-layer');
/* Removed: stack-scene timeline (computeGridPlan + 4-card scrubbed
build + grid morph + copy-stage crossfade). Archived in
protected/_archive/stack-scene.html. */
// 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.17; // matches .in-grid .card-box width (17vw)
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.17; // matches .in-grid .card-box width (17vw)
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
------------------------------------------------------------- */
// Before capturing the .words spans, rebuild the sentence with the
// user's first name if we have one. window.__fenjaFirstName is set
// by timeline.js's /auth/me fetch. Falls back to the no-name variant
// already in the DOM (see public/entrance.html's static fallback).
//
// Sentence shape:
// With name: "This is why we've invited you, <hi>Erik.</hi>
// To ensure Fenja AI is not just built for you — but
// <hi>with</hi> <hi>you.</hi>"
// No name: "This is why we've invited <hi>you.</hi> To ensure
// Fenja AI is not just built for you — but
// <hi>with</hi> <hi>you.</hi>"
//
// We rebuild the .words paragraph in place. The hi-classed spans are
// the ones that fly in from center with extra weight (see below).
(function rebuildWordsSentence() {
const wordsP = document.getElementById('words-sentence');
if (!wordsP) return;
const firstName = (typeof window.__fenjaFirstName === 'string')
? window.__fenjaFirstName.trim()
: null;
// Build the token list. Each token is { text, hi }. Whitespace
// between tokens is handled by natural text-wrap — each .w has
// `display: inline-block` plus normal spacing between siblings.
let tokens;
if (firstName) {
tokens = [
{ text: 'This' }, { text: 'is' }, { text: 'why' },
{ text: 'we\u2019ve' }, { text: 'invited' }, { text: 'you,' },
{ text: firstName + '.', hi: true },
{ text: 'To' }, { text: 'ensure' }, { text: 'Fenja' },
{ text: 'AI' }, { text: 'is' }, { text: 'not' },
{ text: 'just' }, { text: 'built' }, { text: 'for' },
{ text: 'you' }, { text: '\u2014' }, { text: 'but' },
{ text: 'with', hi: true }, { text: 'you.', hi: true },
];
} else {
// No name — structurally identical layout so the same fly-in
// curves work without retuning. "you." after "invited" gets .hi
// to carry the weight the name would've carried.
tokens = [
{ text: 'This' }, { text: 'is' }, { text: 'why' },
{ text: 'we\u2019ve' }, { text: 'invited' },
{ text: 'you.', hi: true },
{ text: 'To' }, { text: 'ensure' }, { text: 'Fenja' },
{ text: 'AI' }, { text: 'is' }, { text: 'not' },
{ text: 'just' }, { text: 'built' }, { text: 'for' },
{ text: 'you' }, { text: '\u2014' }, { text: 'but' },
{ text: 'with', hi: true }, { text: 'you.', hi: true },
];
}
// Flush the fallback content, rebuild. Using explicit createElement
// rather than innerHTML so firstName is never HTML-interpolated.
wordsP.textContent = '';
tokens.forEach((t, i) => {
const span = document.createElement('span');
span.className = t.hi ? 'w hi' : 'w';
span.textContent = t.text;
wordsP.appendChild(span);
// Preserve natural whitespace between tokens (critical for text-wrap).
if (i < tokens.length - 1) wordsP.appendChild(document.createTextNode(' '));
});
})();
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');
}
});
/* Removed: words-scene "This is why we've invited you" timeline.
Archived in protected/_archive/stack-scene.html. */
/* -------------------------------------------------------------
SCENE 4 PROJECT BIFROST REVEAL
@ -1052,103 +666,10 @@
}
});
/* -------------------------------------------------------------
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;
// Record the click on the server. Fire-and-forget — the UI
// transitions below run regardless of network outcome so a
// temporary failure doesn't trap the user in a broken state.
// The server uses INSERT OR IGNORE keyed on email, so repeat
// clicks from the same user are safely deduplicated.
fetch('/api/bifrost-join', {
method: 'POST',
credentials: 'same-origin',
}).catch(() => {
// Network/server error — intentionally swallowed. An admin
// listing missing entries can follow up out-of-band.
});
// 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');
});
});
});
}
/* SCENE 6 Join CTA + Innovationsfonden footer: REMOVED 2026-05-19
in the customer-presentation conversion. The CTA, confirmation
panel, click handler, and three-mark footer all went away with
the #bifrost-join section in protected/index.html. */
/* -------------------------------------------------------------
Refresh ScrollTrigger after fonts and images load so positions
@ -1207,7 +728,7 @@
* Smooth-scroll the Overview's internal scroller to a scene.
* Called by the dot-nav click handler in timeline.js.
*
* @param {string} sceneId id of the scene section (e.g. "stack-scene")
* @param {string} sceneId id of the scene section (e.g. "bifrost")
* see sceneOrder[] inside init().
* Special value "hero" scrolls to top (0).
*/
@ -1215,18 +736,14 @@
// dot-nav button anchors to it, so the reader lands AFTER the scene's
// initial reveal rather than at an empty pre-scrub frame.
//
// stack-scene — offset 0 (top of the pin) so the reader lands right
// when the title appears and the first card starts its fall, and
// sees the full progression through all 4 landings.
//
// bifrost — section is 200vh with a scrubbed reveal that runs from
// top-top to bottom-bottom (100vh scroll range). The sub-headline
// fades in at ~0.83 of that. Offset is computed per viewport as
// 85% of vh so the reader arrives on the fully-drawn arc +
// wordmark, regardless of display size.
//
// hero, bifrost-join — short reveal tweens; offsetTop is already
// the correct landing spot so offset is 0.
// hero — short reveal tween; offsetTop is already the correct
// landing spot so offset is 0.
function getSceneAnchorOffset(sceneId) {
const vh = window.innerHeight;
switch (sceneId) {

File diff suppressed because it is too large Load diff

View file

@ -157,6 +157,360 @@ body:has(#page-product-deepdive.is-active) .dot-nav-tray { opacity: 0; }
.platform-card-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* =============================================================
IMPLEMENTATION ROADMAP #platform-roadmap
Four stage cards in a horizontal row, with chevrons between
each pair, then a continuous cross-cutting band below.
Mirrors #platform-cards section framing (.platform-cards-head,
.platform-eyebrow, .platform-title) and pl-card type density
(sans name + serif italic subtitle + mono meta).
Cards use --secondary (walnut) the deck's primary brand
accent. The cross-cutting band uses --surface-container so it
reads as neutral infrastructure, not a fifth card.
============================================================= */
#platform-roadmap {
position: relative;
width: 100%;
min-height: 100vh;
background: var(--background);
color: var(--on-surface);
padding: clamp(2rem, 6vh, 5rem) clamp(2rem, 5vw, 7rem);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: clamp(2rem, 5vh, 3.5rem);
box-sizing: border-box;
}
/* Stage row four equal cards with a chevron between each pair.
The chevron lives on .rm-card::after; the last card suppresses
it so we never trail off with an arrow pointing into the void. */
.rm-row {
list-style: none;
margin: 0;
padding: 0;
width: 100%;
max-width: var(--content-max);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-5);
align-items: stretch;
}
.rm-card {
position: relative;
background: var(--secondary);
color: var(--on-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-ambient);
padding: var(--space-6) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-2);
min-height: 168px;
/* Clickable. Hover and focus-visible lift the card so it's
obvious the surface is interactive; the focus outline uses
the brand accent and the same offset the rest of the deck
uses for focus rings. */
cursor: pointer;
user-select: none;
transition: transform 200ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 200ms cubic-bezier(0.2, 0, 0, 1);
}
.rm-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-float);
}
.rm-card:focus-visible {
outline: 2px solid var(--secondary);
outline-offset: 4px;
transform: translateY(-3px);
box-shadow: var(--shadow-float);
}
@media (prefers-reduced-motion: reduce) {
.rm-card { transition: none; }
.rm-card:hover,
.rm-card:focus-visible { transform: none; }
}
/* Chevron between cards. SVG-as-background lives in the gutter
between this card's right edge and the next card's left edge,
centered horizontally in that gap. */
.rm-card::after {
content: "";
position: absolute;
top: 50%;
left: 100%;
width: 14px;
height: 24px;
/* Translate right by half the gutter so the chevron's centerline
lands at the gutter midpoint; vertical translate -50% centers
it on the card's vertical midline. */
transform: translate(calc((var(--space-5) - 14px) * 0.5), -50%);
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 24' fill='none' stroke='%238a887f' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><path d='M3 4 L11 12 L3 20'/></svg>");
background-repeat: no-repeat;
background-position: center;
background-size: 14px 24px;
pointer-events: none;
}
.rm-card:last-child::after { content: none; }
.rm-name {
font-family: var(--font-sans);
font-size: 18px;
font-weight: 600;
letter-spacing: -0.005em;
color: inherit;
margin: 0;
}
.rm-italic {
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
font-size: var(--text-body-md);
line-height: 1.35;
color: rgba(255, 252, 247, 0.86);
margin: 0;
}
.rm-mono {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
line-height: 1.4;
color: rgba(255, 252, 247, 0.62);
margin: auto 0 0 0;
}
/* Cross-cutting band sits underneath the row, spans the same
width. Paper surface (not walnut) so the eye reads "continuous
infrastructure" rather than "fifth step". */
.rm-band {
width: 100%;
max-width: var(--content-max);
background: var(--surface-container);
color: var(--on-surface);
border-radius: var(--radius-lg);
padding: var(--space-5) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-2);
box-shadow: var(--shadow-ambient);
}
.rm-band-name {
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--on-surface-variant);
margin: 0;
}
.rm-band-italic {
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
font-size: var(--text-body-md);
line-height: 1.4;
color: var(--on-surface);
margin: 0;
}
.rm-foot {
width: 100%;
max-width: var(--content-max);
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
font-size: var(--text-body-sm);
line-height: 1.4;
color: var(--on-surface-muted);
text-align: center;
margin: 0;
}
/* Narrow desktop drop to a 2×2 grid of stage cards, same
breakpoint .platform-card-grid uses. The horizontal chevron
between columns no longer makes sense in 2×2, so suppress it;
the band + footer keep their full-width layout. */
@media (max-width: 960px) {
.rm-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.rm-card::after { content: none; }
}
/* =============================================================
ROADMAP CARD MORPH same-element expansion (no modal)
The clicked card IS the expanded panel same DOM element,
same walnut surface, same border-radius. On expand the row
grid flips from 4-up × 1 row to 6-up × 2 rows: the clicked
card spans columns 25 of row 1 (66% of the row's content
width); the other three cards drop to row 2, each spanning
2 columns, so they remain visible below. The transition is
FLIP-driven from initRoadmap in platform.js.
============================================================= */
/* Expanded row layout six logical columns, two rows.
Active when one card has .is-expanded; off otherwise. */
.rm-row.has-expanded {
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-rows: auto auto;
row-gap: var(--space-6);
}
.rm-row.has-expanded .rm-card.is-expanded {
grid-column: 2 / span 4;
grid-row: 1;
}
.rm-row.has-expanded .rm-card:not(.is-expanded) {
grid-column: span 2;
grid-row: 2;
/* Subtle recede — readable but visually secondary. */
opacity: 0.7;
}
/* Default body / close-button states (collapsed). The body sits
in the DOM but is removed from layout via display:none so the
card's collapsed face stays compact. */
.rm-card-body { display: none; }
.rm-card-close { display: none; }
/* Expanded card visual treatment. Same brown surface, same
radius. The walnut accent does not change; only the size, the
content arrangement, and a stronger drop-shadow change. */
.rm-card.is-expanded {
/* Walnut surface stays. Stronger shadow reinforces "floating
above the row of cards below". */
box-shadow:
0 24px 48px -16px rgba(56, 56, 49, 0.20),
0 6px 16px -6px rgba(56, 56, 49, 0.10);
padding: var(--space-7) clamp(2rem, 4vw, 3rem);
cursor: default;
z-index: 2;
}
/* Suppress the inter-card chevron on the expanded card and any
neighbour in row 2 the visual sequence is interrupted while
one card is featured. */
.rm-row.has-expanded .rm-card::after { content: none; }
/* Expanded body — visible, normal flow inside the card. */
.rm-card.is-expanded .rm-card-body {
display: block;
margin-top: var(--space-5);
}
/* Meta moves to the TOP of the expanded panel via flex order;
the natural margin-auto that pushed it to the bottom in the
collapsed face is reset to zero. */
.rm-card.is-expanded .rm-mono {
order: -1;
margin: 0 0 var(--space-2) 0;
}
/* Title + subtitle stay in DOM order below the meta. Subtitle
gets a small bottom margin to separate it from the intro. */
.rm-card.is-expanded .rm-name {
font-size: clamp(1.5rem, 2.4vw, 2rem);
margin: 0;
}
.rm-card.is-expanded .rm-italic {
font-size: var(--text-body-lg);
margin: 0 0 var(--space-2) 0;
color: rgba(255, 252, 247, 0.86);
}
/* Intro paragraph serif, lightly literary. Inside the walnut
card the colour is the card's on-secondary tone, slightly
softened. */
.rm-card-intro {
font-family: var(--font-serif);
font-weight: 400;
font-size: var(--text-body-lg);
line-height: var(--leading-relaxed);
color: rgba(255, 252, 247, 0.92);
margin: 0 0 var(--space-5) 0;
}
.rm-card-section-label {
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 252, 247, 0.62);
margin: 0 0 var(--space-3) 0;
}
.rm-card-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.rm-card-list li {
font-family: var(--font-sans);
font-size: var(--text-body-md);
line-height: 1.45;
color: rgba(255, 252, 247, 0.94);
padding-left: 18px;
position: relative;
}
/* Cream bullet against the walnut background. */
.rm-card-list li::before {
content: "";
position: absolute;
left: 4px;
top: 0.55em;
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(255, 252, 247, 0.78);
}
/* Close (×) button top-right of the expanded card. Same
visual idiom as the rest of the deck's circular hit targets. */
.rm-card.is-expanded .rm-card-close {
display: inline-flex;
position: absolute;
top: var(--space-4);
right: var(--space-4);
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
border-radius: 999px;
color: rgba(255, 252, 247, 0.70);
cursor: pointer;
transition: background 180ms ease, color 180ms ease;
}
.rm-card.is-expanded .rm-card-close:hover,
.rm-card.is-expanded .rm-card-close:focus-visible {
background: rgba(255, 252, 247, 0.10);
color: rgba(255, 252, 247, 0.95);
}
.rm-card.is-expanded .rm-card-close:focus-visible {
outline: 2px solid rgba(255, 252, 247, 0.55);
outline-offset: 2px;
}
/* Reduced motion let the layout change happen without a FLIP
transform animation. Cards still toggle states; just no
translate/scale tweening between them. */
@media (prefers-reduced-motion: reduce) {
.rm-card { transition: opacity 200ms ease !important; }
.rm-card.is-expanded,
.rm-row.has-expanded .rm-card { transform: none !important; }
}
/* =============================================================
"The Question" intro section first section of the Deepdive
page. A full-viewport framing statement; fades in on scroll
@ -472,6 +826,596 @@ body:has(#page-product-deepdive.is-active) .dot-nav-tray { opacity: 0; }
.pl-cards--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* =============================================================
WIKI DEEP-DIVE #wiki-deepdive
Pinned scrubbed five-beat section, mirroring #platform-layers's
structure: a .pl-pin-style shell (.wd-pin) with the reused
.pl-pin-header for title/subtitle, and a three-column .wd-body
below. platform.js (initWiki) drives the beats.
Visual contract:
LEFT (scatter) muted, jittered, "messy"
MIDDLE (compiler) walnut/secondary accent (only "active"
element on the slide; reads as configurable engine)
RIGHT (wiki mock) calm, neutral; inline <sup> citations
use the same walnut accent so the trust thread (Beat 4)
has a colour to follow back to the source
============================================================= */
#wiki-deepdive {
position: relative;
width: 100%;
background: var(--background);
color: var(--on-surface);
}
/* Pin shell vertical stack: header at top, action band centred
vertically below. The band has explicit height (58vh) so all
three zones share a top edge, a bottom edge, and a centreline.
Header gets its own row via grid; body row takes the rest and
centres the action band inside it. */
.wd-pin {
position: relative;
width: 100%;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
align-items: start;
padding: clamp(5rem, 12vh, 9rem) clamp(2rem, 5vw, 7rem) clamp(1.5rem, 3vh, 2.5rem);
box-sizing: border-box;
}
.wd-pin .pl-pin-header { grid-row: 1; justify-self: center; }
.wd-pin .pl-pin-title em { font-style: italic; font-weight: 400; }
/* Beat-0 anchor the architecture grid's Wiki card, scaled up
and centered over the action band. platform.js holds it at
opacity 1 / scale 1.6 during Beat 0 and fades it down as Beat 1
reveals the band. The pl-card visual is identical to the one
in #platform-layers; same DOM class, same tokens. */
.wd-anchor {
position: absolute;
top: 50%;
left: 50%;
width: clamp(220px, 22vw, 320px);
transform: translate(-50%, -50%) scale(1);
pointer-events: none;
z-index: 4;
opacity: 0; /* GSAP fades this in for Beat 0 */
}
.wd-anchor .pl-card { box-shadow: var(--shadow-float); }
/* Action band the shared 5560vh strip. Five-column grid:
three zones with two small chevron columns sitting in the
gaps. The chevrons take their natural width; outer gap is
wider than before so the compiler reads as an equal-weight
peer of the cluster and stack, with real whitespace on both
sides instead of crowding either neighbour. */
.wd-body {
grid-row: 2;
align-self: center;
justify-self: center;
width: 100%;
max-width: var(--content-max);
height: clamp(360px, 58vh, 620px);
display: grid;
grid-template-columns:
minmax(0, 1.15fr) auto
minmax(0, 0.78fr) auto
minmax(0, 1.15fr);
/* +20% horizontal whitespace between zones from the previous
`clamp(2.25rem, 5vw, 5rem)`. Wider gutters reinforce the
three zones as distinct movements rather than a continuous
band, and give the chevrons more room to breathe between
them. */
gap: clamp(2.7rem, 6vw, 6rem);
align-items: stretch;
position: relative;
z-index: 2;
}
/* Chevron between zones same geometric mark used in
#platform-roadmap. Vertically centred via align-self; sized
below the body text so it reads as a hint, not a sign. */
.wd-chevron {
align-self: center;
width: 14px;
height: 24px;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 24' fill='none' stroke='%238a887f' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><path d='M3 4 L11 12 L3 20'/></svg>");
background-repeat: no-repeat;
background-position: center;
background-size: 14px 24px;
opacity: 0.55;
}
/* Zone column. Top section (labels) is auto-height; the visual
below fills remaining height. A consistent .wd-zone-head wrapper
keeps the eyebrow/name lines aligned across all three columns. */
.wd-zone {
display: flex;
flex-direction: column;
min-height: 0;
gap: var(--space-3);
}
/* Shared top-label block. Reserves enough height for an optional
sub-caption line (used by the Fenja Wiki zone) so all three
columns hand off to their visual at the same Y baseline. */
.wd-zone-eyebrow {
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--on-surface-variant);
margin: 0;
}
.wd-zone-name {
font-family: var(--font-sans);
font-size: 15px;
font-weight: 600;
letter-spacing: -0.005em;
color: var(--on-surface);
margin: 2px 0 0 0;
}
/* Sub-caption directly under the zone name (used by Fenja Wiki
and Compiler). Italic serif, muted matches the rest of the
deck's secondary captions. Reserved baseline-height block so
zones without a sub-caption keep the same handoff Y. */
.wd-zone-sub {
font-family: var(--font-serif);
font-style: italic;
font-size: 12.5px;
line-height: 1.4;
color: var(--on-surface-muted);
margin: 4px 0 0 0;
min-height: 1em;
}
/* ─── LEFT: Scattered knowledge ───────────────────────────── */
.wd-scatter {
position: relative;
flex: 1 1 auto;
margin-top: var(--space-3);
overflow: hidden; /* bound the cluster strictly inside */
}
/* Icons absolutely positioned via inline --tx / --ty / --r / --s.
--tx/--ty are top-left offsets in % of the scatter zone the
cluster stays inside the action band rather than spreading
floor-to-ceiling like the previous arrangement. */
.wd-doc {
position: absolute;
left: var(--tx, 0);
top: var(--ty, 0);
transform: rotate(var(--r, 0deg)) scale(var(--s, 1));
transform-origin: top left;
color: var(--on-surface-muted);
display: block;
width: clamp(46px, 4.6vw, 66px);
/* --o is the per-icon opacity target (1 = foreground, ~0.45
= background pile). Read by initWiki at reveal-time so the
stagger fades each icon to its own opacity, not all to 1. */
opacity: var(--o, 1);
filter: drop-shadow(0 4px 6px rgba(56, 56, 49, 0.05));
transition: color 280ms ease, filter 280ms ease;
}
.wd-doc svg { display: block; width: 100%; height: auto; }
.wd-doc--slide { width: clamp(60px, 6vw, 86px); }
.wd-doc--tacit { width: clamp(58px, 5.8vw, 84px); }
.wd-doc--note { width: clamp(42px, 4.2vw, 58px); }
.wd-doc--mail { width: clamp(54px, 5.4vw, 76px); }
/* Trust-beat source-tint: the originating document on the left
subtly lifts toward charcoal + a tiny upscale. platform.js
toggles .is-source on the matching .wd-doc at Beat 4. */
.wd-doc.is-source {
color: var(--on-surface);
filter: drop-shadow(0 6px 10px rgba(56, 56, 49, 0.10));
}
/* ─── MIDDLE: Fenja AI Compiler ───────────────────────────── */
/* The compiler zone overrides the default flex layout used by
the other zones so the card can be locked to the zone's exact
vertical centre (matching the chevron midline on either side).
The labels are grouped in .wd-compiler-head and anchored just
above the card so their spacing follows it. */
.wd-zone--compiler {
position: relative;
display: block;
text-align: center;
}
.wd-zone--compiler .wd-zone-eyebrow,
.wd-zone--compiler .wd-zone-name,
.wd-zone--compiler .wd-zone-sub {
text-align: center;
}
/* Label group anchored at the TOP of the zone so the title sits
on the same horizontal baseline as the eyebrows in the other
two zones (Scattered knowledge / Structured output). The card
below is still locked to zone-centre via absolute positioning
the resulting gap between the title block and the card is
intentional.
Flex + gap match the other zones' .wd-zone container so the
internal rhythm between eyebrow name sub is the same as
the left and right columns. */
.wd-compiler-head {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 280px;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.wd-compiler-head .wd-zone-eyebrow { margin: 0; }
.wd-compiler-head .wd-zone-name { margin: 2px 0 0 0; }
.wd-compiler-head .wd-zone-sub { margin: 4px 0 0 0; min-height: 1em; }
/* Rules card outline + paper fill. Locked to the zone's
vertical centre via absolute positioning so its midline
aligns exactly with the chevrons in the gutters on either
side (both centred in the same grid row). */
.wd-compiler {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 100%;
max-width: 240px;
margin: 0;
padding: var(--space-4) var(--space-5);
background: var(--surface-container-lowest);
color: var(--on-surface);
border: 1px solid var(--outline-variant);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-ambient);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
}
.wd-compiler-label {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--on-surface-variant);
}
.wd-compiler-rules {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 11px;
width: 100%;
flex: 0 0 auto;
}
.wd-compiler-rules li {
display: flex;
align-items: center;
gap: 10px;
}
.wd-rule-toggle {
width: 22px;
height: 12px;
border-radius: 999px;
background: var(--surface-container);
border: 1px solid var(--outline-variant);
position: relative;
flex-shrink: 0;
}
.wd-rule-toggle::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--on-surface-muted);
transition: left 200ms ease, background 200ms ease;
}
.wd-rule-toggle.is-on {
background: var(--surface-container-high);
}
.wd-rule-toggle.is-on::after {
left: 12px;
background: var(--on-surface);
}
.wd-rule-line {
flex: 1 1 auto;
height: 1px;
background: var(--outline-variant);
}
/* RIGHT: Abstract layered page stack
Three page-shaped cards stacked with depth. Each subsequent
card is offset toward the TOP-RIGHT of the one behind (60%
overlap), so the eye reads back mid front. The frontmost
card is sharp; cards behind are progressively blurred as if
each layer in front fogs the cards beneath. */
.wd-stack {
position: relative;
flex: 1 1 auto;
margin-top: var(--space-3);
/* Card sizing reflects two reductions from the previous
pass:
Step A internal whitespace between body and source list
is halved (no `margin-top: auto` on the source
list; tighter divider margins), giving a ~20%
height reduction without changing block sizes.
Step B the whole stack is then reduced by a further
~30% in both dimensions.
Combined: roughly 0.8 × 0.7 = 0.56× the previous size. */
--wd-card-w: clamp(140px, 12vw, 200px);
--wd-card-h: clamp(160px, 20vh, 225px);
/* Per-card shift tightened to ~22% (78% overlap) so the
three cards read as one thing with depth rather than three
drifting pages. Same top-right offset direction; the back-
blur depth effect is unchanged. */
--wd-card-step-x: 22%;
--wd-card-step-y: 22%;
}
.wd-stack-card {
position: absolute;
width: var(--wd-card-w);
height: var(--wd-card-h);
background: var(--surface-container-lowest);
border: 1px solid var(--outline-variant);
border-radius: var(--radius-md);
box-shadow:
0 6px 14px -8px rgba(56, 56, 49, 0.10),
0 2px 6px -3px rgba(56, 56, 49, 0.06);
padding: 11px 11px 11px 22px;
display: flex;
flex-direction: column;
}
.wd-stack-content {
display: flex;
flex-direction: column;
gap: 5px;
flex: 1 1 auto;
min-height: 0;
}
/* Per-depth offsets. The back card sits in the bottom-left, the
middle card shifts ~36% right & up, the front card another
~36% right & up total stack runs upper-right. */
.wd-stack-card[data-depth="back"] {
left: 0;
top: calc(var(--wd-card-step-y) * 2);
z-index: 1;
}
.wd-stack-card[data-depth="mid"] {
left: var(--wd-card-step-x);
top: var(--wd-card-step-y);
z-index: 2;
}
.wd-stack-card[data-depth="front"] {
left: calc(var(--wd-card-step-x) * 2);
top: 0;
z-index: 3;
}
/* Thin vertical rail on the left edge abstract sidebar/nav,
not labelled tabs. Three short ticks sit on the rail. */
.wd-stack-rail {
position: absolute;
top: 13px;
bottom: 13px;
left: 11px;
width: 1px;
background: var(--outline-variant);
}
.wd-stack-rail::before,
.wd-stack-rail::after {
content: "";
position: absolute;
left: -3px;
width: 7px;
height: 1.2px;
background: var(--on-surface-muted);
opacity: 0.55;
}
.wd-stack-rail::before { top: 12px; }
.wd-stack-rail::after { top: 30px; }
/* Title bar short underline-style block at the top of each
page card. Thicker and darker than body blocks below. */
.wd-stack-title-bar {
display: block;
height: 5px;
width: 58%;
background: var(--on-surface);
border-radius: 2px;
opacity: 0.55;
margin-bottom: 2px;
}
/* Subtitle shorter darker block between the two body
sections. Smaller than the title bar; reads as an H2. */
.wd-stack-subhead {
display: block;
height: 4px;
width: 34%;
background: var(--on-surface);
border-radius: 2px;
opacity: 0.42;
margin-top: 2px;
margin-bottom: 1px;
}
/* Body line a flex row of inline pieces (blocks, brackets,
citations). Keeps everything on a baseline; spacing between
pieces is the gap. */
.wd-stack-line {
display: flex;
align-items: center;
gap: 4px;
position: relative;
}
/* Body block one inline run of "text". Width comes from a
per-instance --w custom prop so each line reads as varying
prose. The default keeps a fallback if --w isn't set. */
.wd-stack-block {
display: block;
height: 3.5px;
width: var(--w, 100%);
background: var(--surface-container-high);
border-radius: 999px;
flex: 0 0 auto;
}
.wd-stack-block--short { width: 64%; }
/* Wiki-style internal-link marker a proper square-bracket
pair enclosing a short horizontal block. Renders as `[ ]`,
reading as an inline link to another subject. The bracket
characters come from ::before / ::after; the inner block is
a real child element so per-instance --bw varies its width. */
.wd-stack-bracket {
display: inline-flex;
align-items: center;
gap: 2px;
flex: 0 0 auto;
font-family: var(--font-mono);
font-size: 10px;
line-height: 1;
color: var(--on-surface-muted);
padding: 0 1px;
}
.wd-stack-bracket::before { content: "["; opacity: 0.75; }
.wd-stack-bracket::after { content: "]"; opacity: 0.75; }
.wd-stack-bracket > span {
display: inline-block;
height: 3.5px;
width: var(--bw, 14px);
background: var(--surface-container-high);
border-radius: 999px;
}
/* Citation markers sit inline at the trailing edge of the
block/segment they cite. Walnut so they thread the trust
beat. */
.wd-cite {
font-family: var(--font-mono);
font-size: 7.5px;
font-weight: 600;
color: var(--secondary);
line-height: 1;
padding: 0 1px;
border-radius: 3px;
transition: color 220ms ease, transform 220ms ease;
display: inline-block;
margin-left: 1px;
position: relative;
top: -2px;
}
.wd-cite.is-lit {
/* Soft walnut pill behind the lit marker. Subtle by design
pairs with the scale pulse + source highlight + arc draw
to register without dominating. */
background: rgba(120, 95, 83, 0.16);
padding: 1px 3px;
}
/* Thin divider separates the body from the source list at
the bottom of each card. Step A from the size-reduction pass
halved the whitespace here, bringing the body and source
list into close contact (modest gap on either side of the
divider, no auto-margin pushing the sources to the bottom). */
.wd-stack-divider {
border: 0;
height: 1px;
background: var(--outline-variant);
margin: 3px 0 2px 0;
width: 100%;
}
/* Source list three numbered horizontal lines below the
divider. Sits directly under the divider (no auto top
margin) so the bottom of the card is tight rather than
reserving empty space. data-source on each entry pairs it
with an in-text citation so Beat 4 highlights the matching
bottom entry alongside the lit inline marker. */
.wd-stack-sources {
display: flex;
flex-direction: column;
gap: 2px;
}
.wd-stack-source {
display: flex;
align-items: center;
gap: 4px;
transition: opacity 220ms ease;
}
.wd-stack-source sup {
font-family: var(--font-mono);
font-size: 7px;
font-weight: 600;
color: var(--on-surface-muted);
line-height: 1;
min-width: 5px;
}
.wd-stack-source-line {
flex: 1 1 auto;
height: 1px;
background: var(--surface-container-high);
border-radius: 999px;
max-width: 70%;
}
/* Beat-4 highlight on the source entry that pairs with the
lit citation. Subtle tint kept low-key so the lit marker
above the divider stays the focus. */
.wd-stack-source.is-paired sup {
color: var(--secondary);
}
.wd-stack-source.is-paired .wd-stack-source-line {
background: var(--secondary);
opacity: 0.55;
}
/* Narrow desktop single-column stack so the left middle
right narrative direction is preserved. Same breakpoint other
sections use. Action band height + anchor are disabled in
this mode, and the compiler's absolute positioning (used to
lock to the chevron midline on wider screens) collapses back
to natural flow. */
@media (max-width: 960px) {
.wd-body {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-8);
height: auto;
}
.wd-chevron { display: none; }
.wd-anchor { width: 200px; }
.wd-scatter { min-height: 220px; }
.wd-stack { min-height: 280px; }
.wd-zone--compiler { display: flex; flex-direction: column; }
.wd-compiler-head {
position: static;
transform: none;
max-width: none;
}
.wd-compiler {
position: static;
transform: none;
margin: var(--space-4) auto 0;
}
}
/* Reduced motion release the pin entirely, stack header, all
five text panels, and the fully assembled diagram vertically.
platform.js mirrors this gate. The .pq-* opacity:1 lines belong
@ -505,4 +1449,42 @@ body:has(#page-product-deepdive.is-active) .dot-nav-tray { opacity: 0; }
#platform-layers .pl-group { opacity: 1; }
#platform-layers .pl-card { opacity: 1; transform: none; }
#platform-layers .pl-canvas-frame { opacity: 1; }
/* Wiki deep-dive: release the pin, stack columns, show all
content + flow lines in their final composed state. Back-
blur on the stack is also disabled here per the brief:
reduced motion gets the final composed state, no staged
blur animation. */
#wiki-deepdive .wd-pin {
height: auto;
padding: var(--space-12) clamp(2rem, 5vw, 7rem);
gap: var(--space-8);
display: flex;
flex-direction: column;
}
#wiki-deepdive .wd-anchor { display: none; }
#wiki-deepdive .wd-body {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-8);
height: auto;
}
#wiki-deepdive .wd-cite { color: var(--secondary); }
#wiki-deepdive .wd-stack-card { filter: none !important; }
/* Release the compiler's absolute positioning so labels and
the card stack naturally below each other when the pin is
released. */
#wiki-deepdive .wd-zone--compiler {
display: flex;
flex-direction: column;
}
#wiki-deepdive .wd-compiler-head {
position: static;
transform: none;
max-width: none;
}
#wiki-deepdive .wd-compiler {
position: static;
transform: none;
margin: var(--space-4) auto 0;
}
}

View file

@ -1,21 +1,26 @@
// ─────────────────────────────────────────────────────────────
// protected/platform.js — Product Deepdive page
// protected/platform.js — Fenja AI Platform Architecture explainer
//
// Owns #page-product-deepdive: a self-contained top-level page
// reached via the "Product Deepdive" dot. Sections (in order):
// Sections (in order):
// #platform-question — full-viewport framing statement (fade-in)
// #platform-layers — pinned scrubbed four-beat architecture build
// #platform-layers — pinned scrubbed five-beat architecture build
// #platform-cards — "Choose your Capability" deployment options
// (final section; centred when at scroll end)
//
// This page has its OWN internal scroller (#product-deepdive-scroll)
// with its OWN Lenis instance and its OWN ScrollTrigger.scrollerProxy
// — fully isolated from bifrost.js's setup on #overview-scroll. Every
// ScrollTrigger created here passes `scroller` explicitly so it never
// inherits ScrollTrigger.defaults from bifrost.
// Runs in two host pages:
// A. Inlined into protected/index.html's Overview page, after the
// Project Bifrost treasure-map — the customer-presentation flow.
// Scroller: #overview-scroll. Lenis + ScrollTrigger.scrollerProxy
// are set up by bifrost.js, so we just attach our triggers.
// B. Standalone protected/deepdive.html (the original /deepdive
// page). Scroller: #product-deepdive-scroll. We own Lenis +
// scrollerProxy here.
//
// Self-defers init until #page-product-deepdive gains `is-active`,
// so vendor libs are loaded and the scroller has real dimensions.
// Detection: at boot we look for #overview-scroll first; if present
// we wait for #page-overview to gain `is-active` (i.e. bifrost.js
// has run its init) and attach scene triggers without creating a
// second Lenis instance. Otherwise we fall back to the standalone
// deepdive path.
//
// CSP: 'script-src self'. No inline scripts anywhere.
// ─────────────────────────────────────────────────────────────
@ -32,12 +37,18 @@
if (typeof window.gsap === 'undefined' ||
typeof window.ScrollTrigger === 'undefined' ||
typeof window.Lenis === 'undefined') {
console.warn('[deepdive] gsap/ScrollTrigger/Lenis missing; skipping init.');
console.warn('[platform] gsap/ScrollTrigger/Lenis missing; skipping init.');
return;
}
const scroller = document.getElementById('product-deepdive-scroll');
// Prefer the Overview's existing scroller when present — that's
// the inlined customer-presentation path, where bifrost.js owns
// Lenis + scrollerProxy and we must not create a second pair.
const overviewScroller = document.getElementById('overview-scroll');
const deepdiveScroller = document.getElementById('product-deepdive-scroll');
const integrated = !!overviewScroller && !!document.getElementById('platform-layers');
const scroller = integrated ? overviewScroller : deepdiveScroller;
if (!scroller) {
console.warn('[deepdive] #product-deepdive-scroll not found; skipping init.');
console.warn('[platform] no scroller (#overview-scroll or #product-deepdive-scroll) found; skipping init.');
return;
}
initialized = true;
@ -50,10 +61,11 @@
gsap.registerPlugin(ScrollTrigger);
// Lenis on the deepdive's internal scroller (NOT the window).
// Mirrors bifrost.js's pattern so wheel/touch input drives this
// scroller smoothly while the architecture scrub stays buttery.
if (!reduceMotion) {
// Lenis + scrollerProxy: only when standalone. In the integrated
// path, bifrost.js already wired both onto #overview-scroll; we'd
// create a duplicate Lenis fighting the existing one if we ran
// this block.
if (!integrated && !reduceMotion) {
const lenis = new Lenis({
wrapper: scroller,
content: scroller.firstElementChild,
@ -89,7 +101,9 @@
initQuestion(gsap, ScrollTrigger, scroller, reduceMotion);
initLayers(gsap, ScrollTrigger, scroller, reduceMotion);
initWiki(gsap, ScrollTrigger, scroller, reduceMotion);
initCards(gsap, ScrollTrigger, scroller, reduceMotion);
initRoadmap(gsap, ScrollTrigger, scroller, reduceMotion);
// Refresh now that the page is laid out and triggers exist.
if (!reduceMotion) ScrollTrigger.refresh();
@ -250,28 +264,533 @@
});
}
// ─── Section A: Cards ────────────────────────────────────────
// ─── Section A: Deployment options (cards) ──────────────────
// Scroll-tied fade-in for the WHOLE section content (header
// + four cards). Scrub ties opacity directly to scroll
// position so the section "arrives" as the reader scrolls
// into it, reaching full clarity when its top is well into
// the viewport. Paired with a sticky-damping entry in
// bifrost.js (added to sceneIds in collectStickyTargets)
// so once centred the wheel resists further scroll briefly
// — a subtle stop feel.
function initCards(gsap, ScrollTrigger, scroller, reduceMotion) {
const cards = document.querySelectorAll('#platform-cards .platform-card');
if (!cards.length) return;
const head = document.querySelector('#platform-cards .platform-cards-head');
const cards = Array.from(document.querySelectorAll('#platform-cards .platform-card'));
const targets = [head, ...cards].filter(Boolean);
if (!targets.length) return;
if (reduceMotion) {
cards.forEach(c => { c.style.opacity = '1'; });
targets.forEach(c => { c.style.opacity = '1'; c.style.transform = 'none'; });
return;
}
gsap.set(cards, { opacity: 0, y: 24 });
gsap.to(cards, {
gsap.set(targets, { opacity: 0, y: 24 });
gsap.to(targets, {
opacity: 1,
y: 0,
duration: 0.6,
ease: 'power3.out',
stagger: 0.08,
ease: 'power2.out',
stagger: 0.05,
scrollTrigger: {
trigger: '#platform-cards',
scroller,
// Begin fading in as the section's top enters the
// viewport from below; finish by the time it's well
// inside (top at 35% from viewport top) so the section
// is fully clear before the reader reaches centre.
start: 'top bottom',
end: 'top 35%',
scrub: 0.6,
},
});
}
// ─── Wiki deep-dive — pinned scrubbed five-beat ─────────────
//
// Beat 0 Anchor (Wiki pl-card) scales up & fades in centered.
// Beat 1 Left "Scattered knowledge" zone reveals; document
// icons stagger in; anchor fades to a quiet echo.
// Beat 2 Middle "Fenja AI Compiler" reveals; two scatter →
// compiler flow lines draw via strokeDashoffset.
// Beat 3 Right page stack composes — back, then middle, then
// front. Each card landing applies a blur to the
// card(s) beneath it ("each layer in front fogs the
// layers below"). Two compiler → stack flow lines
// draw alongside. Front-card <sup> citation markers
// fade in last.
// Beat 4 Trust beat — citation [1] lights up walnut, the
// source PDF icon on the left tints subtly, and a
// faint arc traces from citation back to PDF.
//
// Mirrors initLayers's timeline structure (BEAT = 1.0 second
// intervals, +=500% scroll range, pinType: 'transform').
function initWiki(gsap, ScrollTrigger, scroller, reduceMotion) {
const section = document.getElementById('wiki-deepdive');
if (!section) return;
const anchor = section.querySelector('.wd-anchor');
const zoneScatter = section.querySelector('.wd-zone--scatter');
const zoneCompiler = section.querySelector('.wd-zone--compiler');
const zoneWiki = section.querySelector('.wd-zone--wiki');
const compiler = section.querySelector('.wd-compiler');
const docs = Array.from(section.querySelectorAll('.wd-doc'));
const chevrons = Array.from(section.querySelectorAll('.wd-chevron'));
const stackBack = section.querySelector('.wd-stack-card[data-depth="back"]');
const stackMid = section.querySelector('.wd-stack-card[data-depth="mid"]');
const stackFront = section.querySelector('.wd-stack-card[data-depth="front"]');
const cites = Array.from(section.querySelectorAll('.wd-cite'));
const firstCite = section.querySelector('.wd-cite[data-cite="1"]');
const pairedSource = stackFront && stackFront.querySelector('.wd-stack-source[data-source="1"]');
/* Trust-beat source-tint target in the cluster. The arc that
used to connect them visually was removed; the citation
pulse + source PDF tint + paired source-row highlight
remain as the trust cues. */
const sourceDoc = section.querySelector('.wd-doc[data-doc="pdf"]');
/* Per-icon target opacity. --o is read from inline style so
foreground items fade to 1.0 and background-pile items fade
to their reduced opacity (0.45) the layered cluster look
isn't flattened by the reveal animation. */
const docTargets = docs.map((d) => {
const raw = getComputedStyle(d).getPropertyValue('--o').trim();
const op = raw ? parseFloat(raw) : 1;
return { el: d, opacity: Number.isFinite(op) ? op : 1 };
});
if (!zoneScatter || !zoneCompiler || !zoneWiki) {
console.warn('[platform] wiki-deepdive DOM missing zones; skipping init.');
return;
}
if (reduceMotion) {
// CSS @media handles the unfold; nothing for JS to do.
return;
}
// Blur targets per-stack-position. Each card lands sharp;
// when the next layer arrives, it gains the blur listed here.
// Tuned to the deck's depth language — soft enough not to
// dominate, strong enough to read as "behind glass".
const BLUR_MID = 3; // back card receives this once mid lands
const BLUR_BACK = 7; // back card's blur deepens once front lands
const BLUR_MID_FINAL = 2; // mid card's blur once front lands
// Initial states.
if (anchor) gsap.set(anchor, { opacity: 0, scale: 0.85 });
gsap.set([zoneScatter, zoneCompiler, zoneWiki], { opacity: 0, y: 14 });
if (compiler) gsap.set(compiler, { scale: 0.94, opacity: 0 });
gsap.set(docs, { opacity: 0, y: 10 });
gsap.set(cites, { opacity: 0 });
if (chevrons.length) gsap.set(chevrons, { opacity: 0 });
// Stack: each card starts off-frame (translated right + down)
// and lands into its CSS-defined position via xPercent/yPercent
// delta. CSS owns absolute position; GSAP only moves the
// transform offset so we don't fight the layout.
[stackBack, stackMid, stackFront].forEach((c) => {
if (!c) return;
gsap.set(c, { opacity: 0, xPercent: 40, yPercent: 24, filter: 'blur(0px)' });
});
const BEAT = 1.0;
const tl = gsap.timeline({
scrollTrigger: {
trigger: '#wiki-deepdive',
scroller,
start: 'top top',
end: '+=500%',
pin: '.wd-pin',
pinType: 'transform',
scrub: 0.5,
invalidateOnRefresh: true,
},
});
// Beat 0 — anchor enters scaled up to the centre.
const t0 = 0 * BEAT;
if (anchor) {
tl.to(anchor, {
opacity: 1, scale: 1.6,
duration: 0.22, ease: 'power3.out',
}, t0);
}
// Beat 1 — scatter zone reveals; anchor fades.
const t1 = 1 * BEAT;
if (anchor) {
tl.to(anchor, { opacity: 0.18, scale: 1.4, duration: 0.16, ease: 'power2.in' }, t1);
}
tl.to(zoneScatter, { opacity: 1, y: 0, duration: 0.18, ease: 'power3.out' }, t1 + 0.04);
// Each doc fades to its OWN target opacity (foreground icons
// to 1.0, background-pile icons to their --o value, ≈0.45).
docTargets.forEach((d, i) => {
tl.to(d.el, {
opacity: d.opacity, y: 0,
duration: 0.18, ease: 'power3.out',
}, t1 + 0.08 + i * 0.02);
});
// Beat 2 — compiler reveals; chevrons fade in (one between
// cluster ↔ compiler, one between compiler ↔ stack — they
// act as the directional cue the removed flow curves used to
// carry).
const t2 = 2 * BEAT;
tl.to(zoneCompiler, { opacity: 1, y: 0, duration: 0.18, ease: 'power3.out' }, t2);
if (compiler) {
tl.to(compiler, {
opacity: 1, scale: 1,
duration: 0.20, ease: 'power3.out',
}, t2 + 0.04);
}
if (chevrons.length) {
tl.to(chevrons, {
opacity: 0.55,
duration: 0.18, ease: 'power2.out',
stagger: 0.06,
}, t2 + 0.10);
}
if (anchor) {
tl.to(anchor, { opacity: 0, duration: 0.10 }, t2 + 0.05);
}
// Beat 3 — page stack composes back → middle → front. As
// each card lands, the card(s) behind it pick up blur ("each
// layer in front fogs the layers below"). Two compiler →
// stack flow lines draw alongside; citation markers on the
// front card fade in after the front card has settled.
const t3 = 3 * BEAT;
tl.to(zoneWiki, { opacity: 1, y: 0, duration: 0.18, ease: 'power3.out' }, t3);
// Back card lands first.
if (stackBack) {
tl.to(stackBack, {
opacity: 1, xPercent: 0, yPercent: 0,
duration: 0.22, ease: 'power3.out',
}, t3 + 0.04);
}
// Mid card lands; back card receives its first blur layer.
if (stackMid) {
tl.to(stackMid, {
opacity: 1, xPercent: 0, yPercent: 0,
duration: 0.22, ease: 'power3.out',
}, t3 + 0.24);
}
if (stackBack) {
tl.to(stackBack, {
filter: `blur(${BLUR_MID}px)`,
duration: 0.18, ease: 'power2.out',
}, t3 + 0.26);
}
// Front card lands; mid picks up its blur, back's deepens.
if (stackFront) {
tl.to(stackFront, {
opacity: 1, xPercent: 0, yPercent: 0,
duration: 0.24, ease: 'power3.out',
}, t3 + 0.44);
}
if (stackMid) {
tl.to(stackMid, {
filter: `blur(${BLUR_MID_FINAL}px)`,
duration: 0.18, ease: 'power2.out',
}, t3 + 0.46);
}
if (stackBack) {
tl.to(stackBack, {
filter: `blur(${BLUR_BACK}px)`,
duration: 0.20, ease: 'power2.out',
}, t3 + 0.46);
}
// Citations on the front card fade in last (the curving
// flow lines that previously drew alongside this beat were
// removed in the geometric-language pass — the chevrons in
// the gaps already carry direction).
tl.to(cites, {
opacity: 1,
duration: 0.18, ease: 'power2.out',
stagger: 0.05,
}, t3 + 0.62);
// Beat 4 — trust beat. Citation [1] lights in walnut, the
// source PDF gains its .is-source tint, and a thin arc draws
// back from the citation to the source doc. Subtle by design.
// Class toggles use paired onStart/onReverseComplete so the
// tint retreats cleanly when the user scrolls back up.
const t4 = 4 * BEAT;
if (firstCite) {
tl.to({}, {
duration: 0.001,
onStart: () => firstCite.classList.add('is-lit'),
onReverseComplete: () => firstCite.classList.remove('is-lit'),
}, t4);
tl.fromTo(firstCite,
{ scale: 1 },
{ scale: 1.4, duration: 0.12, ease: 'power2.out', transformOrigin: 'center bottom' },
t4);
tl.to(firstCite, { scale: 1, duration: 0.24, ease: 'power2.inOut' }, t4 + 0.14);
}
if (sourceDoc) {
tl.to({}, {
duration: 0.001,
onStart: () => sourceDoc.classList.add('is-source'),
onReverseComplete: () => sourceDoc.classList.remove('is-source'),
}, t4 + 0.04);
}
// Pair the front card's matching bottom source entry with
// the lit citation — readers see the in-text marker tied to
// its source row at the foot of the page, AND the arc back
// to the document in the cluster.
if (pairedSource) {
tl.to({}, {
duration: 0.001,
onStart: () => pairedSource.classList.add('is-paired'),
onReverseComplete: () => pairedSource.classList.remove('is-paired'),
}, t4 + 0.05);
}
}
// ─── Implementation roadmap — fade-in stagger + card morph
// Mirrors initCards for the reveal. After the reveal, each
// card is click-to-expand — same DOM element morphs into the
// featured panel via a FLIP layout animation (see
// setupRoadmapMorph below). The expanded card's content
// (intro + key activities) is already in the HTML; we just
// toggle the .is-expanded class and animate the layout shift.
function initRoadmap(gsap, ScrollTrigger, scroller, reduceMotion) {
const stages = document.querySelectorAll('#platform-roadmap .rm-card');
const band = document.querySelector('#platform-roadmap .rm-band');
const foot = document.querySelector('#platform-roadmap .rm-foot');
// Click-to-expand always wires up (even under reduced-motion),
// since the expansion is a discrete interaction rather than
// an ambient animation.
setupRoadmapMorph(reduceMotion);
if (!stages.length) return;
if (reduceMotion) {
stages.forEach(c => { c.style.opacity = '1'; });
if (band) band.style.opacity = '1';
if (foot) foot.style.opacity = '1';
return;
}
gsap.set(stages, { opacity: 0, y: 24 });
if (band) gsap.set(band, { opacity: 0, y: 16 });
if (foot) gsap.set(foot, { opacity: 0, y: 12 });
const tl = gsap.timeline({
scrollTrigger: {
trigger: '#platform-roadmap',
scroller,
start: 'top 70%',
once: true,
},
});
tl.to(stages, {
opacity: 1,
y: 0,
duration: 0.6,
ease: 'power3.out',
stagger: 0.08,
clearProps: 'transform',
});
if (band) {
tl.to(band, {
opacity: 1, y: 0,
duration: 0.5, ease: 'power2.out',
}, '-=0.25');
}
if (foot) {
tl.to(foot, {
opacity: 1, y: 0,
duration: 0.4, ease: 'power2.out',
}, '-=0.25');
}
}
// ─── Roadmap card morph (FLIP) ───────────────────────────────
// Replaces the previous backdropped modal. Clicking a card
// toggles .is-expanded on that card and .has-expanded on the
// row; CSS reconfigures the grid (4×1 → 6×2). We capture
// first/last rects of ALL four cards before and after the
// class flip and apply inverse transforms so the layout shift
// animates as a single continuous morph — the same DOM
// element grows into the featured panel while the others
// slide into row 2.
//
// - Esc, the in-card ×, and clicks outside the expanded
// card all collapse it.
// - Clicking a different card while one is open does a
// single sequenced collapse → expand morph (no abrupt
// swap).
// - prefers-reduced-motion: classes flip with no FLIP
// animation; the CSS @media block handles the cross-fade.
let _roadmapMorphWired = false;
function setupRoadmapMorph(reduceMotion) {
if (_roadmapMorphWired) return;
const row = document.querySelector('#platform-roadmap .rm-row');
if (!row) return;
const cards = Array.from(row.querySelectorAll('.rm-card'));
if (!cards.length) return;
const DURATION = 360; // ms — within the 300400 target
const EASE = 'cubic-bezier(0.2, 0, 0, 1)';
let activeCard = null; // currently expanded card, if any
let animating = false; // ignore re-entry during transition
// FLIP helper. Captures first rects, runs `mutate`, captures
// last rects, then transitions inverse transforms back to
// identity. `done` fires after the visual settles.
function flip(mutate, done) {
if (reduceMotion) {
mutate();
if (done) done();
return;
}
const first = cards.map((c) => c.getBoundingClientRect());
mutate();
const last = cards.map((c) => c.getBoundingClientRect());
cards.forEach((c, i) => {
const f = first[i];
const l = last[i];
const dx = f.left - l.left;
const dy = f.top - l.top;
const sx = l.width > 0 ? f.width / l.width : 1;
const sy = l.height > 0 ? f.height / l.height : 1;
c.style.transformOrigin = 'top left';
c.style.transition = 'none';
c.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
});
// Force a synchronous reflow so the inverse transforms
// commit before the transition starts.
void row.offsetHeight;
cards.forEach((c) => {
c.style.transition = `transform ${DURATION}ms ${EASE}`;
c.style.transform = '';
});
// Clean up at the end; fallback timeout in case transitionend
// gets dropped (browser quirk on hidden tabs etc.).
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
cards.forEach((c) => {
c.style.transition = '';
c.style.transform = '';
c.style.transformOrigin = '';
});
if (done) done();
}
const fallback = setTimeout(cleanup, DURATION + 80);
cards[0].addEventListener('transitionend', function once(e) {
if (e.propertyName !== 'transform') return;
cards[0].removeEventListener('transitionend', once);
clearTimeout(fallback);
cleanup();
});
}
function expand(card) {
if (animating || card === activeCard) return;
animating = true;
flip(() => {
row.classList.add('has-expanded');
card.classList.add('is-expanded');
card.setAttribute('aria-expanded', 'true');
const body = card.querySelector('.rm-card-body');
if (body) body.setAttribute('aria-hidden', 'false');
activeCard = card;
}, () => {
animating = false;
// Focus the close button so keyboard users can dismiss
// immediately with Enter.
const closeBtn = card.querySelector('.rm-card-close');
if (closeBtn) closeBtn.focus();
});
}
function collapse(thenExpand) {
if (animating || !activeCard) {
if (thenExpand) thenExpand();
return;
}
animating = true;
const card = activeCard;
flip(() => {
card.classList.remove('is-expanded');
card.setAttribute('aria-expanded', 'false');
const body = card.querySelector('.rm-card-body');
if (body) body.setAttribute('aria-hidden', 'true');
row.classList.remove('has-expanded');
activeCard = null;
}, () => {
animating = false;
// Return focus to the card so keyboard nav doesn't lose
// its place.
if (card && typeof card.focus === 'function') card.focus();
if (thenExpand) thenExpand();
});
}
function onCardActivate(card) {
if (animating) return;
if (activeCard === card) {
// Re-clicking the expanded card collapses it.
collapse();
return;
}
if (activeCard) {
// Sequenced collapse → expand for a smooth swap.
const next = card;
collapse(() => requestAnimationFrame(() => expand(next)));
} else {
expand(card);
}
}
cards.forEach((card) => {
card.addEventListener('click', (e) => {
// Close button inside the card has its own handler below;
// ignore here so the card click doesn't re-expand.
if (e.target.closest('.rm-card-close')) return;
onCardActivate(card);
});
card.addEventListener('keydown', (e) => {
if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('.rm-card-close')) {
e.preventDefault();
onCardActivate(card);
}
});
const closeBtn = card.querySelector('.rm-card-close');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
collapse();
});
}
});
// Esc closes from anywhere.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && activeCard) {
e.stopPropagation();
collapse();
}
});
// Outside click closes — anywhere that's not inside the
// currently expanded card.
document.addEventListener('click', (e) => {
if (!activeCard) return;
if (e.target.closest('.rm-card.is-expanded')) return;
// Re-activations of OTHER cards are handled in their own
// click listeners above; this catches clicks elsewhere.
if (e.target.closest('.rm-card')) return;
collapse();
});
_roadmapMorphWired = true;
}
// ─── Public scrollTo (used when the dot is re-clicked while
@ -287,32 +806,43 @@
}
// ─── Lazy auto-init on page activation ───────────────────────
// We can't piggyback on bifrost's init (different page) and the
// dot-nav handler in timeline.js doesn't know about us. Instead,
// observe #page-product-deepdive for the `is-active` class flip
// that activatePage() applies — then init a beat later so the
// browser has applied layout.
// Two cases:
// A. Integrated into the Overview: wait for #page-overview to be
// active (bifrost.js's init has run), then attach scene
// triggers without re-creating Lenis.
// B. Standalone deepdive: wait for #page-product-deepdive to be
// active and own the full setup.
function tryInit() {
if (initialized) return;
const page = document.getElementById('page-product-deepdive');
if (!page || !page.classList.contains('is-active')) return;
const overviewActive = !!document.querySelector('#page-overview.is-active');
const deepdiveActive = !!document.querySelector('#page-product-deepdive.is-active');
if (!overviewActive && !deepdiveActive) return;
if (typeof window.gsap === 'undefined' ||
typeof window.ScrollTrigger === 'undefined' ||
typeof window.Lenis === 'undefined') return;
setTimeout(init, 60);
// Small delay: in the integrated path this lets bifrost.js finish
// wiring scrollerProxy + Lenis before we register triggers. In the
// standalone path it just lets layout settle, same as before.
setTimeout(init, overviewActive ? 140 : 60);
}
function attachObserver() {
const page = document.getElementById('page-product-deepdive');
if (!page) return;
new MutationObserver(tryInit).observe(page, {
const pages = [
document.getElementById('page-overview'),
document.getElementById('page-product-deepdive'),
].filter(Boolean);
if (!pages.length) return;
const observer = new MutationObserver(tryInit);
pages.forEach((p) => observer.observe(p, {
attributes: true,
attributeFilter: ['class'],
});
}));
tryInit();
}
window.__deepdive = { init, scrollTo };
window.__platform = { init, scrollTo };
// Backwards-compat: older code referred to `window.__deepdive`.
window.__deepdive = window.__platform;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', attachObserver, { once: true });

View file

@ -379,8 +379,7 @@ function buildGlobe(wrap, opts) {
*
* @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"
* "hero", "bifrost", "bifrost-meaning"
*/
function activatePage(targetId, scrollToId) {
document.querySelectorAll('.page').forEach(p => {

View file

@ -181,6 +181,30 @@
opacity: 0.85;
}
/* "Part of BioInnovation Institute AI Lab" — sits directly below
the welcome-backer line. Same horizontal anchor (left:75%) and
transform offset, just nudged down so the two lines stack with
consistent breathing room. Fades in with the welcome step. */
.welcome-bii {
position: fixed;
top: calc(50% + 112px);
left: 75%;
transform: translate(calc(-38% - 5px), 0);
font-family: "Manrope", system-ui, -apple-system, sans-serif;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-soft);
opacity: 0;
pointer-events: none;
z-index: 5;
transition: opacity 640ms var(--ease) 200ms;
}
body:has(#step-welcome.is-active) .welcome-bii {
opacity: 0.85;
}
/* ───── Welcome ───── */
.welcome-title {
font-family: "Newsreader", Georgia, "Times New Roman", serif;
@ -322,6 +346,7 @@
.welcome-body { font-size: 18px; }
.welcome-logo { display: none; }
.welcome-backer { display: none; }
.welcome-bii { display: none; }
}
</style>
</head>
@ -342,6 +367,9 @@
</g>
</svg>
</div>
<div class="welcome-bii" aria-hidden="true">
Part of BioInnovation Institute AI Lab
</div>
<main class="entrance">
<div class="entrance-inner">
@ -349,7 +377,7 @@
<!-- STEP 1 — EMAIL -->
<section class="step is-active" id="step-email">
<p class="tagline">
Thank you for your commitment and willingness to contribute.
An introduction to Fenja AI.
</p>
<form class="field" id="email-form" novalidate>
<input
@ -369,19 +397,19 @@
<!-- STEP 2 — WELCOME -->
<!-- The title is set dynamically by entrance.js:
with first name: "Thanks for your interest, <em>Erik.</em>"
without first name: "Thank you for your <em>interest.</em>"
with first name: "Welcome, <em>Erik.</em>"
without first name: "<em>Welcome.</em>"
Static fallback text (for no-JS) renders the anonymous variant. -->
<section class="step" id="step-welcome">
<h1 class="welcome-title" id="welcome-title">
Thank you for your <em>interest.</em>
<em>Welcome.</em>
</h1>
<p class="welcome-body">
This is a personal invitation because we believe your perspective
can make a meaningful contribution to an important mission: building
trusted, sovereign AI for Denmark and Europe. In this short web
experience, we will explain why this matters, what Fenja AI is, and
how you, through Project Bifrost, can help shape its future.
In this short walkthrough, we want to introduce Fenja AI &mdash; the
company and the platform &mdash; and the initiative around it,
Project Bifrost. The aim is straightforward: to show why trusted,
sovereign AI matters now, what Fenja is, and how it is being built
in Denmark and Europe.
</p>
<div class="welcome-define">
<h3 class="welcome-term"><em>Fenja AI</em></h3>
@ -389,7 +417,7 @@
</div>
<div class="welcome-define">
<h3 class="welcome-term"><em>Project Bifrost</em></h3>
<p class="welcome-def">The initiative created to ensure that Fenja AI is built not just for organisations like yours, but <em>with</em> you.</p>
<p class="welcome-def">The initiative shaping Fenja AI together with a select group of Danish and European organisations.</p>
</div>
<button type="button" class="welcome-cta" id="welcome-continue">
<svg class="c-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"

View file

@ -71,18 +71,16 @@ function showStep(name) {
function setWelcomeTitle(firstName) {
const el = document.getElementById('welcome-title');
if (!el) return;
// Reset before rebuilding so re-renders don't append stale nodes.
el.textContent = '';
if (firstName) {
// Keep DOM construction to textContent + appended <em> — no innerHTML
// of unsanitised input. firstName came from the server but we still
// construct the node tree explicitly for clarity.
el.textContent = 'Thank you for your interest, ';
el.textContent = 'Welcome, ';
const em = document.createElement('em');
em.textContent = firstName + '.';
el.appendChild(em);
} else {
el.textContent = 'Thank you for your ';
const em = document.createElement('em');
em.textContent = 'interest.';
em.textContent = 'Welcome.';
el.appendChild(em);
}
}