add welcome dot, card shrunk, topo.paralx., sticky scroll

This commit is contained in:
Arlind Ukshini 2026-04-23 12:06:07 +02:00
parent f4cdbf4fd4
commit af35862749
4 changed files with 411 additions and 43 deletions

139
CHANGES 2.md Normal file
View file

@ -0,0 +1,139 @@
# Site update v3 — Welcome dot, smaller cards, parallax topography, sticky scroll, hero polish
Six changes land together. All in `protected/`. Server-side untouched.
## Files to replace
| File | What changed |
|---|---|
| `protected/index.html` | Welcome dot added; card sizes reduced 15%; topography layer added (markup + CSS); hero padding reduced; hero-foot restructured (moved into left column, arrow points down) |
| `protected/timeline.js` | Dot-nav click handler now supports `external-*` targets (Welcome routes back to `/`); `setActiveDot` skips external dots |
| `protected/bifrost.js` | `cellSize` and `targetW` adjusted for the shrunk grid; topography SVG generator + parallax driver added; sticky-scroll velocity damping added for non-pinned scenes |
## Change-by-change
### 1. Welcome dot (nav position 1)
- 8th dot, placed FIRST in the dot-nav (before Timeline).
- On click, navigates to `/` — the entrance page. Since the user's session is still valid, they land on the welcome step with the "Learn more" button.
- **Never shows as active** — even when scrolling `/timeline`, the Welcome dot stays in the default outlined state. It's a navigation link, not a section marker.
- HTML: `<button class="dot-btn" data-target="external-welcome" data-href="/">`
- JS: `timeline.js`'s click handler recognises `external-*` prefixes and uses `window.location.href` instead of the page-switching flow.
### 2. Cards shrunk 15%
Applies to both animation phases:
**Drop phase (`.layer-card` + `.card-box`):**
- Cards previously spanned `left: 0 / right: 0`. Now have `7.5%` margin on each side (= 15% total width reduction).
- `.card-box` padding reduced from `clamp(1.75rem, 3.2vw, 2.8rem)``clamp(1.5rem, 2.7vw, 2.4rem)`.
- `min-height` reduced from 240px → 204px.
- Content gap slightly tightened.
**Grid phase (`.in-grid .card-box`):**
- `width: 20vw``width: 17vw`. Matching `max-width` and inner padding also reduced.
**bifrost.js (the two places that compute grid geometry):**
- `cellSize = vw * 0.20``vw * 0.17` (line ~298)
- `targetW = vw * 0.20``vw * 0.17` (line ~423)
### 3. Parallax topography layer
- New `<div id="overview-topography">` sits inside `#page-overview`, at `z-index: 0` — behind the Europe map (z-index 1) and all content (z-index 2+).
- At init, `bifrost.js` draws 34 concentric rings (each with multi-harmonic sinusoidal distortion) into the SVG. Path coordinates match the entrance page's "currents" generator — intentionally a visual sibling — but with altered parameters:
- **34 rings** (vs 26 on entrance), tighter **28px step**, larger **32px amplitude**
- Positioned with **bottom-left anchor** (entrance uses top-right)
- **Rotated 40°** via CSS so the harmonic pattern reads as distinct from the entrance
- **Lower opacity** (0.04 / 0.07) — it sits behind the map, shouldn't compete
- Parallax: driven from `lenis.on('scroll')`, translating the SVG by `scrollTop * -0.15` pixels (very slow — the layer barely moves, feels like atmospheric depth).
- The CSS `rotate(40deg)` is composed WITH the JS `translateY()` in a single transform string so neither clobbers the other.
### 4. Sticky scroll (non-pinned scenes only)
- Velocity-damping hook on `lenis.on('scroll')`. When a non-pinned scene's vertical midpoint is within ±20% of viewport height from the viewport center, damping engages.
- While engaged: multiplies Lenis's `velocity` by 0.82 per frame. The scene appears to "hold" the scroll briefly.
- **Hard cap: 300ms per scene entry.** No scene can hold the user for longer than that — even if they stop scrolling in the zone, the damping window expires.
- Re-armed when scene leaves the zone. Each entry gets a fresh 300ms window.
- **Excluded scenes:** `stack-scene` (S2) is already pinned + scrubbed by GSAP ScrollTrigger; damping on top would make its card-fall feel sluggish. Included: `hero`, `words-scene`, `bifrost`, `bifrost-meaning`, `bifrost-join`.
- Implementation is in `bifrost.js` init, as part of the Lenis scroll subscribers.
### 5. Hero text moved up
- `#hero` padding-top: `clamp(7rem, 14vh, 11rem)``clamp(3.5rem, 7vh, 5.5rem)`.
- `#page-overview #hero .hero-wrap` padding-top: `clamp(6rem, 16vh, 12rem)``clamp(3rem, 8vh, 6rem)`.
- The overview-specific rule is the more-specific selector — it's the one that actually wins on the Overview page. Both updated so a future refactor doesn't accidentally reintroduce the low position.
- Text size, weights, kerning: untouched.
### 6. Hero footer restructured
**Before:** `<div class="hero-foot">` was `position: absolute; bottom: 0` — a separate row spanning the full viewport width. "Supported by" on the left, "Scroll →" (with horizontal right-arrow) on the right.
**After:** Moved INSIDE the left column, placed immediately after the lede paragraph:
```
[eyebrow]
[headline]
[lede paragraph]
← margin-top: clamp(2rem, 5vh, 3.5rem)
[Supported by [Innov]] [↓ Scroll]
↑ same baseline ↑ aligned to right edge of left column
```
- `.hero-foot` is now `display: flex; justify-content: space-between; align-items: baseline`. Both items share the baseline of "Supported by".
- `.scroll-hint .arrow` rotated to vertical: `width: 1px; height: 28px`. The `::after` chevron cap moved from top-right (pointing right) to bottom-left corner, rotated 45° to point down.
- `@keyframes hint`: `translateX(6px)``translateY(6px)`. The arrow now bounces downward, matching its new meaning.
- Mobile fallback override simplified (no longer needs `position: static` since the element is no longer absolutely positioned).
## Spot-check after deploy
### Nav + Welcome dot
- [ ] 8 dots visible at bottom center.
- [ ] Leftmost dot, hover → tooltip reads **"Welcome"**. Click → browser navigates to `/`, entrance welcome step renders.
- [ ] Second dot, hover → **"Timeline"**. Click → Timeline page (P1) becomes active, Welcome dot stays in default state.
- [ ] Scroll through Overview. Watch the active dot cycle: Hero → Architecture → Words → Bifrost → Participate → Join. Welcome dot never lights up.
### Cards
- [ ] S2 drop phase: cards appear visibly narrower than before (15% reduction — should be noticeable).
- [ ] S2 grid phase (scroll through the pin): the 2x2 grid is slightly smaller; text in copy panels doesn't overflow into the grid area.
### Topography
- [ ] Open Overview page. Behind the Europe map, faint ring patterns visible in the paper — rotated, offset to bottom-left.
- [ ] Scroll slowly. The rings shift vertically at a much slower rate than the scene content — barely perceptible but present (parallax).
- [ ] The rings are visibly **different from the entrance page's currents** — rotated, denser, lower opacity.
### Sticky scroll
- [ ] Scroll with a trackpad or mouse wheel through Overview. When a scene's middle is close to viewport-center, feel a brief slowdown (about 300ms).
- [ ] The effect is subtle — you can still scroll quickly through scenes with determined effort.
- [ ] **Scene 2 (architecture, pinned) is NOT affected.** Its scrubbed scroll speed is unchanged.
- [ ] No "hostile" feel. If it's too much, reduce `DAMP_FACTOR` from 0.82 → 0.88 in bifrost.js.
### Hero
- [ ] Hero text (eyebrow, headline, lede) sits in the upper half of the viewport, closer to the site-mark.
- [ ] Below the lede, a row: "Supported by Innovationsfonden" on the LEFT, vertical-down "↓ Scroll" arrow on the RIGHT, same baseline.
- [ ] The arrow bounces DOWNWARD (translateY animation), not rightward.
- [ ] No absolutely-positioned element at the bottom of the hero viewport.
## Tuning knobs
If any feel wrong after deploy:
| Tuning | File | Default | Change to |
|---|---|---|---|
| Parallax speed | `bifrost.js` | `PARALLAX_SPEED = 0.15` | higher (faster) or lower (slower) |
| Topography density | `bifrost.js` | `RINGS = 34, STEP = 28` | reduce rings or increase step for sparser pattern |
| Topography opacity | `bifrost.js` | `0.04 / 0.07` | lower to fade further |
| Sticky damping strength | `bifrost.js` | `DAMP_FACTOR = 0.82` | `0.88` for weaker, `0.75` for stronger |
| Sticky zone size | `bifrost.js` | `DAMP_ZONE_VH = 0.20` | `0.15` narrower, `0.25` wider |
| Sticky hold duration | `bifrost.js` | `DAMP_MAX_MS = 300` | `200``500` |
| Hero text position | `index.html` | `clamp(3rem, 8vh, 6rem)` | increase for lower text |
## Things NOT touched
- `public/entrance.html` / `entrance.js` — unchanged. The currents pattern there remains the original.
- S2 card content, S3 sentence, S5/S6 content — unchanged.
- Auth flow, session, invite system — unchanged.
- Nav labels (Welcome, Timeline, Hero, Architecture, Words, Bifrost, Participate, Join) — unchanged.
## Race condition to watch
There's a race window between `timeline.js`'s `/auth/me` fetch and `bifrost.js`'s init. If the user clicks the Overview dot before the fetch resolves, S3 may render with the no-name fallback even for a named invite. On refresh, the name appears correctly. Not critical — flagged in previous CHANGES.md. If this bites in practice, the fix is to `await` the fetch in the Overview activation path.

View file

@ -9,7 +9,7 @@
// 2. Rewires Lenis smooth scroll + GSAP ScrollTrigger so the
// scroller is the Overview's internal scrolling container —
// never the window — so the three-page Timeline/Overview/
// Archive model (each fixed-viewport) keeps working.
// Timeline model (each fixed-viewport) keeps working.
// 3. Drives the Europe map's opacity from the scroller's scroll
// position: fully visible at the top, fades to 0 as the user
// scrolls into the hero, fades back in on scrolling up.
@ -214,6 +214,146 @@
// Initial paint: set "hero" active since we start at top.
requestAnimationFrame(updateActiveSceneDot);
// ─── Topography parallax ─────────────────────────────────────
//
// Concentric-ring topographic layer that sits behind the Europe
// map (z-index 0). Generated at runtime — same formula as the
// entrance page's .currents pattern but rotated/offset so it reads
// as a visual sibling rather than a duplicate.
//
// Parallax speed: 0.15× of the scroller's scrollTop. Slow enough to
// feel atmospheric, fast enough that the layer doesn't appear
// static on casual scroll. Applied as a translateY composed WITH
// the 40° rotate from CSS (so we build the full transform here to
// avoid clobbering the CSS rotate).
const topoWrap = document.getElementById('overview-topography');
if (topoWrap && !topoWrap.querySelector('svg')) {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
const W = 1600, H = 1600, cx = W * 0.5, cy = H * 0.5;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
// Slightly different parameters from the entrance page's currents
// so the rings read as "related but not identical":
// - more rings (34 vs 26) — denser
// - smaller step (28 vs 32) — tighter
// - larger amplitude (32 vs 26) — wavier
// - lower base opacity — quieter behind the map
const RINGS = 34, BASE_R = 60, STEP = 28, AMP = 32;
for (let i = 0; i < RINGS; i++) {
const r = BASE_R + i * STEP, segs = 280;
const p1 = (i * 0.7) % (Math.PI * 2);
const p2 = (i * 1.4 + 1.3) % (Math.PI * 2);
const a1 = AMP * (0.9 + (i % 5) * 0.08);
const a2 = AMP * 0.35;
let d = '';
for (let s = 0; s <= segs; s++) {
const t = (s / segs) * Math.PI * 2;
const rr = r + a1 * Math.sin(t * 3 + p1 + i * 0.15)
+ a2 * Math.sin(t * 5 + p2 + i * 0.22)
+ AMP * 0.18 * Math.sin(t * 7 + i);
const x = cx + Math.cos(t) * rr;
const y = cy + Math.sin(t) * rr * 0.92;
d += (s === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1);
}
d += ' Z';
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', '#383831');
path.setAttribute('stroke-width', '1');
path.setAttribute('stroke-linejoin', 'round');
// Lower opacity than entrance page — sits behind the map,
// shouldn't compete for attention.
path.setAttribute('opacity', (i % 3 === 0 ? 0.07 : 0.04).toString());
svg.appendChild(path);
}
topoWrap.appendChild(svg);
}
const PARALLAX_SPEED = 0.15;
const topoSvg = topoWrap ? topoWrap.querySelector('svg') : null;
function updateTopographyParallax() {
if (!topoSvg) return;
const y = scroller.scrollTop * -PARALLAX_SPEED;
// Rotate comes from CSS (40deg); we compose our translateY into
// the same transform string so both apply. `rotate` first so the
// translate happens in screen space, not the rotated local space.
topoSvg.style.transform = `translateY(${y.toFixed(1)}px) rotate(40deg)`;
}
lenis.on('scroll', updateTopographyParallax);
// Initial paint
updateTopographyParallax();
// ─── Sticky-scroll damping ───────────────────────────────────
//
// When a scene's vertical center is close to the viewport center,
// damp Lenis's velocity briefly — scenes "catch" the eye instead
// of flying past at full scroll speed. Applied only to non-pinned
// scenes (S1, S3, S4, S5, S6). S2 is already pinned + scrubbed
// by GSAP ScrollTrigger; adding damping on top would make its
// card-fall feel like a drag.
//
// Implementation: on each scroll tick, if the active scene is in
// the "damp zone" (±20% of viewport height from viewport center),
// scale Lenis's next frame velocity by a damping factor (0.82).
// When outside the zone, pass through unchanged. Keeps power-users
// happy — they can still scroll fast, just feel a soft hold at
// scene boundaries.
const STICKY_SCENES = new Set([
'hero', 'words-scene', 'bifrost', 'bifrost-meaning', 'bifrost-join',
]); // note: 'stack-scene' deliberately excluded (already pinned)
const DAMP_ZONE_VH = 0.20; // ±20% of viewport height from center
const DAMP_FACTOR = 0.82; // multiply velocity while in zone
const DAMP_MAX_MS = 300; // cap: never damp for longer than this per scene-crossing
let dampedUntil = 0; // timestamp (ms); damping active while > now
let lastDampedScene = null; // id of the scene we last entered
function updateStickyDamping() {
const now = performance.now();
const midY = window.innerHeight * 0.5;
const zone = window.innerHeight * DAMP_ZONE_VH;
let inZone = false, zoneScene = null;
for (const id of STICKY_SCENES) {
const el = document.getElementById(id);
if (!el) continue;
const r = el.getBoundingClientRect();
// Use the scene's vertical midpoint as the "attractor."
const sceneMid = (r.top + r.bottom) * 0.5;
if (Math.abs(sceneMid - midY) < zone) {
inZone = true;
zoneScene = id;
break;
}
}
if (inZone) {
// New scene entered the zone → arm a fresh damping window.
// Reusing the same scene (still in zone, already damped) doesn't
// extend — DAMP_MAX_MS is a hard cap per entry to avoid an
// indefinite hold.
if (zoneScene !== lastDampedScene) {
lastDampedScene = zoneScene;
dampedUntil = now + DAMP_MAX_MS;
}
if (now < dampedUntil && lenis.velocity) {
// Lenis exposes .velocity (a number). Multiplying in place
// dampens the next-frame step. Small, contained effect — no
// chance of interfering with ScrollTrigger.
lenis.velocity *= DAMP_FACTOR;
}
} else {
// Left the zone — reset so the NEXT time we enter a scene,
// we get a fresh damping window.
if (lastDampedScene !== null) {
lastDampedScene = null;
dampedUntil = 0;
}
}
}
lenis.on('scroll', updateStickyDamping);
// ─── Site-2 scene animations ─────────────────────────────────
// (transplanted verbatim; all ScrollTriggers below automatically
// use the Overview scroller via ScrollTrigger.defaults above.)
@ -295,7 +435,7 @@
const H = theatre.offsetHeight;
const vw = window.innerWidth;
const cellSize = vw * 0.20; // matches .in-grid .card-box width (20vw)
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;
@ -420,7 +560,7 @@
const cardRect = cards[0].getBoundingClientRect();
const cardW = cardRect.width || vw;
const cardH = cardRect.height || 600;
const targetW = vw * 0.20;
const targetW = vw * 0.17; // matches .in-grid .card-box width (17vw)
const targetH = targetW; // square
const targetScaleX = targetW / cardW;
const targetScaleY = targetH / cardH;

View file

@ -473,6 +473,45 @@
/* ───────── Overview page ───────── */
/* Topography layer — concentric ring pattern, parallax-scrolled,
sitting behind the Europe map. Reads as a visual sibling of the
entrance page's "currents" pattern but rotated and repositioned so
it doesn't look like a duplicate. The SVG contents are drawn at
runtime by drawTopography() in bifrost.js.
Transform is driven by JS from Lenis's scroll position (parallax
speed 0.15× — very slow). z-index 0 so it sits behind the map
(z-index 1) and content (z-index 2+). */
.overview-topography {
position: absolute; inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
opacity: 0;
transition: opacity 900ms var(--ease);
}
.overview-topography svg {
position: absolute;
/* Offset to the opposite corner of the entrance-page currents
(which sit top-right). Here we anchor bottom-left and extend
well beyond the viewport so parallax translation never reveals
an edge. */
left: -20vw;
top: -10vh;
width: 140vw;
height: 140vh;
display: block;
/* Rotate 40° so the rings don't read as an exact copy of the
entrance's pattern; the viewer registers this as "related but
distinct". */
transform: rotate(40deg);
transform-origin: 50% 50%;
will-change: transform;
}
.page-overview.is-active .overview-topography {
opacity: 1;
}
/* Globe background behind the overview — same SVG style as the timeline's,
but centered on Europe. It begins at the timeline's size/position so
that when the page is entered, the CSS transition zooms it into place. */
@ -656,7 +695,10 @@
#page-overview #hero .hero-wrap {
/* Constrain to the left column so Europe is visible to its right. */
max-width: 62ch;
padding-top: clamp(6rem, 16vh, 12rem);
/* Reduced from clamp(6rem, 16vh, 12rem) to move hero text up into
the upper half of the viewport. Same intent as the #hero padding
reduction above — the more-specific selector takes precedence. */
padding-top: clamp(3rem, 8vh, 6rem);
}
/* Make sure scenes don't accidentally inherit `main { position: relative }` */
@ -665,7 +707,7 @@
/* ============================================================
BIFROST SCENES — tokens (scoped to #page-overview only, so
they never leak to the timeline or archive pages).
they never leak to the timeline page).
Palette reconciled with site 1's Nordic Editorial system.
============================================================ */
#page-overview {
@ -726,7 +768,10 @@ html {
============================================================ */
#hero {
min-height: 100vh;
padding-top: clamp(7rem, 14vh, 11rem);
/* Reduced from clamp(7rem, 14vh, 11rem) to pull the hero text up
into the upper half of the viewport. The original value centered
the text too low when measured against the site mark at top. */
padding-top: clamp(3.5rem, 7vh, 5.5rem);
grid-template-columns: 1fr;
align-items: start;
}
@ -784,14 +829,16 @@ html {
letter-spacing: -0.01em;
}
/* Hero foot row — lives INSIDE the left column at the bottom of the
paragraph block. Displays "Supported by Innovationsfonden" on the
left and the scroll-down indicator on the right, sharing a single
baseline. The row sits immediately after the lede paragraph. */
.hero-foot {
position: absolute;
bottom: var(--edge);
left: var(--edge);
right: var(--edge);
display: flex;
justify-content: space-between;
align-items: flex-end;
align-items: baseline;
gap: 1.5rem;
margin-top: clamp(2rem, 5vh, 3.5rem);
font-size: var(--step-sm);
color: var(--ink-mute);
letter-spacing: 0.04em;
@ -815,19 +862,24 @@ html {
font-size: var(--step-sm);
color: var(--ink-soft);
}
/* Arrow reoriented to point DOWN — a vertical 1px line with a chevron
cap at the bottom. Animation moved from translateX to translateY so
the hint visually "drops" downward, matching its meaning. */
.scroll-hint .arrow {
width: 28px; height: 1px; background: currentColor;
width: 1px; height: 28px; background: currentColor;
position: relative;
animation: hint 2.2s ease-in-out infinite;
}
.scroll-hint .arrow::after {
content: ""; position: absolute; right: -1px; top: -3px;
width: 7px; height: 7px; border-right: 1px solid currentColor; border-bottom: 1px solid currentColor;
transform: rotate(-45deg);
content: ""; position: absolute; bottom: -1px; left: -3px;
width: 7px; height: 7px;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: rotate(45deg);
}
@keyframes hint {
0%, 100% { transform: translateX(0); opacity: 0.5; }
50% { transform: translateX(6px); opacity: 1; }
0%, 100% { transform: translateY(0); opacity: 0.5; }
50% { transform: translateY(6px); opacity: 1; }
}
/* ============================================================
@ -916,8 +968,10 @@ html {
/* -------- Layer cards -------- */
.layer-card {
position: absolute;
left: 0;
right: 0;
/* 7.5% margin on each side = 15% total width reduction from the
original edge-to-edge layout. */
left: 7.5%;
right: 7.5%;
top: 50%;
width: auto;
transform: translateY(-50%);
@ -938,10 +992,12 @@ html {
.card-box {
position: relative;
border-radius: 22px;
padding: clamp(1.75rem, 3.2vw, 2.8rem);
/* Reduced 15% from the original clamp(1.75rem, 3.2vw, 2.8rem) for a
slimmer, quieter card presence. */
padding: clamp(1.5rem, 2.7vw, 2.4rem);
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.9fr);
gap: clamp(1rem, 2.5vw, 2.25rem);
gap: clamp(0.85rem, 2.1vw, 1.9rem);
align-items: center;
overflow: hidden;
/* contain: paint forces transformed children (with will-change:
@ -950,7 +1006,8 @@ html {
transform during the morph escapes the box bounds. */
contain: paint;
box-shadow: 0 22px 48px -18px rgba(46,46,40,0.28), 0 8px 22px -8px rgba(46,46,40,0.16);
min-height: 240px;
/* 15% reduction from the original 240px — matches the lateral shrink */
min-height: 204px;
}
.card-content { min-width: 0; }
.card-title {
@ -1036,8 +1093,10 @@ html {
}
.in-grid .card-box {
max-width: 20vw;
width: 20vw;
/* 15% reduction from the original 20vw — matches the drop-phase shrink.
Also matches cellSize and targetW in bifrost.js (both are vw * 0.17). */
max-width: 17vw;
width: 17vw;
aspect-ratio: 1 / 1;
margin: 0 auto;
display: flex;
@ -1046,7 +1105,7 @@ html {
justify-content: flex-start;
grid-template-columns: unset;
grid-template-rows: unset;
padding: clamp(1rem, 1.4vw, 1.4rem);
padding: clamp(0.85rem, 1.2vw, 1.2rem);
gap: 0;
min-height: 0;
border-radius: 16px;
@ -1286,7 +1345,7 @@ html {
aspect-ratio: 20 / 14;
}
.hero-foot { position: static; margin-top: 3rem; flex-direction: column; gap: 1.5rem; align-items: flex-start; }
.hero-foot { margin-top: 2rem; flex-direction: column; gap: 1rem; align-items: flex-start; }
#hero { padding-top: 5rem; padding-bottom: 3rem; min-height: auto; }
}
@ -2124,7 +2183,11 @@ html {
<!-- ───── Page 2 : OVERVIEW ───── -->
<section class="page page-overview" id="page-overview" data-screen-label="02 Overview">
<!-- Europe map — fades in on activation, drives opacity by scroll position (bifrost.js) -->
<!-- Topography background — concentric rings, parallax-scrolling behind
the Europe map. Drawn at runtime by bifrost.js's drawTopography()
into the SVG slot. Lives at z-index 0 so it sits behind the map
(z-index 1) and all content (z-index 2+). -->
<div class="overview-topography" id="overview-topography" aria-hidden="true"></div>
<div class="overview-globe" id="overview-globe"></div>
<!-- Internal scroller: the six Project Bifrost scenes live inside this.
@ -2145,23 +2208,29 @@ html {
<p class="hero-lede" data-reveal>
Enabling highly advanced AI capabilities hosted within the client's own secure infrastructure.
</p>
</div>
</div>
<div class="hero-foot">
<div class="support" aria-label="Supported by Innovationsfonden">
<span>Supported by</span>
<!-- Simplified Innovationsfonden wordmark (redrawn — not their official logo, a respectful representation) -->
<svg viewBox="0 0 190 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g fill="#3c6b6b">
<path d="M4 2 L12 18 L10 2 Z" />
<text x="18" y="15" font-family="Manrope, sans-serif" font-weight="600" font-size="13" letter-spacing="0.2" fill="#3c6b6b">nnovationsfonden</text>
</g>
</svg>
</div>
<div class="scroll-hint" aria-hidden="true">
<span>Scroll</span>
<span class="arrow"></span>
<!-- Hero foot: "Supported by Innovationsfonden" on the left and
the scroll-down indicator on the right, both inside the
left column at the bottom of the paragraph block. Shared
baseline via display:flex + align-items:baseline on
.hero-foot. The scroll arrow points DOWN and gently bounces
downward (see @keyframes hint below). -->
<div class="hero-foot">
<div class="support" aria-label="Supported by Innovationsfonden" data-reveal>
<span>Supported by</span>
<!-- Simplified Innovationsfonden wordmark (redrawn — not their official logo, a respectful representation) -->
<svg viewBox="0 0 190 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g fill="#3c6b6b">
<path d="M4 2 L12 18 L10 2 Z" />
<text x="18" y="15" font-family="Manrope, sans-serif" font-weight="600" font-size="13" letter-spacing="0.2" fill="#3c6b6b">nnovationsfonden</text>
</g>
</svg>
</div>
<div class="scroll-hint" aria-hidden="true" data-reveal>
<span>Scroll</span>
<span class="arrow"></span>
</div>
</div>
</div>
</div>
</section>
@ -2548,6 +2617,10 @@ html {
or scroller.scrollTo() as a fallback. -->
<div class="dot-nav-tray"></div>
<nav class="dot-nav">
<button class="dot-btn" data-target="external-welcome" data-href="/" aria-label="Return to welcome page">
<span class="dot"></span>
<span class="label">Welcome</span>
</button>
<button class="dot-btn is-active" data-target="page-timeline">
<span class="dot"></span>
<span class="label">Timeline</span>

View file

@ -455,6 +455,12 @@ function activatePage(targetId, scrollToId) {
*/
function setActiveDot(targetId, scrollToId) {
document.querySelectorAll('.dot-btn').forEach(b => {
// External-target dots (e.g. Welcome) never show as active — they're
// navigation links, not section markers.
if ((b.dataset.target || '').startsWith('external-')) {
b.classList.remove('is-active');
return;
}
const pageMatch = b.dataset.target === targetId;
const scrollMatch = (b.dataset.scrollTo || '') === (scrollToId || '');
// Timeline dot has no data-scroll-to — it's active when page-timeline
@ -473,6 +479,16 @@ document.querySelectorAll('.dot-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.target;
const scrollToId = btn.dataset.scrollTo || null;
// External targets navigate away rather than switching pages.
// Used by the "Welcome" dot (routes back to the entrance page at /),
// where the user sees the welcome step if their session is valid.
if (targetId && targetId.startsWith('external-')) {
const href = btn.dataset.href || '/';
window.location.href = href;
return;
}
setActiveDot(targetId, scrollToId);
activatePage(targetId, scrollToId);
});