major great updates

This commit is contained in:
Arlind Ukshini 2026-04-23 14:54:25 +02:00
parent d7acae8a2c
commit e3a2d052a7
8 changed files with 257 additions and 56 deletions

77
bin/joins.js Normal file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env node
// ─────────────────────────────────────────────────────────────
// bin/joins.js — read the Bifrost join-click log.
//
// Every press of the final "Join Project Bifrost" CTA is recorded
// as its own row in the `bifrost_joins` table (who, when, session).
//
// Usage:
// node bin/joins.js list # every click, newest first
// node bin/joins.js summary # one row per user, with click count
// node bin/joins.js for <email> # full click history for one user
// node bin/joins.js stats # totals (clicks + unique users)
// ─────────────────────────────────────────────────────────────
import { q } from '../src/db.js';
const [, , cmd, arg] = process.argv;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function help() {
console.log('Usage:');
console.log(' joins list # every click, newest first');
console.log(' joins summary # one row per user');
console.log(' joins for <email> # click history for a user');
console.log(' joins stats # totals');
process.exit(1);
}
function iso(t) { return new Date(t).toISOString(); }
function shortSid(s) { return s ? `[${s.slice(0, 8)}…]` : ''; }
switch (cmd) {
case 'list': {
const rows = q.listJoins.all();
if (rows.length === 0) { console.log('(no clicks yet)'); break; }
for (const r of rows) {
console.log(` ${iso(r.clicked_at)} ${r.email.padEnd(32)} ${shortSid(r.session_id)}`);
}
console.log(`\n${rows.length} click${rows.length === 1 ? '' : 's'} total.`);
break;
}
case 'summary': {
const rows = q.summariseJoins.all();
if (rows.length === 0) { console.log('(no clicks yet)'); break; }
console.log(' CLICKS FIRST LAST EMAIL');
for (const r of rows) {
const n = String(r.click_count).padStart(6);
console.log(` ${n} ${iso(r.first_clicked_at)} ${iso(r.last_clicked_at)} ${r.email}`);
}
console.log(`\n${rows.length} unique user${rows.length === 1 ? '' : 's'}.`);
break;
}
case 'for': {
if (!arg || !EMAIL_RE.test(arg)) help();
const email = arg.trim().toLowerCase();
const rows = q.listJoinsForEmail.all(email);
if (rows.length === 0) { console.log(`(no clicks for ${email})`); break; }
console.log(`Clicks by ${email}:`);
for (const r of rows) {
console.log(` ${iso(r.clicked_at)} ${shortSid(r.session_id)}`);
}
console.log(`\n${rows.length} click${rows.length === 1 ? '' : 's'}.`);
break;
}
case 'stats': {
const total = q.countJoins.get().n;
const unique = q.countUniqueJoiners.get().n;
console.log(` total clicks: ${total}`);
console.log(` unique users: ${unique}`);
break;
}
default:
help();
}

View file

@ -1078,6 +1078,19 @@
if (joinBtn.disabled) return; if (joinBtn.disabled) return;
joinBtn.disabled = true; joinBtn.disabled = true;
// Record the click on the server. Fire-and-forget — the UI
// transitions below run regardless of network outcome so a
// temporary failure doesn't trap the user in a broken state.
// The server uses INSERT OR IGNORE keyed on email, so repeat
// clicks from the same user are safely deduplicated.
fetch('/api/bifrost-join', {
method: 'POST',
credentials: 'same-origin',
}).catch(() => {
// Network/server error — intentionally swallowed. An admin
// listing missing entries can follow up out-of-band.
});
// Kill the CTA's scroll-reveal trigger so scrolling up + back // Kill the CTA's scroll-reveal trigger so scrolling up + back
// down can't replay the reveal and bring the CTA back over the // down can't replay the reveal and bring the CTA back over the
// confirmation. After click, the CTA stays in whatever state // confirmation. After click, the CTA stays in whatever state

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 369 KiB

View file

@ -238,7 +238,7 @@
transform: translate3d(0,0,0); transform: translate3d(0,0,0);
display: flex; align-items: center; display: flex; align-items: center;
padding: 0 120px; padding: 0 120px;
--spine-y: 64%; --spine-y: 54%;
} }
.spine { .spine {
@ -281,8 +281,18 @@
/* Card */ /* Card */
.evt { .evt {
position: absolute; position: absolute;
width: 320px; width: 640px;
padding: 16px 20px 18px; padding: 32px 40px 36px;
/* Two-column editorial layout: headline on the left (15% wider),
body paragraph + source stacked on the right. Keeps cards shorter
so they don't clip below the timeline spine. */
display: grid;
grid-template-columns: 1.15fr 0.85fr;
grid-template-areas:
"head body"
"head source";
column-gap: 32px;
row-gap: 14px;
background: var(--paper-high); background: var(--paper-high);
/* Tonal surface shifts instead of 1px borders */ /* Tonal surface shifts instead of 1px borders */
box-shadow: box-shadow:
@ -318,7 +328,7 @@
.evt::after { .evt::after {
content: ""; content: "";
position: absolute; position: absolute;
left: 26px; left: 52px;
width: 1px; width: 1px;
background: rgba(56,56,49,0.28); background: rgba(56,56,49,0.28);
} }
@ -328,7 +338,7 @@
/* Node on the spine — tiny dot */ /* Node on the spine — tiny dot */
.evt .node { .evt .node {
position: absolute; position: absolute;
left: 20px; left: 46px;
width: 13px; height: 13px; width: 13px; height: 13px;
border-radius: 50%; border-radius: 50%;
background: var(--paper-high); background: var(--paper-high);
@ -373,30 +383,39 @@
.evt[data-accent="crimson"] .kind { color: var(--crimson); } .evt[data-accent="crimson"] .kind { color: var(--crimson); }
.evt h3 { .evt h3 {
grid-area: head;
align-self: start;
font-family: "Newsreader", Georgia, serif; font-family: "Newsreader", Georgia, serif;
font-weight: 400; font-weight: 400;
font-size: 17px; font-size: 41px;
line-height: 1.22; line-height: 1.12;
letter-spacing: -0.01em; letter-spacing: -0.015em;
color: var(--ink); color: var(--ink);
margin: 0 0 8px 0; margin: 0;
text-wrap: pretty; text-wrap: pretty;
} }
/* Bold-italic emphasis in headlines gets the red accent so each card
carries a consistent touch of colour while staying editorial. */
.evt h3 em { .evt h3 em {
font-style: italic; font-style: italic;
font-weight: 700; font-weight: 700;
color: var(--crimson);
} }
.evt p { .evt p {
grid-area: body;
margin: 0; margin: 0;
font-size: 12px; font-size: 16px;
line-height: 1.5; line-height: 1.5;
color: var(--ink-soft); color: var(--ink-soft);
text-wrap: pretty; text-wrap: pretty;
align-self: start;
} }
.evt .source { .evt .source {
margin-top: 10px; grid-area: source;
align-self: end;
margin-top: 0;
font-size: 9.5px; font-size: 9.5px;
letter-spacing: 0.2em; letter-spacing: 0.2em;
text-transform: uppercase; text-transform: uppercase;
@ -404,27 +423,32 @@
font-weight: 500; font-weight: 500;
} }
/* ───────── Continue button ───────── */ /* ───────── Continue button ─────────
Right-side anchor, vertically centered so readers crossing the
timeline can't miss it. Larger editorial block with a circular
icon on the left and the label on the right. Breath animation
translates horizontally only; `translateY(-50%)` is applied via
`top: 50%` centering and kept stable across keyframes. */
.continue-btn { .continue-btn {
all: unset; all: unset;
position: absolute; position: absolute;
right: 72px; right: 72px;
bottom: 140px; top: 50%;
display: inline-flex; display: inline-flex;
align-items: baseline; align-items: center;
gap: 22px; gap: 28px;
padding: 20px 28px; padding: 32px 44px;
background: var(--paper-high); background: var(--paper-high);
color: var(--ink); color: var(--ink);
cursor: pointer; cursor: pointer;
z-index: 30; z-index: 30;
opacity: 0; opacity: 0;
transform: translateX(36px); transform: translate(36px, -50%);
pointer-events: none; pointer-events: none;
box-shadow: box-shadow:
0 0 0 0.5px rgba(56,56,49,0.06), 0 0 0 0.5px rgba(56,56,49,0.08),
0 18px 32px -18px rgba(56,56,49,0.22), 0 26px 48px -22px rgba(56,56,49,0.28),
0 2px 6px -3px rgba(56,56,49,0.08); 0 3px 10px -4px rgba(56,56,49,0.10);
transition: transition:
opacity 520ms var(--ease), opacity 520ms var(--ease),
transform 520ms var(--ease), transform 520ms var(--ease),
@ -433,42 +457,52 @@
} }
.continue-btn.is-visible { .continue-btn.is-visible {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translate(0, -50%);
pointer-events: auto; pointer-events: auto;
animation: continue-breath 2800ms cubic-bezier(0.2, 0, 0, 1) infinite; animation: continue-breath 2800ms cubic-bezier(0.2, 0, 0, 1) infinite;
} }
@keyframes continue-breath { @keyframes continue-breath {
0%, 100% { transform: translateX(0); } 0%, 100% { transform: translate(0, -50%); }
50% { transform: translateX(6px); } 50% { transform: translate(6px, -50%); }
} }
.continue-btn:hover { .continue-btn:hover {
background: #fffbf2; background: #fffbf2;
box-shadow: box-shadow:
0 0 0 0.5px rgba(56,56,49,0.10), 0 0 0 0.5px rgba(56,56,49,0.12),
0 24px 40px -20px rgba(56,56,49,0.28), 0 32px 56px -22px rgba(56,56,49,0.34),
0 3px 8px -4px rgba(56,56,49,0.10); 0 4px 12px -5px rgba(56,56,49,0.12);
}
.continue-btn .c-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
color: var(--crimson);
flex: 0 0 auto;
transition: transform var(--dur) var(--ease);
}
.continue-btn .c-icon svg {
width: 100%;
height: 100%;
display: block;
}
.continue-btn:hover .c-icon {
transform: translateX(4px);
} }
.continue-btn .c-label { .continue-btn .c-label {
font-family: "Newsreader", Georgia, serif; font-family: "Newsreader", Georgia, serif;
font-size: 20px; font-size: 36px;
font-weight: 400; font-weight: 400;
letter-spacing: -0.01em; letter-spacing: -0.015em;
color: var(--ink); color: var(--ink);
line-height: 1; line-height: 1.08;
max-width: 13ch;
} }
.continue-btn .c-label em { .continue-btn .c-label em {
font-style: italic; font-weight: 700;
}
.continue-btn .c-arrow {
font-family: "Newsreader", Georgia, serif;
font-style: italic; font-style: italic;
font-size: 22px; font-weight: 700;
color: var(--crimson); color: var(--crimson);
line-height: 1;
transition: transform var(--dur) var(--ease);
}
.continue-btn:hover .c-arrow {
transform: translateX(4px);
} }
/* ───────── Overview page ───────── */ /* ───────── Overview page ───────── */
@ -1253,14 +1287,19 @@ html {
font-family: var(--type-display); font-family: var(--type-display);
font-weight: 320; font-weight: 320;
font-size: clamp(3rem, 10vw, 9rem); font-size: clamp(3rem, 10vw, 9rem);
line-height: 0.95; /* Loosened from 0.95 to 1.12 and padded so the italic "Bifrost"
token's ascenders/descenders clear the .bifrost-pin's
overflow:hidden (which exists to clip the aurora arc). */
line-height: 1.12;
letter-spacing: -0.04em; letter-spacing: -0.04em;
color: var(--ink); color: var(--ink);
margin: 0; margin: 0;
padding: 0.12em 0.08em;
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 0.15em; gap: 0.15em;
flex-wrap: wrap; flex-wrap: wrap;
overflow: visible;
} }
.bifrost-name .token { .bifrost-name .token {
display: inline-block; display: inline-block;
@ -2176,8 +2215,14 @@ html {
<!-- Continue to the next page --> <!-- Continue to the next page -->
<button class="continue-btn" id="continue-btn" type="button"> <button class="continue-btn" id="continue-btn" type="button">
<span class="c-label">Read the editor&rsquo;s <em>note</em></span> <span class="c-icon" aria-hidden="true">
<span class="c-arrow" aria-hidden="true"></span> <svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.4">
<circle cx="24" cy="24" r="22.5"/>
<path d="M16 24 H32 M26 17.5 L32.5 24 L26 30.5"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="c-label">How Fenja AI <em>addresses</em> this</span>
</button> </button>
<div class="timeline-viewport" id="tl-viewport"> <div class="timeline-viewport" id="tl-viewport">
@ -2209,11 +2254,11 @@ html {
<div> <div>
<div class="eyebrow" data-reveal>For regulated environments</div> <div class="eyebrow" data-reveal>For regulated environments</div>
<h1 id="hero-title" class="hero-title" data-reveal-lines> <h1 id="hero-title" class="hero-title" data-reveal-lines>
Secure &amp; <em>Sovereign</em> AI,<br/> Fenja AI — Secure &amp; <em>Sovereign,</em><br/>
hosted where it <em>belongs.</em> hosted where it <em>belongs.</em>
</h1> </h1>
<p class="hero-lede" data-reveal> <p class="hero-lede" data-reveal>
Enabling highly advanced AI capabilities hosted within the client's own secure infrastructure. Fenja AI is a sovereign AI platform, enabling highly advanced AI capabilities hosted within the client's own secure infrastructure.
</p> </p>
<!-- Hero foot: "Supported by Innovationsfonden" on the left and <!-- Hero foot: "Supported by Innovationsfonden" on the left and

View file

@ -68,9 +68,9 @@ const EVENTS = [
/* /*
Timeline layout Timeline layout
*/ */
const CARD_PITCH = 380; // horizontal spacing between card centers const CARD_PITCH = 760; // horizontal spacing between card centers (doubled to fit 640-wide cards)
const LEFT_PAD = 520; // extra room so first above-card clears the title/sub const LEFT_PAD = 720; // extra room so first above-card clears the title/sub
const RIGHT_PAD = 280; const RIGHT_PAD = 420;
const track = document.getElementById('tl-track'); const track = document.getElementById('tl-track');
const viewport = document.getElementById('tl-viewport'); const viewport = document.getElementById('tl-viewport');
@ -86,16 +86,12 @@ function buildTimeline() {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'evt ' + (above ? 'above' : 'below'); card.className = 'evt ' + (above ? 'above' : 'below');
card.dataset.accent = e.accent; card.dataset.accent = e.accent;
card.style.left = (x - 160) + 'px'; card.style.left = (x - 320) + 'px';
card.innerHTML = ` card.innerHTML = `
<span class="node"></span> <span class="node"></span>
<div class="tag-row">
<span class="date">${e.date}</span>
<span class="kind">${e.kind}</span>
</div>
<h3>${e.hed}</h3> <h3>${e.hed}</h3>
<p>${e.body}</p> <p>${e.body}</p>
<div class="source">${e.source}</div> <div class="source">${e.source} · ${e.date}</div>
`; `;
track.appendChild(card); track.appendChild(card);
}); });

View file

@ -297,8 +297,9 @@
and where, will shape the next decades. and where, will shape the next decades.
</p> </p>
<p class="welcome-body"> <p class="welcome-body">
What follows is a timeline: twenty-three moments that explain why What follows is a timeline: twelve moments that explain why
this matters now, and what the path looks like. this matters now, and &mdash; at the end &mdash; a note on how
Fenja AI addresses it.
</p> </p>
<button type="button" class="welcome-cta" id="welcome-continue"> <button type="button" class="welcome-cta" id="welcome-continue">
<svg class="c-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" <svg class="c-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"

View file

@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
import authRouter from './src/auth.js'; import authRouter from './src/auth.js';
import { requireAuth } from './src/middleware.js'; import { requireAuth } from './src/middleware.js';
import './src/db.js'; // side-effect import: opens DB + runs schema import { q } from './src/db.js'; // also side-effect: opens DB + runs schema
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
@ -65,6 +65,16 @@ app.use((req, res, next) => {
// ─── Auth endpoints (public) ───────────────────────────────── // ─── Auth endpoints (public) ─────────────────────────────────
app.use('/auth', authRouter); app.use('/auth', authRouter);
// ─── Bifrost join tracking (gated) ───────────────────────────
// Records every click of the final CTA button as its own row so the
// full history per user is preserved (who, when, which session).
app.post('/api/bifrost-join', requireAuth, (req, res) => {
const { email, id: sessionId } = req.session;
const clickedAt = Date.now();
q.recordJoin.run(email, clickedAt, sessionId);
res.json({ clicked_at: clickedAt });
});
// ─── Root dispatch ─────────────────────────────────────────── // ─── Root dispatch ───────────────────────────────────────────
// GET / → always the entrance shell. If authed, entrance.js routes // GET / → always the entrance shell. If authed, entrance.js routes
// the user to the welcome step client-side. // the user to the welcome step client-side.

View file

@ -46,6 +46,16 @@ db.exec(`
); );
CREATE INDEX IF NOT EXISTS idx_rate_window ON rate_limits(window_end); CREATE INDEX IF NOT EXISTS idx_rate_window ON rate_limits(window_end);
CREATE TABLE IF NOT EXISTS bifrost_joins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
clicked_at INTEGER NOT NULL,
session_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_bifrost_joins_email ON bifrost_joins(email);
CREATE INDEX IF NOT EXISTS idx_bifrost_joins_clicked_at ON bifrost_joins(clicked_at);
`); `);
// ─── Migrations ────────────────────────────────────────────── // ─── Migrations ──────────────────────────────────────────────
@ -69,6 +79,30 @@ if (!inviteCols.some((c) => c.name === 'first_name')) {
db.exec(`DROP TABLE IF EXISTS codes`); db.exec(`DROP TABLE IF EXISTS codes`);
db.exec(`DROP INDEX IF EXISTS idx_codes_expires`); db.exec(`DROP INDEX IF EXISTS idx_codes_expires`);
// Migration 3: bifrost_joins schema expanded to one-row-per-click.
// The first version of the table used `email` as PRIMARY KEY with
// INSERT OR IGNORE — only the first click was recorded per user.
// The new table has an auto-increment `id` so every click is stored,
// enabling per-user click history. Detect the old schema by the
// absence of an `id` column; if found, rename, rebuild, copy, drop.
const joinCols = db.prepare(`PRAGMA table_info(bifrost_joins)`).all();
if (joinCols.length > 0 && !joinCols.some((c) => c.name === 'id')) {
db.exec(`
ALTER TABLE bifrost_joins RENAME TO bifrost_joins_old;
CREATE TABLE bifrost_joins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
clicked_at INTEGER NOT NULL,
session_id TEXT
);
INSERT INTO bifrost_joins (email, clicked_at, session_id)
SELECT email, clicked_at, session_id FROM bifrost_joins_old;
DROP TABLE bifrost_joins_old;
CREATE INDEX IF NOT EXISTS idx_bifrost_joins_email ON bifrost_joins(email);
CREATE INDEX IF NOT EXISTS idx_bifrost_joins_clicked_at ON bifrost_joins(clicked_at);
`);
}
// ─── Prepared statements ───────────────────────────────────── // ─── Prepared statements ─────────────────────────────────────
export const q = { export const q = {
// invites // invites
@ -102,6 +136,31 @@ export const q = {
window_end = excluded.window_end` window_end = excluded.window_end`
), ),
// bifrost joins — one row per click of the final CTA. Every press
// is logged with the user's email, timestamp, and session ID so the
// full history is available per user and in aggregate.
recordJoin: db.prepare(
`INSERT INTO bifrost_joins (email, clicked_at, session_id) VALUES (?, ?, ?)`
),
listJoins: db.prepare(
`SELECT id, email, clicked_at, session_id FROM bifrost_joins ORDER BY clicked_at DESC`
),
listJoinsForEmail: db.prepare(
`SELECT id, clicked_at, session_id FROM bifrost_joins WHERE email = ? ORDER BY clicked_at DESC`
),
// Per-user summary: most recent click first, with per-user counts.
summariseJoins: db.prepare(
`SELECT email,
COUNT(*) AS click_count,
MIN(clicked_at) AS first_clicked_at,
MAX(clicked_at) AS last_clicked_at
FROM bifrost_joins
GROUP BY email
ORDER BY last_clicked_at DESC`
),
countJoins: db.prepare(`SELECT COUNT(*) AS n FROM bifrost_joins`),
countUniqueJoiners: db.prepare(`SELECT COUNT(DISTINCT email) AS n FROM bifrost_joins`),
// cleanup // cleanup
cleanup: { cleanup: {
sessions: db.prepare('DELETE FROM sessions WHERE expires_at < ?'), sessions: db.prepare('DELETE FROM sessions WHERE expires_at < ?'),