Compare commits

...
Sign in to create a new pull request.

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 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 The `fenja` user needs read access to this repo on `git.fenja.ai`. Use a
account, or your forwarded agent for the first clone): 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 ```bash
sudo -u fenja git clone ssh://git@git.fenja.ai:2222/joh/project-bifrost-platform.git \ # Generate the keypair (no passphrase — it's for unattended deploys)
/opt/bifrost-portal 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`) > **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 > 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
@ -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" 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
echo 'fenja ALL=(root) NOPASSWD: /usr/bin/systemctl restart bifrost-portal, /usr/bin/systemctl status bifrost-portal' \ sudo visudo -f /etc/sudoers.d/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

@ -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": { "engines": {
"node": ">=22" "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 node scripts/migrate.js
echo "==> Restarting $SERVICE" 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" echo "==> Waiting for health"
sleep 2 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)" 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 (?,?,?,?)') 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: 9 items, status meaning 'currently live' rather than // ── Roadmap: mirrors the live bifrost-portal.fenja.ai/roadmap items
// 'shipping soon'. Items 1-2 are live in production; items 3-4 are in // (titles, statuses, target months, order). Descriptions are completed from
// beta even if 'audit log export' has a near-term GA target. Travelled // the truncated live text; the `features` plus-icon bullets are drafted from
// stop = (1 + 0.5) / 9 ≈ 0.17, putting the 'you are here' marker at // each item's description.
// the visible transition between travelled and ahead tones on the path.
const roadmap = [ 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: 'MCP Usage', status: 'shipping', target: 'July 2026', display_order: 1, shipped_at: nowIso(-3 * 24 * 3600), attributed: [], metadata_text: null,
{ 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" }, description: 'Connect Fenja to external MCP servers — databases, internal tools, and other software — so it can read from and act across your systems.',
{ 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' }, features: ['Connect external MCP servers', 'Reach databases, internal tools & software', 'Per-connection credentials & scoping'] },
{ 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: 'Extended Logging', status: 'shipping', target: 'July 2026', display_order: 2, shipped_at: nowIso(-2 * 24 * 3600), attributed: [], metadata_text: null,
{ 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' }, description: 'Granular logging and audit controls, capturing usage down to the individual user, request, and document.',
{ 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' }, features: ['Granular usage & audit logging', 'Per-user, per-request, per-document trails', 'Exportable audit records'] },
{ 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: 'Wiki 2.0', status: 'in_beta', target: 'July 2026', display_order: 3, shipped_at: null, attributed: [], 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' }, description: 'A major wiki upgrade: version history with diffs between revisions, admin-locked pages, and a 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' }, 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(` const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text) INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
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).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); 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)); 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: 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(' 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');

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 ──────────────────────────────────────────────── --> <!-- ── Top strip ──────────────────────────────────────────────── -->
<header class="bs-topbar" role="banner"> <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" /> <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,6 +90,8 @@ 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 = (() => {
@ -133,6 +135,16 @@ 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,7 +264,12 @@ export interface ActionResultInviteLink {
kind: 'invite-link'; kind: 'invite-link';
url: string; 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.) ─────────────────────────────── // ── 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 beta', class: 'pill-in-beta' }, in_beta: { label: 'In dev', class: 'pill-in-beta' },
planned: { label: 'Planned', class: 'pill-planned' }, planned: { label: 'Planning', 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 beta', predicate: (i) => i.status === 'in_beta' }, { key: 'in_beta', label: 'In dev', predicate: (i) => i.status === 'in_beta' },
{ key: 'planned', label: 'Planned', predicate: (i) => i.status === 'planned' }, { key: 'planned', label: 'Planning', 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 beta' }, { value: 'in_beta', label: 'In dev' },
{ value: 'planned', label: 'Planned' }, { value: 'planned', label: 'Planning' },
{ value: 'exploring', label: 'Exploring' }, { value: 'exploring', label: 'Exploring' },
{ value: 'considering', label: 'Considering' }, { value: 'considering', label: 'Considering' },
], ],
@ -123,6 +123,14 @@ 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',
@ -160,6 +168,9 @@ 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)
@ -176,6 +187,9 @@ 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,12 +16,14 @@ 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';
@ -326,6 +328,20 @@ 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,7 +12,9 @@ interface Props {
memberLabel?: string | null; // e.g. "MEMBER · 001" 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 { 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);
@ -49,7 +51,6 @@ 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. -->
@ -68,7 +69,12 @@ 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
@ -90,9 +96,6 @@ 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>
@ -262,10 +265,11 @@ 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; /* pin to the bottom of the taller card */ margin-top: auto;
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,5 +1,6 @@
--- ---
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[];
@ -9,8 +10,8 @@ const { items } = Astro.props;
const STATUS_LABEL: Record<RoadmapStatus, string> = { const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING', shipping: 'SHIPPING',
in_beta: 'IN BETA', in_beta: 'IN DEV',
planned: 'PLANNED', planned: 'PLANNING',
exploring: 'EXPLORING', exploring: 'EXPLORING',
considering: 'CONSIDERING', considering: 'CONSIDERING',
}; };
@ -75,7 +76,10 @@ 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">{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"> <p class="card-desc">
{item.description} {item.description}
{item.attributed.length > 0 && ( {item.attributed.length > 0 && (
@ -245,6 +249,12 @@ 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,6 +1,7 @@
--- ---
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[];
@ -27,13 +28,16 @@ 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 BETA', in_beta: 'IN DEV',
planned: 'PLANNED', planned: 'PLANNING',
exploring: 'EXPLORING', exploring: 'EXPLORING',
considering: 'CONSIDERING', 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-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: 420px;`}> <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="420" aria-hidden="true"> <svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="480" 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"/>
@ -98,8 +102,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={layout.itemY[i]} data-y={Math.round(layout.itemY[i])}
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`} style={`left: ${Math.round(layout.itemX[i])}px; top: ${Math.round(layout.itemY[i])}px;`}
> >
<div <div
class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]} 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]};`}> <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">{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"> <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>
@ -160,8 +179,23 @@ 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">{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.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>
@ -177,10 +211,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 = 320; const MIN_SPACING = 360;
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 = 210; // vertical centreline = track height (420) / 2 const MID_Y = 240; // vertical centreline = track height (480) / 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');
@ -214,13 +248,16 @@ 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( itemX.push(Math.round(
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
@ -253,18 +290,16 @@ 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. Not parallax — every milestone still alive rather than a flat pan. Only OPACITY shifts with position — we
tracks the scroll 1:1; only scale + opacity shift with position. */ deliberately don't scale, because scaling rasterised card text makes
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);
const scale = (1 - 0.10 * t).toFixed(3); m.style.opacity = (1 - 0.42 * t).toFixed(3);
const op = (1 - 0.42 * t).toFixed(3);
m.style.transform = `translate(-50%, -50%) scale(${scale})`;
m.style.opacity = op;
}); });
} }
@ -469,18 +504,19 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
margin-right: calc(50% - 50vw); margin-right: calc(50% - 50vw);
} }
.rr-scroll { .rr-scroll {
/* overflow-x: auto + overflow-y: visible lets hovered cards expand /* Fills the available height on the roadmap page (which is locked to the
above/below the track without being clipped. .rr-scroll-inner is viewport) and centres the track vertically via .rr-scroll-inner, so the
the spec-recommended belt-and-braces wrapper in case a browser expanding cards have the full half-height above/below to grow into
misbehaves on the combination. without the page scrolling. Scrolls horizontally only.
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 the JS drag-momentum + animated-glide implementation below. */
path is meant to glide continuously, not click into fixed /* Height is set by the page (roadmap.astro) to a definite viewport-based
positions. */ value on desktop; defaults to the track's own height elsewhere. */
box-sizing: border-box;
overflow-x: auto; overflow-x: auto;
overflow-y: visible; overflow-y: hidden;
scrollbar-width: none; scrollbar-width: none;
padding: 60px 80px 80px; padding: 24px 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
@ -494,19 +530,28 @@ 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; }
.rr-scroll-inner { /* structural — keeps the track on its own layer */ } /* Centre the track vertically in the (viewport-tall) scroller so card
.rr-track { position: relative; } 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-path { position: absolute; top: 0; left: 0; pointer-events: none; }
.rr-milestone { .rr-milestone {
position: absolute; position: absolute;
/* Inline transform/opacity are driven per-frame from JS based on each /* Centred on its dot. Only opacity is animated per-frame from JS (centre
milestone's distance from the viewport centre, so the track comes milestone bright, edges recede) — no scaling, and no `will-change` on
alive as you move it (centre milestone emphasised, edges recede). transform, so card text is rasterised natively (crisp) instead of being
The short ease softens the per-frame updates into a glide. */ baked into a transformed compositing layer (which looked blurry). */
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
transition: transform .2s ease-out, opacity .2s ease-out; transition: 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
@ -519,10 +564,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
} }
.rr-dot { .rr-dot {
width: 14px; width: 16px;
height: 14px; height: 16px;
border-radius: 50%; 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; transition: transform .25s ease, box-shadow .25s ease;
} }
.rr-dot.rr-current { .rr-dot.rr-current {
@ -556,15 +601,15 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-connector { .rr-connector {
width: 1px; width: 1px;
height: 30px; height: 36px;
background: rgba(0, 0, 0, 0.18); background: rgba(0, 0, 0, 0.18);
} }
.rr-card { .rr-card {
display: block; display: block;
width: 240px; width: 288px;
padding: 14px 16px; padding: 18px 20px;
border-radius: 10px; border-radius: 12px;
background: transparent; background: transparent;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@ -589,19 +634,26 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-eyebrow { .rr-eyebrow {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 11px; font-size: 12.5px;
letter-spacing: 1.4px; letter-spacing: 1.5px;
text-transform: uppercase; text-transform: uppercase;
margin: 0 0 7px; margin: 0 0 8px;
font-weight: 600; font-weight: 600;
} }
.rr-card-title { .rr-card-title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 20px; font-size: 23px;
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;
@ -614,20 +666,44 @@ 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: 340px; max-height: 520px;
opacity: 1; opacity: 1;
margin-top: 12px; margin-top: 14px;
} }
.rr-desc { .rr-desc {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 14px; font-size: 15px;
line-height: 1.6; line-height: 1.6;
color: var(--on-surface-variant); 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 { .rr-trail {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 10px; font-size: 11px;
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-muted); color: var(--on-surface-muted);
@ -638,6 +714,7 @@ 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;
@ -678,13 +755,12 @@ 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 cover only the track itself — the top/bottom padding /* Edge fades run the full height of the (viewport-tall) scroller — they're
zones (60/80) on .rr-scroll exist so hover cards can overflow there just horizontal gradients, so full height reads fine. */
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: 60px; top: 0;
bottom: 80px; bottom: 0;
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,6 +22,14 @@ 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,6 +161,11 @@ 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 } {
@ -709,6 +714,7 @@ 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;
} }
@ -717,6 +723,23 @@ 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;
@ -724,9 +747,11 @@ 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
@ -736,8 +761,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) INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
VALUES (?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?)
`).run( `).run(
data.title, data.title,
data.description, data.description,
@ -746,6 +771,7 @@ 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);
})(); })();
@ -762,6 +788,7 @@ 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;
@ -788,12 +815,13 @@ 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 = ?, updated_at = datetime('now') shipped_at = ?, metadata_text = ?, features = ?, 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, id); `).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, features, id);
return { shippedNow }; return { shippedNow };
})(); })();
@ -814,21 +842,22 @@ export function deleteRoadmapItem(id: number): void {
} }
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null { export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
const item = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItem | undefined; const row = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItemRow | undefined;
if (!item) return null; if (!row) 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 { ...item, attributed }; return { ...rowToRoadmapItem(row), attributed };
} }
export function getAllRoadmapItems(): RoadmapItemWithAttribution[] { export function getAllRoadmapItems(): RoadmapItemWithAttribution[] {
const items = db.prepare( const rows = 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 RoadmapItem[]; ).all() as RoadmapItemRow[];
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,16 +1,34 @@
--- ---
import AppLayout from '../layouts/AppLayout.astro'; 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; 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) {
@ -19,9 +37,11 @@ if (Astro.request.method === 'POST') {
updateUserProfile(user.id, name, bio); updateUserProfile(user.id, name, bio);
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">
@ -41,6 +61,7 @@ const saved = Astro.url.searchParams.get('saved') === '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
@ -94,9 +115,65 @@ const saved = Astro.url.searchParams.get('saved') === '1';
</div> </div>
</dl> </dl>
<p class="body-sm reset-note"> <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> </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,6 +189,9 @@ 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,9 +23,16 @@ 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">Notes from the studio.</h1> <h1 class="head-title">Dispatch.</h1>
<p class="head-sub">Decisions, half-built ideas, and things we've changed our mind about.</p> <p class="head-sub">Where we share news from Fenja — progress, decisions, and what we're building next.</p>
</header> </header>
{dispatches.length === 0 ? ( {dispatches.length === 0 ? (
@ -75,6 +82,25 @@ 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);
@ -94,23 +120,33 @@ 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); }
.d-list { list-style: none; padding: 0; margin: 0; } /* Rows are now standalone cards on a slight tonal background, separated by
.d-row { border-bottom: 0.5px solid var(--surface-card-border); } whitespace rather than borders (per design system). */
.d-row:last-child { border-bottom: none; } .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 { .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-3); padding: var(--space-5) var(--space-5);
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: color-mix(in oklab, var(--surface-card) 60%, transparent); background: var(--surface-card);
border-bottom: none; border-bottom: none;
} }

View file

@ -3,6 +3,7 @@ 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,
@ -10,8 +11,7 @@ import {
getAllCabMembers, getPulseById, castOrChangeVote, getAllCabMembers, getPulseById, castOrChangeVote,
} from '../lib/db'; } from '../lib/db';
import { import {
timeOfDay, relativeTime, timeOfDay, relativeTime, eventKindLabel,
eventKindLabel,
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview, dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
} from '../lib/format'; } 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); 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 {
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); function alsoParts(ev: Event) {
const weekday = (iso: string) => fmt({ weekday: 'short' }, iso).toUpperCase(); return {
const monthShort = (iso: string) => fmt({ month: 'short' }, iso).toUpperCase(); day: fmtPart({ day: 'numeric' }, ev.starts_at),
const timeStr = (iso: string) => fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso); 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 heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
const heroConfirmedCount = heroAttendees.length; const heroConfirmedCount = heroAttendees.length;
@ -121,36 +128,36 @@ const members = getAllCabMembers();
firstName={firstName} firstName={firstName}
memberLabel={memberNumberLabel} memberLabel={memberNumberLabel}
/> />
</section>
<!-- ── Previous + Upcoming strip (plain text on cream) ─────── --> <!-- Previous + upcoming gatherings, sitting just below the box. -->
<section class="cascade also-coming-up" aria-label="Surrounding events"> {(prevAlso || upcAlso) && (
<div class="also-strip">
<div class="also-list"> <div class="also-list">
{previousEvent && ( {prevAlso && (
<div class="also-item"> <a href="/events" class="also-item">
<span class="also-day">{dayNum(previousEvent.starts_at)}</span> <span class="also-day">{prevAlso.day}</span>
<div class="also-meta-col"> <span class="also-meta">
<span class="also-eyebrow">Previous</span> <span class="also-eyebrow">Previous</span>
<span class="also-month-kind">{monthShort(previousEvent.starts_at)} · {eventKindLabel(previousEvent.kind).toUpperCase()}</span> <span class="also-sub">{prevAlso.sub}</span>
<span class="also-title">{previousEvent.title}</span> <span class="also-title">{prevAlso.title}</span>
</div> </span>
</div> </a>
)} )}
{previousEvent && upcomingAfterHero && ( {prevAlso && upcAlso && <span class="also-divider" aria-hidden="true"></span>}
<span class="also-divider" aria-hidden="true"></span> {upcAlso && (
)} <a href="/events" class="also-item">
{upcomingAfterHero && ( <span class="also-day">{upcAlso.day}</span>
<div class="also-item"> <span class="also-meta">
<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-month-kind">{monthShort(upcomingAfterHero.starts_at)} · {eventKindLabel(upcomingAfterHero.kind).toUpperCase()}</span> <span class="also-sub">{upcAlso.sub}</span>
<span class="also-title">{upcomingAfterHero.title}</span> <span class="also-title">{upcAlso.title}</span>
</div> </span>
</div> </a>
)} )}
</div> </div>
<a href="/events" class="also-link">All gatherings →</a> <a href="/events" class="also-all">All gatherings →</a>
</div>
)}
</section> </section>
<!-- ── Editorial row: dispatch (1.7fr) + pulse (1fr) ─────────── --> <!-- ── Editorial row: dispatch (1.7fr) + pulse (1fr) ─────────── -->
@ -282,11 +289,91 @@ 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 */
.also-coming-up { margin-top: 18px; } /* hero → also (tight; pair) */ .editorial-row { margin-top: 96px; } /* hero → editorial */
.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;
@ -350,85 +437,8 @@ 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;
@ -750,6 +760,5 @@ 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,6 +1,5 @@
--- ---
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';
@ -27,86 +26,99 @@ 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 beta</span> <span><i style="background:#b96b58"></i>In dev</span>
<span><i style="background:#5a6d83"></i>Planned</span> <span><i style="background:#5a6d83"></i>Planning</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 80px; padding: 0 36px;
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 56px; /* generous gap to the legend */ margin: 0 auto 18px; /* gap to the legend */
padding-top: 96px; padding-top: 88px; /* nudged down toward the page's vertical centre */
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: 48px; font-size: 58px;
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 14px; margin: 0 0 18px;
} }
.roadmap-sub { .roadmap-sub {
font-size: 14px; font-size: 18px;
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: 520px; max-width: 600px;
} }
/* ── 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: 24px; gap: 28px;
margin: 0 auto 14px; /* tight to the route — they're paired */ margin: 0 auto 18px; /* 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: 7px; gap: 9px;
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 10px; font-size: 13px;
letter-spacing: 1px; letter-spacing: 1.2px;
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-variant); color: var(--on-surface-variant);
} }
.roadmap-legend i { .roadmap-legend i {
width: 8px; width: 10px;
height: 8px; height: 10px;
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: 36px; } .roadmap-title { font-size: 42px; }
.roadmap-legend { margin-bottom: 12px; } .roadmap-legend { margin-bottom: 12px; }
.roadmap-page :global(.rr-dispatch) { margin-top: 72px; }
} }
</style> </style>