Compare commits
No commits in common. "master" and "rework" have entirely different histories.
16 changed files with 323 additions and 865 deletions
19
DEPLOY.md
19
DEPLOY.md
|
|
@ -161,25 +161,14 @@ sudo systemctl status bifrost-portal --no-pager
|
||||||
curl -fsS http://127.0.0.1:4322/login >/dev/null && echo "app responding on 4322"
|
curl -fsS http://127.0.0.1:4322/login >/dev/null && echo "app responding on 4322"
|
||||||
```
|
```
|
||||||
|
|
||||||
Let `fenja` restart just this unit without a password (used by `deploy.sh`).
|
Let `fenja` restart just this unit without a password (used by `deploy.sh`):
|
||||||
`deploy.sh` runs as `fenja` and escalates only for the restart; reading status
|
|
||||||
needs no sudo. Create the rule with `visudo` (validates syntax, sets perms):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo visudo -f /etc/sudoers.d/bifrost-portal
|
echo 'fenja ALL=(root) NOPASSWD: /usr/bin/systemctl restart bifrost-portal, /usr/bin/systemctl status bifrost-portal' \
|
||||||
|
| sudo tee /etc/sudoers.d/bifrost-portal
|
||||||
|
sudo chmod 440 /etc/sudoers.d/bifrost-portal
|
||||||
```
|
```
|
||||||
|
|
||||||
Add exactly this one line (a single-command allowlist — not general sudo):
|
|
||||||
|
|
||||||
```
|
|
||||||
fenja ALL=(root) NOPASSWD: /usr/bin/systemctl restart bifrost-portal
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify: `sudo -l -U fenja | grep systemctl` shows only that command. If you'd
|
|
||||||
rather keep sudo exclusively with admin users, skip this — `deploy.sh` will
|
|
||||||
then stop before the restart and print the `sudo systemctl restart
|
|
||||||
bifrost-portal` command for you to run as an admin.
|
|
||||||
|
|
||||||
## 7. nginx + TLS
|
## 7. nginx + TLS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- Roadmap items gain an optional `features` column — a JSON array of short
|
|
||||||
-- strings, each rendered as a plus-icon bullet in the route card's hover
|
|
||||||
-- expansion (and the mobile timeline). Lists the concrete capabilities an
|
|
||||||
-- item ships. NULL / '[]' when not set; the UI hides the list in that case.
|
|
||||||
|
|
||||||
ALTER TABLE roadmap_items ADD COLUMN features TEXT;
|
|
||||||
|
|
@ -39,21 +39,10 @@ echo "==> Applying database migrations -> $BIFROST_DB_PATH"
|
||||||
node scripts/migrate.js
|
node scripts/migrate.js
|
||||||
|
|
||||||
echo "==> Restarting $SERVICE"
|
echo "==> Restarting $SERVICE"
|
||||||
# Non-interactive: if fenja has the NOPASSWD rule for this unit it restarts
|
sudo systemctl restart "$SERVICE"
|
||||||
# silently; otherwise we don't hang on a password prompt — we tell the
|
|
||||||
# operator to restart as a sudo user.
|
|
||||||
if sudo -n systemctl restart "$SERVICE" 2>/dev/null; then
|
|
||||||
echo " restarted"
|
|
||||||
else
|
|
||||||
echo " !! could not restart without a password."
|
|
||||||
echo " Run as a sudo user: sudo systemctl restart $SERVICE"
|
|
||||||
echo " (or grant fenja the NOPASSWD rule — see DEPLOY.md §6)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> Waiting for health"
|
echo "==> Waiting for health"
|
||||||
sleep 2
|
sleep 2
|
||||||
# status is read-only — no sudo needed
|
sudo systemctl --no-pager --lines=0 status "$SERVICE"
|
||||||
systemctl --no-pager --lines=0 status "$SERVICE" || true
|
|
||||||
|
|
||||||
echo "==> Deploy complete: $(git rev-parse --short HEAD)"
|
echo "==> Deploy complete: $(git rev-parse --short HEAD)"
|
||||||
|
|
|
||||||
|
|
@ -195,50 +195,30 @@ db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES
|
||||||
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
||||||
.run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60));
|
.run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60));
|
||||||
|
|
||||||
// ── Roadmap: mirrors the live bifrost-portal.fenja.ai/roadmap items
|
// ── Roadmap: 9 items, status meaning 'currently live' rather than
|
||||||
// (titles, statuses, target months, order). Descriptions are completed from
|
// 'shipping soon'. Items 1-2 are live in production; items 3-4 are in
|
||||||
// the truncated live text; the `features` plus-icon bullets are drafted from
|
// beta even if 'audit log export' has a near-term GA target. Travelled
|
||||||
// each item's description.
|
// stop = (1 + 0.5) / 9 ≈ 0.17, putting the 'you are here' marker at
|
||||||
|
// the visible transition between travelled and ahead tones on the path.
|
||||||
const roadmap = [
|
const roadmap = [
|
||||||
{ title: 'MCP Usage', status: 'shipping', target: 'July 2026', display_order: 1, shipped_at: nowIso(-3 * 24 * 3600), attributed: [], metadata_text: null,
|
{ title: 'Traceability layer', description: 'Every inference call writes a signed audit record. Shaped by Lars in our March session.', status: 'shipping', target: 'Live since March', display_order: 1, shipped_at: nowIso(-60 * 24 * 3600), attributed: [cabs[0].id], metadata_text: 'Shaped by Lars in our March session' },
|
||||||
description: 'Connect Fenja to external MCP servers — databases, internal tools, and other software — so it can read from and act across your systems.',
|
{ title: 'Document ingestion', description: "Indexing PDF, Word, and plain text with proper chunking. Pilot-tested with Mette's team.", status: 'shipping', target: 'Live since late May', display_order: 2, shipped_at: nowIso(-7 * 24 * 3600), attributed: [cabs[1].id, cabs[2].id], metadata_text: "Pilot-tested with Mette's team" },
|
||||||
features: ['Connect external MCP servers', 'Reach databases, internal tools & software', 'Per-connection credentials & scoping'] },
|
{ title: 'Audit log export', description: 'Stream the signed records to your own S3 or on-prem object store.', status: 'in_beta', target: 'GA next week', display_order: 3, shipped_at: null, attributed: [cabs[3].id], metadata_text: 'Builds on traceability layer' },
|
||||||
{ title: 'Extended Logging', status: 'shipping', target: 'July 2026', display_order: 2, shipped_at: nowIso(-2 * 24 * 3600), attributed: [], metadata_text: null,
|
{ title: 'Agentic query mode', description: 'Multi-step retrieval over locked, on-prem document stores. Currently testing with two pilot organisations.', status: 'in_beta', target: 'July', display_order: 4, shipped_at: null, attributed: [cabs[1].id], metadata_text: 'Request beta access →' },
|
||||||
description: 'Granular logging and audit controls, capturing usage down to the individual user, request, and document.',
|
{ title: 'Contextual memory', description: 'Sessions that remember constraints between calls without leaking context across organisational boundaries.', status: 'exploring', target: 'Q3 2026', display_order: 5, shipped_at: null, attributed: [cabs[3].id], metadata_text: '2 council requests' },
|
||||||
features: ['Granular usage & audit logging', 'Per-user, per-request, per-document trails', 'Exportable audit records'] },
|
{ title: 'Multi-organisation graphs', description: 'Permission-controlled knowledge spaces for departments within a single deployment.', status: 'exploring', target: 'Q3 2026', display_order: 6, shipped_at: null, attributed: [cabs[4].id], metadata_text: 'Open question on key custody' },
|
||||||
{ title: 'Wiki 2.0', status: 'in_beta', target: 'July 2026', display_order: 3, shipped_at: null, attributed: [], metadata_text: null,
|
{ title: 'Multi-tenant isolation', description: 'Cryptographic separation between sub-organisations on shared infrastructure.', status: 'exploring', target: 'Q4 2026', display_order: 7, shipped_at: null, attributed: [cabs[5].id], metadata_text: null },
|
||||||
description: 'A major wiki upgrade: version history with diffs between revisions, admin-locked pages, and a richer page structure.',
|
{ title: 'Federated learning hooks', description: 'Let aligned organisations train on shared signal without sharing the underlying data.', status: 'considering', target: '2027', display_order: 8, shipped_at: null, attributed: [], metadata_text: 'Council input wanted' },
|
||||||
features: ['Version history with revision diffs', 'Admin-locked pages', 'Richer page structure'] },
|
{ title: 'Open evaluation framework', description: 'A public benchmark suite for compliant-AI use in regulated industries.', status: 'considering', target: '2027', display_order: 9, shipped_at: null, attributed: [], metadata_text: 'Long-term direction' },
|
||||||
{ title: 'Interview 2.0', status: 'planned', target: 'July 2026', display_order: 4, shipped_at: null, attributed: [], metadata_text: null,
|
|
||||||
description: 'A major Interviews upgrade: build and manage interview scripts, send invites, and track responses in one place.',
|
|
||||||
features: ['Build & manage interview scripts', 'Send invites & track responses', 'Centralised results view'] },
|
|
||||||
{ title: 'Fenja Analyze', status: 'planned', target: 'August 2026', display_order: 5, shipped_at: null, attributed: [], metadata_text: null,
|
|
||||||
description: 'The first release of Fenja Analyze: ask a question in plain language, run it as a query against your data, and get an answer with sources.',
|
|
||||||
features: ['Plain-language questions → queries', 'Answers grounded in your data', 'Source citations'] },
|
|
||||||
{ title: 'Fenja Agentic', status: 'planned', target: 'September 2026', display_order: 6, shipped_at: null, attributed: [], metadata_text: null,
|
|
||||||
description: 'The first release of Agents: create and orchestrate agents from an admin panel, wiring them to the tools and data they need.',
|
|
||||||
features: ['Create agents from an admin panel', 'Orchestrate multi-agent workflows', 'Wire agents to tools & data'] },
|
|
||||||
{ title: 'Fenja Dev', status: 'planned', target: 'September 2026', display_order: 7, shipped_at: null, attributed: [], metadata_text: null,
|
|
||||||
description: 'The first release of Fenja Dev: a sovereign IDE with terminal and git integration, running entirely inside your environment.',
|
|
||||||
features: ['Sovereign in-environment IDE', 'Integrated terminal', 'Git integration'] },
|
|
||||||
{ title: 'HTML Reports', status: 'planned', target: 'September 2026', display_order: 8, shipped_at: null, attributed: [], metadata_text: null,
|
|
||||||
description: 'Structured reporting for Fenja Analyze: admins define report templates so generated analyses render as clean, shareable HTML.',
|
|
||||||
features: ['Admin-defined report templates', 'Analyses rendered as clean HTML', 'Shareable, structured output'] },
|
|
||||||
{ title: 'Self-Service Agents', status: 'planned', target: 'October 2026', display_order: 9, shipped_at: null, attributed: [], metadata_text: null,
|
|
||||||
description: 'A major expansion of the agent experience, letting users create and run their own agents without admin involvement.',
|
|
||||||
features: ['User-created agents (no admin needed)', 'Run & manage your own agents', 'Reuse across tasks'] },
|
|
||||||
{ title: 'Self-service Routines & Skills', status: 'exploring', target: 'October 2026', display_order: 10, shipped_at: null, attributed: [], metadata_text: null,
|
|
||||||
description: "Personal and domain-level routines and skills: users build reusable, tailored workflows for their own and their team's recurring tasks.",
|
|
||||||
features: ['Personal & domain-level routines', 'Reusable, tailored workflows', 'Built for recurring team tasks'] },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const insertRoad = db.prepare(`
|
const insertRoad = db.prepare(`
|
||||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
|
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||||
VALUES (?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?)
|
||||||
`);
|
`);
|
||||||
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
|
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
|
||||||
for (const r of roadmap) {
|
for (const r of roadmap) {
|
||||||
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, r.metadata_text, JSON.stringify(r.features ?? [])).lastInsertRowid);
|
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, r.metadata_text).lastInsertRowid);
|
||||||
for (const uid of r.attributed) insertAttr.run(id, uid);
|
for (const uid of r.attributed) insertAttr.run(id, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,7 +378,7 @@ insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, no
|
||||||
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
|
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
|
||||||
|
|
||||||
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`);
|
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`);
|
||||||
console.log(' roadmap: 10 items (2 shipping / 1 in_beta / 6 planned / 1 exploring)');
|
console.log(' roadmap: 9 items (2 shipping / 2 in_beta / 3 exploring / 2 considering)');
|
||||||
console.log(' contributions: 3 (most recent has 3 reactions)');
|
console.log(' contributions: 3 (most recent has 3 reactions)');
|
||||||
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
|
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
|
||||||
console.log(' events: dinner + studio hours + working session, 2 past');
|
console.log(' events: dinner + studio hours + working session, 2 past');
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
//
|
|
||||||
// One-off, idempotent sync of the canonical roadmap content into a target DB.
|
|
||||||
//
|
|
||||||
// BIFROST_DB_PATH=/opt/fenja/data/bifrost-portal/bifrost.db node scripts/sync-roadmap.js
|
|
||||||
//
|
|
||||||
// What it does, in order:
|
|
||||||
// 1. Takes its OWN online backup of the target DB to
|
|
||||||
// <db>.pre-roadmap-sync-<timestamp>.bak (safe while the app is running).
|
|
||||||
// 2. Upserts the 10 canonical items MATCHED BY display_order — it UPDATEs
|
|
||||||
// title / description / status / target / features in place, so row ids,
|
|
||||||
// roadmap_attributions, created_at and shipped_at are all preserved.
|
|
||||||
// 3. Inserts a canonical item only if no row exists at that display_order.
|
|
||||||
// 4. Leaves any extra prod rows (display_order outside 1..N) untouched and
|
|
||||||
// logs them — it never deletes.
|
|
||||||
//
|
|
||||||
// Safe to re-run. Does NOT wipe the table (unlike scripts/seed-demo.js, which
|
|
||||||
// must never be run against prod).
|
|
||||||
|
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
|
|
||||||
// ── Canonical roadmap (content only; no demo dates/attributions) ───────────
|
|
||||||
const ROADMAP = [
|
|
||||||
{ display_order: 1, title: 'MCP Usage', status: 'shipping', target: 'July 2026',
|
|
||||||
description: 'Connect Fenja to external MCP servers — databases, internal tools, and other software — so it can read from and act across your systems.',
|
|
||||||
features: ['Connect external MCP servers', 'Reach databases, internal tools & software', 'Per-connection credentials & scoping'] },
|
|
||||||
{ display_order: 2, title: 'Extended Logging', status: 'shipping', target: 'July 2026',
|
|
||||||
description: 'Granular logging and audit controls, capturing usage down to the individual user, request, and document.',
|
|
||||||
features: ['Granular usage & audit logging', 'Per-user, per-request, per-document trails', 'Exportable audit records'] },
|
|
||||||
{ display_order: 3, title: 'Wiki 2.0', status: 'in_beta', target: 'July 2026',
|
|
||||||
description: 'A major wiki upgrade: version history with diffs between revisions, admin-locked pages, and a richer page structure.',
|
|
||||||
features: ['Version history with revision diffs', 'Admin-locked pages', 'Richer page structure'] },
|
|
||||||
{ display_order: 4, title: 'Interview 2.0', status: 'planned', target: 'July 2026',
|
|
||||||
description: 'A major Interviews upgrade: build and manage interview scripts, send invites, and track responses in one place.',
|
|
||||||
features: ['Build & manage interview scripts', 'Send invites & track responses', 'Centralised results view'] },
|
|
||||||
{ display_order: 5, title: 'Fenja Analyze', status: 'planned', target: 'August 2026',
|
|
||||||
description: 'The first release of Fenja Analyze: ask a question in plain language, run it as a query against your data, and get an answer with sources.',
|
|
||||||
features: ['Plain-language questions → queries', 'Answers grounded in your data', 'Source citations'] },
|
|
||||||
{ display_order: 6, title: 'Fenja Agentic', status: 'planned', target: 'September 2026',
|
|
||||||
description: 'The first release of Agents: create and orchestrate agents from an admin panel, wiring them to the tools and data they need.',
|
|
||||||
features: ['Create agents from an admin panel', 'Orchestrate multi-agent workflows', 'Wire agents to tools & data'] },
|
|
||||||
{ display_order: 7, title: 'Fenja Dev', status: 'planned', target: 'September 2026',
|
|
||||||
description: 'The first release of Fenja Dev: a sovereign IDE with terminal and git integration, running entirely inside your environment.',
|
|
||||||
features: ['Sovereign in-environment IDE', 'Integrated terminal', 'Git integration'] },
|
|
||||||
{ display_order: 8, title: 'HTML Reports', status: 'planned', target: 'September 2026',
|
|
||||||
description: 'Structured reporting for Fenja Analyze: admins define report templates so generated analyses render as clean, shareable HTML.',
|
|
||||||
features: ['Admin-defined report templates', 'Analyses rendered as clean HTML', 'Shareable, structured output'] },
|
|
||||||
{ display_order: 9, title: 'Self-Service Agents', status: 'planned', target: 'October 2026',
|
|
||||||
description: 'A major expansion of the agent experience, letting users create and run their own agents without admin involvement.',
|
|
||||||
features: ['User-created agents (no admin needed)', 'Run & manage your own agents', 'Reuse across tasks'] },
|
|
||||||
{ display_order: 10, title: 'Self-service Routines & Skills', status: 'exploring', target: 'October 2026',
|
|
||||||
description: "Personal and domain-level routines and skills: users build reusable, tailored workflows for their own and their team's recurring tasks.",
|
|
||||||
features: ['Personal & domain-level routines', 'Reusable, tailored workflows', 'Built for recurring team tasks'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const dbPath = process.env.BIFROST_DB_PATH;
|
|
||||||
if (!dbPath) {
|
|
||||||
console.error('!! BIFROST_DB_PATH is not set. Refusing to guess the DB location.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (!existsSync(dbPath)) {
|
|
||||||
console.error(`!! No database at ${dbPath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
|
|
||||||
// Guard: the features column must exist (migration 0009). Deploy runs
|
|
||||||
// migrate.js, so run this AFTER deploying.
|
|
||||||
const cols = db.prepare('PRAGMA table_info(roadmap_items)').all().map((c) => c.name);
|
|
||||||
if (!cols.includes('features')) {
|
|
||||||
console.error('!! roadmap_items.features column is missing — run `node scripts/migrate.js` (deploy) first.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Online backup (safe while the app is running). Timestamp from the OS.
|
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
||||||
const backupPath = `${dbPath}.pre-roadmap-sync-${ts}.bak`;
|
|
||||||
console.log(`==> Backing up current DB -> ${backupPath}`);
|
|
||||||
await db.backup(backupPath);
|
|
||||||
|
|
||||||
// Snapshot current titles for the diff log.
|
|
||||||
const before = db.prepare('SELECT display_order, title FROM roadmap_items ORDER BY display_order').all();
|
|
||||||
console.log(`==> Current roadmap (${before.length} rows):`);
|
|
||||||
before.forEach((r) => console.log(` ${r.display_order}. ${r.title}`));
|
|
||||||
|
|
||||||
// 2/3. Upsert by display_order, inside a transaction.
|
|
||||||
const findByOrder = db.prepare('SELECT id FROM roadmap_items WHERE display_order = ?');
|
|
||||||
const update = db.prepare(`
|
|
||||||
UPDATE roadmap_items
|
|
||||||
SET title = @title, description = @description, status = @status,
|
|
||||||
target = @target, features = @features, updated_at = datetime('now')
|
|
||||||
WHERE id = @id
|
|
||||||
`);
|
|
||||||
const insert = db.prepare(`
|
|
||||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
|
|
||||||
VALUES (@title, @description, @status, @target, @display_order, NULL, NULL, @features)
|
|
||||||
`);
|
|
||||||
|
|
||||||
let updated = 0;
|
|
||||||
let inserted = 0;
|
|
||||||
const run = db.transaction(() => {
|
|
||||||
for (const item of ROADMAP) {
|
|
||||||
const row = findByOrder.get(item.display_order);
|
|
||||||
const params = {
|
|
||||||
title: item.title,
|
|
||||||
description: item.description,
|
|
||||||
status: item.status,
|
|
||||||
target: item.target,
|
|
||||||
features: JSON.stringify(item.features),
|
|
||||||
display_order: item.display_order,
|
|
||||||
};
|
|
||||||
if (row) {
|
|
||||||
update.run({ ...params, id: row.id });
|
|
||||||
updated += 1;
|
|
||||||
} else {
|
|
||||||
insert.run(params);
|
|
||||||
inserted += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
run();
|
|
||||||
|
|
||||||
// 4. Report extras that were left untouched (never deleted).
|
|
||||||
const maxOrder = ROADMAP.length;
|
|
||||||
const extras = db
|
|
||||||
.prepare('SELECT display_order, title FROM roadmap_items WHERE display_order > ? ORDER BY display_order')
|
|
||||||
.all(maxOrder);
|
|
||||||
|
|
||||||
console.log(`==> Synced: ${updated} updated in place, ${inserted} inserted.`);
|
|
||||||
if (extras.length > 0) {
|
|
||||||
console.log(`==> Left untouched (display_order > ${maxOrder}) — review in /admin if these should go:`);
|
|
||||||
extras.forEach((r) => console.log(` ${r.display_order}. ${r.title}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const after = db.prepare('SELECT display_order, title, status, target FROM roadmap_items ORDER BY display_order').all();
|
|
||||||
console.log('==> Roadmap now:');
|
|
||||||
after.forEach((r) => console.log(` ${r.display_order}. ${r.title} [${r.status} · ${r.target}]`));
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
console.log(`==> Done. Backup kept at ${backupPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -47,8 +47,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
||||||
width: '120px',
|
width: '120px',
|
||||||
pillVariants: {
|
pillVariants: {
|
||||||
shipping: { label: 'Shipping', class: 'pill-shipping' },
|
shipping: { label: 'Shipping', class: 'pill-shipping' },
|
||||||
in_beta: { label: 'In dev', class: 'pill-in-beta' },
|
in_beta: { label: 'In beta', class: 'pill-in-beta' },
|
||||||
planned: { label: 'Planning', class: 'pill-planned' },
|
planned: { label: 'Planned', class: 'pill-planned' },
|
||||||
exploring: { label: 'Exploring', class: 'pill-exploring' },
|
exploring: { label: 'Exploring', class: 'pill-exploring' },
|
||||||
considering: { label: 'Considering', class: 'pill-considering' },
|
considering: { label: 'Considering', class: 'pill-considering' },
|
||||||
},
|
},
|
||||||
|
|
@ -69,8 +69,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
||||||
filters: [
|
filters: [
|
||||||
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
||||||
{ key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' },
|
{ key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' },
|
||||||
{ key: 'in_beta', label: 'In dev', predicate: (i) => i.status === 'in_beta' },
|
{ key: 'in_beta', label: 'In beta', predicate: (i) => i.status === 'in_beta' },
|
||||||
{ key: 'planned', label: 'Planning', predicate: (i) => i.status === 'planned' },
|
{ key: 'planned', label: 'Planned', predicate: (i) => i.status === 'planned' },
|
||||||
{ key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' },
|
{ key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' },
|
||||||
{ key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' },
|
{ key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' },
|
||||||
],
|
],
|
||||||
|
|
@ -100,8 +100,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
||||||
required: true,
|
required: true,
|
||||||
options: [
|
options: [
|
||||||
{ value: 'shipping', label: 'Shipping' },
|
{ value: 'shipping', label: 'Shipping' },
|
||||||
{ value: 'in_beta', label: 'In dev' },
|
{ value: 'in_beta', label: 'In beta' },
|
||||||
{ value: 'planned', label: 'Planning' },
|
{ value: 'planned', label: 'Planned' },
|
||||||
{ value: 'exploring', label: 'Exploring' },
|
{ value: 'exploring', label: 'Exploring' },
|
||||||
{ value: 'considering', label: 'Considering' },
|
{ value: 'considering', label: 'Considering' },
|
||||||
],
|
],
|
||||||
|
|
@ -123,14 +123,6 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
helperText: 'Lower numbers appear earlier in the route. Items with the same number fall back to creation order.',
|
helperText: 'Lower numbers appear earlier in the route. Items with the same number fall back to creation order.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'features',
|
|
||||||
label: 'Features',
|
|
||||||
kind: 'multi-text',
|
|
||||||
maxItems: 8,
|
|
||||||
placeholderEach: 'e.g. Streaming responses with citations',
|
|
||||||
helperText: 'Bullet-point capabilities this item ships. Shown as a plus-icon list on hover in the /roadmap route.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'metadata_text',
|
key: 'metadata_text',
|
||||||
label: 'Hover metadata',
|
label: 'Hover metadata',
|
||||||
|
|
@ -168,9 +160,6 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
||||||
target: ((data.target as string) ?? '').trim() || null,
|
target: ((data.target as string) ?? '').trim() || null,
|
||||||
display_order: Number(data.display_order ?? 0),
|
display_order: Number(data.display_order ?? 0),
|
||||||
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
|
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
|
||||||
features: Array.isArray(data.features)
|
|
||||||
? (data.features as unknown[]).map((v) => String(v)).filter((v) => v.trim() !== '')
|
|
||||||
: [],
|
|
||||||
});
|
});
|
||||||
const userIds = Array.isArray(data.attributed_members)
|
const userIds = Array.isArray(data.attributed_members)
|
||||||
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
|
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
|
||||||
|
|
@ -187,9 +176,6 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
||||||
target: ((data.target as string) ?? '').trim() || null,
|
target: ((data.target as string) ?? '').trim() || null,
|
||||||
display_order: Number(data.display_order ?? 0),
|
display_order: Number(data.display_order ?? 0),
|
||||||
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
|
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
|
||||||
features: Array.isArray(data.features)
|
|
||||||
? (data.features as unknown[]).map((v) => String(v)).filter((v) => v.trim() !== '')
|
|
||||||
: [],
|
|
||||||
});
|
});
|
||||||
const userIds = Array.isArray(data.attributed_members)
|
const userIds = Array.isArray(data.attributed_members)
|
||||||
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
|
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,7 @@ interface Props {
|
||||||
memberLabel?: string | null; // e.g. "MEMBER · 001"
|
memberLabel?: string | null; // e.g. "MEMBER · 001"
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { event, confirmedCount, myRsvp, greetingPrefix, firstName, memberLabel = null } = Astro.props;
|
||||||
event, confirmedCount, myRsvp, greetingPrefix, firstName, memberLabel = null,
|
|
||||||
} = Astro.props;
|
|
||||||
|
|
||||||
function parseUtc(s: string): Date {
|
function parseUtc(s: string): Date {
|
||||||
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
||||||
|
|
@ -51,6 +49,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{event ? (
|
{event ? (
|
||||||
|
<>
|
||||||
<div class="hero-event">
|
<div class="hero-event">
|
||||||
<!-- Label sits above the date + title so it's clear they describe
|
<!-- Label sits above the date + title so it's clear they describe
|
||||||
the next event. -->
|
the next event. -->
|
||||||
|
|
@ -69,12 +68,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<!-- RSVP row, pinned to the base of the card. -->
|
|
||||||
{event && (
|
|
||||||
<footer class="hero-foot">
|
<footer class="hero-foot">
|
||||||
<p class="hero-status">
|
<p class="hero-status">
|
||||||
{event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED
|
{event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED
|
||||||
|
|
@ -96,6 +90,9 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</footer>
|
</footer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
@ -265,11 +262,10 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bottom strip ────────────────────────────────────────────── */
|
/* ── Bottom strip ────────────────────────────────────────────── */
|
||||||
/* RSVP row pinned to the base of the card. */
|
|
||||||
.hero-foot {
|
.hero-foot {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: auto;
|
margin-top: auto; /* pin to the bottom of the taller card */
|
||||||
border-top: 0.5px solid var(--ink-divider);
|
border-top: 0.5px solid var(--ink-divider);
|
||||||
padding-top: 22px;
|
padding-top: 22px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
---
|
---
|
||||||
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
|
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
|
||||||
import { splitStageSuffix } from '../lib/format';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: RoadmapItemWithAttribution[];
|
items: RoadmapItemWithAttribution[];
|
||||||
|
|
@ -10,8 +9,8 @@ const { items } = Astro.props;
|
||||||
|
|
||||||
const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
||||||
shipping: 'SHIPPING',
|
shipping: 'SHIPPING',
|
||||||
in_beta: 'IN DEV',
|
in_beta: 'IN BETA',
|
||||||
planned: 'PLANNING',
|
planned: 'PLANNED',
|
||||||
exploring: 'EXPLORING',
|
exploring: 'EXPLORING',
|
||||||
considering: 'CONSIDERING',
|
considering: 'CONSIDERING',
|
||||||
};
|
};
|
||||||
|
|
@ -76,10 +75,7 @@ const hasArrows = items.length > 3;
|
||||||
{STATUS_LABEL[item.status]}{item.target ? ` · ${item.target.toUpperCase()}` : ''}
|
{STATUS_LABEL[item.status]}{item.target ? ` · ${item.target.toUpperCase()}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<h3 class="card-title">{(() => {
|
<h3 class="card-title">{item.title}</h3>
|
||||||
const sp = splitStageSuffix(item.title);
|
|
||||||
return sp.stage ? <Fragment>{sp.base} — <span class="card-stage">{sp.stage}</span></Fragment> : item.title;
|
|
||||||
})()}</h3>
|
|
||||||
<p class="card-desc">
|
<p class="card-desc">
|
||||||
{item.description}
|
{item.description}
|
||||||
{item.attributed.length > 0 && (
|
{item.attributed.length > 0 && (
|
||||||
|
|
@ -249,12 +245,6 @@ const hasArrows = items.length > 3;
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.card-stage {
|
|
||||||
font-variant: small-caps;
|
|
||||||
text-transform: lowercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--on-surface-variant);
|
|
||||||
}
|
|
||||||
.card-desc {
|
.card-desc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
---
|
---
|
||||||
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
|
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
|
||||||
import { computeRouteLayout, travelledStopFor } from '../lib/roadmap-layout';
|
import { computeRouteLayout, travelledStopFor } from '../lib/roadmap-layout';
|
||||||
import { splitStageSuffix } from '../lib/format';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: RoadmapItemWithAttribution[];
|
items: RoadmapItemWithAttribution[];
|
||||||
|
|
@ -28,16 +27,13 @@ const layout = computeRouteLayout({
|
||||||
paddingLeft,
|
paddingLeft,
|
||||||
paddingRight: trailing,
|
paddingRight: trailing,
|
||||||
tailLength: trailing,
|
tailLength: trailing,
|
||||||
trackHeight: 480,
|
|
||||||
amplitude: 140,
|
|
||||||
minSpacingX: 360,
|
|
||||||
});
|
});
|
||||||
const travelledStop = travelledStopFor(items.map(i => i.status));
|
const travelledStop = travelledStopFor(items.map(i => i.status));
|
||||||
|
|
||||||
const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
||||||
shipping: 'SHIPPING',
|
shipping: 'SHIPPING',
|
||||||
in_beta: 'IN DEV',
|
in_beta: 'IN BETA',
|
||||||
planned: 'PLANNING',
|
planned: 'PLANNED',
|
||||||
exploring: 'EXPLORING',
|
exploring: 'EXPLORING',
|
||||||
considering: 'CONSIDERING',
|
considering: 'CONSIDERING',
|
||||||
};
|
};
|
||||||
|
|
@ -84,8 +80,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
<div class="rr-wrap rr-fullbleed rr-desktop" data-item-count={items.length}>
|
<div class="rr-wrap rr-fullbleed rr-desktop" data-item-count={items.length}>
|
||||||
<div class="rr-scroll" id="rr-scroll">
|
<div class="rr-scroll" id="rr-scroll">
|
||||||
<div class="rr-scroll-inner">
|
<div class="rr-scroll-inner">
|
||||||
<div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 480px;`}>
|
<div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
|
||||||
<svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="480" aria-hidden="true">
|
<svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="420" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/>
|
<stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/>
|
||||||
|
|
@ -102,8 +98,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
class="rr-milestone"
|
class="rr-milestone"
|
||||||
data-y={Math.round(layout.itemY[i])}
|
data-y={layout.itemY[i]}
|
||||||
style={`left: ${Math.round(layout.itemX[i])}px; top: ${Math.round(layout.itemY[i])}px;`}
|
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]}
|
class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]}
|
||||||
|
|
@ -117,24 +113,9 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
<p class="rr-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
|
<p class="rr-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
|
||||||
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
|
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
|
||||||
</p>
|
</p>
|
||||||
<p class="rr-card-title">{(() => {
|
<p class="rr-card-title">{item.title}</p>
|
||||||
const sp = splitStageSuffix(item.title);
|
|
||||||
return sp.stage ? <Fragment>{sp.base} — <span class="rr-stage">{sp.stage}</span></Fragment> : item.title;
|
|
||||||
})()}</p>
|
|
||||||
<div class="rr-more">
|
<div class="rr-more">
|
||||||
{item.description && <p class="rr-desc">{item.description}</p>}
|
{item.description && <p class="rr-desc">{item.description}</p>}
|
||||||
{item.features.length > 0 && (
|
|
||||||
<ul class="rr-features">
|
|
||||||
{item.features.map(f => (
|
|
||||||
<li class="rr-feature">
|
|
||||||
<svg class="rr-feature-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
|
|
||||||
<path d="M8 3.5v9M3.5 8h9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<span>{f}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{trailingLine(item) && <p class="rr-trail">{trailingLine(item)}</p>}
|
{trailingLine(item) && <p class="rr-trail">{trailingLine(item)}</p>}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -179,23 +160,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
<p class="rrm-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
|
<p class="rrm-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
|
||||||
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
|
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
|
||||||
</p>
|
</p>
|
||||||
<p class="rrm-title">{(() => {
|
<p class="rrm-title">{item.title}</p>
|
||||||
const sp = splitStageSuffix(item.title);
|
|
||||||
return sp.stage ? <Fragment>{sp.base} — <span class="rr-stage">{sp.stage}</span></Fragment> : item.title;
|
|
||||||
})()}</p>
|
|
||||||
{item.description && <p class="rrm-desc">{item.description}</p>}
|
{item.description && <p class="rrm-desc">{item.description}</p>}
|
||||||
{item.features.length > 0 && (
|
|
||||||
<ul class="rr-features">
|
|
||||||
{item.features.map(f => (
|
|
||||||
<li class="rr-feature">
|
|
||||||
<svg class="rr-feature-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
|
|
||||||
<path d="M8 3.5v9M3.5 8h9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<span>{f}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{trailingLine(item) && <p class="rrm-trail">{trailingLine(item)}</p>}
|
{trailingLine(item) && <p class="rrm-trail">{trailingLine(item)}</p>}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -211,10 +177,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
// positions + SVG path d + track width on mount and on (debounced)
|
// positions + SVG path d + track width on mount and on (debounced)
|
||||||
// resize. itemY values come from data-y on each milestone (path
|
// resize. itemY values come from data-y on each milestone (path
|
||||||
// amplitude doesn't change with viewport, only the horizontal spread).
|
// amplitude doesn't change with viewport, only the horizontal spread).
|
||||||
const MIN_SPACING = 360;
|
const MIN_SPACING = 320;
|
||||||
const PADDING_X = 60;
|
const PADDING_X = 60;
|
||||||
const CONTENT_MAX = 1152; // matches --content-max (72rem)
|
const CONTENT_MAX = 1152; // matches --content-max (72rem)
|
||||||
const MID_Y = 240; // vertical centreline = track height (480) / 2
|
const MID_Y = 210; // vertical centreline = track height (420) / 2
|
||||||
|
|
||||||
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
|
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
|
||||||
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
|
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
|
||||||
|
|
@ -248,16 +214,13 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
const trailing = Math.round(vw * 0.25);
|
const trailing = Math.round(vw * 0.25);
|
||||||
const trackWidth = paddingLeft + usableWidth + trailing;
|
const trackWidth = paddingLeft + usableWidth + trailing;
|
||||||
|
|
||||||
// Round to whole pixels — a milestone left-positioned on a fractional
|
|
||||||
// pixel and then translate(-50%)'d renders its text on half-pixels,
|
|
||||||
// which the browser antialiases into a soft/blurry edge.
|
|
||||||
const itemX: number[] = [];
|
const itemX: number[] = [];
|
||||||
for (let i = 0; i < itemCount; i += 1) {
|
for (let i = 0; i < itemCount; i += 1) {
|
||||||
itemX.push(Math.round(
|
itemX.push(
|
||||||
itemCount === 1
|
itemCount === 1
|
||||||
? paddingLeft + usableWidth / 2
|
? paddingLeft + usableWidth / 2
|
||||||
: paddingLeft + (i / (itemCount - 1)) * usableWidth,
|
: paddingLeft + (i / (itemCount - 1)) * usableWidth,
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bezier path: control points at the segment midpoint x with control
|
// Bezier path: control points at the segment midpoint x with control
|
||||||
|
|
@ -290,16 +253,18 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
/* Scroll-proximity focus: emphasise the milestone nearest the centre
|
/* Scroll-proximity focus: emphasise the milestone nearest the centre
|
||||||
of the viewport and let those toward the edges recede + dim. Driven
|
of the viewport and let those toward the edges recede + dim. Driven
|
||||||
every frame that the track moves (via updateNav), so movement feels
|
every frame that the track moves (via updateNav), so movement feels
|
||||||
alive rather than a flat pan. Only OPACITY shifts with position — we
|
alive rather than a flat pan. Not parallax — every milestone still
|
||||||
deliberately don't scale, because scaling rasterised card text makes
|
tracks the scroll 1:1; only scale + opacity shift with position. */
|
||||||
it render blurry. */
|
|
||||||
function updateFocus() {
|
function updateFocus() {
|
||||||
if (!scroll || itemXs.length === 0) return;
|
if (!scroll || itemXs.length === 0) return;
|
||||||
const center = scroll.scrollLeft + scroll.clientWidth / 2;
|
const center = scroll.scrollLeft + scroll.clientWidth / 2;
|
||||||
const half = Math.max(1, scroll.clientWidth / 2);
|
const half = Math.max(1, scroll.clientWidth / 2);
|
||||||
milestones.forEach((m, i) => {
|
milestones.forEach((m, i) => {
|
||||||
const t = Math.min(1, Math.abs((itemXs[i] ?? 0) - center) / half);
|
const t = Math.min(1, Math.abs((itemXs[i] ?? 0) - center) / half);
|
||||||
m.style.opacity = (1 - 0.42 * t).toFixed(3);
|
const scale = (1 - 0.10 * t).toFixed(3);
|
||||||
|
const op = (1 - 0.42 * t).toFixed(3);
|
||||||
|
m.style.transform = `translate(-50%, -50%) scale(${scale})`;
|
||||||
|
m.style.opacity = op;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -504,19 +469,18 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
margin-right: calc(50% - 50vw);
|
margin-right: calc(50% - 50vw);
|
||||||
}
|
}
|
||||||
.rr-scroll {
|
.rr-scroll {
|
||||||
/* Fills the available height on the roadmap page (which is locked to the
|
/* overflow-x: auto + overflow-y: visible lets hovered cards expand
|
||||||
viewport) and centres the track vertically via .rr-scroll-inner, so the
|
above/below the track without being clipped. .rr-scroll-inner is
|
||||||
expanding cards have the full half-height above/below to grow into
|
the spec-recommended belt-and-braces wrapper in case a browser
|
||||||
without the page scrolling. Scrolls horizontally only.
|
misbehaves on the combination.
|
||||||
NO scroll-snap-type and NO scroll-behavior: smooth — both fight
|
NO scroll-snap-type and NO scroll-behavior: smooth — both fight
|
||||||
the JS drag-momentum + animated-glide implementation below. */
|
the JS drag-momentum + animated-glide implementation below. The
|
||||||
/* Height is set by the page (roadmap.astro) to a definite viewport-based
|
path is meant to glide continuously, not click into fixed
|
||||||
value on desktop; defaults to the track's own height elsewhere. */
|
positions. */
|
||||||
box-sizing: border-box;
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: visible;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
padding: 24px 80px;
|
padding: 60px 80px 80px;
|
||||||
|
|
||||||
/* Drag affordance: cursor + suppress native horizontal swipe so
|
/* Drag affordance: cursor + suppress native horizontal swipe so
|
||||||
horizontal drag triggers our handler while vertical drag still
|
horizontal drag triggers our handler while vertical drag still
|
||||||
|
|
@ -530,28 +494,19 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
/* Pointer-events off the cards mid-drag — prevents accidental hover
|
/* Pointer-events off the cards mid-drag — prevents accidental hover
|
||||||
reveal while the track is being dragged past. */
|
reveal while the track is being dragged past. */
|
||||||
.rr-scroll.rr-dragging .rr-card { pointer-events: none; }
|
.rr-scroll.rr-dragging .rr-card { pointer-events: none; }
|
||||||
/* Centre the track vertically in the (viewport-tall) scroller so card
|
.rr-scroll-inner { /* structural — keeps the track on its own layer */ }
|
||||||
expansion has the full half-height to grow into, both up and down. The
|
.rr-track { position: relative; }
|
||||||
track is wider than the viewport and overflows horizontally → scrolls. */
|
|
||||||
.rr-scroll-inner {
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
/* flex: none so the track keeps its explicit (wide) width and doesn't get
|
|
||||||
shrunk by the flex parent down to its ~0 min-content width — its children
|
|
||||||
are absolutely positioned, so without this it collapses to a tiny box. */
|
|
||||||
.rr-track { position: relative; flex: none; }
|
|
||||||
.rr-path { position: absolute; top: 0; left: 0; pointer-events: none; }
|
.rr-path { position: absolute; top: 0; left: 0; pointer-events: none; }
|
||||||
|
|
||||||
.rr-milestone {
|
.rr-milestone {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
/* Centred on its dot. Only opacity is animated per-frame from JS (centre
|
/* Inline transform/opacity are driven per-frame from JS based on each
|
||||||
milestone bright, edges recede) — no scaling, and no `will-change` on
|
milestone's distance from the viewport centre, so the track comes
|
||||||
transform, so card text is rasterised natively (crisp) instead of being
|
alive as you move it (centre milestone emphasised, edges recede).
|
||||||
baked into a transformed compositing layer (which looked blurry). */
|
The short ease softens the per-frame updates into a glide. */
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
transition: opacity .2s ease-out;
|
transition: transform .2s ease-out, opacity .2s ease-out;
|
||||||
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
/* A hovered/focused card always reads at full size and brightness,
|
/* A hovered/focused card always reads at full size and brightness,
|
||||||
regardless of where it sits along the route — overrides the inline
|
regardless of where it sits along the route — overrides the inline
|
||||||
|
|
@ -564,10 +519,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
.rr-dot {
|
.rr-dot {
|
||||||
width: 16px;
|
width: 14px;
|
||||||
height: 16px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 0 6px var(--background); /* halo cuts the path under the dot */
|
box-shadow: 0 0 0 5px var(--background); /* halo cuts the path under the dot */
|
||||||
transition: transform .25s ease, box-shadow .25s ease;
|
transition: transform .25s ease, box-shadow .25s ease;
|
||||||
}
|
}
|
||||||
.rr-dot.rr-current {
|
.rr-dot.rr-current {
|
||||||
|
|
@ -601,15 +556,15 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
|
|
||||||
.rr-connector {
|
.rr-connector {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 36px;
|
height: 30px;
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rr-card {
|
.rr-card {
|
||||||
display: block;
|
display: block;
|
||||||
width: 288px;
|
width: 240px;
|
||||||
padding: 18px 20px;
|
padding: 14px 16px;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -634,26 +589,19 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
|
|
||||||
.rr-eyebrow {
|
.rr-eyebrow {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 12.5px;
|
font-size: 11px;
|
||||||
letter-spacing: 1.5px;
|
letter-spacing: 1.4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 7px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.rr-card-title {
|
.rr-card-title {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 23px;
|
font-size: 20px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
/* Stage suffix (e.g. "alpha") rendered in small capitals. */
|
|
||||||
.rr-stage {
|
|
||||||
font-variant: small-caps;
|
|
||||||
text-transform: lowercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--on-surface-variant);
|
|
||||||
}
|
|
||||||
.rr-more {
|
.rr-more {
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -666,44 +614,20 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
}
|
}
|
||||||
.rr-card:hover .rr-more,
|
.rr-card:hover .rr-more,
|
||||||
.rr-card:focus-visible .rr-more {
|
.rr-card:focus-visible .rr-more {
|
||||||
max-height: 520px;
|
max-height: 340px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
margin-top: 14px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.rr-desc {
|
.rr-desc {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--on-surface-variant);
|
color: var(--on-surface-variant);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 10px;
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Feature bullets (plus-icon list) ──────────────────────────── */
|
|
||||||
.rr-features {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
.rr-feature {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 9px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 13.5px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--on-surface-variant);
|
|
||||||
}
|
|
||||||
.rr-feature-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
color: var(--pigment-terracotta);
|
|
||||||
}
|
}
|
||||||
.rr-trail {
|
.rr-trail {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--on-surface-muted);
|
color: var(--on-surface-muted);
|
||||||
|
|
@ -714,7 +638,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
.rr-advance {
|
.rr-advance {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 32px;
|
right: 32px;
|
||||||
/* The track is vertically centred in the scroller, so 50% lines up. */
|
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 48px;
|
width: 48px;
|
||||||
|
|
@ -755,12 +678,13 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
animation: rr-advance-pulse 1.4s ease-in-out 3;
|
animation: rr-advance-pulse 1.4s ease-in-out 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Edge fades run the full height of the (viewport-tall) scroller — they're
|
/* Edge fades cover only the track itself — the top/bottom padding
|
||||||
just horizontal gradients, so full height reads fine. */
|
zones (60/80) on .rr-scroll exist so hover cards can overflow there
|
||||||
|
without clipping, so the fades shouldn't paint over them. */
|
||||||
.rr-fade-left, .rr-fade-right {
|
.rr-fade-left, .rr-fade-right {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 60px;
|
||||||
bottom: 0;
|
bottom: 80px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity .25s ease;
|
transition: opacity .25s ease;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -23,11 +23,9 @@ export function verifyPassword(password: string, hash: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A readable one-time password for admin resets. Give to the user; they
|
/** 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
|
* change it from /account. */
|
||||||
* (the 'Bifrost-' prefix is fixed/known, so the randomness must carry the
|
|
||||||
* full strength on its own). */
|
|
||||||
export function generateTempPassword(): string {
|
export function generateTempPassword(): string {
|
||||||
return 'Bifrost-' + randomBytes(16).toString('base64url');
|
return 'Bifrost-' + randomBytes(4).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Invite tokens ────────────────────────────────────────────────
|
// ── Invite tokens ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -714,7 +714,6 @@ export interface RoadmapItem {
|
||||||
display_order: number;
|
display_order: number;
|
||||||
shipped_at: string | null;
|
shipped_at: string | null;
|
||||||
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
|
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
|
||||||
features: string[]; // bullet-point capabilities, rendered as a plus-icon list
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
@ -723,23 +722,6 @@ export interface RoadmapItemWithAttribution extends RoadmapItem {
|
||||||
attributed: { id: number; name: string; slug: string | null }[];
|
attributed: { id: number; name: string; slug: string | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The `features` column is stored as a JSON array of strings (or NULL). */
|
|
||||||
type RoadmapItemRow = Omit<RoadmapItem, 'features'> & { features: string | null };
|
|
||||||
|
|
||||||
function parseFeatures(raw: string | null): string[] {
|
|
||||||
if (!raw || raw.trim() === '') return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowToRoadmapItem(row: RoadmapItemRow): RoadmapItem {
|
|
||||||
return { ...row, features: parseFeatures(row.features) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRoadmapItem(data: {
|
export function createRoadmapItem(data: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -747,11 +729,9 @@ export function createRoadmapItem(data: {
|
||||||
target?: string | null;
|
target?: string | null;
|
||||||
display_order?: number;
|
display_order?: number;
|
||||||
metadata_text?: string | null;
|
metadata_text?: string | null;
|
||||||
features?: string[];
|
|
||||||
}): number {
|
}): number {
|
||||||
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
|
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
|
||||||
const requestedOrder = data.display_order ?? 0;
|
const requestedOrder = data.display_order ?? 0;
|
||||||
const features = JSON.stringify((data.features ?? []).filter((f) => f.trim() !== ''));
|
|
||||||
|
|
||||||
return db.transaction(() => {
|
return db.transaction(() => {
|
||||||
// Cascade: insert at position N shifts every existing item at or after N
|
// Cascade: insert at position N shifts every existing item at or after N
|
||||||
|
|
@ -761,8 +741,8 @@ export function createRoadmapItem(data: {
|
||||||
).run(requestedOrder);
|
).run(requestedOrder);
|
||||||
|
|
||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
|
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||||
VALUES (?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?)
|
||||||
`).run(
|
`).run(
|
||||||
data.title,
|
data.title,
|
||||||
data.description,
|
data.description,
|
||||||
|
|
@ -771,7 +751,6 @@ export function createRoadmapItem(data: {
|
||||||
requestedOrder,
|
requestedOrder,
|
||||||
shipped_at,
|
shipped_at,
|
||||||
data.metadata_text ?? null,
|
data.metadata_text ?? null,
|
||||||
features,
|
|
||||||
);
|
);
|
||||||
return Number(r.lastInsertRowid);
|
return Number(r.lastInsertRowid);
|
||||||
})();
|
})();
|
||||||
|
|
@ -788,7 +767,6 @@ export function updateRoadmapItem(id: number, data: {
|
||||||
target: string | null;
|
target: string | null;
|
||||||
display_order: number;
|
display_order: number;
|
||||||
metadata_text?: string | null;
|
metadata_text?: string | null;
|
||||||
features?: string[];
|
|
||||||
}): { shippedNow: boolean } {
|
}): { shippedNow: boolean } {
|
||||||
const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?')
|
const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?')
|
||||||
.get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined;
|
.get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined;
|
||||||
|
|
@ -815,13 +793,12 @@ export function updateRoadmapItem(id: number, data: {
|
||||||
).run(id, to, from);
|
).run(id, to, from);
|
||||||
}
|
}
|
||||||
|
|
||||||
const features = JSON.stringify((data.features ?? []).filter((f) => f.trim() !== ''));
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE roadmap_items
|
UPDATE roadmap_items
|
||||||
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
||||||
shipped_at = ?, metadata_text = ?, features = ?, updated_at = datetime('now')
|
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, features, id);
|
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
|
||||||
|
|
||||||
return { shippedNow };
|
return { shippedNow };
|
||||||
})();
|
})();
|
||||||
|
|
@ -842,22 +819,21 @@ export function deleteRoadmapItem(id: number): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
|
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
|
||||||
const row = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItemRow | undefined;
|
const item = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItem | undefined;
|
||||||
if (!row) return null;
|
if (!item) return null;
|
||||||
const attributed = db.prepare(`
|
const attributed = db.prepare(`
|
||||||
SELECT u.id, u.name, u.slug FROM roadmap_attributions ra
|
SELECT u.id, u.name, u.slug FROM roadmap_attributions ra
|
||||||
JOIN users u ON u.id = ra.user_id
|
JOIN users u ON u.id = ra.user_id
|
||||||
WHERE ra.roadmap_item_id = ?
|
WHERE ra.roadmap_item_id = ?
|
||||||
ORDER BY u.name
|
ORDER BY u.name
|
||||||
`).all(id) as { id: number; name: string; slug: string | null }[];
|
`).all(id) as { id: number; name: string; slug: string | null }[];
|
||||||
return { ...rowToRoadmapItem(row), attributed };
|
return { ...item, attributed };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllRoadmapItems(): RoadmapItemWithAttribution[] {
|
export function getAllRoadmapItems(): RoadmapItemWithAttribution[] {
|
||||||
const rows = db.prepare(
|
const items = db.prepare(
|
||||||
'SELECT * FROM roadmap_items ORDER BY status, display_order, created_at'
|
'SELECT * FROM roadmap_items ORDER BY status, display_order, created_at'
|
||||||
).all() as RoadmapItemRow[];
|
).all() as RoadmapItem[];
|
||||||
const items = rows.map(rowToRoadmapItem);
|
|
||||||
const attribs = db.prepare(`
|
const attribs = db.prepare(`
|
||||||
SELECT ra.roadmap_item_id, u.id, u.name, u.slug
|
SELECT ra.roadmap_item_id, u.id, u.name, u.slug
|
||||||
FROM roadmap_attributions ra
|
FROM roadmap_attributions ra
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -23,16 +23,9 @@ function fmt(iso: string): string {
|
||||||
<AppLayout title="Dispatches" user={user}>
|
<AppLayout title="Dispatches" user={user}>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
||||||
<a href="/pulse" class="back-link">
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
||||||
<path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
Pulse
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<h1 class="head-title">Dispatch.</h1>
|
<h1 class="head-title">Notes from the studio.</h1>
|
||||||
<p class="head-sub">Where we share news from Fenja — progress, decisions, and what we're building next.</p>
|
<p class="head-sub">Decisions, half-built ideas, and things we've changed our mind about.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{dispatches.length === 0 ? (
|
{dispatches.length === 0 ? (
|
||||||
|
|
@ -82,25 +75,6 @@ function fmt(iso: string): string {
|
||||||
gap: var(--space-8);
|
gap: var(--space-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Back-to-Pulse link — a quiet arrow above the page title. */
|
|
||||||
.back-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
align-self: flex-start;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: var(--text-label-md);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--on-surface-variant);
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: none;
|
|
||||||
transition: color var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.back-link:hover { color: var(--pigment-terracotta); border-bottom: none; }
|
|
||||||
.back-link svg { transition: transform var(--duration-fast) var(--ease-standard); }
|
|
||||||
.back-link:hover svg { transform: translateX(-2px); }
|
|
||||||
|
|
||||||
.head { max-width: 46rem; }
|
.head { max-width: 46rem; }
|
||||||
.head-eyebrow {
|
.head-eyebrow {
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
|
|
@ -120,33 +94,23 @@ function fmt(iso: string): string {
|
||||||
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
|
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
|
||||||
.empty { color: var(--on-surface-muted); }
|
.empty { color: var(--on-surface-muted); }
|
||||||
|
|
||||||
/* Rows are now standalone cards on a slight tonal background, separated by
|
.d-list { list-style: none; padding: 0; margin: 0; }
|
||||||
whitespace rather than borders (per design system). */
|
.d-row { border-bottom: 0.5px solid var(--surface-card-border); }
|
||||||
.d-list {
|
.d-row:last-child { border-bottom: none; }
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.d-row { border-bottom: none; }
|
|
||||||
|
|
||||||
.d-link {
|
.d-link {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 180px 1fr 130px;
|
grid-template-columns: 180px 1fr 130px;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
padding: var(--space-5) var(--space-5);
|
padding: var(--space-5) var(--space-3);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: color-mix(in oklab, var(--surface-card) 55%, transparent);
|
|
||||||
transition: background var(--duration-fast) var(--ease-standard);
|
transition: background var(--duration-fast) var(--ease-standard);
|
||||||
}
|
}
|
||||||
.d-link:hover {
|
.d-link:hover {
|
||||||
background: var(--surface-card);
|
background: color-mix(in oklab, var(--surface-card) 60%, transparent);
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import AppLayout from '../layouts/AppLayout.astro';
|
||||||
import Avatar from '../components/Avatar.astro';
|
import Avatar from '../components/Avatar.astro';
|
||||||
import EventHeroCard from '../components/EventHeroCard.astro';
|
import EventHeroCard from '../components/EventHeroCard.astro';
|
||||||
import RoadmapCarousel from '../components/RoadmapCarousel.astro';
|
import RoadmapCarousel from '../components/RoadmapCarousel.astro';
|
||||||
import type { Event } from '../lib/db';
|
|
||||||
import {
|
import {
|
||||||
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
|
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
|
||||||
getUserRsvp, setEventRsvp, recordActivity,
|
getUserRsvp, setEventRsvp, recordActivity,
|
||||||
|
|
@ -11,7 +10,8 @@ import {
|
||||||
getAllCabMembers, getPulseById, castOrChangeVote,
|
getAllCabMembers, getPulseById, castOrChangeVote,
|
||||||
} from '../lib/db';
|
} from '../lib/db';
|
||||||
import {
|
import {
|
||||||
timeOfDay, relativeTime, eventKindLabel,
|
timeOfDay, relativeTime,
|
||||||
|
eventKindLabel,
|
||||||
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
|
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
|
||||||
} from '../lib/format';
|
} from '../lib/format';
|
||||||
|
|
||||||
|
|
@ -75,20 +75,13 @@ function parseUtc(s: string): Date {
|
||||||
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
||||||
return new Date(s.replace(' ', 'T') + 'Z');
|
return new Date(s.replace(' ', 'T') + 'Z');
|
||||||
}
|
}
|
||||||
|
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
|
||||||
// Day / month·kind for the previous + upcoming strip below the hero card.
|
return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
|
||||||
function fmtPart(opts: Intl.DateTimeFormatOptions, iso: string): string {
|
|
||||||
return new Intl.DateTimeFormat('en-GB', { ...opts, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
|
|
||||||
}
|
}
|
||||||
function alsoParts(ev: Event) {
|
const dayNum = (iso: string) => fmt({ day: 'numeric' }, iso);
|
||||||
return {
|
const weekday = (iso: string) => fmt({ weekday: 'short' }, iso).toUpperCase();
|
||||||
day: fmtPart({ day: 'numeric' }, ev.starts_at),
|
const monthShort = (iso: string) => fmt({ month: 'short' }, iso).toUpperCase();
|
||||||
sub: `${fmtPart({ month: 'short' }, ev.starts_at).toUpperCase()} · ${eventKindLabel(ev.kind).toUpperCase()}`,
|
const timeStr = (iso: string) => fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso);
|
||||||
title: ev.title,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const prevAlso = previousEvent ? alsoParts(previousEvent) : null;
|
|
||||||
const upcAlso = upcomingAfterHero ? alsoParts(upcomingAfterHero) : null;
|
|
||||||
|
|
||||||
const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
|
const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
|
||||||
const heroConfirmedCount = heroAttendees.length;
|
const heroConfirmedCount = heroAttendees.length;
|
||||||
|
|
@ -128,36 +121,36 @@ const members = getAllCabMembers();
|
||||||
firstName={firstName}
|
firstName={firstName}
|
||||||
memberLabel={memberNumberLabel}
|
memberLabel={memberNumberLabel}
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Previous + upcoming gatherings, sitting just below the box. -->
|
<!-- ── Previous + Upcoming strip (plain text on cream) ─────── -->
|
||||||
{(prevAlso || upcAlso) && (
|
<section class="cascade also-coming-up" aria-label="Surrounding events">
|
||||||
<div class="also-strip">
|
|
||||||
<div class="also-list">
|
<div class="also-list">
|
||||||
{prevAlso && (
|
{previousEvent && (
|
||||||
<a href="/events" class="also-item">
|
<div class="also-item">
|
||||||
<span class="also-day">{prevAlso.day}</span>
|
<span class="also-day">{dayNum(previousEvent.starts_at)}</span>
|
||||||
<span class="also-meta">
|
<div class="also-meta-col">
|
||||||
<span class="also-eyebrow">Previous</span>
|
<span class="also-eyebrow">Previous</span>
|
||||||
<span class="also-sub">{prevAlso.sub}</span>
|
<span class="also-month-kind">{monthShort(previousEvent.starts_at)} · {eventKindLabel(previousEvent.kind).toUpperCase()}</span>
|
||||||
<span class="also-title">{prevAlso.title}</span>
|
<span class="also-title">{previousEvent.title}</span>
|
||||||
</span>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
)}
|
)}
|
||||||
{prevAlso && upcAlso && <span class="also-divider" aria-hidden="true"></span>}
|
{previousEvent && upcomingAfterHero && (
|
||||||
{upcAlso && (
|
<span class="also-divider" aria-hidden="true"></span>
|
||||||
<a href="/events" class="also-item">
|
)}
|
||||||
<span class="also-day">{upcAlso.day}</span>
|
{upcomingAfterHero && (
|
||||||
<span class="also-meta">
|
<div class="also-item">
|
||||||
|
<span class="also-day">{dayNum(upcomingAfterHero.starts_at)}</span>
|
||||||
|
<div class="also-meta-col">
|
||||||
<span class="also-eyebrow">Upcoming</span>
|
<span class="also-eyebrow">Upcoming</span>
|
||||||
<span class="also-sub">{upcAlso.sub}</span>
|
<span class="also-month-kind">{monthShort(upcomingAfterHero.starts_at)} · {eventKindLabel(upcomingAfterHero.kind).toUpperCase()}</span>
|
||||||
<span class="also-title">{upcAlso.title}</span>
|
<span class="also-title">{upcomingAfterHero.title}</span>
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<a href="/events" class="also-all">All gatherings →</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<a href="/events" class="also-link">All gatherings →</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Editorial row: dispatch (1.7fr) + pulse (1fr) ─────────── -->
|
<!-- ── Editorial row: dispatch (1.7fr) + pulse (1fr) ─────────── -->
|
||||||
|
|
@ -289,91 +282,11 @@ const members = getAllCabMembers();
|
||||||
dispatch → 'Earlier' gap stays tight at the original 48px because
|
dispatch → 'Earlier' gap stays tight at the original 48px because
|
||||||
they're the same story. */
|
they're the same story. */
|
||||||
.hero-slot { margin-top: 24px; } /* first section, below nav */
|
.hero-slot { margin-top: 24px; } /* first section, below nav */
|
||||||
.editorial-row { margin-top: 96px; } /* hero → editorial */
|
.also-coming-up { margin-top: 18px; } /* hero → also (tight; pair) */
|
||||||
|
.editorial-row { margin-top: 96px; } /* also → editorial */
|
||||||
.roadmap-wrap { margin-top: 96px; } /* editorial → roadmap */
|
.roadmap-wrap { margin-top: 96px; } /* editorial → roadmap */
|
||||||
.council-section{ margin-top: 96px; } /* roadmap → council */
|
.council-section{ margin-top: 96px; } /* roadmap → council */
|
||||||
|
|
||||||
/* ── Previous + upcoming strip (below the hero box) ───────────── */
|
|
||||||
.also-strip {
|
|
||||||
margin-top: 18px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 16px 32px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.also-list {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 28px;
|
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.also-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--on-surface);
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: none;
|
|
||||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.also-item:hover { opacity: 0.7; border-bottom: none; }
|
|
||||||
.also-day {
|
|
||||||
font-family: var(--font-serif);
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 32px;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--on-surface);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.also-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
||||||
.also-eyebrow {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--pigment-terracotta);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.also-sub {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--on-surface-variant);
|
|
||||||
}
|
|
||||||
.also-title {
|
|
||||||
font-family: var(--font-serif);
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.25;
|
|
||||||
color: var(--on-surface);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 20rem;
|
|
||||||
}
|
|
||||||
.also-divider {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 1px;
|
|
||||||
height: 34px;
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.also-all {
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: flex-start; /* sit at the top of the strip, not the baseline */
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--pigment-terracotta);
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: none;
|
|
||||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.also-all:hover { opacity: 0.7; border-bottom: none; }
|
|
||||||
|
|
||||||
/* ── Cascade entry (first paint only) ─────────────────────────── */
|
/* ── Cascade entry (first paint only) ─────────────────────────── */
|
||||||
.cascade {
|
.cascade {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -437,8 +350,85 @@ const members = getAllCabMembers();
|
||||||
.page { padding: 32px 20px 64px; }
|
.page { padding: 32px 20px 64px; }
|
||||||
.greeting { grid-template-columns: 1fr; align-items: start; }
|
.greeting { grid-template-columns: 1fr; align-items: start; }
|
||||||
.greeting-right { align-items: flex-start; }
|
.greeting-right { align-items: flex-start; }
|
||||||
|
/* "Also coming up" strip stacks and wraps instead of overflowing. */
|
||||||
|
.also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
||||||
|
.also-list { flex-wrap: wrap; gap: 12px 18px; }
|
||||||
|
.also-title { max-width: 70vw; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 'Also coming up' strip (plain text on cream) ─────────────── */
|
||||||
|
.also-coming-up {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
.also-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.also-divider {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1px;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
.also-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.also-day {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
.also-meta-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.also-eyebrow {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--pigment-terracotta);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.also-month-kind {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
.also-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--on-surface);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
.also-link {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--pigment-terracotta);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.also-link:hover { opacity: 0.8; border-bottom: none; }
|
||||||
|
|
||||||
/* ── Editorial row: dispatch (1.7fr) + pulse (1fr) ────────────── */
|
/* ── Editorial row: dispatch (1.7fr) + pulse (1fr) ────────────── */
|
||||||
.editorial-row {
|
.editorial-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -760,5 +750,6 @@ const members = getAllCabMembers();
|
||||||
/* ── Responsive ───────────────────────────────────────────────── */
|
/* ── Responsive ───────────────────────────────────────────────── */
|
||||||
@media (max-width: 880px) {
|
@media (max-width: 880px) {
|
||||||
.editorial-row { grid-template-columns: 1fr; gap: var(--space-8); }
|
.editorial-row { grid-template-columns: 1fr; gap: var(--space-8); }
|
||||||
|
.also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
import AppLayout from '../layouts/AppLayout.astro';
|
import AppLayout from '../layouts/AppLayout.astro';
|
||||||
|
import LatestDispatchBanner from '../components/LatestDispatchBanner.astro';
|
||||||
import RoadmapRoute from '../components/RoadmapRoute.astro';
|
import RoadmapRoute from '../components/RoadmapRoute.astro';
|
||||||
import { getAllRoadmapItems } from '../lib/db';
|
import { getAllRoadmapItems } from '../lib/db';
|
||||||
|
|
||||||
|
|
@ -26,99 +27,86 @@ const items = getAllRoadmapItems()
|
||||||
up just before walking the path. -->
|
up just before walking the path. -->
|
||||||
<div class="roadmap-legend" aria-label="Status legend">
|
<div class="roadmap-legend" aria-label="Status legend">
|
||||||
<span><i style="background:#6d8c7c"></i>Shipping</span>
|
<span><i style="background:#6d8c7c"></i>Shipping</span>
|
||||||
<span><i style="background:#b96b58"></i>In dev</span>
|
<span><i style="background:#b96b58"></i>In beta</span>
|
||||||
<span><i style="background:#5a6d83"></i>Planning</span>
|
<span><i style="background:#5a6d83"></i>Planned</span>
|
||||||
<span><i style="background:#b4b2a9"></i>Exploring</span>
|
<span><i style="background:#b4b2a9"></i>Exploring</span>
|
||||||
<span><i style="background:#d4d2c8"></i>Considering</span>
|
<span><i style="background:#d4d2c8"></i>Considering</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RoadmapRoute items={items} />
|
<RoadmapRoute items={items} />
|
||||||
|
|
||||||
|
<!-- Latest dispatch sits at the foot of the page with generous
|
||||||
|
space above so it reads as a separate beat, not a continuation
|
||||||
|
of the route. -->
|
||||||
|
<LatestDispatchBanner />
|
||||||
|
|
||||||
</article>
|
</article>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
<!-- Desktop: the roadmap is a fixed, full-viewport view. Lock page scrolling
|
|
||||||
so the wheel only pans the route sideways and the route stays on screen.
|
|
||||||
Mobile keeps normal vertical scrolling for the timeline list. -->
|
|
||||||
<style is:global>
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
html, body { overflow: hidden; }
|
|
||||||
.app > .footer { display: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.roadmap-page {
|
.roadmap-page {
|
||||||
padding: 0 36px;
|
padding: 0 36px 80px;
|
||||||
max-width: var(--content-max);
|
max-width: var(--content-max);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop: give the route a definite, viewport-based height (no fragile
|
|
||||||
percentage-height chain) so it fills the screen and the track stays
|
|
||||||
centred and fully visible while scrolling sideways only. The subtracted
|
|
||||||
amount ≈ nav + the header/legend block above the route. */
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
/* NB: no overflow:hidden here — it would clip the full-bleed route back
|
|
||||||
to the content column. Page scrolling is locked via body instead. */
|
|
||||||
.roadmap-page :global(.rr-scroll) { height: calc(100vh - 310px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Centred header ──────────────────────────────────────────── */
|
/* ── Centred header ──────────────────────────────────────────── */
|
||||||
.roadmap-header {
|
.roadmap-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0 auto 18px; /* gap to the legend */
|
margin: 0 auto 56px; /* generous gap to the legend */
|
||||||
padding-top: 88px; /* nudged down toward the page's vertical centre */
|
padding-top: 96px;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.roadmap-title {
|
.roadmap-title {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 58px;
|
font-size: 48px;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: var(--tracking-tight);
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
margin: 0 0 18px;
|
margin: 0 0 14px;
|
||||||
}
|
}
|
||||||
.roadmap-sub {
|
.roadmap-sub {
|
||||||
font-size: 18px;
|
font-size: 14px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
color: var(--on-surface-variant);
|
color: var(--on-surface-variant);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 600px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Legend (above the route, key-style) ─────────────────────── */
|
/* ── Legend (above the route, key-style) ─────────────────────── */
|
||||||
.roadmap-legend {
|
.roadmap-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 28px;
|
gap: 24px;
|
||||||
margin: 0 auto 18px; /* tight to the route — they're paired */
|
margin: 0 auto 14px; /* tight to the route — they're paired */
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.roadmap-legend span {
|
.roadmap-legend span {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 7px;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 13px;
|
font-size: 10px;
|
||||||
letter-spacing: 1.2px;
|
letter-spacing: 1px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--on-surface-variant);
|
color: var(--on-surface-variant);
|
||||||
}
|
}
|
||||||
.roadmap-legend i {
|
.roadmap-legend i {
|
||||||
width: 10px;
|
width: 8px;
|
||||||
height: 10px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Dispatch banner (foot of page, generous breathing room) ── */
|
||||||
|
.roadmap-page :global(.rr-dispatch) { margin-top: 112px; }
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.roadmap-page { padding: 0 24px 64px; }
|
.roadmap-page { padding: 0 24px 64px; }
|
||||||
.roadmap-header { padding-top: 72px; margin-bottom: 40px; }
|
.roadmap-header { padding-top: 72px; margin-bottom: 40px; }
|
||||||
.roadmap-title { font-size: 42px; }
|
.roadmap-title { font-size: 36px; }
|
||||||
.roadmap-legend { margin-bottom: 12px; }
|
.roadmap-legend { margin-bottom: 12px; }
|
||||||
|
.roadmap-page :global(.rr-dispatch) { margin-top: 72px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue