timeline + overview: pacing, labeling, and viewing guidance

Timeline page:
- .page-sub: final sentence wrapped in .page-sub-accent and styled
  crimson so the rhetorical beat lifts off the block.
- Scroll-to-begin hint is now toggled from applyScroll() against
  state.target, so it dismisses on first commit AND re-appears when
  the reader scrolls all the way back to the start. onWheel no longer
  hard-adds .hint-dismissed; the applyScroll toggle drives both ways.

Overview page:
- Hero scroll icon: size and presence bumped (2px line, 44px tall, 11px
  chevron, weight 600, color:var(--ink)) so it reads as a confident
  cue, not a whisper at the bottom of the hero.
- Architecture scene gains a title bar pinned with the scene: "The
  Fenja AI platform in four steps" with a 1/4 → 4/4 counter driven by
  the scroll-trigger onUpdate. Bar is placed below the site-mark's
  fixed position so the two don't collide.
- Dot-nav: dot size 5px → 10px (1.5px ring) for better click target +
  visual weight. Buttons for "Words" and "Participate" removed — the
  corresponding intermediate sections now map to their nearest
  surviving dot in bifrost.js's scroll-spy (words-scene →
  stack-scene, bifrost-meaning → bifrost).
- Renames: "Hero" → "Fenja introduction", "Architecture" →
  "Capabilities", "Bifrost" → "Project Bifrost".
- scrollTo() adds a per-scene SCENE_ANCHOR_OFFSET — stack-scene lands
  +2100px into its 5000px pin so the reader arrives on the fully
  stacked state instead of an empty pre-animation frame.

Welcome step (public/entrance.html):
- New .welcome-note callout between definitions and CTA advising
  desktop viewing and gentle scrolling so readers don't fly past
  animated sections before they've resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arlind Ukshini 2026-04-24 09:45:24 +02:00
parent e10ee31beb
commit 9b2c166b6c
4 changed files with 198 additions and 36 deletions

View file

@ -192,6 +192,18 @@
'hero', 'stack-scene', 'words-scene',
'bifrost', 'bifrost-meaning', 'bifrost-join',
];
// 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.
const sceneToDot = {
'hero': 'hero',
'stack-scene': 'stack-scene',
'words-scene': 'stack-scene',
'bifrost': 'bifrost',
'bifrost-meaning': 'bifrost',
'bifrost-join': 'bifrost-join',
};
let lastActiveScene = null;
function updateActiveSceneDot() {
if (typeof window.__setActiveDot !== 'function') return;
@ -212,7 +224,7 @@
}
if (visibleId !== lastActiveScene) {
lastActiveScene = visibleId;
window.__setActiveDot('page-overview', visibleId);
window.__setActiveDot('page-overview', sceneToDot[visibleId] || visibleId);
}
}
lenis.on('scroll', updateActiveSceneDot);
@ -451,6 +463,11 @@
// to yPercent, so centering is preserved).
copyLayers.forEach(el => gsap.set(el, { yPercent: -50, opacity: 0, y: 20 }));
// Counter element in the stack-scene title bar. Updated on scroll to
// tick 1/4 → 4/4 as each card lands. Read inside the ScrollTrigger
// onUpdate below. Null-safe so missing markup doesn't break the scene.
const stackCounterEl = document.querySelector('#stack-counter .sc-current');
const stackTl = gsap.timeline({
scrollTrigger: {
trigger: '#stack-scene',
@ -461,6 +478,22 @@
pinSpacing: true,
anticipatePin: 1,
invalidateOnRefresh: true,
// Drive the "N/4" counter in the title bar. Each card in Phase A
// occupies ~0.105 of the timeline (see cards.forEach below), so
// the Nth card is fully landed at roughly N * 0.105. We show the
// *current* card — i.e. the highest-index card that has started
// its fall. After Phase A completes (~0.42) we hold on 4/4.
onUpdate(self) {
if (!stackCounterEl) return;
const p = self.progress;
// Landing midpoints: card i (0-indexed) finishes at roughly
// i * 0.105 + 0.1. Use floor((p - 0.01) / 0.105) + 1 so the
// tick advances slightly after each card starts, then clamp.
const n = Math.min(4, Math.max(1, Math.floor(p / 0.105) + 1));
if (stackCounterEl.textContent !== String(n)) {
stackCounterEl.textContent = String(n);
}
},
}
});
@ -1199,6 +1232,25 @@
* see sceneOrder[] inside init().
* Special value "hero" scrolls to top (0).
*/
// Per-scene scroll offsets in pixels. Added to the scene's offsetTop
// when a dot-nav button anchors to it, so the reader lands AFTER the
// scene's initial reveal rather than at an empty frame where the
// scrub hasn't advanced yet.
//
// stack-scene — the pin is 5000px long; Phase A (cards falling in)
// completes at ~0.42 of that (~2100px). Landing at +2100 puts
// the reader on the fully stacked state, just before the grid
// rearrange begins in Phase B.
//
// hero, bifrost, bifrost-join — short reveal tweens; offsetTop is
// already the correct landing spot so offset is 0.
const SCENE_ANCHOR_OFFSET = {
'hero': 0,
'stack-scene': 2100,
'bifrost': 0,
'bifrost-join': 0,
};
function scrollTo(sceneId) {
if (!scrollerEl) return; // init() hasn't run yet — ignore
const target = document.getElementById(sceneId);
@ -1207,7 +1259,8 @@
// "hero" is the first scene and sits at scrollTop 0. Scrolling to
// the scene element directly works in most cases but produces a tiny
// non-zero offset (padding / border) — hard-code 0 for hero.
const scrollY = sceneId === 'hero' ? 0 : target.offsetTop;
const base = sceneId === 'hero' ? 0 : target.offsetTop;
const scrollY = base + (SCENE_ANCHOR_OFFSET[sceneId] || 0);
if (lenisInstance && typeof lenisInstance.scrollTo === 'function') {
// Lenis does the smooth animation. `immediate: false` uses the

View file

@ -140,6 +140,12 @@
transition: opacity 520ms var(--ease), transform 520ms var(--ease);
}
.page-sub em { font-style: italic; font-weight: 700; color: var(--ink); }
/* Final sentence of the front-matter is pulled up in crimson so the
rhetorical question reads as the emphatic beat of the block. Applied
to the whole sentence, not just the <em> on "Washington?", so the
colour change cues the reader's eye before they reach the italic. */
.page-sub-accent,
.page-sub-accent em { color: var(--crimson); }
/* Once the timeline has been advanced, the front matter steps aside */
.page-timeline.is-scrolled .page-title,
.page-timeline.is-scrolled .page-sub {
@ -252,19 +258,19 @@
display: flex; align-items: center; justify-content: center;
}
.dot-btn .dot {
width: 5px; height: 5px;
width: 10px; height: 10px;
border-radius: 50%;
background: transparent;
box-shadow: inset 0 0 0 1px var(--ink-dim); /* outlined ring, default */
box-shadow: inset 0 0 0 1.5px var(--ink-dim); /* outlined ring, default */
transition: background var(--dur) var(--ease),
box-shadow var(--dur) var(--ease);
}
.dot-btn:hover .dot {
box-shadow: inset 0 0 0 1px var(--ink);
box-shadow: inset 0 0 0 1.5px var(--ink);
}
.dot-btn.is-active .dot {
background: var(--ink); /* filled ink, active */
box-shadow: inset 0 0 0 1px var(--ink);
box-shadow: inset 0 0 0 1.5px var(--ink);
}
/* Label tooltip — rises above the dot on hover or keyboard focus. */
@ -1002,30 +1008,39 @@ html {
.scroll-hint {
display: inline-flex;
align-items: center;
gap: 0.6rem;
gap: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.22em;
font-size: var(--step-sm);
color: var(--ink-soft);
/* Bumped from --step-sm for legibility. The hint is the primary
"what next?" cue at the bottom of the hero and needs to read
confidently, not whisper. */
font-size: calc(var(--step-sm) * 1.15);
font-weight: 600;
/* Switched from --ink-soft to --ink so the icon reads against the
paper background. The hint animation still breathes opacity so
it doesn't shout. */
color: var(--ink);
}
/* Arrow reoriented to point DOWN — a vertical 1px line with a chevron
/* Arrow reoriented to point DOWN — a vertical line with a chevron
cap at the bottom. Animation moved from translateX to translateY so
the hint visually "drops" downward, matching its meaning. */
the hint visually "drops" downward, matching its meaning. Weight
and length both bumped (1px → 2px, 28px → 44px) so the icon has
more visual presence; chevron arms thickened and enlarged to match. */
.scroll-hint .arrow {
width: 1px; height: 28px; background: currentColor;
width: 2px; height: 44px; background: currentColor;
position: relative;
animation: hint 2.2s ease-in-out infinite;
}
.scroll-hint .arrow::after {
content: ""; position: absolute; bottom: -1px; left: -3px;
width: 7px; height: 7px;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
content: ""; position: absolute; bottom: -1px; left: -5px;
width: 11px; height: 11px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
}
@keyframes hint {
0%, 100% { transform: translateY(0); opacity: 0.5; }
50% { transform: translateY(6px); opacity: 1; }
0%, 100% { transform: translateY(0); opacity: 0.75; }
50% { transform: translateY(8px); opacity: 1; }
}
/* ============================================================
@ -1055,6 +1070,55 @@ html {
max-width: none;
}
/* ───────── Stack scene title bar ─────────
Sits above the card theatre and rides with the pin so it remains
visible through all four card landings and the grid rearrange. The
.sc-current token is updated from bifrost.js's ScrollTrigger onUpdate
— 1/4 → 4/4 — one tick per landing card.
Vertical offset clears the fixed .site-mark (top:28px, width:118px)
so the title sits below the wordmark instead of crashing into it.
Horizontal padding on the left is bumped to push past the wordmark's
right edge; the right side uses the same edge token as the theatre. */
.stack-title-bar {
position: absolute;
top: clamp(3.75rem, 7vh, 5.25rem);
left: 0;
right: 0;
z-index: 20;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1.5rem;
padding-left: clamp(10.5rem, 12vw, 12.5rem);
padding-right: clamp(0.75rem, 2vw, 2.25rem);
pointer-events: none;
}
.stack-title {
margin: 0;
font-family: "Newsreader", Georgia, serif;
font-weight: 400;
font-size: clamp(1.4rem, 2.6vw, 2.2rem);
letter-spacing: -0.01em;
line-height: 1.2;
color: var(--ink);
}
.stack-counter {
font-family: "Newsreader", Georgia, serif;
font-weight: 500;
font-size: clamp(1.1rem, 2vw, 1.6rem);
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.stack-counter .sc-current {
color: var(--accent);
font-weight: 600;
transition: color 180ms var(--ease);
}
.stack-counter .sc-sep,
.stack-counter .sc-total { color: var(--ink-soft); }
/* LEFT COPY (visible only during grid phase) */
.copy-stage {
position: absolute;
@ -2321,7 +2385,7 @@ html {
<div class="page-title">When AI runs Europe, who runs the <em>AI?</em></div>
<div class="page-sub">
We&rsquo;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 &mdash; 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.<br/><br/>
As AI moves into our hospitals, our courts, our defence, our schools &mdash; can we afford for the switch to sit in <em>Washington?</em>
<span class="page-sub-accent">As AI moves into our hospitals, our courts, our defence, our schools &mdash; can we afford for the switch to sit in <em>Washington?</em></span>
</div>
<!-- Globe ghost -->
@ -2420,6 +2484,15 @@ html {
============================================================ -->
<section id="stack-scene" aria-label="The Fenja AI architecture">
<div class="stack-pin">
<!-- Title bar: rides along with the pin so it stays visible while
the reader scrolls through all 4 capability cards. The counter
ticks 1/4 → 4/4 as each layer lands (driven from bifrost.js). -->
<div class="stack-title-bar" aria-hidden="true">
<h2 class="stack-title">The Fenja AI platform in four steps</h2>
<span class="stack-counter" id="stack-counter">
<span class="sc-current">1</span><span class="sc-sep">/</span><span class="sc-total">4</span>
</span>
</div>
<div class="layer-theatre">
<!-- LEFT SIDE — explanatory copy, visible only during the grid phase. -->
@ -2807,23 +2880,15 @@ html {
</button>
<button class="dot-btn" data-target="page-overview" data-scroll-to="hero">
<span class="dot"></span>
<span class="label">Hero</span>
<span class="label">Fenja introduction</span>
</button>
<button class="dot-btn" data-target="page-overview" data-scroll-to="stack-scene">
<span class="dot"></span>
<span class="label">Architecture</span>
</button>
<button class="dot-btn" data-target="page-overview" data-scroll-to="words-scene">
<span class="dot"></span>
<span class="label">Words</span>
<span class="label">Capabilities</span>
</button>
<button class="dot-btn" data-target="page-overview" data-scroll-to="bifrost">
<span class="dot"></span>
<span class="label">Bifrost</span>
</button>
<button class="dot-btn" data-target="page-overview" data-scroll-to="bifrost-meaning">
<span class="dot"></span>
<span class="label">Participate</span>
<span class="label">Project Bifrost</span>
</button>
<button class="dot-btn" data-target="page-overview" data-scroll-to="bifrost-join">
<span class="dot"></span>

View file

@ -137,12 +137,9 @@ function onWheel(e) {
state.target = clamp(state.target + dy * 1.1, 0, state.max);
e.preventDefault();
kick();
// Dismiss the scroll hint on the very first wheel tick — instant fade.
// A separate class from `.is-scrolled` (which keys off 40px of travel
// and controls the front-matter) so the hint goes the moment the
// reader commits, not after inertia catches up.
document.getElementById('page-timeline')?.classList.add('hint-dismissed');
// The hint class is toggled in applyScroll() based on state.target, so
// it dismisses on the first wheel tick (target jumps instantly) AND
// re-appears if the reader scrolls all the way back to the start.
}
function kick() {
@ -179,6 +176,12 @@ function applyScroll() {
const pageEl = document.getElementById('page-timeline');
pageEl.classList.toggle('is-scrolled', state.current > 40);
// Scroll-to-begin hint: tied to state.target (not state.current) so
// it leaves the moment the reader commits to a wheel tick, and comes
// back if they scroll all the way back to the start. A small threshold
// keeps it from flickering on tiny trackpad noise.
pageEl.classList.toggle('hint-dismissed', state.target > 10);
// Progress across the entire catalog (0 → 1)
const p = state.max > 0 ? state.current / state.max : 0;

View file

@ -211,6 +211,39 @@
color: var(--ink);
}
/* Practical viewing-experience note. Lives between the definitions
and the CTA so readers see it before committing to "Learn more".
Left-bordered callout in the editorial voice — same Newsreader as
the surrounding copy, tempered colour so it reads as guidance not
body text. */
.welcome-note {
max-width: 620px;
margin: 22px 0 8px 0;
padding: 14px 18px;
border-left: 3px solid var(--crimson);
background: rgba(138, 58, 47, 0.04);
font-family: "Newsreader", Georgia, "Times New Roman", serif;
font-weight: 400;
font-size: 17px;
line-height: 1.5;
color: var(--ink);
}
.welcome-note strong {
font-weight: 600;
color: var(--ink);
}
.welcome-note em {
font-style: italic;
font-weight: 700;
color: var(--crimson);
}
.welcome-note p {
margin: 0;
}
.welcome-note p + p {
margin-top: 8px;
}
.welcome-cta {
all: unset;
display: inline-flex;
@ -345,6 +378,14 @@
<h3 class="welcome-term"><em>Project Bifrost</em></h3>
<p class="welcome-def">The initiative created to ensure that Fenja AI is built not just for organisations like yours, but <em>with</em> you.</p>
</div>
<aside class="welcome-note" aria-label="Viewing recommendation">
<p>
This site is a <strong>desktop experience</strong>. It showcases many of the capabilities of our products, and is therefore not optimised for mobile — we recommend viewing it on a <em>desktop screen</em>.
</p>
<p>
Be mindful of the scrolling experience: <em>scroll gently</em> so you don&rsquo;t accidentally pass over a section before it finishes animating.
</p>
</aside>
<button type="button" class="welcome-cta" id="welcome-continue">
<svg class="c-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.3"