From 72590b08bc1d10346a5dcf022414682b20682560 Mon Sep 17 00:00:00 2001 From: Arlind Ukshini Date: Fri, 24 Apr 2026 10:03:13 +0200 Subject: [PATCH] add mobile view at protected/mobile/ (UA-dispatched) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHECKLIST.md | 32 +++ CLAUDE.md | 7 +- PROJECT.md | 19 +- protected/mobile/index.html | 260 +++++++++++++++++++ protected/mobile/mobile.css | 482 ++++++++++++++++++++++++++++++++++++ protected/mobile/mobile.js | 84 +++++++ server.js | 17 ++ 7 files changed, 897 insertions(+), 4 deletions(-) create mode 100644 protected/mobile/index.html create mode 100644 protected/mobile/mobile.css create mode 100644 protected/mobile/mobile.js diff --git a/CHECKLIST.md b/CHECKLIST.md index 3e5d796..747d867 100644 --- a/CHECKLIST.md +++ b/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) - [ ] 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='` → 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 `` 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) - [ ] [browser, logged in] Click the final "Join Project Bifrost" CTA → confirmation panel renders (staggered checkmarks appear) diff --git a/CLAUDE.md b/CLAUDE.md index 0c32555..0ed9cce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) 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 -4. `express.static(public)` — ungated assets -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. +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. `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. @@ -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**: 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. ## Conventions diff --git a/PROJECT.md b/PROJECT.md index 0db723c..7a9481d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -38,10 +38,15 @@ The root URL `/` is context-aware: unauthenticated → entrance, authenticated │ ├── entrance.html The login page │ └── entrance.js Two-step form behaviour ├── 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 +│ ├── bifrost.js Overview page pinned scenes (capabilities, bifrost, meaning, join) │ ├── archive.html Legacy deep-link placeholder │ ├── 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/ │ │ ├── colors_and_type.css │ │ └── 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 ``` +## 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 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). diff --git a/protected/mobile/index.html b/protected/mobile/index.html new file mode 100644 index 0000000..e1a2f4e --- /dev/null +++ b/protected/mobile/index.html @@ -0,0 +1,260 @@ + + + + + + + + Fenja AI — Project Bifrost + + + + + +
+ + +
+ +
+ + +
+

When AI runs Europe, who runs the AI?

+

+ We’ve spent years building data and AI across Denmark and Europe, watching one dependency harden after another. AI is different. The United States has made that clear. China has made that clear. You cannot stand strong in this century on AI you do not control — and for the first time in a generation, Europe has both the reason and the moment to build its own. The window is closing faster than most realise. It is open now. It will not be open long. +

+

+ As AI moves into our hospitals, our courts, our defence, our schools — can we afford for the switch to sit in Washington? +

+
+ + +
+

The last 18 months, in twelve moments.

+ +
+
EditorialSeptember 2024
+

Three American firms run 70% of Europe’s cloud — and almost all of its AI.

+

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.”

+
The Draghi Report · Brussels
+
+ +
+
Field NoteDecember 2024
+

Denmark warns: digital society is now “extremely vulnerable.

+

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.”

+
Danish Expert Group · Copenhagen
+
+ +
+
RuptureJanuary 2025
+

Trump refuses to rule out military force against Greenland.

+

Two weeks before inauguration, the president-elect threatens “very high tariffs” on Denmark. The shock in Copenhagen is total.

+
Mar-a-Lago · Press Conference
+
+ +
+
RuptureMay 2025
+

Microsoft cuts off the ICC chief prosecutor’s email.

+

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.

+
Associated Press · The Hague
+
+ +
+
RegulationJune 2025
+

Microsoft admits under oath: it cannot guarantee European sovereignty.

+

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.

+
French Senate Hearing · Paris
+
+ +
+
Field NoteJune 2025
+

Copenhagen’s Microsoft bill jumps 72% in five years.

+

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.”

+
Copenhagen Municipality · Finance Report
+
+ +
+
RegulationSummer 2025
+

A Danish minister tells industry: prepare your exit plans for cloud services.

+

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.

+
Danish Ministry of Digital Affairs
+
+ +
+
RuptureAugust 2025
+

Trump threatens tariffs against any country with digital regulations.

+

“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.

+
Truth Social · Washington
+
+ +
+
RuptureJanuary 2026
+

Trump imposes tariffs on Denmark and seven European nations.

+

10% in February. 25% from June — until Denmark cedes Greenland. Denmark, Norway, Sweden, Finland, France, Germany, Netherlands, UK. The post-war alliance, weaponised.

+
Presidential Executive Order
+
+ +
+
RuptureJanuary 2026
+

Denmark names the United States as a national security threat.

+

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.”

+
Danish Defence Intelligence · FE
+
+ +
+
ProductFebruary 2026
+

The court that prosecutes war crimes can no longer use American software.

+

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.

+
Handelsblatt · The Hague
+
+ +
+
RegulationQ1 2026
+

Europe drafts a sovereignty law as US firms still hold 70% of the cloud.

+

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.

+
European Commission · Brussels
+
+
+ + +
+

How Fenja AI addresses this

+
+ + +
+

For regulated environments

+

+ Fenja AI — Secure & Sovereign, hosted where it belongs. +

+

+ Fenja AI is a sovereign AI platform, enabling highly advanced AI capabilities hosted within the client’s own secure infrastructure. +

+
+ Supported by + Innovationsfonden +
+
+ + +
+

One complete platform

+

The Fenja AI platform in four steps.

+ +
+ 1 / 4 +

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 / 4 +

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 / 4 +

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 / 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.

+
+
+ + +
+

Introducing

+

Project Bifrost

+

+ The bridge between an industrial-grade AI platform and the realities of regulated organisations — built with them, not just for them. +

+
+ + +
+

The invitation

+

+ What being part of Project Bifrost means +

+

+ Three ways to shape, to influence, and to build with the platform from the inside. +

+ +
+

Be part of a

+

Community

+

Shape the future together

+

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.

+
+ +
+

Be part of an

+

Advisory Council

+

Turn insight into influence

+

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.

+
+ +
+

Be part of

+

Pilot Projects

+

Access the platform before others

+

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.

+
+
+ + +
+
+

Ready?

+

+ Join us in shaping the future of trusted sovereign AI. +

+ +

Built in Denmark. Supported by the Innovation Fund.

+
+ + +
+ + + +
+ + + + + diff --git a/protected/mobile/mobile.css b/protected/mobile/mobile.css new file mode 100644 index 0000000..e4e8a9b --- /dev/null +++ b/protected/mobile/mobile.css @@ -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); } diff --git a/protected/mobile/mobile.js b/protected/mobile/mobile.js new file mode 100644 index 0000000..ea6e089 --- /dev/null +++ b/protected/mobile/mobile.js @@ -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 = '/'; + }); +} diff --git a/server.js b/server.js index 563adaf..08d5b63 100644 --- a/server.js +++ b/server.js @@ -149,7 +149,24 @@ app.get('/', (req, res) => { 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) => { + if (wantsMobileView(req)) { + return res.sendFile(path.join(__dirname, 'protected', 'mobile', 'index.html')); + } return res.sendFile(path.join(__dirname, 'protected', 'index.html')); });