diff --git a/protected/bifrost.js b/protected/bifrost.js index ad45c59..d9f0e55 100644 --- a/protected/bifrost.js +++ b/protected/bifrost.js @@ -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 diff --git a/protected/index.html b/protected/index.html index f69c113..3e70b6d 100644 --- a/protected/index.html +++ b/protected/index.html @@ -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 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 {
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 courts, our defence, our schools — can we afford for the switch to sit in Washington?
@@ -2420,6 +2484,15 @@ html { ============================================================ -->
+ +
@@ -2807,23 +2880,15 @@ html { - -
+