Compare commits

..

No commits in common. "master" and "chore/deploy-nginx" have entirely different histories.

24 changed files with 342 additions and 1044 deletions

View file

@ -74,42 +74,14 @@ sudo install -d -o fenja -g fenja /opt/fenja/data/bifrost-portal/uploads
sudo install -d -o fenja -g fenja /opt/fenja/data/bifrost-portal/backups sudo install -d -o fenja -g fenja /opt/fenja/data/bifrost-portal/backups
``` ```
## 3. Deploy key + clone ## 3. Clone the repo
The `fenja` user needs read access to this repo on `git.fenja.ai`. Use a The `fenja` user needs read access to `git.fenja.ai` (a deploy key on its
dedicated, repo-scoped **deploy key** generated on the server (private key never account, or your forwarded agent for the first clone):
leaves the box) plus an SSH alias so it's used only for this repo.
```bash ```bash
# Generate the keypair (no passphrase — it's for unattended deploys) sudo -u fenja git clone ssh://git@git.fenja.ai:2222/joh/project-bifrost-platform.git \
sudo -u fenja install -d -m 700 /home/fenja/.ssh /opt/bifrost-portal
sudo -u fenja ssh-keygen -t ed25519 -N "" \
-f /home/fenja/.ssh/bifrost_portal_deploy \
-C "bifrost-portal-deploy@$(hostname)"
# SSH alias so git uses THIS key only for the portal repo
sudo -u fenja tee -a /home/fenja/.ssh/config >/dev/null <<'EOF'
Host bifrost-portal-git
HostName git.fenja.ai
Port 2222
User git
IdentityFile ~/.ssh/bifrost_portal_deploy
IdentitiesOnly yes
EOF
sudo -u fenja chmod 600 /home/fenja/.ssh/config
sudo -u fenja bash -c 'ssh-keyscan -p 2222 git.fenja.ai >> /home/fenja/.ssh/known_hosts 2>/dev/null'
# Print the PUBLIC key to register
sudo -u fenja cat /home/fenja/.ssh/bifrost_portal_deploy.pub
```
Upload that public key on `git.fenja.ai`: **Repo → Settings → Deploy Keys →
Add Deploy Key**, read-only (leave write access off). Then test and clone:
```bash
sudo -u fenja ssh -T bifrost-portal-git # expect a greeting, no password prompt
sudo -u fenja git clone bifrost-portal-git:joh/project-bifrost-platform.git /opt/bifrost-portal
``` ```
> **Keep the git checkouts separate.** This portal (`project-bifrost-platform`) > **Keep the git checkouts separate.** This portal (`project-bifrost-platform`)
@ -117,8 +89,6 @@ sudo -u fenja git clone bifrost-portal-git:joh/project-bifrost-platform.git /opt
> git projects with their own remotes. `/opt/bifrost-portal` is a self-contained > git projects with their own remotes. `/opt/bifrost-portal` is a self-contained
> checkout — never nest it inside another app's tree, never point its remote at > checkout — never nest it inside another app's tree, never point its remote at
> theirs, and only ever run `scripts/deploy.sh` from inside `/opt/bifrost-portal`. > theirs, and only ever run `scripts/deploy.sh` from inside `/opt/bifrost-portal`.
> Cloning via the `bifrost-portal-git` alias makes `origin` resolve through the
> dedicated deploy key, which `scripts/deploy.sh` uses transparently.
## 4. Environment file ## 4. Environment file
@ -161,25 +131,14 @@ sudo systemctl status bifrost-portal --no-pager
curl -fsS http://127.0.0.1:4322/login >/dev/null && echo "app responding on 4322" curl -fsS http://127.0.0.1:4322/login >/dev/null && echo "app responding on 4322"
``` ```
Let `fenja` restart just this unit without a password (used by `deploy.sh`). Let `fenja` restart just this unit without a password (used by `deploy.sh`):
`deploy.sh` runs as `fenja` and escalates only for the restart; reading status
needs no sudo. Create the rule with `visudo` (validates syntax, sets perms):
```bash ```bash
sudo visudo -f /etc/sudoers.d/bifrost-portal echo 'fenja ALL=(root) NOPASSWD: /usr/bin/systemctl restart bifrost-portal, /usr/bin/systemctl status bifrost-portal' \
| sudo tee /etc/sudoers.d/bifrost-portal
sudo chmod 440 /etc/sudoers.d/bifrost-portal
``` ```
Add exactly this one line (a single-command allowlist — not general sudo):
```
fenja ALL=(root) NOPASSWD: /usr/bin/systemctl restart bifrost-portal
```
Verify: `sudo -l -U fenja | grep systemctl` shows only that command. If you'd
rather keep sudo exclusively with admin users, skip this — `deploy.sh` will
then stop before the restart and print the `sudo systemctl restart
bifrost-portal` command for you to run as an admin.
## 7. nginx + TLS ## 7. nginx + TLS
```bash ```bash

View file

@ -1,6 +0,0 @@
-- Roadmap items gain an optional `features` column — a JSON array of short
-- strings, each rendered as a plus-icon bullet in the route card's hover
-- expansion (and the mobile timeline). Lists the concrete capabilities an
-- item ships. NULL / '[]' when not set; the UI hides the list in that case.
ALTER TABLE roadmap_items ADD COLUMN features TEXT;

View file

@ -34,5 +34,8 @@
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "sharp"]
} }
} }

View file

@ -1,9 +0,0 @@
# pnpm 10+ stopped reading build-script settings from package.json's "pnpm"
# field and blocks dependency build scripts by default. pnpm 11 reads the
# allow-list from `allowBuilds` here (captured via `pnpm approve-builds`).
# Without it, better-sqlite3's native binary is never compiled and the SSR
# server crashes at runtime (ERR_DLOPEN_FAILED / NODE_MODULE_VERSION mismatch).
allowBuilds:
better-sqlite3: true
esbuild: true
sharp: true

View file

@ -39,21 +39,10 @@ echo "==> Applying database migrations -> $BIFROST_DB_PATH"
node scripts/migrate.js node scripts/migrate.js
echo "==> Restarting $SERVICE" echo "==> Restarting $SERVICE"
# Non-interactive: if fenja has the NOPASSWD rule for this unit it restarts sudo systemctl restart "$SERVICE"
# silently; otherwise we don't hang on a password prompt — we tell the
# operator to restart as a sudo user.
if sudo -n systemctl restart "$SERVICE" 2>/dev/null; then
echo " restarted"
else
echo " !! could not restart without a password."
echo " Run as a sudo user: sudo systemctl restart $SERVICE"
echo " (or grant fenja the NOPASSWD rule — see DEPLOY.md §6)"
exit 1
fi
echo "==> Waiting for health" echo "==> Waiting for health"
sleep 2 sleep 2
# status is read-only — no sudo needed sudo systemctl --no-pager --lines=0 status "$SERVICE"
systemctl --no-pager --lines=0 status "$SERVICE" || true
echo "==> Deploy complete: $(git rev-parse --short HEAD)" echo "==> Deploy complete: $(git rev-parse --short HEAD)"

View file

@ -195,50 +195,30 @@ db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60)); .run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60));
// ── Roadmap: mirrors the live bifrost-portal.fenja.ai/roadmap items // ── Roadmap: 9 items, status meaning 'currently live' rather than
// (titles, statuses, target months, order). Descriptions are completed from // 'shipping soon'. Items 1-2 are live in production; items 3-4 are in
// the truncated live text; the `features` plus-icon bullets are drafted from // beta even if 'audit log export' has a near-term GA target. Travelled
// each item's description. // stop = (1 + 0.5) / 9 ≈ 0.17, putting the 'you are here' marker at
// the visible transition between travelled and ahead tones on the path.
const roadmap = [ const roadmap = [
{ title: 'MCP Usage', status: 'shipping', target: 'July 2026', display_order: 1, shipped_at: nowIso(-3 * 24 * 3600), attributed: [], metadata_text: null, { title: 'Traceability layer', description: 'Every inference call writes a signed audit record. Shaped by Lars in our March session.', status: 'shipping', target: 'Live since March', display_order: 1, shipped_at: nowIso(-60 * 24 * 3600), attributed: [cabs[0].id], metadata_text: 'Shaped by Lars in our March session' },
description: 'Connect Fenja to external MCP servers — databases, internal tools, and other software — so it can read from and act across your systems.', { title: 'Document ingestion', description: "Indexing PDF, Word, and plain text with proper chunking. Pilot-tested with Mette's team.", status: 'shipping', target: 'Live since late May', display_order: 2, shipped_at: nowIso(-7 * 24 * 3600), attributed: [cabs[1].id, cabs[2].id], metadata_text: "Pilot-tested with Mette's team" },
features: ['Connect external MCP servers', 'Reach databases, internal tools & software', 'Per-connection credentials & scoping'] }, { title: 'Audit log export', description: 'Stream the signed records to your own S3 or on-prem object store.', status: 'in_beta', target: 'GA next week', display_order: 3, shipped_at: null, attributed: [cabs[3].id], metadata_text: 'Builds on traceability layer' },
{ title: 'Extended Logging', status: 'shipping', target: 'July 2026', display_order: 2, shipped_at: nowIso(-2 * 24 * 3600), attributed: [], metadata_text: null, { title: 'Agentic query mode', description: 'Multi-step retrieval over locked, on-prem document stores. Currently testing with two pilot organisations.', status: 'in_beta', target: 'July', display_order: 4, shipped_at: null, attributed: [cabs[1].id], metadata_text: 'Request beta access →' },
description: 'Granular logging and audit controls, capturing usage down to the individual user, request, and document.', { title: 'Contextual memory', description: 'Sessions that remember constraints between calls without leaking context across organisational boundaries.', status: 'exploring', target: 'Q3 2026', display_order: 5, shipped_at: null, attributed: [cabs[3].id], metadata_text: '2 council requests' },
features: ['Granular usage & audit logging', 'Per-user, per-request, per-document trails', 'Exportable audit records'] }, { title: 'Multi-organisation graphs', description: 'Permission-controlled knowledge spaces for departments within a single deployment.', status: 'exploring', target: 'Q3 2026', display_order: 6, shipped_at: null, attributed: [cabs[4].id], metadata_text: 'Open question on key custody' },
{ title: 'Wiki 2.0', status: 'in_beta', target: 'July 2026', display_order: 3, shipped_at: null, attributed: [], metadata_text: null, { title: 'Multi-tenant isolation', description: 'Cryptographic separation between sub-organisations on shared infrastructure.', status: 'exploring', target: 'Q4 2026', display_order: 7, shipped_at: null, attributed: [cabs[5].id], metadata_text: null },
description: 'A major wiki upgrade: version history with diffs between revisions, admin-locked pages, and a richer page structure.', { title: 'Federated learning hooks', description: 'Let aligned organisations train on shared signal without sharing the underlying data.', status: 'considering', target: '2027', display_order: 8, shipped_at: null, attributed: [], metadata_text: 'Council input wanted' },
features: ['Version history with revision diffs', 'Admin-locked pages', 'Richer page structure'] }, { title: 'Open evaluation framework', description: 'A public benchmark suite for compliant-AI use in regulated industries.', status: 'considering', target: '2027', display_order: 9, shipped_at: null, attributed: [], metadata_text: 'Long-term direction' },
{ title: 'Interview 2.0', status: 'planned', target: 'July 2026', display_order: 4, shipped_at: null, attributed: [], metadata_text: null,
description: 'A major Interviews upgrade: build and manage interview scripts, send invites, and track responses in one place.',
features: ['Build & manage interview scripts', 'Send invites & track responses', 'Centralised results view'] },
{ title: 'Fenja Analyze', status: 'planned', target: 'August 2026', display_order: 5, shipped_at: null, attributed: [], metadata_text: null,
description: 'The first release of Fenja Analyze: ask a question in plain language, run it as a query against your data, and get an answer with sources.',
features: ['Plain-language questions → queries', 'Answers grounded in your data', 'Source citations'] },
{ title: 'Fenja Agentic', status: 'planned', target: 'September 2026', display_order: 6, shipped_at: null, attributed: [], metadata_text: null,
description: 'The first release of Agents: create and orchestrate agents from an admin panel, wiring them to the tools and data they need.',
features: ['Create agents from an admin panel', 'Orchestrate multi-agent workflows', 'Wire agents to tools & data'] },
{ title: 'Fenja Dev', status: 'planned', target: 'September 2026', display_order: 7, shipped_at: null, attributed: [], metadata_text: null,
description: 'The first release of Fenja Dev: a sovereign IDE with terminal and git integration, running entirely inside your environment.',
features: ['Sovereign in-environment IDE', 'Integrated terminal', 'Git integration'] },
{ title: 'HTML Reports', status: 'planned', target: 'September 2026', display_order: 8, shipped_at: null, attributed: [], metadata_text: null,
description: 'Structured reporting for Fenja Analyze: admins define report templates so generated analyses render as clean, shareable HTML.',
features: ['Admin-defined report templates', 'Analyses rendered as clean HTML', 'Shareable, structured output'] },
{ title: 'Self-Service Agents', status: 'planned', target: 'October 2026', display_order: 9, shipped_at: null, attributed: [], metadata_text: null,
description: 'A major expansion of the agent experience, letting users create and run their own agents without admin involvement.',
features: ['User-created agents (no admin needed)', 'Run & manage your own agents', 'Reuse across tasks'] },
{ title: 'Self-service Routines & Skills', status: 'exploring', target: 'October 2026', display_order: 10, shipped_at: null, attributed: [], metadata_text: null,
description: "Personal and domain-level routines and skills: users build reusable, tailored workflows for their own and their team's recurring tasks.",
features: ['Personal & domain-level routines', 'Reusable, tailored workflows', 'Built for recurring team tasks'] },
]; ];
const insertRoad = db.prepare(` const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features) INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?)
`); `);
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)'); const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
for (const r of roadmap) { for (const r of roadmap) {
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, r.metadata_text, JSON.stringify(r.features ?? [])).lastInsertRowid); const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, r.metadata_text).lastInsertRowid);
for (const uid of r.attributed) insertAttr.run(id, uid); for (const uid of r.attributed) insertAttr.run(id, uid);
} }
@ -398,7 +378,7 @@ insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, no
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600)); insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`); console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`);
console.log(' roadmap: 10 items (2 shipping / 1 in_beta / 6 planned / 1 exploring)'); console.log(' roadmap: 9 items (2 shipping / 2 in_beta / 3 exploring / 2 considering)');
console.log(' contributions: 3 (most recent has 3 reactions)'); console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)'); console.log(' dispatches: 4 published (2/5/9/12 days ago)');
console.log(' events: dinner + studio hours + working session, 2 past'); console.log(' events: dinner + studio hours + working session, 2 past');

View file

@ -1,150 +0,0 @@
#!/usr/bin/env node
//
// One-off, idempotent sync of the canonical roadmap content into a target DB.
//
// BIFROST_DB_PATH=/opt/fenja/data/bifrost-portal/bifrost.db node scripts/sync-roadmap.js
//
// What it does, in order:
// 1. Takes its OWN online backup of the target DB to
// <db>.pre-roadmap-sync-<timestamp>.bak (safe while the app is running).
// 2. Upserts the 10 canonical items MATCHED BY display_order — it UPDATEs
// title / description / status / target / features in place, so row ids,
// roadmap_attributions, created_at and shipped_at are all preserved.
// 3. Inserts a canonical item only if no row exists at that display_order.
// 4. Leaves any extra prod rows (display_order outside 1..N) untouched and
// logs them — it never deletes.
//
// Safe to re-run. Does NOT wipe the table (unlike scripts/seed-demo.js, which
// must never be run against prod).
import Database from 'better-sqlite3';
import { existsSync } from 'node:fs';
// ── Canonical roadmap (content only; no demo dates/attributions) ───────────
const ROADMAP = [
{ display_order: 1, title: 'MCP Usage', status: 'shipping', target: 'July 2026',
description: 'Connect Fenja to external MCP servers — databases, internal tools, and other software — so it can read from and act across your systems.',
features: ['Connect external MCP servers', 'Reach databases, internal tools & software', 'Per-connection credentials & scoping'] },
{ display_order: 2, title: 'Extended Logging', status: 'shipping', target: 'July 2026',
description: 'Granular logging and audit controls, capturing usage down to the individual user, request, and document.',
features: ['Granular usage & audit logging', 'Per-user, per-request, per-document trails', 'Exportable audit records'] },
{ display_order: 3, title: 'Wiki 2.0', status: 'in_beta', target: 'July 2026',
description: 'A major wiki upgrade: version history with diffs between revisions, admin-locked pages, and a richer page structure.',
features: ['Version history with revision diffs', 'Admin-locked pages', 'Richer page structure'] },
{ display_order: 4, title: 'Interview 2.0', status: 'planned', target: 'July 2026',
description: 'A major Interviews upgrade: build and manage interview scripts, send invites, and track responses in one place.',
features: ['Build & manage interview scripts', 'Send invites & track responses', 'Centralised results view'] },
{ display_order: 5, title: 'Fenja Analyze', status: 'planned', target: 'August 2026',
description: 'The first release of Fenja Analyze: ask a question in plain language, run it as a query against your data, and get an answer with sources.',
features: ['Plain-language questions → queries', 'Answers grounded in your data', 'Source citations'] },
{ display_order: 6, title: 'Fenja Agentic', status: 'planned', target: 'September 2026',
description: 'The first release of Agents: create and orchestrate agents from an admin panel, wiring them to the tools and data they need.',
features: ['Create agents from an admin panel', 'Orchestrate multi-agent workflows', 'Wire agents to tools & data'] },
{ display_order: 7, title: 'Fenja Dev', status: 'planned', target: 'September 2026',
description: 'The first release of Fenja Dev: a sovereign IDE with terminal and git integration, running entirely inside your environment.',
features: ['Sovereign in-environment IDE', 'Integrated terminal', 'Git integration'] },
{ display_order: 8, title: 'HTML Reports', status: 'planned', target: 'September 2026',
description: 'Structured reporting for Fenja Analyze: admins define report templates so generated analyses render as clean, shareable HTML.',
features: ['Admin-defined report templates', 'Analyses rendered as clean HTML', 'Shareable, structured output'] },
{ display_order: 9, title: 'Self-Service Agents', status: 'planned', target: 'October 2026',
description: 'A major expansion of the agent experience, letting users create and run their own agents without admin involvement.',
features: ['User-created agents (no admin needed)', 'Run & manage your own agents', 'Reuse across tasks'] },
{ display_order: 10, title: 'Self-service Routines & Skills', status: 'exploring', target: 'October 2026',
description: "Personal and domain-level routines and skills: users build reusable, tailored workflows for their own and their team's recurring tasks.",
features: ['Personal & domain-level routines', 'Reusable, tailored workflows', 'Built for recurring team tasks'] },
];
async function main() {
const dbPath = process.env.BIFROST_DB_PATH;
if (!dbPath) {
console.error('!! BIFROST_DB_PATH is not set. Refusing to guess the DB location.');
process.exit(1);
}
if (!existsSync(dbPath)) {
console.error(`!! No database at ${dbPath}`);
process.exit(1);
}
const db = new Database(dbPath);
db.pragma('foreign_keys = ON');
// Guard: the features column must exist (migration 0009). Deploy runs
// migrate.js, so run this AFTER deploying.
const cols = db.prepare('PRAGMA table_info(roadmap_items)').all().map((c) => c.name);
if (!cols.includes('features')) {
console.error('!! roadmap_items.features column is missing — run `node scripts/migrate.js` (deploy) first.');
process.exit(1);
}
// 1. Online backup (safe while the app is running). Timestamp from the OS.
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${dbPath}.pre-roadmap-sync-${ts}.bak`;
console.log(`==> Backing up current DB -> ${backupPath}`);
await db.backup(backupPath);
// Snapshot current titles for the diff log.
const before = db.prepare('SELECT display_order, title FROM roadmap_items ORDER BY display_order').all();
console.log(`==> Current roadmap (${before.length} rows):`);
before.forEach((r) => console.log(` ${r.display_order}. ${r.title}`));
// 2/3. Upsert by display_order, inside a transaction.
const findByOrder = db.prepare('SELECT id FROM roadmap_items WHERE display_order = ?');
const update = db.prepare(`
UPDATE roadmap_items
SET title = @title, description = @description, status = @status,
target = @target, features = @features, updated_at = datetime('now')
WHERE id = @id
`);
const insert = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
VALUES (@title, @description, @status, @target, @display_order, NULL, NULL, @features)
`);
let updated = 0;
let inserted = 0;
const run = db.transaction(() => {
for (const item of ROADMAP) {
const row = findByOrder.get(item.display_order);
const params = {
title: item.title,
description: item.description,
status: item.status,
target: item.target,
features: JSON.stringify(item.features),
display_order: item.display_order,
};
if (row) {
update.run({ ...params, id: row.id });
updated += 1;
} else {
insert.run(params);
inserted += 1;
}
}
});
run();
// 4. Report extras that were left untouched (never deleted).
const maxOrder = ROADMAP.length;
const extras = db
.prepare('SELECT display_order, title FROM roadmap_items WHERE display_order > ? ORDER BY display_order')
.all(maxOrder);
console.log(`==> Synced: ${updated} updated in place, ${inserted} inserted.`);
if (extras.length > 0) {
console.log(`==> Left untouched (display_order > ${maxOrder}) — review in /admin if these should go:`);
extras.forEach((r) => console.log(` ${r.display_order}. ${r.title}`));
}
const after = db.prepare('SELECT display_order, title, status, target FROM roadmap_items ORDER BY display_order').all();
console.log('==> Roadmap now:');
after.forEach((r) => console.log(` ${r.display_order}. ${r.title} [${r.status} · ${r.target}]`));
db.close();
console.log(`==> Done. Backup kept at ${backupPath}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -59,7 +59,7 @@ const hasAnyResources = groupedEntries.some((g) => g.entries.length > 0);
<!-- ── Top strip ──────────────────────────────────────────────── --> <!-- ── Top strip ──────────────────────────────────────────────── -->
<header class="bs-topbar" role="banner"> <header class="bs-topbar" role="banner">
<a href="/" class="bs-brand" aria-label="Back to the main menu"> <a href="/admin" class="bs-brand" aria-label="Backstage — home">
<img src="/logo.svg" alt="Fenja AI" class="bs-brand-mark" /> <img src="/logo.svg" alt="Fenja AI" class="bs-brand-mark" />
<span class="bs-brand-sep" aria-hidden="true">·</span> <span class="bs-brand-sep" aria-hidden="true">·</span>
<span class="bs-brand-project">Project <em class="bs-brand-bifrost">Bifrost</em></span> <span class="bs-brand-project">Project <em class="bs-brand-bifrost">Bifrost</em></span>

View file

@ -90,8 +90,6 @@ const summaryEntries = isReviewMode && item
// One-shot invite link surfaced after create/action — read from URL // One-shot invite link surfaced after create/action — read from URL
const inviteUrl = Astro.url.searchParams.get('invite_url'); const inviteUrl = Astro.url.searchParams.get('invite_url');
// One-shot temp password surfaced after a password reset — read from URL
const tempPassword = Astro.url.searchParams.get('temp_password');
// Build the close URL — drop edit/new but keep filter/q/page // Build the close URL — drop edit/new but keep filter/q/page
const closeUrl = (() => { const closeUrl = (() => {
@ -135,16 +133,6 @@ const formAction = Astro.url.pathname + Astro.url.search;
</section> </section>
)} )}
{tempPassword && (
<section class="bs-invite-result" data-temp-password-block>
<p class="bs-invite-result-label">New temporary password — copy and send it to the user. They can change it from their account page. It will not be shown again.</p>
<div class="bs-invite-link-row">
<code class="bs-invite-link" id="bs-temp-password">{tempPassword}</code>
<button type="button" class="bs-copy-btn" data-copy-target="#bs-temp-password">Copy</button>
</div>
</section>
)}
{isReviewMode ? ( {isReviewMode ? (
<dl class="bs-summary"> <dl class="bs-summary">
{summaryEntries.map((entry) => ( {summaryEntries.map((entry) => (

View file

@ -264,12 +264,7 @@ export interface ActionResultInviteLink {
kind: 'invite-link'; kind: 'invite-link';
url: string; url: string;
} }
/** Render a freshly-generated temp password in the panel with a Copy button. */ export type ActionResult = ActionResultToast | ActionResultInviteLink;
export interface ActionResultTempPassword {
kind: 'temp-password';
password: string;
}
export type ActionResult = ActionResultToast | ActionResultInviteLink | ActionResultTempPassword;
// ── Actions (publish, archive, approve, etc.) ─────────────────────────────── // ── Actions (publish, archive, approve, etc.) ───────────────────────────────
export interface ResourceAction<T> { export interface ResourceAction<T> {

View file

@ -47,8 +47,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
width: '120px', width: '120px',
pillVariants: { pillVariants: {
shipping: { label: 'Shipping', class: 'pill-shipping' }, shipping: { label: 'Shipping', class: 'pill-shipping' },
in_beta: { label: 'In dev', class: 'pill-in-beta' }, in_beta: { label: 'In beta', class: 'pill-in-beta' },
planned: { label: 'Planning', class: 'pill-planned' }, planned: { label: 'Planned', class: 'pill-planned' },
exploring: { label: 'Exploring', class: 'pill-exploring' }, exploring: { label: 'Exploring', class: 'pill-exploring' },
considering: { label: 'Considering', class: 'pill-considering' }, considering: { label: 'Considering', class: 'pill-considering' },
}, },
@ -69,8 +69,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
filters: [ filters: [
{ key: 'all', label: 'All', predicate: () => true, isDefault: true }, { key: 'all', label: 'All', predicate: () => true, isDefault: true },
{ key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' }, { key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' },
{ key: 'in_beta', label: 'In dev', predicate: (i) => i.status === 'in_beta' }, { key: 'in_beta', label: 'In beta', predicate: (i) => i.status === 'in_beta' },
{ key: 'planned', label: 'Planning', predicate: (i) => i.status === 'planned' }, { key: 'planned', label: 'Planned', predicate: (i) => i.status === 'planned' },
{ key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' }, { key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' },
{ key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' }, { key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' },
], ],
@ -100,8 +100,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
required: true, required: true,
options: [ options: [
{ value: 'shipping', label: 'Shipping' }, { value: 'shipping', label: 'Shipping' },
{ value: 'in_beta', label: 'In dev' }, { value: 'in_beta', label: 'In beta' },
{ value: 'planned', label: 'Planning' }, { value: 'planned', label: 'Planned' },
{ value: 'exploring', label: 'Exploring' }, { value: 'exploring', label: 'Exploring' },
{ value: 'considering', label: 'Considering' }, { value: 'considering', label: 'Considering' },
], ],
@ -123,14 +123,6 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
defaultValue: 0, defaultValue: 0,
helperText: 'Lower numbers appear earlier in the route. Items with the same number fall back to creation order.', helperText: 'Lower numbers appear earlier in the route. Items with the same number fall back to creation order.',
}, },
{
key: 'features',
label: 'Features',
kind: 'multi-text',
maxItems: 8,
placeholderEach: 'e.g. Streaming responses with citations',
helperText: 'Bullet-point capabilities this item ships. Shown as a plus-icon list on hover in the /roadmap route.',
},
{ {
key: 'metadata_text', key: 'metadata_text',
label: 'Hover metadata', label: 'Hover metadata',
@ -168,9 +160,6 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
target: ((data.target as string) ?? '').trim() || null, target: ((data.target as string) ?? '').trim() || null,
display_order: Number(data.display_order ?? 0), display_order: Number(data.display_order ?? 0),
metadata_text: ((data.metadata_text as string) ?? '').trim() || null, metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
features: Array.isArray(data.features)
? (data.features as unknown[]).map((v) => String(v)).filter((v) => v.trim() !== '')
: [],
}); });
const userIds = Array.isArray(data.attributed_members) const userIds = Array.isArray(data.attributed_members)
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite) ? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
@ -187,9 +176,6 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
target: ((data.target as string) ?? '').trim() || null, target: ((data.target as string) ?? '').trim() || null,
display_order: Number(data.display_order ?? 0), display_order: Number(data.display_order ?? 0),
metadata_text: ((data.metadata_text as string) ?? '').trim() || null, metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
features: Array.isArray(data.features)
? (data.features as unknown[]).map((v) => String(v)).filter((v) => v.trim() !== '')
: [],
}); });
const userIds = Array.isArray(data.attributed_members) const userIds = Array.isArray(data.attributed_members)
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite) ? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)

View file

@ -16,14 +16,12 @@ import {
getUserPublicById, getUserPublicById,
updateUserAdminFields, updateUserAdminFields,
updateUserEmail, updateUserEmail,
updateUserPassword,
updateUserProfile, updateUserProfile,
updateUserRole, updateUserRole,
deactivateUser, deactivateUser,
type Role, type Role,
type UserPublic, type UserPublic,
} from '../../lib/db'; } from '../../lib/db';
import { generateTempPassword, hashPassword } from '../../lib/auth';
import { parseFocusTags, readFocusTags } from '../../lib/format'; import { parseFocusTags, readFocusTags } from '../../lib/format';
import type { Resource } from '../resource-types'; import type { Resource } from '../resource-types';
@ -328,20 +326,6 @@ export const usersResource: Resource<UserPublic> = {
delete: (id) => deactivateUser(id), delete: (id) => deactivateUser(id),
}, },
actions: [
{
key: 'reset-password',
label: 'Reset password',
confirmText:
'Generate a new temporary password for this user? Their current password stops working immediately. You will get a password to send them.',
handler: (id) => {
const temp = generateTempPassword();
updateUserPassword(id, hashPassword(temp));
return { kind: 'temp-password', password: temp };
},
},
],
notifyCount: { notifyCount: {
// CAB members without focus tags read as half-finished profiles — // CAB members without focus tags read as half-finished profiles —
// surface them as something to attend to. // surface them as something to attend to.

View file

@ -12,9 +12,7 @@ interface Props {
memberLabel?: string | null; // e.g. "MEMBER · 001" memberLabel?: string | null; // e.g. "MEMBER · 001"
} }
const { const { event, confirmedCount, myRsvp, greetingPrefix, firstName, memberLabel = null } = Astro.props;
event, confirmedCount, myRsvp, greetingPrefix, firstName, memberLabel = null,
} = Astro.props;
function parseUtc(s: string): Date { function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s); if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
@ -51,6 +49,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
</div> </div>
{event ? ( {event ? (
<>
<div class="hero-event"> <div class="hero-event">
<!-- Label sits above the date + title so it's clear they describe <!-- Label sits above the date + title so it's clear they describe
the next event. --> the next event. -->
@ -69,12 +68,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
</div> </div>
</div> </div>
</div> </div>
) : (
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
)}
<!-- RSVP row, pinned to the base of the card. -->
{event && (
<footer class="hero-foot"> <footer class="hero-foot">
<p class="hero-status"> <p class="hero-status">
{event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED {event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED
@ -96,6 +90,9 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
)} )}
</form> </form>
</footer> </footer>
</>
) : (
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
)} )}
</article> </article>
@ -265,11 +262,10 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
} }
/* ── Bottom strip ────────────────────────────────────────────── */ /* ── Bottom strip ────────────────────────────────────────────── */
/* RSVP row pinned to the base of the card. */
.hero-foot { .hero-foot {
position: relative; position: relative;
z-index: 1; z-index: 1;
margin-top: auto; margin-top: auto; /* pin to the bottom of the taller card */
border-top: 0.5px solid var(--ink-divider); border-top: 0.5px solid var(--ink-divider);
padding-top: 22px; padding-top: 22px;
display: flex; display: flex;

View file

@ -1,6 +1,5 @@
--- ---
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db'; import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
import { splitStageSuffix } from '../lib/format';
interface Props { interface Props {
items: RoadmapItemWithAttribution[]; items: RoadmapItemWithAttribution[];
@ -10,8 +9,8 @@ const { items } = Astro.props;
const STATUS_LABEL: Record<RoadmapStatus, string> = { const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING', shipping: 'SHIPPING',
in_beta: 'IN DEV', in_beta: 'IN BETA',
planned: 'PLANNING', planned: 'PLANNED',
exploring: 'EXPLORING', exploring: 'EXPLORING',
considering: 'CONSIDERING', considering: 'CONSIDERING',
}; };
@ -76,10 +75,7 @@ const hasArrows = items.length > 3;
{STATUS_LABEL[item.status]}{item.target ? ` · ${item.target.toUpperCase()}` : ''} {STATUS_LABEL[item.status]}{item.target ? ` · ${item.target.toUpperCase()}` : ''}
</span> </span>
</header> </header>
<h3 class="card-title">{(() => { <h3 class="card-title">{item.title}</h3>
const sp = splitStageSuffix(item.title);
return sp.stage ? <Fragment>{sp.base} — <span class="card-stage">{sp.stage}</span></Fragment> : item.title;
})()}</h3>
<p class="card-desc"> <p class="card-desc">
{item.description} {item.description}
{item.attributed.length > 0 && ( {item.attributed.length > 0 && (
@ -249,12 +245,6 @@ const hasArrows = items.length > 3;
color: var(--on-surface); color: var(--on-surface);
margin: 0; margin: 0;
} }
.card-stage {
font-variant: small-caps;
text-transform: lowercase;
letter-spacing: 0.04em;
color: var(--on-surface-variant);
}
.card-desc { .card-desc {
font-size: 13px; font-size: 13px;
line-height: 1.55; line-height: 1.55;

View file

@ -1,7 +1,6 @@
--- ---
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db'; import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
import { computeRouteLayout, travelledStopFor } from '../lib/roadmap-layout'; import { computeRouteLayout, travelledStopFor } from '../lib/roadmap-layout';
import { splitStageSuffix } from '../lib/format';
interface Props { interface Props {
items: RoadmapItemWithAttribution[]; items: RoadmapItemWithAttribution[];
@ -28,16 +27,13 @@ const layout = computeRouteLayout({
paddingLeft, paddingLeft,
paddingRight: trailing, paddingRight: trailing,
tailLength: trailing, tailLength: trailing,
trackHeight: 480,
amplitude: 140,
minSpacingX: 360,
}); });
const travelledStop = travelledStopFor(items.map(i => i.status)); const travelledStop = travelledStopFor(items.map(i => i.status));
const STATUS_LABEL: Record<RoadmapStatus, string> = { const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING', shipping: 'SHIPPING',
in_beta: 'IN DEV', in_beta: 'IN BETA',
planned: 'PLANNING', planned: 'PLANNED',
exploring: 'EXPLORING', exploring: 'EXPLORING',
considering: 'CONSIDERING', considering: 'CONSIDERING',
}; };
@ -84,8 +80,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
<div class="rr-wrap rr-fullbleed rr-desktop" data-item-count={items.length}> <div class="rr-wrap rr-fullbleed rr-desktop" data-item-count={items.length}>
<div class="rr-scroll" id="rr-scroll"> <div class="rr-scroll" id="rr-scroll">
<div class="rr-scroll-inner"> <div class="rr-scroll-inner">
<div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 480px;`}> <div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
<svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="480" aria-hidden="true"> <svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="420" aria-hidden="true">
<defs> <defs>
<linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0"> <linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/> <stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/>
@ -102,8 +98,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
{items.map((item, i) => ( {items.map((item, i) => (
<div <div
class="rr-milestone" class="rr-milestone"
data-y={Math.round(layout.itemY[i])} data-y={layout.itemY[i]}
style={`left: ${Math.round(layout.itemX[i])}px; top: ${Math.round(layout.itemY[i])}px;`} style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
> >
<div <div
class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]} class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]}
@ -117,24 +113,9 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
<p class="rr-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}> <p class="rr-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]} {item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
</p> </p>
<p class="rr-card-title">{(() => { <p class="rr-card-title">{item.title}</p>
const sp = splitStageSuffix(item.title);
return sp.stage ? <Fragment>{sp.base} — <span class="rr-stage">{sp.stage}</span></Fragment> : item.title;
})()}</p>
<div class="rr-more"> <div class="rr-more">
{item.description && <p class="rr-desc">{item.description}</p>} {item.description && <p class="rr-desc">{item.description}</p>}
{item.features.length > 0 && (
<ul class="rr-features">
{item.features.map(f => (
<li class="rr-feature">
<svg class="rr-feature-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
<path d="M8 3.5v9M3.5 8h9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>{f}</span>
</li>
))}
</ul>
)}
{trailingLine(item) && <p class="rr-trail">{trailingLine(item)}</p>} {trailingLine(item) && <p class="rr-trail">{trailingLine(item)}</p>}
</div> </div>
</a> </a>
@ -179,23 +160,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
<p class="rrm-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}> <p class="rrm-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]} {item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
</p> </p>
<p class="rrm-title">{(() => { <p class="rrm-title">{item.title}</p>
const sp = splitStageSuffix(item.title);
return sp.stage ? <Fragment>{sp.base} — <span class="rr-stage">{sp.stage}</span></Fragment> : item.title;
})()}</p>
{item.description && <p class="rrm-desc">{item.description}</p>} {item.description && <p class="rrm-desc">{item.description}</p>}
{item.features.length > 0 && (
<ul class="rr-features">
{item.features.map(f => (
<li class="rr-feature">
<svg class="rr-feature-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
<path d="M8 3.5v9M3.5 8h9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>{f}</span>
</li>
))}
</ul>
)}
{trailingLine(item) && <p class="rrm-trail">{trailingLine(item)}</p>} {trailingLine(item) && <p class="rrm-trail">{trailingLine(item)}</p>}
</div> </div>
</li> </li>
@ -211,10 +177,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
// positions + SVG path d + track width on mount and on (debounced) // positions + SVG path d + track width on mount and on (debounced)
// resize. itemY values come from data-y on each milestone (path // resize. itemY values come from data-y on each milestone (path
// amplitude doesn't change with viewport, only the horizontal spread). // amplitude doesn't change with viewport, only the horizontal spread).
const MIN_SPACING = 360; const MIN_SPACING = 320;
const PADDING_X = 60; const PADDING_X = 60;
const CONTENT_MAX = 1152; // matches --content-max (72rem) const CONTENT_MAX = 1152; // matches --content-max (72rem)
const MID_Y = 240; // vertical centreline = track height (480) / 2 const MID_Y = 210; // vertical centreline = track height (420) / 2
document.querySelectorAll<HTMLElement>('.route').forEach((section) => { document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('#rr-scroll'); const scroll = section.querySelector<HTMLElement>('#rr-scroll');
@ -248,16 +214,13 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const trailing = Math.round(vw * 0.25); const trailing = Math.round(vw * 0.25);
const trackWidth = paddingLeft + usableWidth + trailing; const trackWidth = paddingLeft + usableWidth + trailing;
// Round to whole pixels — a milestone left-positioned on a fractional
// pixel and then translate(-50%)'d renders its text on half-pixels,
// which the browser antialiases into a soft/blurry edge.
const itemX: number[] = []; const itemX: number[] = [];
for (let i = 0; i < itemCount; i += 1) { for (let i = 0; i < itemCount; i += 1) {
itemX.push(Math.round( itemX.push(
itemCount === 1 itemCount === 1
? paddingLeft + usableWidth / 2 ? paddingLeft + usableWidth / 2
: paddingLeft + (i / (itemCount - 1)) * usableWidth, : paddingLeft + (i / (itemCount - 1)) * usableWidth,
)); );
} }
// Bezier path: control points at the segment midpoint x with control // Bezier path: control points at the segment midpoint x with control
@ -290,16 +253,18 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
/* Scroll-proximity focus: emphasise the milestone nearest the centre /* Scroll-proximity focus: emphasise the milestone nearest the centre
of the viewport and let those toward the edges recede + dim. Driven of the viewport and let those toward the edges recede + dim. Driven
every frame that the track moves (via updateNav), so movement feels every frame that the track moves (via updateNav), so movement feels
alive rather than a flat pan. Only OPACITY shifts with position — we alive rather than a flat pan. Not parallax — every milestone still
deliberately don't scale, because scaling rasterised card text makes tracks the scroll 1:1; only scale + opacity shift with position. */
it render blurry. */
function updateFocus() { function updateFocus() {
if (!scroll || itemXs.length === 0) return; if (!scroll || itemXs.length === 0) return;
const center = scroll.scrollLeft + scroll.clientWidth / 2; const center = scroll.scrollLeft + scroll.clientWidth / 2;
const half = Math.max(1, scroll.clientWidth / 2); const half = Math.max(1, scroll.clientWidth / 2);
milestones.forEach((m, i) => { milestones.forEach((m, i) => {
const t = Math.min(1, Math.abs((itemXs[i] ?? 0) - center) / half); const t = Math.min(1, Math.abs((itemXs[i] ?? 0) - center) / half);
m.style.opacity = (1 - 0.42 * t).toFixed(3); const scale = (1 - 0.10 * t).toFixed(3);
const op = (1 - 0.42 * t).toFixed(3);
m.style.transform = `translate(-50%, -50%) scale(${scale})`;
m.style.opacity = op;
}); });
} }
@ -504,19 +469,18 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
margin-right: calc(50% - 50vw); margin-right: calc(50% - 50vw);
} }
.rr-scroll { .rr-scroll {
/* Fills the available height on the roadmap page (which is locked to the /* overflow-x: auto + overflow-y: visible lets hovered cards expand
viewport) and centres the track vertically via .rr-scroll-inner, so the above/below the track without being clipped. .rr-scroll-inner is
expanding cards have the full half-height above/below to grow into the spec-recommended belt-and-braces wrapper in case a browser
without the page scrolling. Scrolls horizontally only. misbehaves on the combination.
NO scroll-snap-type and NO scroll-behavior: smooth — both fight NO scroll-snap-type and NO scroll-behavior: smooth — both fight
the JS drag-momentum + animated-glide implementation below. */ the JS drag-momentum + animated-glide implementation below. The
/* Height is set by the page (roadmap.astro) to a definite viewport-based path is meant to glide continuously, not click into fixed
value on desktop; defaults to the track's own height elsewhere. */ positions. */
box-sizing: border-box;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: visible;
scrollbar-width: none; scrollbar-width: none;
padding: 24px 80px; padding: 60px 80px 80px;
/* Drag affordance: cursor + suppress native horizontal swipe so /* Drag affordance: cursor + suppress native horizontal swipe so
horizontal drag triggers our handler while vertical drag still horizontal drag triggers our handler while vertical drag still
@ -530,28 +494,19 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
/* Pointer-events off the cards mid-drag — prevents accidental hover /* Pointer-events off the cards mid-drag — prevents accidental hover
reveal while the track is being dragged past. */ reveal while the track is being dragged past. */
.rr-scroll.rr-dragging .rr-card { pointer-events: none; } .rr-scroll.rr-dragging .rr-card { pointer-events: none; }
/* Centre the track vertically in the (viewport-tall) scroller so card .rr-scroll-inner { /* structural — keeps the track on its own layer */ }
expansion has the full half-height to grow into, both up and down. The .rr-track { position: relative; }
track is wider than the viewport and overflows horizontally → scrolls. */
.rr-scroll-inner {
min-height: 100%;
display: flex;
align-items: center;
}
/* flex: none so the track keeps its explicit (wide) width and doesn't get
shrunk by the flex parent down to its ~0 min-content width — its children
are absolutely positioned, so without this it collapses to a tiny box. */
.rr-track { position: relative; flex: none; }
.rr-path { position: absolute; top: 0; left: 0; pointer-events: none; } .rr-path { position: absolute; top: 0; left: 0; pointer-events: none; }
.rr-milestone { .rr-milestone {
position: absolute; position: absolute;
/* Centred on its dot. Only opacity is animated per-frame from JS (centre /* Inline transform/opacity are driven per-frame from JS based on each
milestone bright, edges recede) — no scaling, and no `will-change` on milestone's distance from the viewport centre, so the track comes
transform, so card text is rasterised natively (crisp) instead of being alive as you move it (centre milestone emphasised, edges recede).
baked into a transformed compositing layer (which looked blurry). */ The short ease softens the per-frame updates into a glide. */
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
transition: opacity .2s ease-out; transition: transform .2s ease-out, opacity .2s ease-out;
will-change: transform, opacity;
} }
/* A hovered/focused card always reads at full size and brightness, /* A hovered/focused card always reads at full size and brightness,
regardless of where it sits along the route — overrides the inline regardless of where it sits along the route — overrides the inline
@ -564,10 +519,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
} }
.rr-dot { .rr-dot {
width: 16px; width: 14px;
height: 16px; height: 14px;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 6px var(--background); /* halo cuts the path under the dot */ box-shadow: 0 0 0 5px var(--background); /* halo cuts the path under the dot */
transition: transform .25s ease, box-shadow .25s ease; transition: transform .25s ease, box-shadow .25s ease;
} }
.rr-dot.rr-current { .rr-dot.rr-current {
@ -601,15 +556,15 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-connector { .rr-connector {
width: 1px; width: 1px;
height: 36px; height: 30px;
background: rgba(0, 0, 0, 0.18); background: rgba(0, 0, 0, 0.18);
} }
.rr-card { .rr-card {
display: block; display: block;
width: 288px; width: 240px;
padding: 18px 20px; padding: 14px 16px;
border-radius: 12px; border-radius: 10px;
background: transparent; background: transparent;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@ -634,26 +589,19 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-eyebrow { .rr-eyebrow {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 12.5px; font-size: 11px;
letter-spacing: 1.5px; letter-spacing: 1.4px;
text-transform: uppercase; text-transform: uppercase;
margin: 0 0 8px; margin: 0 0 7px;
font-weight: 600; font-weight: 600;
} }
.rr-card-title { .rr-card-title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 23px; font-size: 20px;
line-height: 1.25; line-height: 1.25;
color: var(--on-surface); color: var(--on-surface);
margin: 0; margin: 0;
} }
/* Stage suffix (e.g. "alpha") rendered in small capitals. */
.rr-stage {
font-variant: small-caps;
text-transform: lowercase;
letter-spacing: 0.04em;
color: var(--on-surface-variant);
}
.rr-more { .rr-more {
max-height: 0; max-height: 0;
opacity: 0; opacity: 0;
@ -666,44 +614,20 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
} }
.rr-card:hover .rr-more, .rr-card:hover .rr-more,
.rr-card:focus-visible .rr-more { .rr-card:focus-visible .rr-more {
max-height: 520px; max-height: 340px;
opacity: 1; opacity: 1;
margin-top: 14px; margin-top: 12px;
} }
.rr-desc { .rr-desc {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 15px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
color: var(--on-surface-variant); color: var(--on-surface-variant);
margin: 0 0 12px; margin: 0 0 10px;
}
/* ── Feature bullets (plus-icon list) ──────────────────────────── */
.rr-features {
list-style: none;
padding: 0;
margin: 0 0 12px;
display: flex;
flex-direction: column;
gap: 7px;
}
.rr-feature {
display: flex;
align-items: flex-start;
gap: 9px;
font-family: var(--font-sans);
font-size: 13.5px;
line-height: 1.45;
color: var(--on-surface-variant);
}
.rr-feature-icon {
flex-shrink: 0;
margin-top: 2px;
color: var(--pigment-terracotta);
} }
.rr-trail { .rr-trail {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 11px; font-size: 10px;
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-muted); color: var(--on-surface-muted);
@ -714,7 +638,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-advance { .rr-advance {
position: absolute; position: absolute;
right: 32px; right: 32px;
/* The track is vertically centred in the scroller, so 50% lines up. */
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 48px; width: 48px;
@ -755,12 +678,13 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
animation: rr-advance-pulse 1.4s ease-in-out 3; animation: rr-advance-pulse 1.4s ease-in-out 3;
} }
/* Edge fades run the full height of the (viewport-tall) scroller — they're /* Edge fades cover only the track itself — the top/bottom padding
just horizontal gradients, so full height reads fine. */ zones (60/80) on .rr-scroll exist so hover cards can overflow there
without clipping, so the fades shouldn't paint over them. */
.rr-fade-left, .rr-fade-right { .rr-fade-left, .rr-fade-right {
position: absolute; position: absolute;
top: 0; top: 60px;
bottom: 0; bottom: 80px;
pointer-events: none; pointer-events: none;
transition: opacity .25s ease; transition: opacity .25s ease;
} }

File diff suppressed because one or more lines are too long

View file

@ -22,14 +22,6 @@ export function verifyPassword(password: string, hash: string): boolean {
return bcrypt.compareSync(password, hash); return bcrypt.compareSync(password, hash);
} }
/** A readable one-time password for admin resets. Give to the user; they
* change it from /account. 16 bytes = 128 bits of entropy from the CSPRNG
* (the 'Bifrost-' prefix is fixed/known, so the randomness must carry the
* full strength on its own). */
export function generateTempPassword(): string {
return 'Bifrost-' + randomBytes(16).toString('base64url');
}
// ── Invite tokens ──────────────────────────────────────────────── // ── Invite tokens ────────────────────────────────────────────────
/** Returns the URL-safe token (give to user) and its hash (store in DB). */ /** Returns the URL-safe token (give to user) and its hash (store in DB). */

View file

@ -161,11 +161,6 @@ export function updateUserEmail(id: number, email: string): void {
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id); db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
} }
/** Set a user's password hash (self-service change or admin reset). */
export function updateUserPassword(id: number, passwordHash: string): void {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, id);
}
/** Returns the newly-allocated member_number when the transition lands on /** Returns the newly-allocated member_number when the transition lands on
* cab and the user had none; null otherwise. Callers may ignore. */ * cab and the user had none; null otherwise. Callers may ignore. */
export function updateUserRole(id: number, role: Role): { allocated: number | null } { export function updateUserRole(id: number, role: Role): { allocated: number | null } {
@ -714,7 +709,6 @@ export interface RoadmapItem {
display_order: number; display_order: number;
shipped_at: string | null; shipped_at: string | null;
metadata_text: string | null; // short narrative cue shown on hover in /roadmap metadata_text: string | null; // short narrative cue shown on hover in /roadmap
features: string[]; // bullet-point capabilities, rendered as a plus-icon list
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -723,23 +717,6 @@ export interface RoadmapItemWithAttribution extends RoadmapItem {
attributed: { id: number; name: string; slug: string | null }[]; attributed: { id: number; name: string; slug: string | null }[];
} }
/** The `features` column is stored as a JSON array of strings (or NULL). */
type RoadmapItemRow = Omit<RoadmapItem, 'features'> & { features: string | null };
function parseFeatures(raw: string | null): string[] {
if (!raw || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
} catch {
return [];
}
}
function rowToRoadmapItem(row: RoadmapItemRow): RoadmapItem {
return { ...row, features: parseFeatures(row.features) };
}
export function createRoadmapItem(data: { export function createRoadmapItem(data: {
title: string; title: string;
description: string; description: string;
@ -747,11 +724,9 @@ export function createRoadmapItem(data: {
target?: string | null; target?: string | null;
display_order?: number; display_order?: number;
metadata_text?: string | null; metadata_text?: string | null;
features?: string[];
}): number { }): number {
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
const requestedOrder = data.display_order ?? 0; const requestedOrder = data.display_order ?? 0;
const features = JSON.stringify((data.features ?? []).filter((f) => f.trim() !== ''));
return db.transaction(() => { return db.transaction(() => {
// Cascade: insert at position N shifts every existing item at or after N // Cascade: insert at position N shifts every existing item at or after N
@ -761,8 +736,8 @@ export function createRoadmapItem(data: {
).run(requestedOrder); ).run(requestedOrder);
const r = db.prepare(` const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features) INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?)
`).run( `).run(
data.title, data.title,
data.description, data.description,
@ -771,7 +746,6 @@ export function createRoadmapItem(data: {
requestedOrder, requestedOrder,
shipped_at, shipped_at,
data.metadata_text ?? null, data.metadata_text ?? null,
features,
); );
return Number(r.lastInsertRowid); return Number(r.lastInsertRowid);
})(); })();
@ -788,7 +762,6 @@ export function updateRoadmapItem(id: number, data: {
target: string | null; target: string | null;
display_order: number; display_order: number;
metadata_text?: string | null; metadata_text?: string | null;
features?: string[];
}): { shippedNow: boolean } { }): { shippedNow: boolean } {
const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?') const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?')
.get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined; .get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined;
@ -815,13 +788,12 @@ export function updateRoadmapItem(id: number, data: {
).run(id, to, from); ).run(id, to, from);
} }
const features = JSON.stringify((data.features ?? []).filter((f) => f.trim() !== ''));
db.prepare(` db.prepare(`
UPDATE roadmap_items UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?, SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
shipped_at = ?, metadata_text = ?, features = ?, updated_at = datetime('now') shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, features, id); `).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
return { shippedNow }; return { shippedNow };
})(); })();
@ -842,22 +814,21 @@ export function deleteRoadmapItem(id: number): void {
} }
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null { export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
const row = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItemRow | undefined; const item = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItem | undefined;
if (!row) return null; if (!item) return null;
const attributed = db.prepare(` const attributed = db.prepare(`
SELECT u.id, u.name, u.slug FROM roadmap_attributions ra SELECT u.id, u.name, u.slug FROM roadmap_attributions ra
JOIN users u ON u.id = ra.user_id JOIN users u ON u.id = ra.user_id
WHERE ra.roadmap_item_id = ? WHERE ra.roadmap_item_id = ?
ORDER BY u.name ORDER BY u.name
`).all(id) as { id: number; name: string; slug: string | null }[]; `).all(id) as { id: number; name: string; slug: string | null }[];
return { ...rowToRoadmapItem(row), attributed }; return { ...item, attributed };
} }
export function getAllRoadmapItems(): RoadmapItemWithAttribution[] { export function getAllRoadmapItems(): RoadmapItemWithAttribution[] {
const rows = db.prepare( const items = db.prepare(
'SELECT * FROM roadmap_items ORDER BY status, display_order, created_at' 'SELECT * FROM roadmap_items ORDER BY status, display_order, created_at'
).all() as RoadmapItemRow[]; ).all() as RoadmapItem[];
const items = rows.map(rowToRoadmapItem);
const attribs = db.prepare(` const attribs = db.prepare(`
SELECT ra.roadmap_item_id, u.id, u.name, u.slug SELECT ra.roadmap_item_id, u.id, u.name, u.slug
FROM roadmap_attributions ra FROM roadmap_attributions ra

Binary file not shown.

View file

@ -1,34 +1,16 @@
--- ---
import AppLayout from '../layouts/AppLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
import { updateUserProfile, updateUserPassword, getUserByEmail } from '../lib/db'; import { updateUserProfile } from '../lib/db';
import { verifyPassword, hashPassword } from '../lib/auth';
const user = Astro.locals.user; const user = Astro.locals.user;
let success = false;
let error: string | null = null; let error: string | null = null;
let pwError: string | null = null;
if (Astro.request.method === 'POST') { if (Astro.request.method === 'POST') {
const data = await Astro.request.formData(); const data = await Astro.request.formData();
const intent = String(data.get('intent') ?? 'profile');
if (intent === 'password') {
const current = String(data.get('current_password') ?? '');
const next = String(data.get('new_password') ?? '');
const confirm = String(data.get('confirm_password') ?? '');
const row = getUserByEmail(user.email);
if (!row || !verifyPassword(current, row.password_hash)) {
pwError = 'Current password is incorrect.';
} else if (next.length < 8) {
pwError = 'New password must be at least 8 characters.';
} else if (next !== confirm) {
pwError = 'New password and confirmation do not match.';
} else {
updateUserPassword(user.id, hashPassword(next));
return Astro.redirect('/account?pwchanged=1');
}
} else {
const name = String(data.get('name') ?? '').trim(); const name = String(data.get('name') ?? '').trim();
const bio = String(data.get('bio') ?? '').trim(); const bio = String(data.get('bio') ?? '').trim();
if (name.length < 2) { if (name.length < 2) {
error = 'Name must be at least 2 characters.'; error = 'Name must be at least 2 characters.';
} else if (bio.length > 280) { } else if (bio.length > 280) {
@ -38,10 +20,8 @@ if (Astro.request.method === 'POST') {
return Astro.redirect('/account?saved=1'); return Astro.redirect('/account?saved=1');
} }
} }
}
const saved = Astro.url.searchParams.get('saved') === '1'; const saved = Astro.url.searchParams.get('saved') === '1';
const pwChanged = Astro.url.searchParams.get('pwchanged') === '1';
--- ---
<AppLayout title="Account" user={user}> <AppLayout title="Account" user={user}>
<div class="page"> <div class="page">
@ -61,7 +41,6 @@ const pwChanged = Astro.url.searchParams.get('pwchanged') === '1';
)} )}
<form method="POST" class="account-form" novalidate> <form method="POST" class="account-form" novalidate>
<input type="hidden" name="intent" value="profile" />
<div class="field"> <div class="field">
<label for="name" class="label-sm field-label"> <label for="name" class="label-sm field-label">
Display name Display name
@ -115,65 +94,9 @@ const pwChanged = Astro.url.searchParams.get('pwchanged') === '1';
</div> </div>
</dl> </dl>
<p class="body-sm reset-note"> <p class="body-sm reset-note">
To change your email, contact Fenja — we will issue a new invite link. To change your email or password, contact Fenja — we will issue a new invite link.
</p> </p>
</div> </div>
<form method="POST" class="account-form" novalidate>
<input type="hidden" name="intent" value="password" />
<h2 class="label-sm section-heading">Change password</h2>
{pwChanged && (
<p class="success-msg body-sm" role="status">Password updated.</p>
)}
{pwError && (
<p class="error-msg body-sm" role="alert">{pwError}</p>
)}
<div class="field">
<label for="current_password" class="label-sm field-label">Current password</label>
<input
type="password"
id="current_password"
name="current_password"
class="input body-md"
required
autocomplete="current-password"
/>
</div>
<div class="field">
<label for="new_password" class="label-sm field-label">
New password
<span class="label-sm field-hint">At least 8 characters</span>
</label>
<input
type="password"
id="new_password"
name="new_password"
class="input body-md"
required
minlength="8"
autocomplete="new-password"
/>
</div>
<div class="field">
<label for="confirm_password" class="label-sm field-label">Confirm new password</label>
<input
type="password"
id="confirm_password"
name="confirm_password"
class="input body-md"
required
minlength="8"
autocomplete="new-password"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary body-md">Update password</button>
</div>
</form>
</div> </div>
</div> </div>

View file

@ -189,9 +189,6 @@ function resultRedirectParam(r: ActionResult | undefined): string {
if (r.kind === 'invite-link') { if (r.kind === 'invite-link') {
return `&invite_url=${encodeURIComponent(r.url)}`; return `&invite_url=${encodeURIComponent(r.url)}`;
} }
if (r.kind === 'temp-password') {
return `&temp_password=${encodeURIComponent(r.password)}`;
}
return ''; return '';
} }

View file

@ -23,16 +23,9 @@ function fmt(iso: string): string {
<AppLayout title="Dispatches" user={user}> <AppLayout title="Dispatches" user={user}>
<div class="page"> <div class="page">
<a href="/pulse" class="back-link">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Pulse
</a>
<header class="head"> <header class="head">
<h1 class="head-title">Dispatch.</h1> <h1 class="head-title">Notes from the studio.</h1>
<p class="head-sub">Where we share news from Fenja — progress, decisions, and what we're building next.</p> <p class="head-sub">Decisions, half-built ideas, and things we've changed our mind about.</p>
</header> </header>
{dispatches.length === 0 ? ( {dispatches.length === 0 ? (
@ -82,25 +75,6 @@ function fmt(iso: string): string {
gap: var(--space-8); gap: var(--space-8);
} }
/* Back-to-Pulse link — a quiet arrow above the page title. */
.back-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
align-self: flex-start;
font-family: var(--font-sans);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
text-decoration: none;
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard);
}
.back-link:hover { color: var(--pigment-terracotta); border-bottom: none; }
.back-link svg { transition: transform var(--duration-fast) var(--ease-standard); }
.back-link:hover svg { transform: translateX(-2px); }
.head { max-width: 46rem; } .head { max-width: 46rem; }
.head-eyebrow { .head-eyebrow {
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
@ -120,33 +94,23 @@ function fmt(iso: string): string {
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; } .head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
.empty { color: var(--on-surface-muted); } .empty { color: var(--on-surface-muted); }
/* Rows are now standalone cards on a slight tonal background, separated by .d-list { list-style: none; padding: 0; margin: 0; }
whitespace rather than borders (per design system). */ .d-row { border-bottom: 0.5px solid var(--surface-card-border); }
.d-list { .d-row:last-child { border-bottom: none; }
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.d-row { border-bottom: none; }
.d-link { .d-link {
display: grid; display: grid;
grid-template-columns: 180px 1fr 130px; grid-template-columns: 180px 1fr 130px;
gap: var(--space-5); gap: var(--space-5);
padding: var(--space-5) var(--space-5); padding: var(--space-5) var(--space-3);
align-items: start; align-items: start;
text-decoration: none; text-decoration: none;
border-bottom: none; border-bottom: none;
color: inherit; color: inherit;
border-radius: var(--radius-md);
background: color-mix(in oklab, var(--surface-card) 55%, transparent);
transition: background var(--duration-fast) var(--ease-standard); transition: background var(--duration-fast) var(--ease-standard);
} }
.d-link:hover { .d-link:hover {
background: var(--surface-card); background: color-mix(in oklab, var(--surface-card) 60%, transparent);
border-bottom: none; border-bottom: none;
} }

View file

@ -3,7 +3,6 @@ import AppLayout from '../layouts/AppLayout.astro';
import Avatar from '../components/Avatar.astro'; import Avatar from '../components/Avatar.astro';
import EventHeroCard from '../components/EventHeroCard.astro'; import EventHeroCard from '../components/EventHeroCard.astro';
import RoadmapCarousel from '../components/RoadmapCarousel.astro'; import RoadmapCarousel from '../components/RoadmapCarousel.astro';
import type { Event } from '../lib/db';
import { import {
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees, getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity, getUserRsvp, setEventRsvp, recordActivity,
@ -11,7 +10,8 @@ import {
getAllCabMembers, getPulseById, castOrChangeVote, getAllCabMembers, getPulseById, castOrChangeVote,
} from '../lib/db'; } from '../lib/db';
import { import {
timeOfDay, relativeTime, eventKindLabel, timeOfDay, relativeTime,
eventKindLabel,
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview, dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
} from '../lib/format'; } from '../lib/format';
@ -75,20 +75,13 @@ function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s); if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z'); return new Date(s.replace(' ', 'T') + 'Z');
} }
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
// Day / month·kind for the previous + upcoming strip below the hero card. return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
function fmtPart(opts: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...opts, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
} }
function alsoParts(ev: Event) { const dayNum = (iso: string) => fmt({ day: 'numeric' }, iso);
return { const weekday = (iso: string) => fmt({ weekday: 'short' }, iso).toUpperCase();
day: fmtPart({ day: 'numeric' }, ev.starts_at), const monthShort = (iso: string) => fmt({ month: 'short' }, iso).toUpperCase();
sub: `${fmtPart({ month: 'short' }, ev.starts_at).toUpperCase()} · ${eventKindLabel(ev.kind).toUpperCase()}`, const timeStr = (iso: string) => fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso);
title: ev.title,
};
}
const prevAlso = previousEvent ? alsoParts(previousEvent) : null;
const upcAlso = upcomingAfterHero ? alsoParts(upcomingAfterHero) : null;
const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : []; const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
const heroConfirmedCount = heroAttendees.length; const heroConfirmedCount = heroAttendees.length;
@ -128,36 +121,36 @@ const members = getAllCabMembers();
firstName={firstName} firstName={firstName}
memberLabel={memberNumberLabel} memberLabel={memberNumberLabel}
/> />
</section>
<!-- Previous + upcoming gatherings, sitting just below the box. --> <!-- ── Previous + Upcoming strip (plain text on cream) ─────── -->
{(prevAlso || upcAlso) && ( <section class="cascade also-coming-up" aria-label="Surrounding events">
<div class="also-strip">
<div class="also-list"> <div class="also-list">
{prevAlso && ( {previousEvent && (
<a href="/events" class="also-item"> <div class="also-item">
<span class="also-day">{prevAlso.day}</span> <span class="also-day">{dayNum(previousEvent.starts_at)}</span>
<span class="also-meta"> <div class="also-meta-col">
<span class="also-eyebrow">Previous</span> <span class="also-eyebrow">Previous</span>
<span class="also-sub">{prevAlso.sub}</span> <span class="also-month-kind">{monthShort(previousEvent.starts_at)} · {eventKindLabel(previousEvent.kind).toUpperCase()}</span>
<span class="also-title">{prevAlso.title}</span> <span class="also-title">{previousEvent.title}</span>
</span> </div>
</a> </div>
)} )}
{prevAlso && upcAlso && <span class="also-divider" aria-hidden="true"></span>} {previousEvent && upcomingAfterHero && (
{upcAlso && ( <span class="also-divider" aria-hidden="true"></span>
<a href="/events" class="also-item"> )}
<span class="also-day">{upcAlso.day}</span> {upcomingAfterHero && (
<span class="also-meta"> <div class="also-item">
<span class="also-day">{dayNum(upcomingAfterHero.starts_at)}</span>
<div class="also-meta-col">
<span class="also-eyebrow">Upcoming</span> <span class="also-eyebrow">Upcoming</span>
<span class="also-sub">{upcAlso.sub}</span> <span class="also-month-kind">{monthShort(upcomingAfterHero.starts_at)} · {eventKindLabel(upcomingAfterHero.kind).toUpperCase()}</span>
<span class="also-title">{upcAlso.title}</span> <span class="also-title">{upcomingAfterHero.title}</span>
</span>
</a>
)}
</div> </div>
<a href="/events" class="also-all">All gatherings →</a>
</div> </div>
)} )}
</div>
<a href="/events" class="also-link">All gatherings →</a>
</section> </section>
<!-- ── Editorial row: dispatch (1.7fr) + pulse (1fr) ─────────── --> <!-- ── Editorial row: dispatch (1.7fr) + pulse (1fr) ─────────── -->
@ -289,91 +282,11 @@ const members = getAllCabMembers();
dispatch → 'Earlier' gap stays tight at the original 48px because dispatch → 'Earlier' gap stays tight at the original 48px because
they're the same story. */ they're the same story. */
.hero-slot { margin-top: 24px; } /* first section, below nav */ .hero-slot { margin-top: 24px; } /* first section, below nav */
.editorial-row { margin-top: 96px; } /* hero → editorial */ .also-coming-up { margin-top: 18px; } /* hero → also (tight; pair) */
.editorial-row { margin-top: 96px; } /* also → editorial */
.roadmap-wrap { margin-top: 96px; } /* editorial → roadmap */ .roadmap-wrap { margin-top: 96px; } /* editorial → roadmap */
.council-section{ margin-top: 96px; } /* roadmap → council */ .council-section{ margin-top: 96px; } /* roadmap → council */
/* ── Previous + upcoming strip (below the hero box) ───────────── */
.also-strip {
margin-top: 18px;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px 32px;
flex-wrap: wrap;
}
.also-list {
display: flex;
align-items: center;
gap: 28px;
min-width: 0;
flex-wrap: wrap;
}
.also-item {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
color: var(--on-surface);
text-decoration: none;
border-bottom: none;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.also-item:hover { opacity: 0.7; border-bottom: none; }
.also-day {
font-family: var(--font-serif);
font-weight: 400;
font-size: 32px;
line-height: 1;
color: var(--on-surface);
flex-shrink: 0;
}
.also-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.also-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
font-weight: 600;
}
.also-sub {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.also-title {
font-family: var(--font-serif);
font-size: 15px;
line-height: 1.25;
color: var(--on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 20rem;
}
.also-divider {
flex-shrink: 0;
width: 1px;
height: 34px;
background: rgba(0, 0, 0, 0.1);
}
.also-all {
flex-shrink: 0;
align-self: flex-start; /* sit at the top of the strip, not the baseline */
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.also-all:hover { opacity: 0.7; border-bottom: none; }
/* ── Cascade entry (first paint only) ─────────────────────────── */ /* ── Cascade entry (first paint only) ─────────────────────────── */
.cascade { .cascade {
opacity: 0; opacity: 0;
@ -437,8 +350,85 @@ const members = getAllCabMembers();
.page { padding: 32px 20px 64px; } .page { padding: 32px 20px 64px; }
.greeting { grid-template-columns: 1fr; align-items: start; } .greeting { grid-template-columns: 1fr; align-items: start; }
.greeting-right { align-items: flex-start; } .greeting-right { align-items: flex-start; }
/* "Also coming up" strip stacks and wraps instead of overflowing. */
.also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
.also-list { flex-wrap: wrap; gap: 12px 18px; }
.also-title { max-width: 70vw; }
} }
/* ── 'Also coming up' strip (plain text on cream) ─────────────── */
.also-coming-up {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
gap: var(--space-5);
}
.also-list {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
}
.also-divider {
display: inline-block;
width: 1px;
height: 28px;
background: rgba(0, 0, 0, 0.08);
transform: scaleX(1);
}
.also-item {
display: flex;
align-items: center;
gap: 12px;
}
.also-day {
font-family: var(--font-serif);
font-weight: 400;
font-size: 22px;
line-height: 1;
color: var(--on-surface);
}
.also-meta-col {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.also-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
font-weight: 600;
}
.also-month-kind {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.also-title {
font-size: 14px;
color: var(--on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 16rem;
}
.also-link {
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
}
.also-link:hover { opacity: 0.8; border-bottom: none; }
/* ── Editorial row: dispatch (1.7fr) + pulse (1fr) ────────────── */ /* ── Editorial row: dispatch (1.7fr) + pulse (1fr) ────────────── */
.editorial-row { .editorial-row {
display: grid; display: grid;
@ -760,5 +750,6 @@ const members = getAllCabMembers();
/* ── Responsive ───────────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) { @media (max-width: 880px) {
.editorial-row { grid-template-columns: 1fr; gap: var(--space-8); } .editorial-row { grid-template-columns: 1fr; gap: var(--space-8); }
.also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
} }
</style> </style>

View file

@ -1,5 +1,6 @@
--- ---
import AppLayout from '../layouts/AppLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
import LatestDispatchBanner from '../components/LatestDispatchBanner.astro';
import RoadmapRoute from '../components/RoadmapRoute.astro'; import RoadmapRoute from '../components/RoadmapRoute.astro';
import { getAllRoadmapItems } from '../lib/db'; import { getAllRoadmapItems } from '../lib/db';
@ -26,99 +27,86 @@ const items = getAllRoadmapItems()
up just before walking the path. --> up just before walking the path. -->
<div class="roadmap-legend" aria-label="Status legend"> <div class="roadmap-legend" aria-label="Status legend">
<span><i style="background:#6d8c7c"></i>Shipping</span> <span><i style="background:#6d8c7c"></i>Shipping</span>
<span><i style="background:#b96b58"></i>In dev</span> <span><i style="background:#b96b58"></i>In beta</span>
<span><i style="background:#5a6d83"></i>Planning</span> <span><i style="background:#5a6d83"></i>Planned</span>
<span><i style="background:#b4b2a9"></i>Exploring</span> <span><i style="background:#b4b2a9"></i>Exploring</span>
<span><i style="background:#d4d2c8"></i>Considering</span> <span><i style="background:#d4d2c8"></i>Considering</span>
</div> </div>
<RoadmapRoute items={items} /> <RoadmapRoute items={items} />
<!-- Latest dispatch sits at the foot of the page with generous
space above so it reads as a separate beat, not a continuation
of the route. -->
<LatestDispatchBanner />
</article> </article>
</AppLayout> </AppLayout>
<!-- Desktop: the roadmap is a fixed, full-viewport view. Lock page scrolling
so the wheel only pans the route sideways and the route stays on screen.
Mobile keeps normal vertical scrolling for the timeline list. -->
<style is:global>
@media (min-width: 768px) {
html, body { overflow: hidden; }
.app > .footer { display: none; }
}
</style>
<style> <style>
.roadmap-page { .roadmap-page {
padding: 0 36px; padding: 0 36px 80px;
max-width: var(--content-max); max-width: var(--content-max);
margin: 0 auto; margin: 0 auto;
} }
/* Desktop: give the route a definite, viewport-based height (no fragile
percentage-height chain) so it fills the screen and the track stays
centred and fully visible while scrolling sideways only. The subtracted
amount ≈ nav + the header/legend block above the route. */
@media (min-width: 768px) {
/* NB: no overflow:hidden here — it would clip the full-bleed route back
to the content column. Page scrolling is locked via body instead. */
.roadmap-page :global(.rr-scroll) { height: calc(100vh - 310px); }
}
/* ── Centred header ──────────────────────────────────────────── */ /* ── Centred header ──────────────────────────────────────────── */
.roadmap-header { .roadmap-header {
text-align: center; text-align: center;
max-width: 640px; max-width: 640px;
margin: 0 auto 18px; /* gap to the legend */ margin: 0 auto 56px; /* generous gap to the legend */
padding-top: 88px; /* nudged down toward the page's vertical centre */ padding-top: 96px;
flex-shrink: 0;
} }
.roadmap-title { .roadmap-title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 400; font-weight: 400;
font-size: 58px; font-size: 48px;
line-height: 1.05; line-height: 1.05;
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
color: var(--on-surface); color: var(--on-surface);
margin: 0 0 18px; margin: 0 0 14px;
} }
.roadmap-sub { .roadmap-sub {
font-size: 18px; font-size: 14px;
line-height: 1.65; line-height: 1.65;
color: var(--on-surface-variant); color: var(--on-surface-variant);
margin: 0 auto; margin: 0 auto;
max-width: 600px; max-width: 520px;
} }
/* ── Legend (above the route, key-style) ─────────────────────── */ /* ── Legend (above the route, key-style) ─────────────────────── */
.roadmap-legend { .roadmap-legend {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 28px; gap: 24px;
margin: 0 auto 18px; /* tight to the route — they're paired */ margin: 0 auto 14px; /* tight to the route — they're paired */
flex-wrap: wrap; flex-wrap: wrap;
flex-shrink: 0;
} }
.roadmap-legend span { .roadmap-legend span {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 9px; gap: 7px;
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 13px; font-size: 10px;
letter-spacing: 1.2px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-variant); color: var(--on-surface-variant);
} }
.roadmap-legend i { .roadmap-legend i {
width: 10px; width: 8px;
height: 10px; height: 8px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Dispatch banner (foot of page, generous breathing room) ── */
.roadmap-page :global(.rr-dispatch) { margin-top: 112px; }
@media (max-width: 767px) { @media (max-width: 767px) {
.roadmap-page { padding: 0 24px 64px; } .roadmap-page { padding: 0 24px 64px; }
.roadmap-header { padding-top: 72px; margin-bottom: 40px; } .roadmap-header { padding-top: 72px; margin-bottom: 40px; }
.roadmap-title { font-size: 42px; } .roadmap-title { font-size: 36px; }
.roadmap-legend { margin-bottom: 12px; } .roadmap-legend { margin-bottom: 12px; }
.roadmap-page :global(.rr-dispatch) { margin-top: 72px; }
} }
</style> </style>