add mobile view at protected/mobile/ (UA-dispatched)
Desktop is a GSAP/Lenis/d3 animated experience that doesn't hold up on phones. Rather than retrofitting media queries across 1200+ lines of scroll-trigger code, add a completely isolated static mobile tree: - protected/mobile/index.html — one-page static flow covering the intro, 12 timeline events, hero, 4 capability cards, Bifrost reveal, 3 participation stops, and Join CTA. All copy duplicated from the desktop HTML on purpose — a shared data module would re-couple the two trees. - protected/mobile/mobile.css — paper/ink palette, all m-prefixed, zero cascade overlap with the desktop CSS. - protected/mobile/mobile.js — 60-line client: /auth/me check, /api/bifrost-join POST + panel swap, /auth/logout. No GSAP, no Lenis, no d3. Routing (server.js): - GET /timeline now UA-dispatches via MOBILE_UA_RE. Phone UAs get the mobile page; everything else gets the desktop page. - ?view=mobile and ?view=desktop query overrides take precedence over the UA sniff — for bad guesses or previewing the other version. - Gating is unchanged: protected/mobile/ is inside protected/ so the existing requireAuth + express.static gate covers it. Docs: - CLAUDE.md §routing now lists the UA dispatch as step 4. - PROJECT.md gets a new "Mobile view" section explaining the isolation rules (no shared JS/CSS, content duplicated manually). - CHECKLIST.md gains section H0 with dispatch curl checks, render verification on a phone, and an isolation audit that fails if mobile classes leak into the desktop HTML or vice versa. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f466e68a9
commit
72590b08bc
7 changed files with 897 additions and 4 deletions
32
CHECKLIST.md
32
CHECKLIST.md
|
|
@ -68,6 +68,38 @@ Do section A with extra attention to:
|
||||||
- [ ] After reload, `curl -I https://project-bifrost.fenja.ai/` includes `X-Powered-By: Express` (proves Nginx is still proxying to Node, not serving static files)
|
- [ ] After reload, `curl -I https://project-bifrost.fenja.ai/` includes `X-Powered-By: Express` (proves Nginx is still proxying to Node, not serving static files)
|
||||||
- [ ] Rate-limit zone still exists: `grep -r "limit_req_zone" /etc/nginx/`
|
- [ ] Rate-limit zone still exists: `grep -r "limit_req_zone" /etc/nginx/`
|
||||||
|
|
||||||
|
## H0. After changes to the mobile view (`protected/mobile/*`)
|
||||||
|
|
||||||
|
Covers `protected/mobile/{index.html,mobile.css,mobile.js}` and the `wantsMobileView()` / `MOBILE_UA_RE` dispatch in `server.js`.
|
||||||
|
|
||||||
|
Dispatch (simulate mobile from a desktop browser by setting a phone UA, or actually load on a phone):
|
||||||
|
- [ ] `curl -I -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' https://project-bifrost.fenja.ai/timeline -b 'fenja_session=<id>'` → 200 and HTML that contains `/mobile/mobile.css` (i.e. served the mobile page, not desktop)
|
||||||
|
- [ ] Same curl with a desktop UA (the default curl UA is fine) → 200 HTML that does NOT reference `/mobile/mobile.css` (served desktop)
|
||||||
|
- [ ] `/timeline?view=mobile` on a desktop browser → mobile HTML
|
||||||
|
- [ ] `/timeline?view=desktop` on a phone → desktop HTML (falls back gracefully — expected to look awkward but render)
|
||||||
|
- [ ] Logged-out GET `/timeline` still redirects to `/` regardless of UA (`requireAuth` runs before the UA dispatch)
|
||||||
|
|
||||||
|
Mobile page render (on a phone or DevTools device-emulation at 390×844):
|
||||||
|
- [ ] Masthead shows Fenja wordmark on the left, "Log out" button on the right. No horizontal scrollbar at any vertical position
|
||||||
|
- [ ] Intro title + body renders; the second paragraph ("As AI moves into our hospitals...") is crimson
|
||||||
|
- [ ] 12 timeline events render as a vertical list, each with kind + date + headline + body + source. Accent colours correct: Rupture = crimson, Editorial/Regulation = copper, Field Note (2024) = copper, Field Note (2025 CPH bill) = ochre, Product = terracotta
|
||||||
|
- [ ] Hero reads "Fenja AI — Secure & Sovereign, hosted where it belongs." with the italics visible
|
||||||
|
- [ ] Capabilities section renders 4 cards ("1 / 4" through "4 / 4") stacked vertically
|
||||||
|
- [ ] "Project Bifrost" reveal renders as a centered paragraph
|
||||||
|
- [ ] Three stops (Community, Advisory Council, Pilot Projects) render in order; no illustration files are required for the mobile view to be usable
|
||||||
|
- [ ] Join button is tappable and large enough (no 40px-minimum-tap-target issue)
|
||||||
|
|
||||||
|
Join CTA behaviour:
|
||||||
|
- [ ] Tap "Join Project Bifrost" → button disables, POST `/api/bifrost-join` returns 200, CTA panel hides and confirmation panel appears
|
||||||
|
- [ ] `sudo -u fenja node /opt/fenja/bin/joins.js list` on the VPS shows a new row with the user's email
|
||||||
|
- [ ] Logout button → POST `/auth/logout`, redirect to `/`, next visit to `/timeline` redirects to `/` (no session cookie)
|
||||||
|
|
||||||
|
Isolation audit (run after any change to desktop CSS/JS to confirm no mobile regression — and vice versa):
|
||||||
|
- [ ] `grep -r 'class="m-' protected/index.html` returns nothing (mobile classes only live in `protected/mobile/`)
|
||||||
|
- [ ] `grep -r 'from .*bifrost' protected/mobile/` returns nothing (mobile doesn't import desktop JS)
|
||||||
|
- [ ] Mobile page `<head>` loads exactly two stylesheets: `/fenja/colors_and_type.css` and `/mobile/mobile.css`
|
||||||
|
- [ ] Mobile page loads exactly one script: `/mobile/mobile.js` — no gsap/lenis/d3 references
|
||||||
|
|
||||||
## H1. After changes to the Join-CTA tracking (bifrost_joins)
|
## H1. After changes to the Join-CTA tracking (bifrost_joins)
|
||||||
|
|
||||||
- [ ] [browser, logged in] Click the final "Join Project Bifrost" CTA → confirmation panel renders (staggered checkmarks appear)
|
- [ ] [browser, logged in] Click the final "Join Project Bifrost" CTA → confirmation panel renders (staggered checkmarks appear)
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,9 @@ Single-process Express app bound to `127.0.0.1:3000`. Nginx is the only public i
|
||||||
1. Security headers + CSP (strict — `script-src 'self'`, no inline scripts)
|
1. Security headers + CSP (strict — `script-src 'self'`, no inline scripts)
|
||||||
2. `/auth/*` router (public)
|
2. `/auth/*` router (public)
|
||||||
3. `GET /` dispatches to `protected/index.html` (timeline) if `currentSession(req)` is set, else `public/entrance.html` — the same URL serves different pages depending on cookie state
|
3. `GET /` dispatches to `protected/index.html` (timeline) if `currentSession(req)` is set, else `public/entrance.html` — the same URL serves different pages depending on cookie state
|
||||||
4. `express.static(public)` — ungated assets
|
4. `GET /timeline` is UA-dispatched: mobile UAs (see `MOBILE_UA_RE` in `server.js`) get `protected/mobile/index.html`; everyone else gets the animated desktop view at `protected/index.html`. A `?view=mobile|desktop` query override exists for forcing one or the other when the guess is wrong.
|
||||||
5. `requireAuth` **then** `express.static(protected)` — gating runs *before* the file is read off disk. Adding a file to `protected/` gates it automatically; adding to `public/` exposes it automatically.
|
5. `express.static(public)` — ungated assets
|
||||||
|
6. `requireAuth` **then** `express.static(protected)` — gating runs *before* the file is read off disk. Adding a file to `protected/` gates it automatically; adding to `public/` exposes it automatically. `protected/mobile/` is itself a child of `protected/`, so the mobile CSS + JS are gated just like the desktop assets.
|
||||||
|
|
||||||
**Auth flow** (see `src/auth.js`): email → `POST /auth/login` → the server checks the invite list; on hit it issues an opaque 256-bit session ID stored in SQLite and sets it as an `HttpOnly; Secure; SameSite=Lax` cookie (returns `{ok, firstName}`). On miss it returns `403 {error:"not_invited"}` — email enumeration is acceptable here by design (invite-list-only, preview content). `POST /auth/logout` deletes the session row. `GET /auth/me` returns `{email, firstName}` or 401. No one-time codes, no SMTP, no JWTs; revocation is a DELETE.
|
**Auth flow** (see `src/auth.js`): email → `POST /auth/login` → the server checks the invite list; on hit it issues an opaque 256-bit session ID stored in SQLite and sets it as an `HttpOnly; Secure; SameSite=Lax` cookie (returns `{ok, firstName}`). On miss it returns `403 {error:"not_invited"}` — email enumeration is acceptable here by design (invite-list-only, preview content). `POST /auth/logout` deletes the session row. `GET /auth/me` returns `{email, firstName}` or 401. No one-time codes, no SMTP, no JWTs; revocation is a DELETE.
|
||||||
|
|
||||||
|
|
@ -59,7 +60,7 @@ These are from `PROJECT.md`. A change that breaks any of them is a security regr
|
||||||
|
|
||||||
## Safe-to-change vs. flag-before-touching
|
## Safe-to-change vs. flag-before-touching
|
||||||
|
|
||||||
- **Safe**: content/layout in `public/` or `protected/`, the timeline (`protected/timeline.js`, data, visuals), copy, fonts/colors in `protected/fenja/colors_and_type.css`. Adding a new gated page = drop file in `protected/` and it's automatically gated.
|
- **Safe**: content/layout in `public/` or `protected/`, the timeline (`protected/timeline.js`, data, visuals), copy, fonts/colors in `protected/fenja/colors_and_type.css`. Adding a new gated page = drop file in `protected/` and it's automatically gated. Mobile view: `protected/mobile/*` is its own static tree with `m-`-prefixed CSS and zero shared JS — edit freely, it can't collide with the desktop cascade.
|
||||||
- **Flag in the change summary**: anything in `src/`, `server.js`, `deploy/`, `package.json`, or `.env.example`. These touch operational surface area and need a human to redeploy/verify.
|
- **Flag in the change summary**: anything in `src/`, `server.js`, `deploy/`, `package.json`, or `.env.example`. These touch operational surface area and need a human to redeploy/verify.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
|
||||||
19
PROJECT.md
19
PROJECT.md
|
|
@ -38,10 +38,15 @@ The root URL `/` is context-aware: unauthenticated → entrance, authenticated
|
||||||
│ ├── entrance.html The login page
|
│ ├── entrance.html The login page
|
||||||
│ └── entrance.js Two-step form behaviour
|
│ └── entrance.js Two-step form behaviour
|
||||||
├── protected/ Served only with valid session cookie
|
├── protected/ Served only with valid session cookie
|
||||||
│ ├── index.html The timeline (authed home page)
|
│ ├── index.html The animated desktop timeline (authed home page)
|
||||||
│ ├── timeline.js Timeline scroll/globe/archive logic
|
│ ├── timeline.js Timeline scroll/globe/archive logic
|
||||||
|
│ ├── bifrost.js Overview page pinned scenes (capabilities, bifrost, meaning, join)
|
||||||
│ ├── archive.html Legacy deep-link placeholder
|
│ ├── archive.html Legacy deep-link placeholder
|
||||||
│ ├── archive.js Logout button
|
│ ├── archive.js Logout button
|
||||||
|
│ ├── mobile/ Minimum-viable mobile view, UA-dispatched from GET /timeline.
|
||||||
|
│ │ ├── index.html Static one-page flow: intro → events → hero → caps → bifrost → join
|
||||||
|
│ │ ├── mobile.css All m-prefixed; zero overlap with the desktop cascade
|
||||||
|
│ │ └── mobile.js Auth check + join POST + logout. No GSAP/Lenis/d3.
|
||||||
│ ├── fenja/
|
│ ├── fenja/
|
||||||
│ │ ├── colors_and_type.css
|
│ │ ├── colors_and_type.css
|
||||||
│ │ └── fonts/ Manrope + Newsreader variable fonts
|
│ │ └── fonts/ Manrope + Newsreader variable fonts
|
||||||
|
|
@ -63,6 +68,18 @@ The root URL `/` is context-aware: unauthenticated → entrance, authenticated
|
||||||
└── CHECKLIST.md Manual test checklist after any change
|
└── CHECKLIST.md Manual test checklist after any change
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Mobile view
|
||||||
|
|
||||||
|
The animated desktop site is a heavy GSAP / Lenis / d3 experience that does not hold up on phones. Rather than retrofitting the animations, there is a completely separate static mobile tree at `protected/mobile/`. The server inspects the `User-Agent` on `GET /timeline` (see `MOBILE_UA_RE` in `server.js`) and serves `protected/mobile/index.html` to phone UAs, the animated view to everything else.
|
||||||
|
|
||||||
|
Key properties:
|
||||||
|
|
||||||
|
- **No shared JS or CSS.** The mobile page loads `/fenja/colors_and_type.css` (fonts only) and its own `mobile.css`. No GSAP, no Lenis, no d3, no ScrollTrigger. Every class is `m-`-prefixed so there is no cascade collision with `protected/index.html`.
|
||||||
|
- **One page, static content.** All 12 timeline events, the 4 capability cards, the Bifrost reveal, the three participation stops, and the Join CTA are rendered as plain HTML. No pinning, no scrubbed animations, no horizontal scroll.
|
||||||
|
- **Same auth + gating.** `protected/mobile/` is inside `protected/`, so the existing `requireAuth` + `express.static(protected)` gate covers the assets automatically. No new route-level gating needed.
|
||||||
|
- **Override.** `/timeline?view=mobile` and `/timeline?view=desktop` force one or the other, in case the UA sniff guesses wrong. The mobile page footer links to `/timeline?view=desktop` so a phone user who wants the full experience can opt in.
|
||||||
|
- **Content stays in sync manually.** The 12-event copy and the capability / bifrost copy is duplicated in both the desktop HTML and the mobile HTML. When rewriting that copy, remember to update both — there is no shared data module on purpose (a shared file would re-couple the two trees).
|
||||||
|
|
||||||
## How auth works
|
## How auth works
|
||||||
|
|
||||||
The site is invite-list-only. The invite list IS the authentication factor — a person who knows an invited email can log in as them. The site exposes only marketing/preview content, so this is acceptable by design. If a user needs to be kicked out, remove their invite AND delete their session rows (see OPERATIONS.md).
|
The site is invite-list-only. The invite list IS the authentication factor — a person who knows an invited email can log in as them. The site exposes only marketing/preview content, so this is acceptable by design. If a user needs to be kicked out, remove their invite AND delete their session rows (see OPERATIONS.md).
|
||||||
|
|
|
||||||
260
protected/mobile/index.html
Normal file
260
protected/mobile/index.html
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<meta name="theme-color" content="#faf6ee" />
|
||||||
|
<title>Fenja AI — Project Bifrost</title>
|
||||||
|
<link rel="stylesheet" href="/fenja/colors_and_type.css" />
|
||||||
|
<link rel="stylesheet" href="/mobile/mobile.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="m-masthead">
|
||||||
|
<a class="m-logo" href="/" aria-label="Back to entrance">
|
||||||
|
<img src="/fenja/fenja-wordmark-black.svg" alt="Fenja" />
|
||||||
|
</a>
|
||||||
|
<button type="button" class="m-logout" id="m-logout" aria-label="Log out">
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="m-main">
|
||||||
|
|
||||||
|
<!-- ─── Timeline intro ─── -->
|
||||||
|
<section class="m-section m-intro">
|
||||||
|
<h1 class="m-title">When AI runs Europe, who runs the <em>AI?</em></h1>
|
||||||
|
<p class="m-body">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p class="m-body m-body--accent">
|
||||||
|
As AI moves into our hospitals, our courts, our defence, our schools — can we afford for the switch to sit in <em>Washington?</em>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── Vertical timeline ─── -->
|
||||||
|
<section class="m-section m-timeline" aria-label="Timeline of events">
|
||||||
|
<h2 class="m-section-head">The last 18 months, in twelve <em>moments.</em></h2>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="copper">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Editorial</span><span class="m-event-date">September 2024</span></div>
|
||||||
|
<h3 class="m-event-hed">Three American firms run 70% of Europe’s cloud — and almost all of its <em>AI.</em></h3>
|
||||||
|
<p class="m-event-body">Mario Draghi’s verdict to the European Parliament: only four of the world’s top fifty tech companies are European. “It is too late,” he writes, to challenge American cloud providers. Without radical reform, the EU faces “slow agony.”</p>
|
||||||
|
<div class="m-event-source">The Draghi Report · Brussels</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="copper">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Field Note</span><span class="m-event-date">December 2024</span></div>
|
||||||
|
<h3 class="m-event-hed">Denmark warns: digital society is now “extremely <em>vulnerable.</em>”</h3>
|
||||||
|
<p class="m-event-body">The expert group on tech giants reports: dependence on a handful of foreign suppliers is no longer a procurement question. It is a national security one. Minister Bodskov: “we need to fence in the tech giants.”</p>
|
||||||
|
<div class="m-event-source">Danish Expert Group · Copenhagen</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="crimson">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Rupture</span><span class="m-event-date">January 2025</span></div>
|
||||||
|
<h3 class="m-event-hed">Trump refuses to rule out military force against <em>Greenland.</em></h3>
|
||||||
|
<p class="m-event-body">Two weeks before inauguration, the president-elect threatens “very high tariffs” on Denmark. The shock in Copenhagen is total.</p>
|
||||||
|
<div class="m-event-source">Mar-a-Lago · Press Conference</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="crimson">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Rupture</span><span class="m-event-date">May 2025</span></div>
|
||||||
|
<h3 class="m-event-hed">Microsoft cuts off the ICC chief prosecutor’s <em>email.</em></h3>
|
||||||
|
<p class="m-event-body">A US tech company, complying with a US executive order, disables the digital life of an officer of an international tribunal in the Netherlands. The “kill switch” stops being theoretical.</p>
|
||||||
|
<div class="m-event-source">Associated Press · The Hague</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="copper">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Regulation</span><span class="m-event-date">June 2025</span></div>
|
||||||
|
<h3 class="m-event-hed">Microsoft admits under oath: it cannot guarantee European <em>sovereignty.</em></h3>
|
||||||
|
<p class="m-event-body">Even data on European soil, with European staff, encrypted with European keys — US authorities can compel disclosure under the CLOUD Act. The legal fiction collapses.</p>
|
||||||
|
<div class="m-event-source">French Senate Hearing · Paris</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="ochre">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Field Note</span><span class="m-event-date">June 2025</span></div>
|
||||||
|
<h3 class="m-event-hed">Copenhagen’s Microsoft bill jumps <em>72% in five years.</em></h3>
|
||||||
|
<p class="m-event-body">From 313 to 538 million Danish kroner. Copenhagen and Aarhus announce they will leave Microsoft entirely. The minister of emergency tells companies: “create exit plans for cloud services.”</p>
|
||||||
|
<div class="m-event-source">Copenhagen Municipality · Finance Report</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="copper">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Regulation</span><span class="m-event-date">Summer 2025</span></div>
|
||||||
|
<h3 class="m-event-hed">A Danish minister tells industry: prepare your exit plans for <em>cloud services.</em></h3>
|
||||||
|
<p class="m-event-body">Caroline Stage Olsen begins moving her ministry off Microsoft 365. The minister of emergency preparedness urges every Danish company to do the same. Continued dependence is now classified as a vulnerability.</p>
|
||||||
|
<div class="m-event-source">Danish Ministry of Digital Affairs</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="crimson">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Rupture</span><span class="m-event-date">August 2025</span></div>
|
||||||
|
<h3 class="m-event-hed">Trump threatens tariffs against any country with digital <em>regulations.</em></h3>
|
||||||
|
<p class="m-event-body">“American technology is not the world’s piggy bank.” The DSA, the DMA, the AI Act — all reframed as discriminatory trade barriers. Chip export restrictions are added to the list of consequences.</p>
|
||||||
|
<div class="m-event-source">Truth Social · Washington</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="crimson">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Rupture</span><span class="m-event-date">January 2026</span></div>
|
||||||
|
<h3 class="m-event-hed">Trump imposes tariffs on Denmark and seven <em>European nations.</em></h3>
|
||||||
|
<p class="m-event-body">10% in February. 25% from June — until Denmark cedes Greenland. Denmark, Norway, Sweden, Finland, France, Germany, Netherlands, UK. The post-war alliance, weaponised.</p>
|
||||||
|
<div class="m-event-source">Presidential Executive Order</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="crimson">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Rupture</span><span class="m-event-date">January 2026</span></div>
|
||||||
|
<h3 class="m-event-hed">Denmark names the United States as a national security <em>threat.</em></h3>
|
||||||
|
<p class="m-event-body">For the first time in history, the official Danish threat assessment lists the US alongside Russia and China. Defence committee chair Rasmus Jarlov tells Washington: “You are the threat. Not them.”</p>
|
||||||
|
<div class="m-event-source">Danish Defence Intelligence · FE</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="terracotta">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Product</span><span class="m-event-date">February 2026</span></div>
|
||||||
|
<h3 class="m-event-hed">The court that prosecutes war crimes can no longer use American <em>software.</em></h3>
|
||||||
|
<p class="m-event-body">The ICC migrates to OpenDesk — an open-source suite delivered by the German Centre for Digital Sovereignty. If a global tribunal cannot trust Microsoft, the implication for every other European institution is unavoidable.</p>
|
||||||
|
<div class="m-event-source">Handelsblatt · The Hague</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-event" data-accent="copper">
|
||||||
|
<div class="m-event-meta"><span class="m-event-kind">Regulation</span><span class="m-event-date">Q1 2026</span></div>
|
||||||
|
<h3 class="m-event-hed">Europe drafts a sovereignty law as US firms still hold 70% of the <em>cloud.</em></h3>
|
||||||
|
<p class="m-event-body">Europe writes rules for infrastructure it does not own. US hyperscalers add €10 billion of European capacity every quarter — more than Gaia-X spent in a decade. The servers stay in Texas. The AI models stay in California. The law changes neither.</p>
|
||||||
|
<div class="m-event-source">European Commission · Brussels</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── Transition ─── -->
|
||||||
|
<section class="m-section m-transition">
|
||||||
|
<p class="m-transition-eyebrow">How Fenja AI <em>addresses</em> this</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── Hero ─── -->
|
||||||
|
<section class="m-section m-hero">
|
||||||
|
<p class="m-eyebrow">For regulated environments</p>
|
||||||
|
<h2 class="m-hero-title">
|
||||||
|
Fenja AI — Secure & <em>Sovereign,</em> hosted where it <em>belongs.</em>
|
||||||
|
</h2>
|
||||||
|
<p class="m-hero-lede">
|
||||||
|
Fenja AI is a sovereign AI platform, enabling highly advanced AI capabilities hosted within the client’s own secure infrastructure.
|
||||||
|
</p>
|
||||||
|
<div class="m-support">
|
||||||
|
<span class="m-support-label">Supported by</span>
|
||||||
|
<span class="m-support-name">Innovationsfonden</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── Capabilities (4 layers) ─── -->
|
||||||
|
<section class="m-section m-caps" aria-labelledby="m-caps-head">
|
||||||
|
<p class="m-section-eyebrow">One complete platform</p>
|
||||||
|
<h2 id="m-caps-head" class="m-section-head">The Fenja AI platform in <em>four steps.</em></h2>
|
||||||
|
|
||||||
|
<article class="m-cap">
|
||||||
|
<span class="m-cap-num">1 / 4</span>
|
||||||
|
<p class="m-cap-eyebrow">The AI</p>
|
||||||
|
<h3 class="m-cap-title">An <b>open-source</b> model, running on your <em>own hardware.</em></h3>
|
||||||
|
<p class="m-cap-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>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-cap">
|
||||||
|
<span class="m-cap-num">2 / 4</span>
|
||||||
|
<p class="m-cap-eyebrow">The Knowledge</p>
|
||||||
|
<h3 class="m-cap-title">The business context that makes <em>AI understand your world.</em></h3>
|
||||||
|
<p class="m-cap-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>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-cap">
|
||||||
|
<span class="m-cap-num">3 / 4</span>
|
||||||
|
<p class="m-cap-eyebrow">The Tools</p>
|
||||||
|
<h3 class="m-cap-title">How AI <b>acts</b> — not just what it <em>knows.</em></h3>
|
||||||
|
<p class="m-cap-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>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-cap">
|
||||||
|
<span class="m-cap-num">4 / 4</span>
|
||||||
|
<p class="m-cap-eyebrow">The Agents</p>
|
||||||
|
<h3 class="m-cap-title">Specialized AI agents <b>working together</b> around <em>real tasks.</em></h3>
|
||||||
|
<p class="m-cap-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 — through specialisation and coordination.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── Project Bifrost reveal ─── -->
|
||||||
|
<section class="m-section m-bifrost">
|
||||||
|
<p class="m-bifrost-eyebrow">Introducing</p>
|
||||||
|
<h2 class="m-bifrost-name">Project <em>Bifrost</em></h2>
|
||||||
|
<p class="m-bifrost-sub">
|
||||||
|
The bridge <em>between</em> an industrial-grade AI platform and the realities of regulated organisations — built <em>with</em> them, not just for them.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── What Project Bifrost means ─── -->
|
||||||
|
<section class="m-section m-meaning" aria-labelledby="m-meaning-head">
|
||||||
|
<p class="m-section-eyebrow">The invitation</p>
|
||||||
|
<h2 id="m-meaning-head" class="m-section-head">
|
||||||
|
What being part of <em>Project Bifrost</em> means
|
||||||
|
</h2>
|
||||||
|
<p class="m-meaning-lede">
|
||||||
|
Three ways to <em>shape</em>, to <em>influence</em>, and to <em>build with</em> the platform from the inside.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<article class="m-stop">
|
||||||
|
<p class="m-stop-eyebrow">Be part of a</p>
|
||||||
|
<h3 class="m-stop-title"><em>Community</em></h3>
|
||||||
|
<p class="m-stop-sub">Shape the future together</p>
|
||||||
|
<p class="m-stop-body">Join a select community of organisations helping define the future of trusted sovereign AI in Denmark and Europe. At a time when Europe needs greater technological independence, this is an opportunity to contribute to an AI platform built on trust, shared ambition, and a common mission.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-stop">
|
||||||
|
<p class="m-stop-eyebrow">Be part of an</p>
|
||||||
|
<h3 class="m-stop-title"><em>Advisory Council</em></h3>
|
||||||
|
<p class="m-stop-sub">Turn insight into influence</p>
|
||||||
|
<p class="m-stop-body">Take part in regular advisory council sessions where your input directly shapes the product and platform roadmap. Gain first-hand insight into cutting-edge AI developments and help influence what is built, which capabilities are prioritised, and how the platform evolves to meet real organisational needs.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="m-stop">
|
||||||
|
<p class="m-stop-eyebrow">Be part of</p>
|
||||||
|
<h3 class="m-stop-title"><em>Pilot Projects</em></h3>
|
||||||
|
<p class="m-stop-sub">Access the platform before others</p>
|
||||||
|
<p class="m-stop-body">A select number of Project Bifrost participants will have the opportunity to join pilot projects and gain early access to the platform at a significantly reduced price, subsidised by the Innovation Fund. This gives your organisation the chance to explore cutting-edge sovereign AI early, realise value at low cost, and help shape the platform through real-world use.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── Join CTA ─── -->
|
||||||
|
<section class="m-section m-join" aria-labelledby="m-join-head">
|
||||||
|
<div class="m-join-panel" id="m-join-cta">
|
||||||
|
<p class="m-join-eyebrow">Ready?</p>
|
||||||
|
<h2 id="m-join-head" class="m-join-headline">
|
||||||
|
Join us in shaping the future of <em>trusted sovereign AI.</em>
|
||||||
|
</h2>
|
||||||
|
<button type="button" class="m-join-button" id="m-join-btn">
|
||||||
|
Join Project Bifrost
|
||||||
|
</button>
|
||||||
|
<p class="m-join-subtext">Built in Denmark. Supported by the Innovation Fund.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-join-panel m-join-confirmation" id="m-join-confirm" hidden>
|
||||||
|
<p class="m-join-eyebrow">You’re in</p>
|
||||||
|
<h2 class="m-join-headline">Thank you for joining <em>Project Bifrost</em>.</h2>
|
||||||
|
<ul class="m-confirm-list">
|
||||||
|
<li>The <em>Fenja AI team</em> will reach out to you shortly.</li>
|
||||||
|
<li>You’ll receive an invitation to the <em>project portal</em> soon — where all project communication, materials, and updates will live.</li>
|
||||||
|
<li>We’re currently setting the date for the <em>first advisory council meeting</em>. You’ll be invited as soon as it’s confirmed.</li>
|
||||||
|
<li>We’ll be in touch shortly about your participation in the <em>pilot project</em>.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="m-foot">
|
||||||
|
<div class="m-foot-row">
|
||||||
|
<span class="m-foot-project">Project <em>Bifrost</em></span>
|
||||||
|
<img class="m-foot-fenja" src="/fenja/fenja-wordmark-black.svg" alt="Fenja AI" />
|
||||||
|
</div>
|
||||||
|
<p class="m-foot-note">
|
||||||
|
You’re viewing the mobile-optimised version. <a href="/timeline?view=desktop">Open the desktop experience</a>.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/mobile/mobile.js" defer></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
482
protected/mobile/mobile.css
Normal file
482
protected/mobile/mobile.css
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
protected/mobile/mobile.css — minimal mobile view.
|
||||||
|
|
||||||
|
Design intent: the desktop site is an animated editorial
|
||||||
|
experience; this file is the legible fallback. Zero animations,
|
||||||
|
zero dependencies beyond /fenja/colors_and_type.css (loaded
|
||||||
|
first for the font-face declarations). Every class here is
|
||||||
|
`m-`-prefixed so there is no accidental cascade overlap with
|
||||||
|
the desktop CSS in protected/index.html.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--paper: #faf6ee;
|
||||||
|
--paper-2: #f3efe4;
|
||||||
|
--ink: #2e2e28;
|
||||||
|
--ink-soft: #5f5e5e;
|
||||||
|
--ink-dim: #8a887f;
|
||||||
|
--line: rgba(46, 46, 40, 0.12);
|
||||||
|
--line-soft: rgba(46, 46, 40, 0.06);
|
||||||
|
--crimson: #8a3a2f;
|
||||||
|
--copper: #6d8c7c;
|
||||||
|
--ochre: #c29d59;
|
||||||
|
--terracotta: #b96b58;
|
||||||
|
--accent: #b96b58;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "Newsreader", Georgia, "Times New Roman", serif;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Masthead ───────────────────────────────────────────── */
|
||||||
|
.m-masthead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--paper);
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
.m-logo { display: inline-block; line-height: 0; }
|
||||||
|
.m-logo img { height: 22px; width: auto; display: block; }
|
||||||
|
.m-logout {
|
||||||
|
all: unset;
|
||||||
|
font-family: "Manrope", system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.m-logout:active { color: var(--ink); }
|
||||||
|
|
||||||
|
/* ─── Main layout ────────────────────────────────────────── */
|
||||||
|
.m-main {
|
||||||
|
padding-bottom: 32px;
|
||||||
|
}
|
||||||
|
.m-section {
|
||||||
|
padding: 40px 22px;
|
||||||
|
}
|
||||||
|
.m-section + .m-section {
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Shared type ────────────────────────────────────────── */
|
||||||
|
.m-title {
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
margin: 0 0 22px 0;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.m-title em { font-style: italic; font-weight: 700; }
|
||||||
|
|
||||||
|
.m-section-head {
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.012em;
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.m-section-head em { font-style: italic; font-weight: 700; }
|
||||||
|
|
||||||
|
.m-section-eyebrow {
|
||||||
|
font-family: "Manrope", system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-body {
|
||||||
|
margin: 0 0 14px 0;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.m-body em { font-style: italic; font-weight: 700; color: var(--ink); }
|
||||||
|
|
||||||
|
/* Crimson bottom paragraph — consistent with the desktop intro */
|
||||||
|
.m-body--accent,
|
||||||
|
.m-body--accent em { color: var(--crimson); }
|
||||||
|
|
||||||
|
/* ─── Intro ──────────────────────────────────────────────── */
|
||||||
|
.m-intro { padding-top: 28px; }
|
||||||
|
|
||||||
|
/* ─── Timeline events ────────────────────────────────────── */
|
||||||
|
.m-timeline {
|
||||||
|
background: var(--paper-2);
|
||||||
|
}
|
||||||
|
.m-event {
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.m-event:last-child { border-bottom: none; }
|
||||||
|
.m-event-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-family: "Manrope", system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.m-event-kind {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.m-event[data-accent="crimson"] .m-event-kind { color: var(--crimson); }
|
||||||
|
.m-event[data-accent="copper"] .m-event-kind { color: var(--copper); }
|
||||||
|
.m-event[data-accent="ochre"] .m-event-kind { color: var(--ochre); }
|
||||||
|
.m-event[data-accent="terracotta"] .m-event-kind { color: var(--terracotta); }
|
||||||
|
.m-event-date { color: var(--ink-dim); font-weight: 500; }
|
||||||
|
.m-event-hed {
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.22;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: var(--ink);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.m-event-hed em { font-style: italic; font-weight: 700; }
|
||||||
|
.m-event-body {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.m-event-source {
|
||||||
|
font-family: "Manrope", system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Transition strip ───────────────────────────────────── */
|
||||||
|
.m-transition {
|
||||||
|
padding-top: 56px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.m-transition-eyebrow {
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.m-transition-eyebrow em { font-style: italic; font-weight: 700; }
|
||||||
|
|
||||||
|
/* ─── Hero ───────────────────────────────────────────────── */
|
||||||
|
.m-hero { padding-top: 24px; padding-bottom: 52px; }
|
||||||
|
.m-eyebrow {
|
||||||
|
font-family: "Manrope", system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
.m-hero-title {
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.018em;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.m-hero-title em { font-style: italic; font-weight: 700; color: var(--accent); }
|
||||||
|
.m-hero-lede {
|
||||||
|
margin: 0 0 28px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.m-support {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.m-support-label {
|
||||||
|
font-family: "Manrope", system-ui, sans-serif;
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
.m-support-name {
|
||||||
|
font-family: "Manrope", system-ui, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #3c6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Capabilities ───────────────────────────────────────── */
|
||||||
|
.m-cap {
|
||||||
|
padding: 22px 20px;
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
background: #fffdf6;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.m-cap:last-child { margin-bottom: 0; }
|
||||||
|
.m-cap-num {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.m-cap-eyebrow {
|
||||||
|
font-family: "Manrope", system-ui, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
.m-cap-title {
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 21px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: var(--ink);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.m-cap-title b {
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.m-cap-title em {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.m-cap-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Bifrost reveal ─────────────────────────────────────── */
|
||||||
|
.m-bifrost {
|
||||||
|
padding-top: 56px;
|
||||||
|
padding-bottom: 56px;
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(to bottom,
|
||||||
|
var(--paper) 0%, var(--paper-2) 50%, var(--paper) 100%);
|
||||||
|
}
|
||||||
|
.m-bifrost-eyebrow {
|
||||||
|
font-family: "Manrope", system-ui, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
}
|
||||||
|
.m-bifrost-name {
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 44px;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0 0 22px 0;
|
||||||
|
}
|
||||||
|
.m-bifrost-name em {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.m-bifrost-sub {
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
max-width: 38ch;
|
||||||
|
}
|
||||||
|
.m-bifrost-sub em { font-style: italic; font-weight: 700; color: var(--ink); }
|
||||||
|
|
||||||
|
/* ─── What Bifrost means ─────────────────────────────────── */
|
||||||
|
.m-meaning-lede {
|
||||||
|
margin: 0 0 28px 0;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.m-meaning-lede em { font-style: italic; font-weight: 700; color: var(--ink); }
|
||||||
|
.m-stop {
|
||||||
|
padding: 22px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.m-stop:first-of-type { border-top: none; padding-top: 6px; }
|
||||||
|
.m-stop-eyebrow {
|
||||||
|
font-family: "Manrope", system-ui, sans-serif;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
.m-stop-title {
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.012em;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
}
|
||||||
|
.m-stop-title em { font-style: italic; font-weight: 700; color: var(--accent); }
|
||||||
|
.m-stop-sub {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.m-stop-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Join CTA ───────────────────────────────────────────── */
|
||||||
|
.m-join {
|
||||||
|
background: var(--paper-2);
|
||||||
|
}
|
||||||
|
.m-join-panel {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.m-join-eyebrow {
|
||||||
|
font-family: "Manrope", system-ui, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 14px 0;
|
||||||
|
}
|
||||||
|
.m-join-headline {
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.12;
|
||||||
|
letter-spacing: -0.012em;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.m-join-headline em { font-style: italic; font-weight: 700; color: var(--accent); }
|
||||||
|
.m-join-button {
|
||||||
|
all: unset;
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
padding: 14px 26px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 6px 14px -8px rgba(46,46,40,0.35);
|
||||||
|
}
|
||||||
|
.m-join-button:active { background: #000; transform: translateY(1px); }
|
||||||
|
.m-join-button:disabled { opacity: 0.55; cursor: progress; }
|
||||||
|
.m-join-subtext {
|
||||||
|
margin: 20px 0 0 0;
|
||||||
|
font-family: "Manrope", system-ui, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
.m-confirm-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.m-confirm-list li {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 0 12px 28px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.m-confirm-list li:first-child { border-top: none; }
|
||||||
|
.m-confirm-list li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 19px;
|
||||||
|
width: 14px;
|
||||||
|
height: 8px;
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
border-bottom: 2px solid var(--accent);
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
.m-confirm-list em { font-style: italic; font-weight: 700; color: var(--accent); }
|
||||||
|
|
||||||
|
/* ─── Footer ─────────────────────────────────────────────── */
|
||||||
|
.m-foot {
|
||||||
|
padding: 26px 22px 40px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.m-foot-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.m-foot-project {
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.m-foot-project em { font-style: italic; font-weight: 700; color: var(--accent); }
|
||||||
|
.m-foot-fenja { height: 18px; width: auto; display: block; opacity: 0.85; }
|
||||||
|
.m-foot-note {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Manrope", system-ui, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
.m-foot-note a { color: var(--ink-soft); }
|
||||||
84
protected/mobile/mobile.js
Normal file
84
protected/mobile/mobile.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// protected/mobile/mobile.js — minimal client for the mobile view.
|
||||||
|
//
|
||||||
|
// Three behaviours, nothing else:
|
||||||
|
// 1. Confirm the session is still valid on page load. If the
|
||||||
|
// session expired since the server rendered the HTML, bounce
|
||||||
|
// to "/" so the user doesn't read gated content without a
|
||||||
|
// session cookie (defensive — requireAuth already gates the
|
||||||
|
// page request itself).
|
||||||
|
// 2. POST /api/bifrost-join on CTA click; swap CTA panel →
|
||||||
|
// confirmation panel on success.
|
||||||
|
// 3. POST /auth/logout on log-out button; navigate to "/".
|
||||||
|
//
|
||||||
|
// No GSAP, no Lenis, no d3. No sharing of globals with the desktop
|
||||||
|
// timeline/bifrost scripts — this file is only loaded by
|
||||||
|
// protected/mobile/index.html and never by the desktop view.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
(async function checkSession() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/me', { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error — do not boot the user out; desktop behaviour is
|
||||||
|
// the same. If the next action actually needs the server, we'll
|
||||||
|
// surface the error there.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const joinBtn = document.getElementById('m-join-btn');
|
||||||
|
const joinCta = document.getElementById('m-join-cta');
|
||||||
|
const joinConfirm = document.getElementById('m-join-confirm');
|
||||||
|
|
||||||
|
if (joinBtn && joinCta && joinConfirm) {
|
||||||
|
joinBtn.addEventListener('click', async () => {
|
||||||
|
joinBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/bifrost-join', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
// Server reads email + sessionId from the session cookie, body
|
||||||
|
// just needs to be parseable JSON for express.json() to keep
|
||||||
|
// its rhythm.
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
joinBtn.disabled = false;
|
||||||
|
joinBtn.textContent = 'Try again';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
joinCta.hidden = true;
|
||||||
|
joinConfirm.hidden = false;
|
||||||
|
joinConfirm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
} catch {
|
||||||
|
joinBtn.disabled = false;
|
||||||
|
joinBtn.textContent = 'Try again';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoutBtn = document.getElementById('m-logout');
|
||||||
|
if (logoutBtn) {
|
||||||
|
logoutBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If logout POST fails, still navigate home — the user's
|
||||||
|
// intent is "leave". The server-side session will still be
|
||||||
|
// valid until it expires, but the cookie on this device
|
||||||
|
// will be cleared by the navigation away.
|
||||||
|
}
|
||||||
|
window.location.href = '/';
|
||||||
|
});
|
||||||
|
}
|
||||||
17
server.js
17
server.js
|
|
@ -149,7 +149,24 @@ app.get('/', (req, res) => {
|
||||||
return res.sendFile(path.join(__dirname, 'public', 'entrance.html'));
|
return res.sendFile(path.join(__dirname, 'public', 'entrance.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// UA sniff for the mobile minimum-viable view. Covers the common
|
||||||
|
// phone cases; tablets on iPadOS falsely identify as desktop and will
|
||||||
|
// see the full animated site, which is the right default for a 10"+
|
||||||
|
// screen. The `?view=mobile` / `?view=desktop` query override exists
|
||||||
|
// for cases where the guess is wrong or someone wants to preview the
|
||||||
|
// other version — it takes precedence over the UA regex.
|
||||||
|
const MOBILE_UA_RE = /\b(iPhone|iPod|Android.*Mobile|Mobile.*Firefox|IEMobile|BlackBerry|Opera Mini)\b/i;
|
||||||
|
function wantsMobileView(req) {
|
||||||
|
const forced = (req.query.view || '').toLowerCase();
|
||||||
|
if (forced === 'mobile') return true;
|
||||||
|
if (forced === 'desktop') return false;
|
||||||
|
return MOBILE_UA_RE.test(req.headers['user-agent'] || '');
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/timeline', requireAuth, (req, res) => {
|
app.get('/timeline', requireAuth, (req, res) => {
|
||||||
|
if (wantsMobileView(req)) {
|
||||||
|
return res.sendFile(path.join(__dirname, 'protected', 'mobile', 'index.html'));
|
||||||
|
}
|
||||||
return res.sendFile(path.join(__dirname, 'protected', 'index.html'));
|
return res.sendFile(path.join(__dirname, 'protected', 'index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue