diff --git a/bin/joins.js b/bin/joins.js new file mode 100644 index 0000000..4f57489 --- /dev/null +++ b/bin/joins.js @@ -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 # 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 # 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(); +} diff --git a/protected/bifrost.js b/protected/bifrost.js index c7ab935..384bc87 100644 --- a/protected/bifrost.js +++ b/protected/bifrost.js @@ -1078,6 +1078,19 @@ if (joinBtn.disabled) return; 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 // down can't replay the reveal and bring the CTA back over the // confirmation. After click, the CTA stays in whatever state diff --git a/protected/fenja/illustrations/blocs tools.png b/protected/fenja/illustrations/blocs tools.png index 5167607..63cf610 100644 Binary files a/protected/fenja/illustrations/blocs tools.png and b/protected/fenja/illustrations/blocs tools.png differ diff --git a/protected/index.html b/protected/index.html index 63836c4..ff193ab 100644 --- a/protected/index.html +++ b/protected/index.html @@ -238,7 +238,7 @@ transform: translate3d(0,0,0); display: flex; align-items: center; padding: 0 120px; - --spine-y: 64%; + --spine-y: 54%; } .spine { @@ -281,8 +281,18 @@ /* Card */ .evt { position: absolute; - width: 320px; - padding: 16px 20px 18px; + width: 640px; + 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); /* Tonal surface shifts instead of 1px borders */ box-shadow: @@ -318,7 +328,7 @@ .evt::after { content: ""; position: absolute; - left: 26px; + left: 52px; width: 1px; background: rgba(56,56,49,0.28); } @@ -328,7 +338,7 @@ /* Node on the spine — tiny dot */ .evt .node { position: absolute; - left: 20px; + left: 46px; width: 13px; height: 13px; border-radius: 50%; background: var(--paper-high); @@ -373,30 +383,39 @@ .evt[data-accent="crimson"] .kind { color: var(--crimson); } .evt h3 { + grid-area: head; + align-self: start; font-family: "Newsreader", Georgia, serif; font-weight: 400; - font-size: 17px; - line-height: 1.22; - letter-spacing: -0.01em; + font-size: 41px; + line-height: 1.12; + letter-spacing: -0.015em; color: var(--ink); - margin: 0 0 8px 0; + margin: 0; 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 { font-style: italic; font-weight: 700; + color: var(--crimson); } .evt p { + grid-area: body; margin: 0; - font-size: 12px; + font-size: 16px; line-height: 1.5; color: var(--ink-soft); text-wrap: pretty; + align-self: start; } .evt .source { - margin-top: 10px; + grid-area: source; + align-self: end; + margin-top: 0; font-size: 9.5px; letter-spacing: 0.2em; text-transform: uppercase; @@ -404,27 +423,32 @@ 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 { all: unset; position: absolute; right: 72px; - bottom: 140px; + top: 50%; display: inline-flex; - align-items: baseline; - gap: 22px; - padding: 20px 28px; + align-items: center; + gap: 28px; + padding: 32px 44px; background: var(--paper-high); color: var(--ink); cursor: pointer; z-index: 30; opacity: 0; - transform: translateX(36px); + transform: translate(36px, -50%); pointer-events: none; box-shadow: - 0 0 0 0.5px rgba(56,56,49,0.06), - 0 18px 32px -18px rgba(56,56,49,0.22), - 0 2px 6px -3px rgba(56,56,49,0.08); + 0 0 0 0.5px rgba(56,56,49,0.08), + 0 26px 48px -22px rgba(56,56,49,0.28), + 0 3px 10px -4px rgba(56,56,49,0.10); transition: opacity 520ms var(--ease), transform 520ms var(--ease), @@ -433,42 +457,52 @@ } .continue-btn.is-visible { opacity: 1; - transform: translateX(0); + transform: translate(0, -50%); pointer-events: auto; animation: continue-breath 2800ms cubic-bezier(0.2, 0, 0, 1) infinite; } @keyframes continue-breath { - 0%, 100% { transform: translateX(0); } - 50% { transform: translateX(6px); } + 0%, 100% { transform: translate(0, -50%); } + 50% { transform: translate(6px, -50%); } } .continue-btn:hover { background: #fffbf2; box-shadow: - 0 0 0 0.5px rgba(56,56,49,0.10), - 0 24px 40px -20px rgba(56,56,49,0.28), - 0 3px 8px -4px rgba(56,56,49,0.10); + 0 0 0 0.5px rgba(56,56,49,0.12), + 0 32px 56px -22px rgba(56,56,49,0.34), + 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 { font-family: "Newsreader", Georgia, serif; - font-size: 20px; + font-size: 36px; font-weight: 400; - letter-spacing: -0.01em; + letter-spacing: -0.015em; color: var(--ink); - line-height: 1; + line-height: 1.08; + max-width: 13ch; } .continue-btn .c-label em { - font-style: italic; font-weight: 700; - } - .continue-btn .c-arrow { - font-family: "Newsreader", Georgia, serif; font-style: italic; - font-size: 22px; + font-weight: 700; color: var(--crimson); - line-height: 1; - transition: transform var(--dur) var(--ease); - } - .continue-btn:hover .c-arrow { - transform: translateX(4px); } /* ───────── Overview page ───────── */ @@ -1253,14 +1287,19 @@ html { font-family: var(--type-display); font-weight: 320; 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; color: var(--ink); margin: 0; + padding: 0.12em 0.08em; display: flex; justify-content: center; gap: 0.15em; flex-wrap: wrap; + overflow: visible; } .bifrost-name .token { display: inline-block; @@ -2176,8 +2215,14 @@ html {
@@ -2209,11 +2254,11 @@ html {
For regulated environments

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

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