Compare commits

..

12 commits

Author SHA1 Message Date
e4accb614b chore(roadmap): add idempotent prod roadmap sync script
scripts/sync-roadmap.js takes its own online backup of the target DB, then
upserts the 10 canonical roadmap items matched by display_order — updating
title/description/status/target/features in place so ids, attributions and
shipped_at are preserved. Inserts only when a display_order is missing; never
deletes. Run on the box after deploy with BIFROST_DB_PATH set.
2026-06-18 16:29:14 +02:00
ec24694a6d style(dispatches): rename to "Dispatch", tonal rows, back-to-Pulse link
Retitle the page, rewrite the lead, give each row a subtle tonal background,
and add a back arrow to Pulse.
2026-06-18 16:05:09 +02:00
436e19170e style(pulse): previous/upcoming gatherings strip below the hero card
Move the previous + upcoming dates out of the dark hero box into an editorial
strip beneath it (large serif day, terracotta eyebrow, "All gatherings" link
top-aligned on the right). Hero card keeps just the RSVP footer.
2026-06-18 16:05:09 +02:00
c0d4a6fdff feat(nav): account dropdown + resized logo lockup
- Replace the name link/logout with a faded person-icon button that opens a
  Settings + Sign out dropdown on hover/click; opaque (non-glass) panel.
- Inline the logo SVG so it never re-fetches (kills the flicker).
- Size the Fenja AI logo larger than the "Project · Bifrost" wordmark and
  align them on a shared optical centre.
2026-06-18 16:05:02 +02:00
a9e8a57642 feat(roadmap): feature bullets, live items, larger scroll-locked route
- Add per-item feature bullets (features column, migration 0009, db helpers,
  admin field) rendered as plus-icon lists on desktop + mobile.
- Reseed with the live roadmap items; status labels renamed
  (In dev / Planning) and "— Alpha" suffix dropped from titles.
- Enlarge the route and lock the roadmap page to sideways-only scroll so the
  timeline stays on screen; full-bleed edge-to-edge width; nudge the header
  down toward the page centre.
- Small-caps stage suffix helper (splitStageSuffix) in format.ts.
2026-06-18 16:04:56 +02:00
29b30b27e6 fix(auth): strengthen admin temp-password entropy to 128 bits
generateTempPassword() used randomBytes(4) — 32 bits behind a known
'Bifrost-' prefix — for a directly-usable login password set by the admin
reset action. Brute-forceable. Bump to randomBytes(16) (128 bits) base64url.
Flagged by automated security review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:57:14 +02:00
505484124d fix(deploy): non-interactive restart in deploy.sh; restart-only sudoers rule
deploy.sh runs as fenja and called `sudo systemctl restart`, which prompted
for fenja's (nonexistent) password and aborted the deploy. Use `sudo -n` so
it never hangs: restart silently when the NOPASSWD rule is present, else
print the manual restart command and exit non-zero. Drop sudo from the
read-only status line. Narrow the documented sudoers rule to restart-only
and create it via visudo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:55:38 +02:00
0a62984e91 merge(rework): self-service + admin password reset into deploy master 2026-06-17 15:45:39 +02:00
096c9bc297 feat(auth): self-service password change + admin password reset
- /account gains a Change password form (verify current, 8+ char new,
  confirm match) backed by updateUserPassword + verifyPassword/hashPassword.
- Admin users resource gains a "Reset password" action that generates a
  fresh temp password, sets it immediately, and reveals it once in the panel
  (new temp-password action-result, reusing the copy-box UI) for the admin
  to send to the user.
- Backstage top-left logo now links to the portal (main menu).

Temp passwords are generated + hashed at request time; never stored in git
or logged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:42:45 +02:00
9d0326e3ea fix(deploy): use pnpm 11 allowBuilds key; drop dead package.json pnpm field
pnpm 11 ignores both package.json's pnpm.onlyBuiltDependencies and the
pnpm-workspace.yaml onlyBuiltDependencies key — it reads the build allow-list
from `allowBuilds` (as written by `pnpm approve-builds`). Switch to that so
`pnpm install` compiles better-sqlite3/esbuild/sharp automatically on deploy,
and remove the now-ignored package.json field that emitted a warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:07:39 +02:00
cf534777af fix(deploy): move onlyBuiltDependencies to pnpm-workspace.yaml
pnpm 10+ no longer reads pnpm.onlyBuiltDependencies from package.json and
blocks build scripts by default, so better-sqlite3's native binary was
never compiled on install (ERR_PNPM_IGNORED_BUILDS) — the SSR server would
crash at runtime. Allow-list the native/build-script deps here so every
`pnpm install` (including scripts/deploy.sh) builds them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:40:29 +02:00
d27ab4c98b docs(deploy): document the repo-scoped deploy key + SSH alias flow
Generate a dedicated ed25519 deploy key on the server (private key stays
put), register the public half read-only, and clone via a bifrost-portal-git
SSH alias with IdentitiesOnly so it can't clash with the existing apps' keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:02:18 +02:00
24 changed files with 1044 additions and 342 deletions

View file

@ -74,14 +74,42 @@ 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
```
## 3. Clone the repo
## 3. Deploy key + clone
The `fenja` user needs read access to `git.fenja.ai` (a deploy key on its
account, or your forwarded agent for the first clone):
The `fenja` user needs read access to this repo on `git.fenja.ai`. Use a
dedicated, repo-scoped **deploy key** generated on the server (private key never
leaves the box) plus an SSH alias so it's used only for this repo.
```bash
sudo -u fenja git clone ssh://git@git.fenja.ai:2222/joh/project-bifrost-platform.git \
/opt/bifrost-portal
# Generate the keypair (no passphrase — it's for unattended deploys)
sudo -u fenja install -d -m 700 /home/fenja/.ssh
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`)
@ -89,6 +117,8 @@ sudo -u fenja git clone ssh://git@git.fenja.ai:2222/joh/project-bifrost-platform
> 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
> 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
@ -131,14 +161,25 @@ sudo systemctl status bifrost-portal --no-pager
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
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
sudo visudo -f /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
```bash

View file

@ -0,0 +1,6 @@
-- 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,8 +34,5 @@
},
"engines": {
"node": ">=22"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "sharp"]
}
}

9
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,9 @@
# 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,10 +39,21 @@ echo "==> Applying database migrations -> $BIFROST_DB_PATH"
node scripts/migrate.js
echo "==> Restarting $SERVICE"
sudo systemctl restart "$SERVICE"
# Non-interactive: if fenja has the NOPASSWD rule for this unit it restarts
# 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"
sleep 2
sudo systemctl --no-pager --lines=0 status "$SERVICE"
# status is read-only — no sudo needed
systemctl --no-pager --lines=0 status "$SERVICE" || true
echo "==> Deploy complete: $(git rev-parse --short HEAD)"

View file

@ -195,30 +195,50 @@ 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));
// ── Roadmap: 9 items, status meaning 'currently live' rather than
// 'shipping soon'. Items 1-2 are live in production; items 3-4 are in
// beta even if 'audit log export' has a near-term GA target. Travelled
// 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.
// ── Roadmap: mirrors the live bifrost-portal.fenja.ai/roadmap items
// (titles, statuses, target months, order). Descriptions are completed from
// the truncated live text; the `features` plus-icon bullets are drafted from
// each item's description.
const roadmap = [
{ 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' },
{ 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" },
{ 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: '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 →' },
{ 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' },
{ 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: '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 },
{ 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' },
{ 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: 'MCP Usage', status: 'shipping', target: 'July 2026', display_order: 1, shipped_at: nowIso(-3 * 24 * 3600), attributed: [], metadata_text: null,
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'] },
{ title: 'Extended Logging', status: 'shipping', target: 'July 2026', display_order: 2, shipped_at: nowIso(-2 * 24 * 3600), attributed: [], metadata_text: null,
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'] },
{ title: 'Wiki 2.0', status: 'in_beta', target: 'July 2026', display_order: 3, shipped_at: null, attributed: [], metadata_text: null,
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'] },
{ 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(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?)
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
VALUES (?,?,?,?,?,?,?,?)
`);
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
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).lastInsertRowid);
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);
for (const uid of r.attributed) insertAttr.run(id, uid);
}
@ -378,7 +398,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));
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`);
console.log(' roadmap: 9 items (2 shipping / 2 in_beta / 3 exploring / 2 considering)');
console.log(' roadmap: 10 items (2 shipping / 1 in_beta / 6 planned / 1 exploring)');
console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
console.log(' events: dinner + studio hours + working session, 2 past');

150
scripts/sync-roadmap.js Normal file
View file

@ -0,0 +1,150 @@
#!/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 ──────────────────────────────────────────────── -->
<header class="bs-topbar" role="banner">
<a href="/admin" class="bs-brand" aria-label="Backstage — home">
<a href="/" class="bs-brand" aria-label="Back to the main menu">
<img src="/logo.svg" alt="Fenja AI" class="bs-brand-mark" />
<span class="bs-brand-sep" aria-hidden="true">·</span>
<span class="bs-brand-project">Project <em class="bs-brand-bifrost">Bifrost</em></span>

View file

@ -90,6 +90,8 @@ const summaryEntries = isReviewMode && item
// One-shot invite link surfaced after create/action — read from 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
const closeUrl = (() => {
@ -133,6 +135,16 @@ const formAction = Astro.url.pathname + Astro.url.search;
</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 ? (
<dl class="bs-summary">
{summaryEntries.map((entry) => (

View file

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

View file

@ -47,8 +47,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
width: '120px',
pillVariants: {
shipping: { label: 'Shipping', class: 'pill-shipping' },
in_beta: { label: 'In beta', class: 'pill-in-beta' },
planned: { label: 'Planned', class: 'pill-planned' },
in_beta: { label: 'In dev', class: 'pill-in-beta' },
planned: { label: 'Planning', class: 'pill-planned' },
exploring: { label: 'Exploring', class: 'pill-exploring' },
considering: { label: 'Considering', class: 'pill-considering' },
},
@ -69,8 +69,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
filters: [
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
{ key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' },
{ key: 'in_beta', label: 'In beta', predicate: (i) => i.status === 'in_beta' },
{ key: 'planned', label: 'Planned', predicate: (i) => i.status === 'planned' },
{ key: 'in_beta', label: 'In dev', predicate: (i) => i.status === 'in_beta' },
{ key: 'planned', label: 'Planning', predicate: (i) => i.status === 'planned' },
{ key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' },
{ key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' },
],
@ -100,8 +100,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
required: true,
options: [
{ value: 'shipping', label: 'Shipping' },
{ value: 'in_beta', label: 'In beta' },
{ value: 'planned', label: 'Planned' },
{ value: 'in_beta', label: 'In dev' },
{ value: 'planned', label: 'Planning' },
{ value: 'exploring', label: 'Exploring' },
{ value: 'considering', label: 'Considering' },
],
@ -123,6 +123,14 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
defaultValue: 0,
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',
label: 'Hover metadata',
@ -160,6 +168,9 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
target: ((data.target as string) ?? '').trim() || null,
display_order: Number(data.display_order ?? 0),
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)
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
@ -176,6 +187,9 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
target: ((data.target as string) ?? '').trim() || null,
display_order: Number(data.display_order ?? 0),
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)
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)

View file

@ -16,12 +16,14 @@ import {
getUserPublicById,
updateUserAdminFields,
updateUserEmail,
updateUserPassword,
updateUserProfile,
updateUserRole,
deactivateUser,
type Role,
type UserPublic,
} from '../../lib/db';
import { generateTempPassword, hashPassword } from '../../lib/auth';
import { parseFocusTags, readFocusTags } from '../../lib/format';
import type { Resource } from '../resource-types';
@ -326,6 +328,20 @@ export const usersResource: Resource<UserPublic> = {
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: {
// CAB members without focus tags read as half-finished profiles —
// surface them as something to attend to.

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,14 @@ export function verifyPassword(password: string, hash: string): boolean {
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 ────────────────────────────────────────────────
/** Returns the URL-safe token (give to user) and its hash (store in DB). */

View file

@ -161,6 +161,11 @@ export function updateUserEmail(id: number, email: string): void {
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
* cab and the user had none; null otherwise. Callers may ignore. */
export function updateUserRole(id: number, role: Role): { allocated: number | null } {
@ -709,6 +714,7 @@ export interface RoadmapItem {
display_order: number;
shipped_at: string | null;
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;
updated_at: string;
}
@ -717,6 +723,23 @@ export interface RoadmapItemWithAttribution extends RoadmapItem {
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: {
title: string;
description: string;
@ -724,9 +747,11 @@ export function createRoadmapItem(data: {
target?: string | null;
display_order?: number;
metadata_text?: string | null;
features?: string[];
}): number {
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
const requestedOrder = data.display_order ?? 0;
const features = JSON.stringify((data.features ?? []).filter((f) => f.trim() !== ''));
return db.transaction(() => {
// Cascade: insert at position N shifts every existing item at or after N
@ -736,8 +761,8 @@ export function createRoadmapItem(data: {
).run(requestedOrder);
const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?)
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
VALUES (?,?,?,?,?,?,?,?)
`).run(
data.title,
data.description,
@ -746,6 +771,7 @@ export function createRoadmapItem(data: {
requestedOrder,
shipped_at,
data.metadata_text ?? null,
features,
);
return Number(r.lastInsertRowid);
})();
@ -762,6 +788,7 @@ export function updateRoadmapItem(id: number, data: {
target: string | null;
display_order: number;
metadata_text?: string | null;
features?: string[];
}): { shippedNow: boolean } {
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;
@ -788,12 +815,13 @@ export function updateRoadmapItem(id: number, data: {
).run(id, to, from);
}
const features = JSON.stringify((data.features ?? []).filter((f) => f.trim() !== ''));
db.prepare(`
UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
shipped_at = ?, metadata_text = ?, features = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, features, id);
return { shippedNow };
})();
@ -814,21 +842,22 @@ export function deleteRoadmapItem(id: number): void {
}
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
const item = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItem | undefined;
if (!item) return null;
const row = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItemRow | undefined;
if (!row) return null;
const attributed = db.prepare(`
SELECT u.id, u.name, u.slug FROM roadmap_attributions ra
JOIN users u ON u.id = ra.user_id
WHERE ra.roadmap_item_id = ?
ORDER BY u.name
`).all(id) as { id: number; name: string; slug: string | null }[];
return { ...item, attributed };
return { ...rowToRoadmapItem(row), attributed };
}
export function getAllRoadmapItems(): RoadmapItemWithAttribution[] {
const items = db.prepare(
const rows = db.prepare(
'SELECT * FROM roadmap_items ORDER BY status, display_order, created_at'
).all() as RoadmapItem[];
).all() as RoadmapItemRow[];
const items = rows.map(rowToRoadmapItem);
const attribs = db.prepare(`
SELECT ra.roadmap_item_id, u.id, u.name, u.slug
FROM roadmap_attributions ra

Binary file not shown.

View file

@ -1,16 +1,34 @@
---
import AppLayout from '../layouts/AppLayout.astro';
import { updateUserProfile } from '../lib/db';
import { updateUserProfile, updateUserPassword, getUserByEmail } from '../lib/db';
import { verifyPassword, hashPassword } from '../lib/auth';
const user = Astro.locals.user;
let success = false;
let error: string | null = null;
let pwError: string | null = null;
if (Astro.request.method === 'POST') {
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 bio = String(data.get('bio') ?? '').trim();
if (name.length < 2) {
error = 'Name must be at least 2 characters.';
} else if (bio.length > 280) {
@ -20,8 +38,10 @@ if (Astro.request.method === 'POST') {
return Astro.redirect('/account?saved=1');
}
}
}
const saved = Astro.url.searchParams.get('saved') === '1';
const pwChanged = Astro.url.searchParams.get('pwchanged') === '1';
---
<AppLayout title="Account" user={user}>
<div class="page">
@ -41,6 +61,7 @@ const saved = Astro.url.searchParams.get('saved') === '1';
)}
<form method="POST" class="account-form" novalidate>
<input type="hidden" name="intent" value="profile" />
<div class="field">
<label for="name" class="label-sm field-label">
Display name
@ -94,9 +115,65 @@ const saved = Astro.url.searchParams.get('saved') === '1';
</div>
</dl>
<p class="body-sm reset-note">
To change your email or password, contact Fenja — we will issue a new invite link.
To change your email, contact Fenja — we will issue a new invite link.
</p>
</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>

View file

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

View file

@ -23,9 +23,16 @@ function fmt(iso: string): string {
<AppLayout title="Dispatches" user={user}>
<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">
<h1 class="head-title">Notes from the studio.</h1>
<p class="head-sub">Decisions, half-built ideas, and things we've changed our mind about.</p>
<h1 class="head-title">Dispatch.</h1>
<p class="head-sub">Where we share news from Fenja — progress, decisions, and what we're building next.</p>
</header>
{dispatches.length === 0 ? (
@ -75,6 +82,25 @@ function fmt(iso: string): string {
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-eyebrow {
letter-spacing: var(--tracking-wider);
@ -94,23 +120,33 @@ function fmt(iso: string): string {
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
.empty { color: var(--on-surface-muted); }
.d-list { list-style: none; padding: 0; margin: 0; }
.d-row { border-bottom: 0.5px solid var(--surface-card-border); }
.d-row:last-child { border-bottom: none; }
/* Rows are now standalone cards on a slight tonal background, separated by
whitespace rather than borders (per design system). */
.d-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.d-row { border-bottom: none; }
.d-link {
display: grid;
grid-template-columns: 180px 1fr 130px;
gap: var(--space-5);
padding: var(--space-5) var(--space-3);
padding: var(--space-5) var(--space-5);
align-items: start;
text-decoration: none;
border-bottom: none;
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);
}
.d-link:hover {
background: color-mix(in oklab, var(--surface-card) 60%, transparent);
background: var(--surface-card);
border-bottom: none;
}

View file

@ -3,6 +3,7 @@ import AppLayout from '../layouts/AppLayout.astro';
import Avatar from '../components/Avatar.astro';
import EventHeroCard from '../components/EventHeroCard.astro';
import RoadmapCarousel from '../components/RoadmapCarousel.astro';
import type { Event } from '../lib/db';
import {
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity,
@ -10,8 +11,7 @@ import {
getAllCabMembers, getPulseById, castOrChangeVote,
} from '../lib/db';
import {
timeOfDay, relativeTime,
eventKindLabel,
timeOfDay, relativeTime, eventKindLabel,
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
} from '../lib/format';
@ -75,13 +75,20 @@ function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
// Day / month·kind for the previous + upcoming strip below the hero card.
function fmtPart(opts: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...opts, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
}
const dayNum = (iso: string) => fmt({ day: 'numeric' }, iso);
const weekday = (iso: string) => fmt({ weekday: 'short' }, iso).toUpperCase();
const monthShort = (iso: string) => fmt({ month: 'short' }, iso).toUpperCase();
const timeStr = (iso: string) => fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso);
function alsoParts(ev: Event) {
return {
day: fmtPart({ day: 'numeric' }, ev.starts_at),
sub: `${fmtPart({ month: 'short' }, ev.starts_at).toUpperCase()} · ${eventKindLabel(ev.kind).toUpperCase()}`,
title: ev.title,
};
}
const prevAlso = previousEvent ? alsoParts(previousEvent) : null;
const upcAlso = upcomingAfterHero ? alsoParts(upcomingAfterHero) : null;
const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
const heroConfirmedCount = heroAttendees.length;
@ -121,36 +128,36 @@ const members = getAllCabMembers();
firstName={firstName}
memberLabel={memberNumberLabel}
/>
</section>
<!-- ── Previous + Upcoming strip (plain text on cream) ─────── -->
<section class="cascade also-coming-up" aria-label="Surrounding events">
<!-- Previous + upcoming gatherings, sitting just below the box. -->
{(prevAlso || upcAlso) && (
<div class="also-strip">
<div class="also-list">
{previousEvent && (
<div class="also-item">
<span class="also-day">{dayNum(previousEvent.starts_at)}</span>
<div class="also-meta-col">
{prevAlso && (
<a href="/events" class="also-item">
<span class="also-day">{prevAlso.day}</span>
<span class="also-meta">
<span class="also-eyebrow">Previous</span>
<span class="also-month-kind">{monthShort(previousEvent.starts_at)} · {eventKindLabel(previousEvent.kind).toUpperCase()}</span>
<span class="also-title">{previousEvent.title}</span>
</div>
</div>
<span class="also-sub">{prevAlso.sub}</span>
<span class="also-title">{prevAlso.title}</span>
</span>
</a>
)}
{previousEvent && upcomingAfterHero && (
<span class="also-divider" aria-hidden="true"></span>
)}
{upcomingAfterHero && (
<div class="also-item">
<span class="also-day">{dayNum(upcomingAfterHero.starts_at)}</span>
<div class="also-meta-col">
{prevAlso && upcAlso && <span class="also-divider" aria-hidden="true"></span>}
{upcAlso && (
<a href="/events" class="also-item">
<span class="also-day">{upcAlso.day}</span>
<span class="also-meta">
<span class="also-eyebrow">Upcoming</span>
<span class="also-month-kind">{monthShort(upcomingAfterHero.starts_at)} · {eventKindLabel(upcomingAfterHero.kind).toUpperCase()}</span>
<span class="also-title">{upcomingAfterHero.title}</span>
</div>
</div>
<span class="also-sub">{upcAlso.sub}</span>
<span class="also-title">{upcAlso.title}</span>
</span>
</a>
)}
</div>
<a href="/events" class="also-link">All gatherings →</a>
<a href="/events" class="also-all">All gatherings →</a>
</div>
)}
</section>
<!-- ── Editorial row: dispatch (1.7fr) + pulse (1fr) ─────────── -->
@ -282,11 +289,91 @@ const members = getAllCabMembers();
dispatch → 'Earlier' gap stays tight at the original 48px because
they're the same story. */
.hero-slot { margin-top: 24px; } /* first section, below nav */
.also-coming-up { margin-top: 18px; } /* hero → also (tight; pair) */
.editorial-row { margin-top: 96px; } /* also → editorial */
.editorial-row { margin-top: 96px; } /* hero → editorial */
.roadmap-wrap { margin-top: 96px; } /* editorial → roadmap */
.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 {
opacity: 0;
@ -350,85 +437,8 @@ const members = getAllCabMembers();
.page { padding: 32px 20px 64px; }
.greeting { grid-template-columns: 1fr; align-items: 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 {
display: grid;
@ -750,6 +760,5 @@ const members = getAllCabMembers();
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) {
.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>

View file

@ -1,6 +1,5 @@
---
import AppLayout from '../layouts/AppLayout.astro';
import LatestDispatchBanner from '../components/LatestDispatchBanner.astro';
import RoadmapRoute from '../components/RoadmapRoute.astro';
import { getAllRoadmapItems } from '../lib/db';
@ -27,86 +26,99 @@ const items = getAllRoadmapItems()
up just before walking the path. -->
<div class="roadmap-legend" aria-label="Status legend">
<span><i style="background:#6d8c7c"></i>Shipping</span>
<span><i style="background:#b96b58"></i>In beta</span>
<span><i style="background:#5a6d83"></i>Planned</span>
<span><i style="background:#b96b58"></i>In dev</span>
<span><i style="background:#5a6d83"></i>Planning</span>
<span><i style="background:#b4b2a9"></i>Exploring</span>
<span><i style="background:#d4d2c8"></i>Considering</span>
</div>
<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>
</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>
.roadmap-page {
padding: 0 36px 80px;
padding: 0 36px;
max-width: var(--content-max);
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 ──────────────────────────────────────────── */
.roadmap-header {
text-align: center;
max-width: 640px;
margin: 0 auto 56px; /* generous gap to the legend */
padding-top: 96px;
margin: 0 auto 18px; /* gap to the legend */
padding-top: 88px; /* nudged down toward the page's vertical centre */
flex-shrink: 0;
}
.roadmap-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 48px;
font-size: 58px;
line-height: 1.05;
letter-spacing: var(--tracking-tight);
color: var(--on-surface);
margin: 0 0 14px;
margin: 0 0 18px;
}
.roadmap-sub {
font-size: 14px;
font-size: 18px;
line-height: 1.65;
color: var(--on-surface-variant);
margin: 0 auto;
max-width: 520px;
max-width: 600px;
}
/* ── Legend (above the route, key-style) ─────────────────────── */
.roadmap-legend {
display: flex;
justify-content: center;
gap: 24px;
margin: 0 auto 14px; /* tight to the route — they're paired */
gap: 28px;
margin: 0 auto 18px; /* tight to the route — they're paired */
flex-wrap: wrap;
flex-shrink: 0;
}
.roadmap-legend span {
display: inline-flex;
align-items: center;
gap: 7px;
gap: 9px;
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 1px;
font-size: 13px;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--on-surface-variant);
}
.roadmap-legend i {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Dispatch banner (foot of page, generous breathing room) ── */
.roadmap-page :global(.rr-dispatch) { margin-top: 112px; }
@media (max-width: 767px) {
.roadmap-page { padding: 0 24px 64px; }
.roadmap-header { padding-top: 72px; margin-bottom: 40px; }
.roadmap-title { font-size: 36px; }
.roadmap-title { font-size: 42px; }
.roadmap-legend { margin-bottom: 12px; }
.roadmap-page :global(.rr-dispatch) { margin-top: 72px; }
}
</style>