remove archive and other changes

This commit is contained in:
Arlind Ukshini 2026-04-23 11:40:44 +02:00
parent 19a88f50b3
commit f4cdbf4fd4
6 changed files with 504 additions and 358 deletions

118
CHANGES.md Normal file
View file

@ -0,0 +1,118 @@
# Site update — dot-nav redesign, archive removal, S2/S3 copy rewrite
Everything happens inside `protected/`. Server-side (src/, server.js, auth)
is untouched.
## Files to replace
| File | Action |
|---|---|
| `protected/index.html` | **Overwrite** — dot-nav CSS/markup, S2 cards + panels rewritten, S3 sentence rewritten, archive page + CSS removed, Fenja footer logo sized correctly, footer bottom padding increased |
| `protected/timeline.js` | **Overwrite**`buildArchive()` deleted, `activatePage()` now takes a scroll target, scroll-spy integration, fetches `/auth/me` for first name |
| `protected/bifrost.js` | **Overwrite** — S3 sentence rebuilt with first name at init, `scrollTo()` exposed via `window.__bifrost`, scroll-spy updates active dot |
## Files to delete
| File | Why |
|---|---|
| `protected/archive.html` | Legacy standalone archive placeholder, no longer referenced |
| `protected/archive.js` | Companion to the above |
On the VPS: `sudo rm /opt/fenja/protected/archive.html /opt/fenja/protected/archive.js`
or via rsync with `--delete`, which also picks them up automatically.
## What changed, by section
### Dot-nav
- **5px dots**, inline flex with 10px hit-padding each (so they're still tappable).
- **Default state**: transparent fill, 1px `var(--ink-dim)` ring (outlined).
- **Hover**: ring darkens to `var(--ink)`.
- **Active**: fills with `var(--ink)` (solid).
- **Label**: hidden by default, appears as a paper-toned tooltip ABOVE the dot on hover or keyboard focus.
- **Nav composition (flat, 7 dots):**
1. Timeline (`page-timeline`)
2. Hero (`page-overview` → scrolls to `#hero`)
3. Architecture (`page-overview``#stack-scene`)
4. Words (`page-overview``#words-scene`)
5. Bifrost (`page-overview``#bifrost`)
6. Participate (`page-overview``#bifrost-meaning`)
7. Join (`page-overview``#bifrost-join`)
- **Scroll-spy**: as the user scrolls within Overview, the currently-visible scene's dot becomes active automatically.
- **Dot-nav tray gradient** (paper fade at page bottom) is suppressed on the Overview page only — so the S6 footer reads as a hard terminus.
### Footer
- **Fenja logo**: previously stretched to fill the center column (`width: 100%`). Now height-matched to the other two footer items via the existing `--foot-h` variable — same size as "Project Bifrost" wordmark and the Innovationsfonden placeholder.
- **Bottom padding**: bumped from `clamp(0.5rem, 2vh, 1.5rem)` to `clamp(5rem, 10vh, 8rem)` so the three logos sit clearly above the dot-nav even on short viewports.
### Archive
- `<section id="page-archive">` removed from `index.html`.
- All `.archive` CSS removed (~155 lines).
- `buildArchive()` IIFE removed from `timeline.js`.
- Standalone `protected/archive.html` / `archive.js` — deletion noted above.
### S2 — Architecture
**Four cards (new copy throughout):**
1. **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.
2. **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.
3. **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.
4. **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.
**Three copy panels (left side, during grid phase):**
- Panel A (*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.
- Panel B (*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.
- Panel C (*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.
### S3 — Words fly in
Sentence is now personalised per user. `timeline.js` fetches `/auth/me` on load; `bifrost.js` reads `window.__fenjaFirstName` at init and rebuilds the `.words` paragraph in place before the fly-in animation captures the spans.
- **With first name**: *"This is why we've invited you, **Erik.** To ensure Fenja AI is not just built for you — but **with you.**"* — Bold Italic emphasis on `Erik.` and `with you.`
- **No first name (fallback)**: *"This is why we've invited **you.** To ensure Fenja AI is not just built for you — but **with you.**"* — Bold Italic emphasis on `you.` (after invited) and `with you.`
- Animation preserved: each word flies in from a scatter, with emphasis (`.hi`) words coming in from center with extra weight.
## Spot-check after deploy
- [ ] Open `/timeline`. Bottom of screen: 7 small dots, no labels.
- [ ] Hover any dot → paper-toned tooltip appears ABOVE it with the section name.
- [ ] Timeline dot is filled (active); others are outlined.
- [ ] Click "Architecture" dot → switches to Overview, smooth-scrolls to the stack scene.
- [ ] Click "Bifrost" dot → Overview → smooth-scrolls to the aurora arc scene.
- [ ] Inside Overview, manually scroll. The active dot changes as each scene passes the viewport midline.
- [ ] Go to S3 (Words scene). With a named invite (e.g. "Erik"), the sentence reads: *"This is why we've invited you, **Erik.** To ensure Fenja AI is not just built for you — but **with you.**"*
- [ ] With a no-name invite (like the existing `quka93@gmail.com`), the sentence reads: *"This is why we've invited **you.** To ensure Fenja AI is not just built for you — but **with you.**"*
- [ ] Scroll to S6 footer. The three brand marks (Project Bifrost / Fenja logo / Innovationsfonden) all read as equal height.
- [ ] Logos have clear space below them — not hidden by the dot-nav tray fade.
- [ ] On Overview page only, the bottom paper-fade tray is gone — footer meets the bottom of the viewport cleanly.
- [ ] On Timeline page (scroll to the end of the 23 headlines), the paper-fade tray is still there behind the dot-nav (so cards fade smoothly into it).
- [ ] Clicking "Read the editor's note" from P1 still jumps to S1 Hero of the Overview (behaves like the Hero dot).
- [ ] No console errors, no CSP violations, no 404s.
## Things NOT touched
- `src/` (auth, db, mail, sessions, middleware) — untouched.
- `server.js` — untouched.
- `public/entrance.html`, `public/entrance.js` — untouched.
- `protected/fenja/*` — colors, fonts, logos, illustrations all untouched.
- `protected/vendor/*` — untouched (lenis, gsap, scrolltrigger, d3, topojson, countries data).
- Server-side CSP, auth gate, rate limits — untouched.
## Open items (for a future iteration if you want them)
- The "Read the editor's note" button on P1 currently wires straight to the Overview dot's click handler. It now scrolls to `#hero` (top of Overview) — which matches the previous behaviour. Fine as-is.
- The S3 sentence uses the Overview's internal scroller for its fly-in, but the current fly-in distances (scatter radius 220×160 px) are unchanged from the old 16-word sentence. With 21 words, the animation may feel slightly faster. If it feels too fast, we can bump the `0.055` stagger in bifrost.js to `0.07`.
- If you change the 7-dot nav order or add/remove scenes, update:
- The `data-scroll-to` attributes in `index.html`'s `<nav class="dot-nav">`.
- The `sceneOrder` array inside `bifrost.js` `init()` (search for `const sceneOrder = [`).

View file

@ -1,59 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Archive — Fenja AI</title>
<style>
:root {
--paper: #faf6ee; --ink: #383831; --ink-soft: #5f5e5e; --walnut: #785f53;
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; min-height: 100%;
background: var(--paper); color: var(--ink);
font-family: "Manrope", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
min-height: 100vh;
background: radial-gradient(1100px 760px at 22% 42%, #fffcf7 0%, var(--paper) 58%, #f2ecdd 100%);
display: flex; align-items: center; padding: 0 112px;
}
.wrap { max-width: 640px; }
h1 {
font-family: "Newsreader", Georgia, serif;
font-weight: 400; font-size: 56px;
letter-spacing: -0.022em; line-height: 1.06;
margin: 0 0 20px 0;
}
h1 em { font-style: italic; font-weight: 700; }
p {
font-family: "Newsreader", Georgia, serif;
font-style: italic; font-size: 19px;
color: var(--ink-soft); line-height: 1.5;
margin: 0 0 32px 0;
}
.logout {
all: unset;
font-family: "Manrope", system-ui, sans-serif;
font-size: 14px; color: var(--walnut);
cursor: pointer; border-bottom: 1px solid rgba(120, 95, 83, 0.35);
}
.logout:hover { border-bottom-color: var(--walnut); }
@media (max-width: 720px) { body { padding: 80px 28px; } h1 { font-size: 36px; } }
</style>
</head>
<body>
<div class="wrap">
<h1>You are in the <em>archive.</em></h1>
<p>
This is a placeholder. Drop the real archive page here — timeline, overview, the lot — and it will be
served from <code>protected/archive.html</code>, gated by the session cookie.
</p>
<button class="logout" id="logout">Close the archive &rarr;</button>
</div>
<script src="/archive/archive.js" defer></script>
</body>
</html>

View file

@ -1,15 +0,0 @@
// ─────────────────────────────────────────────────────────────
// protected/archive.js — client-side behaviour for the archive page.
// Served from /archive/archive.js, gated by requireAuth like the
// rest of the protected directory.
// ─────────────────────────────────────────────────────────────
document.getElementById('logout').addEventListener('click', async () => {
try {
await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });
} catch (err) {
// Even if the fetch fails, sending them to / will prompt a fresh login
console.warn('logout request failed', err);
}
window.location.href = '/';
});

View file

@ -28,6 +28,12 @@
let initialized = false;
let refreshScheduled = false;
// Shared state between init() and the public surface (scrollTo etc).
// These are assigned once during init(); scrollTo reads them to drive
// the Overview's internal scroller instead of window scroll.
let lenisInstance = null;
let scrollerEl = null;
// A tiny helper: schedule a ScrollTrigger.refresh() on the next
// animation frame, de-duplicating calls within the same frame.
function scheduleRefresh() {
@ -82,6 +88,7 @@
console.error('[bifrost] #overview-scroll not found');
return;
}
scrollerEl = scroller;
const gsap = window.gsap;
const ScrollTrigger = window.ScrollTrigger;
@ -124,6 +131,9 @@
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
// Expose lenis to scrollTo() via the shared closure var.
lenisInstance = lenis;
// ─── Europe map fade ─────────────────────────────────────────
//
// Fully visible at scrollTop=0. Fades to 0 between 20% and 80%
@ -162,6 +172,48 @@
// Initial paint
updateMapOpacity();
// ─── Scroll-spy for the dot-nav ──────────────────────────────
//
// As the user scrolls through the Overview, update the active dot
// to match the scene currently in view. Called from lenis.on('scroll')
// at display rate; debounced implicitly by requestAnimationFrame
// through the shared refresh scheduler.
//
// Logic: a scene is "active" when its top is above the viewport's
// 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.
const sceneOrder = [
'hero', 'stack-scene', 'words-scene',
'bifrost', 'bifrost-meaning', 'bifrost-join',
];
let lastActiveScene = null;
function updateActiveSceneDot() {
if (typeof window.__setActiveDot !== 'function') return;
const midY = window.innerHeight * 0.5;
let visibleId = sceneOrder[0];
for (const id of sceneOrder) {
const el = document.getElementById(id);
if (!el) continue;
const r = el.getBoundingClientRect();
// A scene whose top is at or above the midline, but whose
// bottom hasn't scrolled past the midline yet.
if (r.top <= midY && r.bottom > midY) {
visibleId = id;
break;
}
// Edge case: scrolled past — keep latest seen as fallback.
if (r.top <= midY) visibleId = id;
}
if (visibleId !== lastActiveScene) {
lastActiveScene = visibleId;
window.__setActiveDot('page-overview', visibleId);
}
}
lenis.on('scroll', updateActiveSceneDot);
// Initial paint: set "hero" active since we start at top.
requestAnimationFrame(updateActiveSceneDot);
// ─── Site-2 scene animations ─────────────────────────────────
// (transplanted verbatim; all ScrollTriggers below automatically
// use the Overview scroller via ScrollTrigger.defaults above.)
@ -518,6 +570,74 @@
/* -------------------------------------------------------------
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, <hi>Erik.</hi>
// To ensure Fenja AI is not just built for you — but
// <hi>with</hi> <hi>you.</hi>"
// No name: "This is why we've invited <hi>you.</hi> To ensure
// Fenja AI is not just built for you — but
// <hi>with</hi> <hi>you.</hi>"
//
// 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.
@ -941,6 +1061,36 @@
scheduleRefresh();
}
// Public surface — timeline.js calls this when the Overview tab activates.
window.__bifrost = { init };
/**
* 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")
* see sceneOrder[] inside init().
* Special value "hero" scrolls to top (0).
*/
function scrollTo(sceneId) {
if (!scrollerEl) return; // init() hasn't run yet — ignore
const target = document.getElementById(sceneId);
if (!target) return;
// "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;
if (lenisInstance && typeof lenisInstance.scrollTo === 'function') {
// Lenis does the smooth animation. `immediate: false` uses the
// same easing as wheel input — feels consistent.
lenisInstance.scrollTo(scrollY, { immediate: false });
} else {
// Fallback for pre-init / reduced-motion: hard-jump.
scrollerEl.scrollTo({ top: scrollY, behavior: 'smooth' });
}
}
// Public surface — timeline.js calls these when the Overview tab
// activates (init) or when a dot-nav button targeting a scene is
// clicked (scrollTo).
window.__bifrost = { init, scrollTo };
})();

View file

@ -117,7 +117,14 @@
transform: translateY(-12px);
}
/* ───────── Dot-nav ───────── */
/* ───────── Dot-nav ─────────
5px dots, filled when active, outlined ring otherwise. Labels hidden
by default and appear as a floating tooltip above the dot on hover.
The `.dot-nav-tray` (bottom paper-fade behind the nav) is still
declared but suppressed on #page-overview so the S6 footer reads
as a hard terminus — see the #page-overview.is-active rule further
down. */
.dot-nav-tray {
position: fixed;
left: 0; right: 0; bottom: 0;
@ -128,39 +135,70 @@
rgba(250,246,238,0) 0%,
rgba(250,246,238,0.88) 45%,
rgba(250,246,238,0.98) 100%);
transition: opacity var(--dur) var(--ease);
}
/* Fade the tray away on the Overview page so the S6 footer meets the
bottom of the viewport cleanly without a paper wash over the logos. */
body:has(#page-overview.is-active) .dot-nav-tray { opacity: 0; }
.dot-nav {
position: fixed;
bottom: 36px; left: 50%;
transform: translateX(-50%);
display: flex; gap: 44px;
display: flex; gap: 22px;
z-index: 40;
}
.dot-btn {
all: unset;
display: flex; flex-direction: column; align-items: center;
gap: 10px;
position: relative;
padding: 10px; /* invisible hit target — the dot itself is 5px */
cursor: pointer;
color: var(--ink-dim);
transition: color var(--dur) var(--ease);
display: flex; align-items: center; justify-content: center;
}
.dot-btn:hover { color: var(--ink); }
.dot-btn .dot {
width: 7px; height: 7px;
width: 5px; height: 5px;
border-radius: 50%;
background: var(--ink-dim);
transition: background var(--dur) var(--ease), transform var(--dur) var(--ease);
background: transparent;
box-shadow: inset 0 0 0 1px 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);
}
.dot-btn.is-active { color: var(--ink); }
.dot-btn.is-active .dot {
background: var(--crimson);
transform: scale(1.15);
background: var(--ink); /* filled ink, active */
box-shadow: inset 0 0 0 1px var(--ink);
}
/* Label tooltip — rises above the dot on hover or keyboard focus. */
.dot-btn .label {
position: absolute;
bottom: calc(100% - 6px); /* sit just above the hit area */
left: 50%;
transform: translate(-50%, 4px);
background: #fffcf7;
color: var(--ink);
font-size: 10.5px;
letter-spacing: 0.24em;
letter-spacing: 0.22em;
text-transform: uppercase;
font-weight: 500;
font-family: "Manrope", system-ui, sans-serif;
padding: 7px 11px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
box-shadow:
0 0 0 0.5px rgba(56,56,49,0.08),
0 10px 18px -10px rgba(56,56,49,0.2),
0 2px 6px -3px rgba(56,56,49,0.08);
transition: opacity var(--dur) var(--ease),
transform var(--dur) var(--ease);
}
.dot-btn:hover .label,
.dot-btn:focus-visible .label {
opacity: 1;
transform: translate(-50%, 0);
}
/* ───────── Globe ghost ───────── */
@ -563,154 +601,6 @@
}
.overview .meta-strip .v em { font-style: italic; font-weight: 700; }
/* ───────── Archive page ───────── */
.archive {
position: absolute; inset: 0;
overflow: auto;
padding: 120px 80px 180px;
}
.archive .inner {
max-width: 1180px; margin: 0 auto;
}
.archive .headline {
font-family: "Newsreader", Georgia, serif;
font-weight: 400;
font-size: 60px;
line-height: 1.05;
letter-spacing: -0.02em;
margin: 0 0 14px 0;
text-wrap: balance;
}
.archive .headline em { font-style: italic; font-weight: 700; }
.archive .sub {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 19px;
color: var(--ink-soft);
margin: 0 0 48px 0;
max-width: 700px;
}
.archive table {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
}
.archive thead th {
text-align: left;
padding: 10px 12px;
font-size: 10.5px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--ink-dim);
font-weight: 600;
background:
linear-gradient(to right, rgba(56,56,49,0.28), rgba(56,56,49,0.28))
bottom / 100% 1px no-repeat;
}
.archive tbody tr {
transition: background var(--dur) var(--ease);
background:
linear-gradient(to right, rgba(56,56,49,0.10), rgba(56,56,49,0.10))
bottom / 100% 1px no-repeat;
}
.archive tbody tr:hover {
background:
var(--paper-high)
linear-gradient(to right, rgba(56,56,49,0.10), rgba(56,56,49,0.10))
bottom / 100% 1px no-repeat;
}
.archive tbody td {
padding: 18px 12px;
vertical-align: top;
color: var(--ink);
line-height: 1.45;
}
.archive td.num {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
color: var(--ink-dim);
font-size: 13px;
width: 48px;
}
.archive td.date {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
color: var(--ink-soft);
width: 130px;
white-space: nowrap;
}
.archive td.kind {
width: 150px;
font-size: 10.5px;
letter-spacing: 0.22em;
text-transform: uppercase;
font-weight: 600;
color: var(--ink-dim);
}
.archive tr[data-accent="copper"] td.kind { color: var(--copper); }
.archive tr[data-accent="ochre"] td.kind { color: var(--ochre); }
.archive tr[data-accent="terracotta"] td.kind { color: var(--terracotta); }
.archive tr[data-accent="crimson"] td.kind { color: var(--crimson); }
.archive td.hed {
font-family: "Newsreader", Georgia, serif;
font-size: 16px;
letter-spacing: -0.005em;
color: var(--ink);
max-width: 520px;
}
.archive td.hed em { font-style: italic; font-weight: 700; }
.archive td.src {
color: var(--ink-dim);
font-size: 12px;
font-family: "Newsreader", Georgia, serif;
font-style: italic;
width: 160px;
white-space: nowrap;
}
/* Mark on key rows — tonal, no border */
.archive tr[data-accent] td:first-child {
position: relative;
}
.archive tr[data-accent] td:first-child::before {
content: "";
position: absolute;
left: -10px; top: 50%;
transform: translateY(-50%);
width: 5px; height: 5px;
border-radius: 50%;
background: var(--ink-dim);
}
.archive tr[data-accent="copper"] td:first-child::before { background: var(--copper); }
.archive tr[data-accent="ochre"] td:first-child::before { background: var(--ochre); }
.archive tr[data-accent="terracotta"] td:first-child::before { background: var(--terracotta); }
.archive tr[data-accent="crimson"] td:first-child::before { background: var(--crimson); }
/* Archive footer */
.archive .footer {
margin-top: 72px;
padding-top: 28px;
background:
linear-gradient(to right, rgba(56,56,49,0.18), rgba(56,56,49,0.18))
top / 100% 1px no-repeat;
display: flex; justify-content: space-between;
color: var(--ink-dim);
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
font-weight: 500;
}
.archive .footer em {
font-family: "Newsreader", Georgia, serif;
font-style: italic;
font-size: 13px;
color: var(--ink-soft);
letter-spacing: 0;
text-transform: none;
font-weight: 400;
}
/* Short-viewport safety: collapse the page-title block so cards never collide */
@media (max-height: 620px) {
.page-title { font-size: 36px; max-width: 640px; top: 38vh; }
@ -720,16 +610,12 @@
.page-title { display: none; }
.page-sub { display: none; }
}
.overview::-webkit-scrollbar,
.archive::-webkit-scrollbar { width: 6px; }
.overview::-webkit-scrollbar-thumb,
.archive::-webkit-scrollbar-thumb {
.overview::-webkit-scrollbar { width: 6px; }
.overview::-webkit-scrollbar-thumb {
background: rgba(56,56,49,0.18);
border-radius: 3px;
}
/* Folio marks removed for a cleaner page. */
/* ============================================================
BIFROST OVERLAY — scenes inside the Overview page
============================================================ */
@ -1968,7 +1854,11 @@ html {
.join-footer {
margin-top: auto;
padding-top: clamp(2.5rem, 6vh, 4rem);
padding-bottom: clamp(0.5rem, 2vh, 1.5rem);
/* Generous bottom padding — the dot-nav sits 36px from the bottom of
the viewport, plus its own hit area; logos need clear room below
them. Without this, the rightmost footer items can appear to slide
under the nav on short viewports. */
padding-bottom: clamp(5rem, 10vh, 8rem);
border-top: 1px solid rgba(46, 46, 40, 0.1);
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@ -2008,10 +1898,14 @@ html {
margin-left: 0.2em;
}
/* Centre: the Fenja AI SVG logo */
/* Centre: the Fenja AI SVG logo.
Same height target (--foot-h) as the "Project Bifrost" wordmark on
the left and the Innovationsfonden mark on the right, so all three
read as equal-weight brand marks. */
.join-footer .foot-fenja {
justify-self: center;
}
.join-footer .foot-fenja img,
.join-footer .foot-fenja svg {
height: 100%;
width: auto;
@ -2282,30 +2176,30 @@ html {
<!-- LEFT SIDE — explanatory copy, visible only during the grid phase. -->
<div class="copy-stage" aria-live="polite">
<div class="copy-layer" data-copy="0">
<span class="tag">The stack</span>
<h2>All the capabilities to solve business use cases with AI.</h2>
<p>Four layers. One architecture. Every piece yours to own.</p>
<span class="tag">One complete platform</span>
<h2>Everything you need <em>in one place.</em></h2>
<p>Fenja AI brings models, knowledge, tools, and agents together in one platform for using and scaling AI across your organisation.</p>
</div>
<div class="copy-layer" data-copy="1">
<span class="tag">Yours, entirely</span>
<h2>Full client <strong>control.</strong><br/>Complete <em>sovereignty.</em></h2>
<p>Nothing proprietary above the hardware. Nothing that can be switched off from elsewhere.</p>
<span class="tag">Full control</span>
<h2>Your <strong>infrastructure.</strong><br/>Your <em>rules.</em></h2>
<p>Fenja AI is installed in your own client-managed environment, giving you full control over data, security, and governance.</p>
</div>
<div class="copy-layer" data-copy="2">
<span class="tag">Origin</span>
<h2>Built in <strong>Denmark.</strong><br/>For <em>Europe.</em></h2>
<p>Engineered against the standards that matter here — the regulations, the expectations, the posture.</p>
<span class="tag">Sovereignty</span>
<h2>Built in <strong>Denmark.</strong><br/>Ready for <em>Europe.</em></h2>
<p>Fenja AI is built in Denmark for European organisations that want trusted, sovereign AI on their own terms.</p>
</div>
</div>
<!-- LAYER CARDS — drop in, stack, then rearrange to grid -->
<article class="layer-card" data-layer="0" aria-label="Layer 1: the Foundation">
<span class="card-eyebrow">The Foundation</span>
<article class="layer-card" data-layer="0" aria-label="Layer 1: the AI">
<span class="card-eyebrow">The AI</span>
<div class="card-box">
<span class="card-grid-label" aria-hidden="true">The Foundation</span>
<span class="card-grid-label" aria-hidden="true">The AI</span>
<div class="card-content">
<h3 class="card-title"><b>AI</b> - An <b>open-source</b> model, running on your <em>own hardware.</em></h3>
<p class="card-body">Locally hosted and cutting edge in functionality. Completely safe and ready to support you and your business.</p>
<h3 class="card-title">An <b>open-source</b> model, running on your <em>own hardware.</em></h3>
<p class="card-body">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.</p>
</div>
<div class="card-brain" aria-hidden="true"></div>
</div>
@ -2316,8 +2210,8 @@ html {
<div class="card-box">
<span class="card-grid-label" aria-hidden="true">The Knowledge</span>
<div class="card-content">
<h3 class="card-title">The <b>vocabulary</b> of your business — <em>learned, retained.</em></h3>
<p class="card-body">The business vocabulary through which the AIs understand your domain, and the capability to learn while you collaborate.</p>
<h3 class="card-title">The business context that makes <em>AI understand your world.</em></h3>
<p class="card-body">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.</p>
</div>
<div class="card-brain" aria-hidden="true"></div>
</div>
@ -2328,8 +2222,8 @@ html {
<div class="card-box">
<span class="card-grid-label" aria-hidden="true">The Tools</span>
<div class="card-content">
<h3 class="card-title">How the AIs <b>act</b> — not just what they <em>know.</em></h3>
<p class="card-body">Data connections, RAG, semantic search, Python scripts — the instruments your agents reach for when solving real tasks.</p>
<h3 class="card-title">How AI <b>acts</b> &mdash; not just what it <em>knows.</em></h3>
<p class="card-body">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.</p>
</div>
<div class="card-brain" aria-hidden="true"></div>
</div>
@ -2340,8 +2234,8 @@ html {
<div class="card-box">
<span class="card-grid-label" aria-hidden="true">The Agents</span>
<div class="card-content">
<h3 class="card-title"><b>Specialists</b>, <em>collaborating</em> to solve distinct tasks.</h3>
<p class="card-body">Specialized AI agents solving distinct tasks in coordination — the way your people already work.</p>
<h3 class="card-title">Specialized AI agents <b>working together</b> around <em>real tasks.</em></h3>
<p class="card-body">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 &mdash; through specialisation and coordination.</p>
</div>
<div class="card-brain" aria-hidden="true"></div>
</div>
@ -2357,27 +2251,50 @@ html {
============================================================ -->
<section id="words-scene" aria-labelledby="words-head">
<h3 id="words-head" class="sr-only" style="position:absolute;left:-9999px;">
But a platform for regulated organisations has to be built with them, not just for them.
This is why we've invited you. To ensure Fenja AI is not just built for you, but with you.
</h3>
<div class="words-pin">
<p class="words" aria-hidden="true">
<!-- JS will replace these spans' opacity/transform; text is already here for no-JS / reduced-motion -->
<span class="w">But</span>
<span class="w">a</span>
<span class="w">platform</span>
<span class="w">for</span>
<span class="w">regulated</span>
<span class="w">organisations</span>
<span class="w">has</span>
<span class="w">to</span>
<span class="w">be</span>
<span class="w">built</span>
<span class="w hi">with</span>
<span class="w hi">them,</span>
<!-- The words sentence is rebuilt client-side when a first name is
available from /auth/me, so the user sees their own name fly in
as the "Erik." token. Static text below is the no-JS / no-name
fallback, used verbatim if /auth/me returns no firstName.
Spans must be laid out verbatim so the word-fly-in animation
has known DOM targets; the injected name variant swaps them in
place with the same .w structure preserved.
When a first name is present:
"This is why we've invited you, [Name]. To ensure Fenja AI is
not just built for you — but with you."
→ .hi on "[Name]." (the personalization beat)
→ .hi on "with" + "you." (the thesis)
When no first name:
"This is why we've invited you. To ensure Fenja AI is not
just built for you — but with you."
→ .hi on "you." (after invited)
→ .hi on "with" + "you." (the thesis) -->
<p class="words" aria-hidden="true" id="words-sentence">
<span class="w">This</span>
<span class="w">is</span>
<span class="w">why</span>
<span class="w">we&rsquo;ve</span>
<span class="w">invited</span>
<span class="w hi">you.</span>
<span class="w">To</span>
<span class="w">ensure</span>
<span class="w">Fenja</span>
<span class="w">AI</span>
<span class="w">is</span>
<span class="w">not</span>
<span class="w">just</span>
<span class="w">built</span>
<span class="w">for</span>
<span class="w">them.</span>
<span class="w">you</span>
<span class="w">&mdash;</span>
<span class="w">but</span>
<span class="w hi">with</span>
<span class="w hi">you.</span>
</p>
</div>
</section>
@ -2593,9 +2510,9 @@ html {
<div class="foot-project" aria-label="Project Bifrost">Project <em>Bifrost</em></div>
<div class="foot-fenja" aria-label="Fenja AI">
<!-- Fenja AI logo — same source geometry as the fixed brand mark
at top-left, with IDs prefixed "foot-" to avoid collisions. -->
<img src="/fenja/fenja-wordmark-black.svg" alt="Fenja AI" style="width: 100%; height: auto; display: block;" />
<!-- Fenja AI wordmark. Sized via .join-footer .foot-fenja img CSS,
which makes it match the height of the other two footer items. -->
<img src="/fenja/fenja-wordmark-black.svg" alt="Fenja AI" />
</div>
<div class="foot-innov" aria-label="Innovationsfonden">
@ -2618,52 +2535,46 @@ html {
</div><!-- /#overview-scroll -->
</section>
<!-- ───── Page 3 : ARCHIVE ───── -->
<section class="page page-archive" id="page-archive" data-screen-label="03 Archive">
<div class="archive">
<div class="inner">
<h1 class="headline">All twenty-three entries, in order of <em>publication.</em></h1>
<p class="sub">
Dates, sources and plate numbers for every card in the catalog. Hover a row to
lift it from the paper.
</p>
<!-- Dot-nav tray + nav (shared across all pages)
Seven entries, flat. The first targets the Timeline page (P1). The
next five each target a scene inside the Overview page (P2) — clicking
switches to Overview AND scrolls the overview's internal scroller to
that scene. The last (Join) goes to the final scene of Overview.
<table id="archive-table">
<thead>
<tr>
<th></th>
<th>Date</th>
<th>Register</th>
<th>Headline</th>
<th>Source</th>
</tr>
</thead>
<tbody id="archive-body"><!-- filled by JS --></tbody>
</table>
<div class="footer">
<div>Fenja AI&nbsp;&middot;&nbsp;Field Notes, No.&nbsp;IV</div>
<em>Catalog closed 14 April 2026.</em>
<div>Page III of III</div>
</div>
</div>
</div>
</section>
<!-- Dot-nav tray + nav (shared) -->
data-target : page id to activate
data-scroll-to : (optional) element id inside #overview-scroll to
scroll to AFTER the page switch. Scroll runs on the
Overview's internal scroller via Lenis (if booted)
or scroller.scrollTo() as a fallback. -->
<div class="dot-nav-tray"></div>
<nav class="dot-nav">
<button class="dot-btn is-active" data-target="page-timeline">
<span class="dot"></span>
<span class="label">Timeline</span>
</button>
<button class="dot-btn" data-target="page-overview">
<button class="dot-btn" data-target="page-overview" data-scroll-to="hero">
<span class="dot"></span>
<span class="label">Overview</span>
<span class="label">Hero</span>
</button>
<button class="dot-btn" data-target="page-archive">
<button class="dot-btn" data-target="page-overview" data-scroll-to="stack-scene">
<span class="dot"></span>
<span class="label">Archive</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>
</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>
</button>
<button class="dot-btn" data-target="page-overview" data-scroll-to="bifrost-join">
<span class="dot"></span>
<span class="label">Join</span>
</button>
</nav>

View file

@ -1,8 +1,9 @@
// ─────────────────────────────────────────────────────────────
// protected/timeline.js — timeline scroll, dot-nav, globe, archive.
// This file now also boots the Bifrost scenes (bifrost.js) the first
// protected/timeline.js — timeline scroll, dot-nav, globe.
// This file also boots the Bifrost scenes (bifrost.js) the first
// time the Overview page is activated via the dot-nav or the
// "Read the editor's note" button.
// "Read the editor's note" button, and fetches the user's first
// name from /auth/me so Scene 3 can personalise its sentence.
// ─────────────────────────────────────────────────────────────
// Signal to the Bifrost CSS that JS is running (enables the scroll-
@ -409,49 +410,89 @@ function buildGlobe(wrap, opts) {
});
}
/*
Archive table
*/
(function buildArchive() {
const tbody = document.getElementById('archive-body');
EVENTS.forEach((e, i) => {
const tr = document.createElement('tr');
tr.dataset.accent = e.accent;
tr.innerHTML = `
<td class="num">${String(i + 1).padStart(2, '0')}</td>
<td class="date">${e.date}</td>
<td class="kind">${e.kind}</td>
<td class="hed">${e.hed}</td>
<td class="src">${e.source}</td>
`;
tbody.appendChild(tr);
});
})();
/*
Dot-nav + Bifrost lazy-boot on Overview activation
*/
// Switch active page. When Overview becomes active, fire
// window.__bifrost.init() (idempotent) so the scenes wire up
// exactly once, on first visit. Subsequent activations just
// refresh ScrollTrigger in case the window was resized.
function activatePage(targetId) {
/**
* Switch active page. When Overview becomes active, boot Bifrost (once)
* and optionally scroll the Overview's internal scroller to a target.
*
* @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"
*/
function activatePage(targetId, scrollToId) {
document.querySelectorAll('.page').forEach(p => {
p.classList.toggle('is-active', p.id === targetId);
});
document.querySelectorAll('.dot-btn').forEach(b => {
b.classList.toggle('is-active', b.dataset.target === targetId);
});
if (targetId === 'page-overview' && window.__bifrost && typeof window.__bifrost.init === 'function') {
if (targetId === 'page-overview') {
if (window.__bifrost && typeof window.__bifrost.init === 'function') {
// Let the page transition start painting first, then init.
// 60ms is about one-and-a-half frames at 120Hz, plenty for the
// .page-overview.is-active class to flip. Short enough that the
// user can't scroll before ScrollTriggers are wired up.
setTimeout(() => window.__bifrost.init(), 60);
// 60ms is about 1.5 frames at 120Hz — plenty for the
// .page-overview.is-active class to flip, short enough that
// the user can't scroll before ScrollTriggers are wired up.
setTimeout(() => {
window.__bifrost.init();
// After init resolves, scroll to the requested scene (or top
// if none specified). Bifrost exposes scrollTo() which drives
// Lenis on the overview's internal scroller.
if (window.__bifrost.scrollTo) {
// One more frame so Lenis has picked up its first measurements.
requestAnimationFrame(() => window.__bifrost.scrollTo(scrollToId || 'hero'));
}
}, 60);
}
}
}
/**
* Update the .dot-btn.is-active highlight. Called on nav clicks (forward
* activation) and will be called from bifrost.js on scroll to track which
* Overview scene is currently visible.
*/
function setActiveDot(targetId, scrollToId) {
document.querySelectorAll('.dot-btn').forEach(b => {
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
// is active. Overview dots only match their specific scrollToId.
const isActive = targetId === 'page-overview'
? pageMatch && scrollMatch
: pageMatch;
b.classList.toggle('is-active', isActive);
});
}
// Expose so bifrost.js can call it from its scroll-spy. Reads happen from
// non-module code, so the global is the simplest integration surface.
window.__setActiveDot = setActiveDot;
document.querySelectorAll('.dot-btn').forEach(btn => {
btn.addEventListener('click', () => activatePage(btn.dataset.target));
btn.addEventListener('click', () => {
const targetId = btn.dataset.target;
const scrollToId = btn.dataset.scrollTo || null;
setActiveDot(targetId, scrollToId);
activatePage(targetId, scrollToId);
});
});
/*
First name propagation fetched from /auth/me on load.
Used by Scene 3 ("This is why we've invited you, [Name].").
Bifrost.js looks for window.__fenjaFirstName at init time and
rewrites the .words sentence before priming the fly-in animation.
*/
(async function fetchFirstName() {
try {
const res = await fetch('/auth/me', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json().catch(() => ({}));
window.__fenjaFirstName = data.firstName || null;
}
} catch {
// Offline — leave undefined; bifrost.js falls back to the
// no-name variant of the sentence.
}
})();