From fb815768e2f1d1164ef35882dc0c04f402d9df02 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Wed, 20 May 2026 12:56:30 +0200 Subject: [PATCH] customer-presentation: convert deck from Bifrost invitation to customer-facing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- protected/_archive/stack-scene.html | 480 ++++++++++++ protected/bifrost.js | 601 ++------------- protected/index.html | 1053 +++++++++++++++++++++------ protected/platform.css | 982 +++++++++++++++++++++++++ protected/platform.js | 610 +++++++++++++++- protected/timeline.js | 3 +- public/entrance.html | 48 +- public/entrance.js | 10 +- 8 files changed, 2958 insertions(+), 829 deletions(-) create mode 100644 protected/_archive/stack-scene.html diff --git a/protected/_archive/stack-scene.html b/protected/_archive/stack-scene.html new file mode 100644 index 0000000..6bb00f2 --- /dev/null +++ b/protected/_archive/stack-scene.html @@ -0,0 +1,480 @@ + + + + +Archive — stack-scene + words-scene (removed 2026-05-19) + + + + +

Archive — 4-capabilities (#stack-scene) + "This is why we've invited you" (#words-scene)

+ +

These two sections were removed from protected/index.html 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.

+ +

To restore

+
    +
  1. Copy the <template id="stack-scene-html"> and + <template id="words-scene-html"> contents back into + #overview-scroll in protected/index.html, between + #hero and #bifrost (in that order).
  2. +
  3. Copy the JS in <script type="text/x-archived-js" id="stack-words-js"> + back into protected/bifrost.js, right after the HERO fade-in (the + gsap.to('.hero-wrap', { opacity: 1, ... }) block).
  4. +
  5. In bifrost.js, add 'stack-scene' and 'words-scene' + back into sceneOrder (between 'hero' and 'bifrost'), + add their entries to sceneToDot, and add 'words-scene' back to + the sceneIds array inside collectStickyTargets().
  6. +
  7. Restore the "Capabilities" dot-nav button in protected/index.html: +
    <button class="dot-btn" data-target="page-overview" data-scroll-to="stack-scene">
    +  <span class="dot"></span>
    +  <span class="label">Capabilities</span>
    +</button>
  8. +
+ +

HTML — #stack-scene (Scene 2)

+ + +

HTML — #words-scene (Scene 3)

+ + +

JS — bifrost.js timelines (stack-scene + words-scene)

+ + + + diff --git a/protected/bifrost.js b/protected/bifrost.js index e55b543..70f732b 100644 --- a/protected/bifrost.js +++ b/protected/bifrost.js @@ -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, Erik. - // To ensure Fenja AI is not just built for you — but - // with you." - // No name: "This is why we've invited you. To ensure - // Fenja AI is not just built for you — but - // with you." - // - // 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) { diff --git a/protected/index.html b/protected/index.html index aba5f32..b6a7a93 100644 --- a/protected/index.html +++ b/protected/index.html @@ -5,6 +5,7 @@ A Catalog of Sovereignty — 2022–2026 + @@ -1051,6 +1052,23 @@ html { } .support svg { height: 24px; width: auto; } + /* Two-line backer block: the Innovationsfonden lockup above, the + "Part of BioInnovation Institute AI Lab" line directly below. + Mirrors the entrance page's .welcome-backer + .welcome-bii pair. */ + .support-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + .support-bii { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--ink-soft, var(--ink-mute)); + } + .scroll-hint { display: inline-flex; align-items: center; @@ -2443,7 +2461,7 @@ html {
When AI runs Europe, who runs the AI?
We’ve spent years building data and AI across Denmark and Europe, watching one dependency harden after another. AI is different. The United States has made that clear. China has made that clear. You cannot stand strong in this century on AI you do not control — and for the first time in a generation, Europe has both the reason and the moment to build its own. The window is closing faster than most realise. It is open now. It will not be open long.

- As AI moves into our hospitals, our courts, our defence, our schools — can we afford for the switch to sit in Washington? + As AI moves into our hospitals, our laboratories, our boardrooms, our regulated workflows — into the data and the intellectual property our organisations have spent decades protecting — can we afford for the switch to sit in Washington?
@@ -2521,15 +2539,18 @@ html { .hero-foot. The scroll arrow points DOWN and gently bounces downward (see @keyframes hint below). -->
-
- Backed by - - +
+
+ Backed by + + +
+
Part of BioInnovation Institute AI Lab
- -
-
- - -
- - -
-
- One complete platform -

Everything you need in one place.

-

Fenja AI brings models, knowledge, tools, and agents together in one platform for using and scaling AI across your organisation.

-
-
- Full control -

Your infrastructure.
Your rules.

-

Fenja AI is installed in your own client-managed environment, giving you full control over data, security, and governance.

-
-
- Sovereignty -

Built in Denmark.
Ready for Europe.

-

Fenja AI is built in Denmark for European organisations that want trusted, sovereign AI on their own terms.

-
-
- - -
- The AI -
- - -
-

An open-source model, running on your own hardware.

-

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.

-
- -
-
- -
- The Knowledge -
- - -
-

The business context that makes AI understand your world.

-

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.

-
- -
-
- -
- The Tools -
- - -
-

How AI acts — not just what it knows.

-

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.

-
- -
-
- -
- The Agents -
- - -
-

Specialized AI agents working together around real tasks.

-

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 — through specialisation and coordination.

-
- -
-
- -
-
-
- - - -
-

- This is why we've invited you. To ensure Fenja AI is not just built for you, but with you. -

-
- - -
-
- @@ -2859,85 +2736,794 @@ html { -
- -
- - -
-
Ready?
-

- Join us in shaping the future of trusted sovereign AI. +
+
+

+ Renting a few AI capabilities from American companies isn't enough.
+ Installing an open-source language model isn't enough.

- -

Built in Denmark. Supported by the Innovation Fund.

+

+ You need a platform you control — with the + tools, the knowledge, and the framework to make AI + actually do the work your organization needs done. +

+
- - - - - -

+

+ Setup is bounded · waves of use cases continue as your needs evolve. +

+
+ scroller.scrollTo() as a fallback. The + sceneOrder / sceneToDot maps in bifrost.js + must include every scrollable scene so the + scroll-spy highlights the right dot as the + user moves through. -->
@@ -2982,6 +3576,7 @@ html { + diff --git a/protected/platform.css b/protected/platform.css index ed149fd..f16cfb2 100644 --- a/protected/platform.css +++ b/protected/platform.css @@ -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,"); + 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 2–5 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 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 55–60vh 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,"); + 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; + } } diff --git a/protected/platform.js b/protected/platform.js index b169a6b..2ac70eb 100644 --- a/protected/platform.js +++ b/protected/platform.js @@ -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 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 300–400 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 }); diff --git a/protected/timeline.js b/protected/timeline.js index cda007c..d8d3c27 100644 --- a/protected/timeline.js +++ b/protected/timeline.js @@ -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 => { diff --git a/public/entrance.html b/public/entrance.html index 6e0f711..549d09d 100644 --- a/public/entrance.html +++ b/public/entrance.html @@ -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; } } @@ -342,6 +367,9 @@
+
@@ -349,7 +377,7 @@

- Thank you for your commitment and willingness to contribute. + An introduction to Fenja AI.

- Thank you for your interest. + Welcome.

- 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 — the + company and the platform — 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.

Fenja AI

@@ -389,7 +417,7 @@

Project Bifrost

-

The initiative created to ensure that Fenja AI is built not just for organisations like yours, but with you.

+

The initiative shaping Fenja AI together with a select group of Danish and European organisations.