add welcome dot, card shrunk, topo.paralx., sticky scroll
This commit is contained in:
parent
f4cdbf4fd4
commit
af35862749
4 changed files with 411 additions and 43 deletions
139
CHANGES 2.md
Normal file
139
CHANGES 2.md
Normal 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.
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
// 2. Rewires Lenis smooth scroll + GSAP ScrollTrigger so the
|
// 2. Rewires Lenis smooth scroll + GSAP ScrollTrigger so the
|
||||||
// scroller is the Overview's internal scrolling container —
|
// scroller is the Overview's internal scrolling container —
|
||||||
// never the window — so the three-page Timeline/Overview/
|
// 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
|
// 3. Drives the Europe map's opacity from the scroller's scroll
|
||||||
// position: fully visible at the top, fades to 0 as the user
|
// position: fully visible at the top, fades to 0 as the user
|
||||||
// scrolls into the hero, fades back in on scrolling up.
|
// scrolls into the hero, fades back in on scrolling up.
|
||||||
|
|
@ -214,6 +214,146 @@
|
||||||
// Initial paint: set "hero" active since we start at top.
|
// Initial paint: set "hero" active since we start at top.
|
||||||
requestAnimationFrame(updateActiveSceneDot);
|
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 ─────────────────────────────────
|
// ─── Site-2 scene animations ─────────────────────────────────
|
||||||
// (transplanted verbatim; all ScrollTriggers below automatically
|
// (transplanted verbatim; all ScrollTriggers below automatically
|
||||||
// use the Overview scroller via ScrollTrigger.defaults above.)
|
// use the Overview scroller via ScrollTrigger.defaults above.)
|
||||||
|
|
@ -295,7 +435,7 @@
|
||||||
const H = theatre.offsetHeight;
|
const H = theatre.offsetHeight;
|
||||||
const vw = window.innerWidth;
|
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 gap = Math.max(14, vw * 0.014);
|
||||||
|
|
||||||
const totalW = 2 * cellSize + gap;
|
const totalW = 2 * cellSize + gap;
|
||||||
|
|
@ -420,7 +560,7 @@
|
||||||
const cardRect = cards[0].getBoundingClientRect();
|
const cardRect = cards[0].getBoundingClientRect();
|
||||||
const cardW = cardRect.width || vw;
|
const cardW = cardRect.width || vw;
|
||||||
const cardH = cardRect.height || 600;
|
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 targetH = targetW; // square
|
||||||
const targetScaleX = targetW / cardW;
|
const targetScaleX = targetW / cardW;
|
||||||
const targetScaleY = targetH / cardH;
|
const targetScaleY = targetH / cardH;
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,45 @@
|
||||||
|
|
||||||
/* ───────── Overview page ───────── */
|
/* ───────── 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,
|
/* 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
|
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. */
|
that when the page is entered, the CSS transition zooms it into place. */
|
||||||
|
|
@ -656,7 +695,10 @@
|
||||||
#page-overview #hero .hero-wrap {
|
#page-overview #hero .hero-wrap {
|
||||||
/* Constrain to the left column so Europe is visible to its right. */
|
/* Constrain to the left column so Europe is visible to its right. */
|
||||||
max-width: 62ch;
|
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 }` */
|
/* Make sure scenes don't accidentally inherit `main { position: relative }` */
|
||||||
|
|
@ -665,7 +707,7 @@
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
BIFROST SCENES — tokens (scoped to #page-overview only, so
|
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.
|
Palette reconciled with site 1's Nordic Editorial system.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
#page-overview {
|
#page-overview {
|
||||||
|
|
@ -726,7 +768,10 @@ html {
|
||||||
============================================================ */
|
============================================================ */
|
||||||
#hero {
|
#hero {
|
||||||
min-height: 100vh;
|
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;
|
grid-template-columns: 1fr;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
@ -784,14 +829,16 @@ html {
|
||||||
letter-spacing: -0.01em;
|
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 {
|
.hero-foot {
|
||||||
position: absolute;
|
|
||||||
bottom: var(--edge);
|
|
||||||
left: var(--edge);
|
|
||||||
right: var(--edge);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
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);
|
font-size: var(--step-sm);
|
||||||
color: var(--ink-mute);
|
color: var(--ink-mute);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
|
|
@ -815,19 +862,24 @@ html {
|
||||||
font-size: var(--step-sm);
|
font-size: var(--step-sm);
|
||||||
color: var(--ink-soft);
|
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 {
|
.scroll-hint .arrow {
|
||||||
width: 28px; height: 1px; background: currentColor;
|
width: 1px; height: 28px; background: currentColor;
|
||||||
position: relative;
|
position: relative;
|
||||||
animation: hint 2.2s ease-in-out infinite;
|
animation: hint 2.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.scroll-hint .arrow::after {
|
.scroll-hint .arrow::after {
|
||||||
content: ""; position: absolute; right: -1px; top: -3px;
|
content: ""; position: absolute; bottom: -1px; left: -3px;
|
||||||
width: 7px; height: 7px; border-right: 1px solid currentColor; border-bottom: 1px solid currentColor;
|
width: 7px; height: 7px;
|
||||||
transform: rotate(-45deg);
|
border-right: 1px solid currentColor;
|
||||||
|
border-bottom: 1px solid currentColor;
|
||||||
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
@keyframes hint {
|
@keyframes hint {
|
||||||
0%, 100% { transform: translateX(0); opacity: 0.5; }
|
0%, 100% { transform: translateY(0); opacity: 0.5; }
|
||||||
50% { transform: translateX(6px); opacity: 1; }
|
50% { transform: translateY(6px); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|
@ -916,8 +968,10 @@ html {
|
||||||
/* -------- Layer cards -------- */
|
/* -------- Layer cards -------- */
|
||||||
.layer-card {
|
.layer-card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
/* 7.5% margin on each side = 15% total width reduction from the
|
||||||
right: 0;
|
original edge-to-edge layout. */
|
||||||
|
left: 7.5%;
|
||||||
|
right: 7.5%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: auto;
|
width: auto;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
|
|
@ -938,10 +992,12 @@ html {
|
||||||
.card-box {
|
.card-box {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 22px;
|
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;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.9fr);
|
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;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* contain: paint forces transformed children (with will-change:
|
/* contain: paint forces transformed children (with will-change:
|
||||||
|
|
@ -950,7 +1006,8 @@ html {
|
||||||
transform during the morph escapes the box bounds. */
|
transform during the morph escapes the box bounds. */
|
||||||
contain: paint;
|
contain: paint;
|
||||||
box-shadow: 0 22px 48px -18px rgba(46,46,40,0.28), 0 8px 22px -8px rgba(46,46,40,0.16);
|
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-content { min-width: 0; }
|
||||||
.card-title {
|
.card-title {
|
||||||
|
|
@ -1036,8 +1093,10 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
.in-grid .card-box {
|
.in-grid .card-box {
|
||||||
max-width: 20vw;
|
/* 15% reduction from the original 20vw — matches the drop-phase shrink.
|
||||||
width: 20vw;
|
Also matches cellSize and targetW in bifrost.js (both are vw * 0.17). */
|
||||||
|
max-width: 17vw;
|
||||||
|
width: 17vw;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1046,7 +1105,7 @@ html {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
grid-template-columns: unset;
|
grid-template-columns: unset;
|
||||||
grid-template-rows: unset;
|
grid-template-rows: unset;
|
||||||
padding: clamp(1rem, 1.4vw, 1.4rem);
|
padding: clamp(0.85rem, 1.2vw, 1.2rem);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
|
@ -1286,7 +1345,7 @@ html {
|
||||||
aspect-ratio: 20 / 14;
|
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; }
|
#hero { padding-top: 5rem; padding-bottom: 3rem; min-height: auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2124,7 +2183,11 @@ html {
|
||||||
|
|
||||||
<!-- ───── Page 2 : OVERVIEW ───── -->
|
<!-- ───── Page 2 : OVERVIEW ───── -->
|
||||||
<section class="page page-overview" id="page-overview" data-screen-label="02 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>
|
<div class="overview-globe" id="overview-globe"></div>
|
||||||
|
|
||||||
<!-- Internal scroller: the six Project Bifrost scenes live inside this.
|
<!-- Internal scroller: the six Project Bifrost scenes live inside this.
|
||||||
|
|
@ -2145,23 +2208,29 @@ html {
|
||||||
<p class="hero-lede" data-reveal>
|
<p class="hero-lede" data-reveal>
|
||||||
Enabling highly advanced AI capabilities hosted within the client's own secure infrastructure.
|
Enabling highly advanced AI capabilities hosted within the client's own secure infrastructure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-foot">
|
<!-- Hero foot: "Supported by Innovationsfonden" on the left and
|
||||||
<div class="support" aria-label="Supported by Innovationsfonden">
|
the scroll-down indicator on the right, both inside the
|
||||||
<span>Supported by</span>
|
left column at the bottom of the paragraph block. Shared
|
||||||
<!-- Simplified Innovationsfonden wordmark (redrawn — not their official logo, a respectful representation) -->
|
baseline via display:flex + align-items:baseline on
|
||||||
<svg viewBox="0 0 190 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
.hero-foot. The scroll arrow points DOWN and gently bounces
|
||||||
<g fill="#3c6b6b">
|
downward (see @keyframes hint below). -->
|
||||||
<path d="M4 2 L12 18 L10 2 Z" />
|
<div class="hero-foot">
|
||||||
<text x="18" y="15" font-family="Manrope, sans-serif" font-weight="600" font-size="13" letter-spacing="0.2" fill="#3c6b6b">nnovationsfonden</text>
|
<div class="support" aria-label="Supported by Innovationsfonden" data-reveal>
|
||||||
</g>
|
<span>Supported by</span>
|
||||||
</svg>
|
<!-- Simplified Innovationsfonden wordmark (redrawn — not their official logo, a respectful representation) -->
|
||||||
</div>
|
<svg viewBox="0 0 190 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
<div class="scroll-hint" aria-hidden="true">
|
<g fill="#3c6b6b">
|
||||||
<span>Scroll</span>
|
<path d="M4 2 L12 18 L10 2 Z" />
|
||||||
<span class="arrow"></span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -2548,6 +2617,10 @@ html {
|
||||||
or scroller.scrollTo() as a fallback. -->
|
or scroller.scrollTo() as a fallback. -->
|
||||||
<div class="dot-nav-tray"></div>
|
<div class="dot-nav-tray"></div>
|
||||||
<nav class="dot-nav">
|
<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">
|
<button class="dot-btn is-active" data-target="page-timeline">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span class="label">Timeline</span>
|
<span class="label">Timeline</span>
|
||||||
|
|
|
||||||
|
|
@ -455,6 +455,12 @@ function activatePage(targetId, scrollToId) {
|
||||||
*/
|
*/
|
||||||
function setActiveDot(targetId, scrollToId) {
|
function setActiveDot(targetId, scrollToId) {
|
||||||
document.querySelectorAll('.dot-btn').forEach(b => {
|
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 pageMatch = b.dataset.target === targetId;
|
||||||
const scrollMatch = (b.dataset.scrollTo || '') === (scrollToId || '');
|
const scrollMatch = (b.dataset.scrollTo || '') === (scrollToId || '');
|
||||||
// Timeline dot has no data-scroll-to — it's active when page-timeline
|
// 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', () => {
|
btn.addEventListener('click', () => {
|
||||||
const targetId = btn.dataset.target;
|
const targetId = btn.dataset.target;
|
||||||
const scrollToId = btn.dataset.scrollTo || null;
|
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);
|
setActiveDot(targetId, scrollToId);
|
||||||
activatePage(targetId, scrollToId);
|
activatePage(targetId, scrollToId);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue