Compare commits

..

No commits in common. "66c3f6492f9bf4f3cb533007c566e8f17b4eaf83" and "58faeffbc25bce5098fe0af38a31dd36fc540f86" have entirely different histories.

20 changed files with 528 additions and 2435 deletions

View file

@ -12,39 +12,7 @@
"Bash(node scripts/seed.js)",
"Bash(pnpm typecheck *)",
"Bash(pnpm build *)",
"Bash(identify /home/jonathan/Documents/DEV/Project-Bifrost/public/innofounder-logo.png)",
"Bash(pnpm dev *)",
"Bash(awk '{print $1}')",
"Bash(pnpm db:migrate *)",
"Bash(sqlite3 bifrost.db \".schema users\")",
"Bash(sqlite3 bifrost.db \".schema attendance\")",
"Bash(sqlite3 bifrost.db \"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;\")",
"Bash(sqlite3 bifrost.db \"SELECT id, name, slug FROM users;\")",
"Bash(node *)",
"Bash(pnpm db:seed:roadmap)",
"Bash(pnpm test *)",
"Bash(curl -sI http://localhost:4321/pulse -b \"bifrost_session=test\")",
"Bash(curl -sI http://localhost:4321/admin)",
"Bash(pnpm db:seed:demo *)",
"Bash(curl -s -c /tmp/jar.txt -b /tmp/jar.txt -d \"email=lars@rigspolitiet.dk&password=cab123\" http://localhost:4321/login -i)",
"Bash(curl -sI -b /tmp/jar.txt http://localhost:4321/)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/pulse)",
"Bash(curl -s -c /tmp/jonjar.txt -b /tmp/jonjar.txt -d \"email=jonathan@fenja.ai&password=fenja123\" http://localhost:4321/login -i)",
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=pulses\")",
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=roadmap\")",
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=events\")",
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=activity\")",
"Bash(awk NR>=107 && NR<=117 *)",
"Bash(awk 'NR==111' src/lib/format.ts)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@rigspolitiet.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
"Bash(curl -sI -b /tmp/jar.txt http://localhost:4321/pulse)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/members)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/events)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches/1-we-are-deprioritising-public-cloud-parity-for-q3)",
"Bash(pnpm db:seed)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)"
"Bash(identify /home/jonathan/Documents/DEV/Project-Bifrost/public/innofounder-logo.png)"
]
}
}

View file

@ -1,16 +1,13 @@
#!/usr/bin/env node
// Phase 2 demo seed — produces the visual state described in SPEC §Phase 2:
// 4 cab members with title/pull_quote/focus_tags/member_number, 1 active
// pulse (2 of 4 voted), 4 roadmap items (1 shipping / 1 beta / 2 exploring),
// 3 contributions with reactions, 4 dispatches at staggered ages, 1 hero
// event + 1 studio hours + 1 working session + 2 past events.
// Demo seed for first-load credibility: one open pulse, one shipped roadmap
// item attributed to the cab user, one dinner + one office hours event, and
// a handful of hand-crafted activity rows so the ticker has something to
// scroll on a fresh demo.
//
// Destructive in scope: wipes the data tables it owns then re-inserts.
// Users (created by seed.js) are kept; new ones are added with INSERT OR
// IGNORE. Idempotent: re-running produces the same demo state.
// Idempotent: skips if a pulse already exists. Run AFTER scripts/seed.js
// and scripts/seed-roadmap.js (or via `pnpm db:setup`).
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
@ -19,24 +16,15 @@ const dbPath = process.env.BIFROST_DB_PATH ?? join(__dirname, '..', 'bifrost.db'
const db = new Database(dbPath);
db.pragma('foreign_keys = ON');
// ── Wipe the tables this seed owns ─────────────────────────────────
db.exec(`
DELETE FROM activity;
DELETE FROM attendance;
DELETE FROM reactions;
DELETE FROM replies;
DELETE FROM contributions;
DELETE FROM roadmap_attributions;
DELETE FROM roadmap_items;
DELETE FROM votes;
DELETE FROM pulses;
DELETE FROM dispatches;
DELETE FROM events;
`);
const existing = db.prepare('SELECT COUNT(*) AS n FROM pulses').get().n;
if (existing > 0) {
console.log(` demo data already present (${existing} pulse(s)) — skipping.`);
db.close();
process.exit(0);
}
// ── Locate canonical users from seed.js ────────────────────────────
const allUsers = db.prepare("SELECT id, name, role FROM users WHERE active = 1").all();
const byRole = (r) => allUsers.find(u => u.role === r);
const users = db.prepare("SELECT id, name, role FROM users WHERE active = 1").all();
const byRole = (r) => users.find(u => u.role === r);
const mette = byRole('pilot');
const lars = byRole('cab');
const jon = byRole('fenja');
@ -46,297 +34,105 @@ if (!mette || !lars || !jon) {
process.exit(1);
}
// ── Add 3 additional CAB members, then populate metadata on all 4 ──
const ROUNDS = 10;
const hash = bcrypt.hashSync('cab123', ROUNDS);
// Backdate Lars's cab membership to give realistic tenure on /pulse
db.prepare(`UPDATE users SET cab_joined_date = date('now', '-2 years', '-4 months') WHERE id = ?`).run(lars.id);
// Mark all three as recently seen so the "online now" chip strip has content
// (current viewer is excluded from "others online" — see /pulse)
db.prepare(`UPDATE users SET last_seen_at = datetime('now', '-2 minutes') WHERE id IN (?, ?, ?)`)
.run(lars.id, mette.id, jon.id);
function kebab(s) {
return s.toLowerCase()
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const newCabs = [
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
];
const insertUser = db.prepare(`
INSERT OR IGNORE INTO users (email, password_hash, name, organisation, role, slug, cab_joined_date)
VALUES (?, ?, ?, ?, 'cab', ?, NULL)
`);
for (const c of newCabs) {
insertUser.run(c.email, hash, c.name, c.org, kebab(c.name));
}
// Allocate member_numbers in member-since order, tiebreak id asc.
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
const setCabMeta = db.prepare(`
UPDATE users
SET cab_joined_date = date('now', ?),
title = ?,
pull_quote = ?,
focus_tags = ?,
last_seen_at = datetime('now', '-2 minutes')
WHERE id = ?
`);
const cabMeta = {
'lars@virk2.dk': {
title: 'Senior Adviser, Operational Risk',
pull_quote: 'A model is only as auditable as the chain of evidence behind it. That chain is the work.',
focus_tags: ['Risk', 'Audit trail', 'GDPR'],
},
'anna@virk3.dk': {
title: 'Director of Digital Services',
pull_quote: 'Municipalities can\'t outsource sovereignty. We need tools that assume that.',
focus_tags: ['Public sector', 'Sovereignty'],
},
'soren@virk4.dk': {
title: 'Head of Data Engineering',
pull_quote: 'Make it boring to deploy and surprising to query.',
focus_tags: ['Infrastructure', 'Telemetry'],
},
'henriette@virk5.dk': {
title: 'Lead Counsel, Compliance',
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
focus_tags: ['Legal', 'Policy', 'EU AI Act'],
},
};
for (const u of cabRows) {
const m = cabMeta[u.email] ?? null;
if (!m) continue;
const weeks = tenureWeeks[u.email] ?? 0;
setCabMeta.run(`-${weeks} weeks`, m.title, m.pull_quote, JSON.stringify(m.focus_tags), u.id);
}
// Allocate member_numbers using the SQL backfill from migration 0004,
// but only for cab rows that still lack one (idempotent).
db.exec(`
WITH ranked AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY COALESCE(cab_joined_date, created_at) ASC, id ASC) AS rn
FROM users WHERE role = 'cab'
)
UPDATE users
SET member_number = (SELECT rn FROM ranked WHERE ranked.id = users.id)
WHERE role = 'cab' AND member_number IS NULL;
`);
// Mark Mette + Jonathan recently seen too (for the avatar pile on /events)
db.prepare("UPDATE users SET last_seen_at = datetime('now', '-2 minutes') WHERE id IN (?, ?)").run(mette.id, jon.id);
// Re-fetch cab rows ordered by member_number
const cabs = db.prepare("SELECT id, name, email, member_number FROM users WHERE role = 'cab' AND active = 1 ORDER BY member_number ASC").all();
console.log(` cab members: ${cabs.map(c => `${c.name} #${c.member_number}`).join(', ')}`);
// ── Helper: SQL datetime string at offset seconds from now ─────────
const nowIso = (offsetSeconds = 0) => {
const d = new Date(Date.now() + offsetSeconds * 1000);
return d.toISOString().replace('T', ' ').slice(0, 19);
};
// ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
const pulseOptions = [
// ── Pulse: open now, closes in 5 days ────────────────────────────────
const opensAt = nowIso(-3600); // opened an hour ago
const closesAt = nowIso(5 * 24 * 3600); // closes in 5 days
const options = [
'Locking down on-prem deployment first',
'Pushing the traceability layer to GA',
'Going wide on document ingestion',
'Building the agentic query loop',
];
const pulseId = db.prepare(`
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
VALUES (?,?,?,?,?,?,?)
`).run(
'Which milestone should we anchor Q3 around?',
'Council input on this directly shapes what the team works on in JulySeptember. Read the roadmap before voting.',
JSON.stringify(pulseOptions),
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
JSON.stringify(options),
opensAt,
closesAt,
'open',
jon.id,
).lastInsertRowid;
// 2 votes from cabs[0] and cabs[1]
// Lars votes for the traceability option
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600));
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(pulseId, cabs[1].id, 1, nowIso(-30 * 60));
.run(pulseId, lars.id, 1, nowIso(-2 * 3600));
// ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
const roadmap = [
{ title: 'Traceability layer', description: 'Every response cites its sources with structured provenance.', status: 'shipping', target: 'Live now', display_order: 10, shipped_at: nowIso(-2 * 24 * 3600), attributed: [cabs[0].id] },
{ title: 'Document ingestion pipeline', description: 'Upload PDF, Word, plain text. Chunked, indexed, retrievable.', status: 'beta', target: null, display_order: 10, shipped_at: null, attributed: [cabs[1].id, cabs[2].id] },
{ title: 'Contextual memory', description: 'The system learns the regulatory and organisational context over time.', status: 'exploring', target: 'Q3 2026', display_order: 10, shipped_at: null, attributed: [cabs[3].id] },
{ title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, shipped_at: null, attributed: [] },
];
const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
VALUES (?,?,?,?,?,?)
`);
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
for (const r of roadmap) {
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at).lastInsertRowid);
for (const uid of r.attributed) insertAttr.run(id, uid);
// ── Roadmap: mark "Traceability layer" as shipping, attribute to Lars ──
const traceability = db.prepare("SELECT id FROM roadmap_items WHERE title LIKE 'Traceability%'").get();
if (traceability) {
db.prepare(`UPDATE roadmap_items SET status = 'shipping', shipped_at = datetime('now', '-2 days'), target = 'Live now' WHERE id = ?`).run(traceability.id);
db.prepare('INSERT OR IGNORE INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)').run(traceability.id, lars.id);
}
// ── Contributions: 3 entries; most recent gets 3 reactions ─────────
const contribs = [
{ user_id: cabs[2].id, type: 'idea',
body_md: 'What if we let council members write **inline annotations** on the roadmap items they shaped? A trail of "here\'s what I pushed for."',
when: nowIso(-2 * 60 * 60), reactors: [cabs[0].id, cabs[1].id, jon.id] },
{ user_id: cabs[1].id, type: 'question',
body_md: 'How will the traceability layer handle documents with conflicting metadata across versions?',
when: nowIso(-2 * 24 * 60 * 60) },
{ user_id: cabs[0].id, type: 'inspiration',
body_md: 'A piece on **institutional memory** in regulated industries — it lines up almost exactly with what we\'re trying to build.',
when: nowIso(-9 * 24 * 60 * 60) },
];
const insertContrib = db.prepare(`
INSERT INTO contributions (user_id, type, body_md, created_at) VALUES (?,?,?,?)
`);
const insertReact = db.prepare('INSERT INTO reactions (user_id, contribution_id) VALUES (?,?)');
for (const c of contribs) {
const id = Number(insertContrib.run(c.user_id, c.type, c.body_md, c.when).lastInsertRowid);
for (const uid of c.reactors ?? []) insertReact.run(uid, id);
}
// ── Dispatches: 4 published at staggered ages ──────────────────────
const dispatchSeed = [
{ kind: 'decision', ageDays: 2,
title: 'We are deprioritising public-cloud parity for Q3',
excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.',
body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction.
So for Q3 the platform supports two deployment targets only on-prem inside the customer's own VPC, and our Hetzner sovereign cloud in Helsinki. Everything else is parked. The agentic query work moves up a quarter to fill the gap.
We'll revisit public cloud in Q4 once the on-prem story is boring.`,
},
{ kind: 'behind_the_scenes', ageDays: 5,
title: 'A morning at the council kickoff',
excerpt: 'Four members in the room, two on video. The whiteboard ended up with three lists: must-have, would-be-nice, do-not-build.',
body: `Four council members in the room, two on video. The kickoff meeting was meant to be 90 minutes. It went four hours.
The whiteboard ended up with three lists: must-have, would-be-nice, and the most interesting one do-not-build.
Henriette pushed back hard on the "agent that emails on your behalf" pattern. "I don't want a system speaking on my legal team's behalf. Ever." That note alone reshaped a whole feature.
Photos to come, with permission.`,
},
{ kind: 'update', ageDays: 9,
title: 'Document ingestion is now feature-complete in beta',
excerpt: 'PDF, Word, plain text. Chunking, metadata, deduplication, basic OCR. Three pilots have run it against their corpora.',
body: `Document ingestion is feature-complete in beta. PDF, Word, plain text. Chunking, metadata extraction, deduplication, and basic OCR for scanned PDFs.
Three pilots have now run it against their internal corpora biggest was 47,000 documents, smallest was 380. Both worked. The 47k run took 8 hours and surfaced some neat edge cases (mostly around tables that span pages).
Next week we open it to the full pilot group. We'll need notes.`,
},
{ kind: 'note', ageDays: 12,
title: 'Welcome to the council',
excerpt: 'A short note to mark the start. This page will fill up with decisions, half-built ideas, and things we have changed our minds about.',
body: `This page will fill up with decisions, half-built ideas, and things we have changed our minds about.
It is not a blog. It is the studio talking to the room short, dated, signed.`,
},
];
const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
const insertDispatch = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
VALUES (?,?,?,?,?,'published',?,?,?)
`);
for (let i = 0; i < dispatchSeed.length; i += 1) {
const d = dispatchSeed[i];
const when = nowIso(-d.ageDays * 24 * 60 * 60);
const authorId = fenjas[i % fenjas.length].id;
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
}
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
const insertEvent = db.prepare(`
INSERT INTO events (slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, audience, duration_label, action_label, notes_url, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`);
insertEvent.run(
'kickoff-dinner-2026-06', 'Council kickoff dinner', 'dinner',
// ── Events ────────────────────────────────────────────────────────────
const dinnerStart = nowIso(38 * 24 * 3600); // ~5.5 weeks out
db.prepare(`
INSERT INTO events (slug, title, kind, description, location, starts_at, capacity, created_by)
VALUES (?,?,?,?,?,?,?,?)
`).run(
'kickoff-dinner-2026-06',
'Council kickoff dinner',
'dinner',
'A private dinner at the studio. Conversation about what we ship next, no slides.',
'Studio, Refshalevej · Copenhagen',
nowIso(38 * 24 * 3600), null, 12, null,
'Members only', null, null, null,
dinnerStart,
12,
jon.id,
);
const dinnerSlug = 'kickoff-dinner-2026-06';
const dinnerId = db.prepare("SELECT id FROM events WHERE slug = 'kickoff-dinner-2026-06'").get().id;
insertEvent.run(
'studio-hours-2026-05', 'Studio hours with Jonathan', 'office_hours',
'30-minute slots. Open agenda. Drop in when you\'ve got something to talk through.',
const officeHoursStart = nowIso(14 * 24 * 3600); // 2 weeks out
db.prepare(`
INSERT INTO events (slug, title, kind, description, location, starts_at, created_by)
VALUES (?,?,?,?,?,?,?)
`).run(
'office-hours-2026-05',
'Office hours with the founder',
'office_hours',
'30-minute one-on-one slots. Open agenda. Book one or just drop by.',
'Virtual (link sent after RSVP)',
nowIso(14 * 24 * 3600), null, null, null,
'Council members', '30 minutes', null, null,
officeHoursStart,
jon.id,
);
const officeHoursId = db.prepare("SELECT id FROM events WHERE slug = 'office-hours-2026-05'").get().id;
insertEvent.run(
'working-session-2026-06', 'Working session: traceability UX', 'working_session',
'Three-person session to walk through the new traceability UI before it lands in beta.',
'Studio, Refshalevej · Copenhagen',
nowIso(21 * 24 * 3600), null, 6, null,
'Council + Fenja team', '90 minutes', 'RSVP →', null,
jon.id,
);
insertEvent.run(
'apr-roundtable-2026', 'April roundtable', 'summit',
'A half-day session anchored around the EU AI Act compliance roadmap.',
'Studio, Refshalevej · Copenhagen',
nowIso(-21 * 24 * 3600), null, 10, null,
'Members only', null, null, 'https://example.invalid/notes/april',
jon.id,
);
const aprilSlug = 'apr-roundtable-2026';
insertEvent.run(
'march-launch-dinner', 'Launch dinner', 'dinner',
'The first dinner. Where the council was formally introduced.',
'Aamanns Etablissement · Copenhagen',
nowIso(-52 * 24 * 3600), null, 10, null,
'Members only', null, null, null,
jon.id,
);
const marchSlug = 'march-launch-dinner';
// Past-event RSVPs (drives the "attended_count" on past cards)
const insertRsvp = db.prepare(`
INSERT INTO attendance (user_id, meeting_slug, kind, status, updated_at)
VALUES (?, ?, 'event', 'yes', datetime('now'))
`);
for (const c of cabs) insertRsvp.run(c.id, aprilSlug);
for (const c of cabs.slice(0, 3)) insertRsvp.run(c.id, marchSlug);
// Hero dinner: 1 confirmed so far (Lars) — keeps the avatar pile small at first
insertRsvp.run(cabs[0].id, dinnerSlug);
// ── Activity rows for the (now hidden but still-written) feed ──────
// ── Activity rows ─────────────────────────────────────────────────────
// Mix of real (Lars's vote, Jonathan's publish, Jonathan's ship) and
// hand-crafted demo rows so the ticker has six items to scroll.
const insertActivity = db.prepare(`
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
VALUES (?,?,?,?,?)
`);
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60));
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
console.log(' pulse #' + pulseId + ' open, 2 of 4 voted');
console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
console.log(' events: dinner + studio hours + working session, 2 past');
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
insertActivity.run(lars.id, 'voted', 'pulse', pulseId, nowIso(-2 * 3600));
if (traceability) {
insertActivity.run(jon.id, 'roadmap_shipped', 'roadmap', traceability.id, nowIso(-2 * 24 * 3600));
}
insertActivity.run(lars.id, 'rsvped', 'event', dinnerId, nowIso(-8 * 3600));
insertActivity.run(mette.id,'rsvped', 'event', officeHoursId, nowIso(-30 * 60));
insertActivity.run(jon.id, 'booked_office_hours', 'event', officeHoursId, nowIso(-1 * 24 * 3600));
console.log(' demo data seeded:');
console.log(` pulse #${pulseId} (open, closes in 5 days)`);
if (traceability) console.log(` roadmap #${traceability.id} → shipping, attributed to ${lars.name}`);
console.log(` events: kickoff-dinner-2026-06, office-hours-2026-05`);
console.log(` activity: 6 rows`);
db.close();

View file

@ -17,22 +17,14 @@ try {
process.exit(1);
}
// Wipe existing seed data (idempotent). Order matters: every table that
// FK-references users (without ON DELETE CASCADE) must be cleared first.
// Wipe existing seed data (idempotent)
db.exec(`
DELETE FROM activity;
DELETE FROM votes;
DELETE FROM dispatches;
DELETE FROM events;
DELETE FROM pulses;
DELETE FROM roadmap_attributions;
DELETE FROM reactions;
DELETE FROM replies;
DELETE FROM contributions;
DELETE FROM attendance;
DELETE FROM join_requests;
DELETE FROM invites;
DELETE FROM sessions;
DELETE FROM invites;
DELETE FROM users;
`);
@ -40,21 +32,21 @@ const ROUNDS = 10;
const users = [
{
email: 'mette@virk1.dk',
email: 'mette@ssi.dk',
password: 'pilot123',
name: 'Mette Hansen',
organisation: 'Virksomhed 1',
organisation: 'Statens Serum Institut',
role: 'pilot',
},
{
email: 'lars@virk2.dk',
email: 'lars@rigspolitiet.dk',
password: 'cab123',
name: 'Lars Thomsen',
organisation: 'Virksomhed 2',
organisation: 'Rigspolitiet',
role: 'cab',
},
{
email: 'jonathan@studio.test',
email: 'jonathan@fenja.ai',
password: 'fenja123',
name: 'Jonathan',
organisation: 'Fenja AI',
@ -63,15 +55,14 @@ const users = [
];
const insertUser = db.prepare(`
INSERT INTO users (email, password_hash, name, organisation, role, bio, slug)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (email, password_hash, name, organisation, role, bio)
VALUES (?, ?, ?, ?, ?, ?)
`);
const userIds = {};
for (const u of users) {
const hash = bcrypt.hashSync(u.password, ROUNDS);
const slug = u.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
const result = insertUser.run(u.email, hash, u.name, u.organisation, u.role, '', slug);
const result = insertUser.run(u.email, hash, u.name, u.organisation, u.role, '');
userIds[u.role] = Number(result.lastInsertRowid);
console.log(` created user: ${u.name} (${u.role}) — password: ${u.password}`);
}
@ -144,7 +135,7 @@ insertAttendance.run(userIds['cab'], '2026-04-25-cab-q2-session', 'no');
console.log('\n Seed complete.');
console.log('\n Test credentials:');
console.log(' mette@virk1.dk / pilot123 (pilot)');
console.log(' lars@virk2.dk / cab123 (cab)');
console.log(' jonathan@studio.test / fenja123 (fenja)');
console.log(' mette@ssi.dk / pilot123 (pilot)');
console.log(' lars@rigspolitiet.dk / cab123 (cab)');
console.log(' jonathan@fenja.ai / fenja123 (fenja)');
db.close();

View file

@ -1,56 +0,0 @@
---
import Avatar from './Avatar.astro';
import type { UserPublic } from '../lib/db';
interface Props {
users: UserPublic[];
max?: number;
size?: number;
/** Border color between overlapping avatars — defaults to --surface for cream surfaces. */
borderColor?: string;
}
const { users, max = 5, size = 22, borderColor = 'var(--surface)' } = Astro.props;
const shown = users.slice(0, max);
const overflow = Math.max(0, users.length - shown.length);
---
<div class="pile" style={`--pile-size: ${size}px; --pile-border: ${borderColor};`}>
{shown.map((u, i) => (
<span class="pile-slot" style={`z-index: ${shown.length - i}`}>
<Avatar id={u.id} name={u.name} size={size} />
</span>
))}
{overflow > 0 && (
<span class="pile-slot pile-overflow label-sm" aria-label={`${overflow} more`}>
+{overflow}
</span>
)}
</div>
<style>
.pile {
display: inline-flex;
align-items: center;
}
.pile-slot {
display: inline-flex;
border-radius: 50%;
box-shadow: 0 0 0 1.5px var(--pile-border);
}
.pile-slot:not(:first-child) {
margin-left: calc(var(--pile-size) * -0.32);
}
.pile-overflow {
width: var(--pile-size);
height: var(--pile-size);
background: var(--ink-muted);
color: var(--ink);
align-items: center;
justify-content: center;
font-family: var(--font-sans);
letter-spacing: var(--tracking-wide);
font-weight: 600;
font-size: calc(var(--pile-size) * 0.36);
}
</style>

View file

@ -1,175 +0,0 @@
---
import type { DispatchWithAuthor, UserPublic } from '../../lib/db';
import { fmtDateTime } from '../../lib/markdown';
import { dispatchKindLabel } from '../../lib/format';
interface Props {
dispatches: DispatchWithAuthor[];
editing: DispatchWithAuthor | null;
fenjaUsers: UserPublic[];
currentUserId: number;
}
const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
const STATUS_LABEL: Record<string, string> = {
draft: 'Draft',
published: 'Published',
archived: 'Archived',
};
const formAction = editing ? 'update_dispatch' : 'create_dispatch';
const defaultAuthorId = editing?.author_id ?? currentUserId;
---
<div class="tab-content">
<section class="section">
<h2 class="label-sm section-heading">{editing ? 'Edit dispatch' : 'New dispatch'}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value={formAction} />
{editing && <input type="hidden" name="dispatch_id" value={editing.id} />}
<div class="field">
<label for="d-title" class="label-sm field-label">Title</label>
<input type="text" id="d-title" name="title" class="input body-md" required value={editing?.title ?? ''} />
</div>
<div class="form-grid">
<div class="field">
<label for="d-kind" class="label-sm field-label">Kind</label>
<select id="d-kind" name="kind" class="select body-md" required>
{(['decision','update','behind_the_scenes','note'] as const).map(k => (
<option value={k} selected={editing?.kind === k}>{dispatchKindLabel(k)}</option>
))}
</select>
</div>
<div class="field">
<label for="d-author" class="label-sm field-label">Author (Fenja team)</label>
<select id="d-author" name="author_id" class="select body-md" required>
{fenjaUsers.map(u => (
<option value={u.id} selected={u.id === defaultAuthorId}>
{u.name}{u.title ? ` — ${u.title}` : ''}
</option>
))}
</select>
</div>
</div>
<div class="field">
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional — falls back to first ~200 chars of body)</label>
<input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
</div>
<div class="field">
<label for="d-body" class="label-sm field-label">Body (markdown)</label>
<textarea id="d-body" name="body" class="input body-md mono" rows="12" required>{editing?.body ?? ''}</textarea>
</div>
{!editing && (
<div class="form-grid">
<div class="field">
<label for="d-status" class="label-sm field-label">Status on save</label>
<select id="d-status" name="status" class="select body-md">
<option value="draft" selected>Draft (hidden from members)</option>
<option value="published">Published (stamps published_at)</option>
</select>
</div>
</div>
)}
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
</div>
</form>
</section>
<section class="section">
<h2 class="label-sm section-heading">All dispatches</h2>
{dispatches.length === 0 ? (
<p class="body-sm empty-msg">No dispatches yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Title</th>
<th class="label-sm">Kind</th>
<th class="label-sm">Author</th>
<th class="label-sm">Status</th>
<th class="label-sm">Published</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{dispatches.map(d => (
<tr>
<td class="body-sm">{d.title}</td>
<td class="body-sm muted">{dispatchKindLabel(d.kind)}</td>
<td class="body-sm">{d.author_name}</td>
<td class="body-sm"><span class:list={['status-pill', `status-${d.status}`]}>{STATUS_LABEL[d.status]}</span></td>
<td class="body-sm muted">{d.published_at ? fmtDateTime(d.published_at) : '—'}</td>
<td class="action-cell">
<a href={`/admin?tab=dispatches&edit=${d.id}`} class="action-link label-sm">Edit</a>
{d.status === 'draft' && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="publish_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="action-link label-sm">Publish</button>
</form>
)}
{d.status === 'published' && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="archive_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="action-link label-sm">Archive</button>
</form>
)}
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="delete_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this dispatch?')">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
<style>
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.status-pill {
display: inline-block;
padding: 0.15em var(--space-3);
border-radius: var(--radius-full);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.status-draft { background: var(--surface-container); color: var(--on-surface-muted); }
.status-published { background: rgba(109, 140, 124, 0.18); color: var(--pigment-copper); font-weight: 600; }
.status-archived { background: var(--surface-container-low); color: var(--on-surface-muted); font-style: italic; }
.action-cell { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style>

View file

@ -13,10 +13,9 @@ const { events, editing, viewing, viewingRsvps } = Astro.props;
const KIND_LABEL = {
dinner: 'Dinner',
office_hours: 'Studio hours',
office_hours: 'Office hours',
summit: 'Summit',
virtual: 'Virtual',
working_session: 'Working session',
} as const;
function toInputValue(sql: string | null | undefined): string {
@ -60,8 +59,7 @@ const formAction = editing ? 'update_event' : 'create_event';
<label for="kind" class="label-sm field-label">Kind</label>
<select id="kind" name="kind" class="select body-md" required>
<option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option>
<option value="office_hours" selected={editing?.kind === 'office_hours'}>Studio hours</option>
<option value="working_session" selected={editing?.kind === 'working_session'}>Working session</option>
<option value="office_hours" selected={editing?.kind === 'office_hours'}>Office hours</option>
<option value="summit" selected={editing?.kind === 'summit'}>Summit</option>
<option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option>
</select>
@ -86,22 +84,6 @@ const formAction = editing ? 'update_event' : 'create_event';
<label for="photo_url" class="label-sm field-label">Photo URL (optional, for past events)</label>
<input type="text" id="photo_url" name="photo_url" class="input body-md" value={editing?.photo_url ?? ''} />
</div>
<div class="field">
<label for="audience" class="label-sm field-label">Audience (e.g. "Members only")</label>
<input type="text" id="audience" name="audience" class="input body-md" value={editing?.audience ?? ''} />
</div>
<div class="field">
<label for="duration_label" class="label-sm field-label">Duration label</label>
<input type="text" id="duration_label" name="duration_label" class="input body-md" value={editing?.duration_label ?? ''} placeholder="e.g. 30 minutes, 7pm onwards" />
</div>
<div class="field">
<label for="action_label" class="label-sm field-label">Action label (optional)</label>
<input type="text" id="action_label" name="action_label" class="input body-md" value={editing?.action_label ?? ''} placeholder="Override the default for this event kind" />
</div>
<div class="field">
<label for="notes_url" class="label-sm field-label">Notes URL (optional)</label>
<input type="url" id="notes_url" name="notes_url" class="input body-md" value={editing?.notes_url ?? ''} placeholder="https://…" />
</div>
</div>
<div class="field">

View file

@ -1,90 +0,0 @@
---
import type { UserPublic } from '../../lib/db';
import { readFocusTags } from '../../lib/format';
interface Props {
member: UserPublic;
}
const { member } = Astro.props;
const tagsStr = readFocusTags(member.focus_tags).join(', ');
---
<div class="tab-content">
<section class="section">
<a href="/admin?tab=participants" class="action-link label-sm">← Back to participants</a>
<h2 class="label-sm section-heading">Edit member — {member.name}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value="update_user_admin" />
<input type="hidden" name="user_id" value={member.id} />
<div class="form-grid">
<div class="field">
<label class="label-sm field-label">Name</label>
<input type="text" class="input body-md" value={member.name} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Email</label>
<input type="text" class="input body-md" value={member.email} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Organisation</label>
<input type="text" class="input body-md" value={member.organisation} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Member number {member.role === 'cab' ? '(allocated)' : '(only set for cab role)'}</label>
<input type="text" class="input body-md" value={member.member_number ?? '—'} disabled />
</div>
<div class="field">
<label for="title" class="label-sm field-label">Job title</label>
<input type="text" id="title" name="title" class="input body-md" value={member.title ?? ''} placeholder="e.g. Senior Adviser" />
</div>
<div class="field">
<label for="focus_tags" class="label-sm field-label">Focus tags (comma-separated, max 3 × 24 chars)</label>
<input type="text" id="focus_tags" name="focus_tags" class="input body-md" value={tagsStr} placeholder="GDPR, Telemetry, Policy" />
</div>
</div>
<div class="field">
<label for="pull_quote" class="label-sm field-label">Pull quote (one sentence in their voice — max 200 chars)</label>
<textarea id="pull_quote" name="pull_quote" class="input body-md" rows="3" maxlength="200" data-counter>{member.pull_quote ?? ''}</textarea>
<span class="char-counter label-sm" data-counter-for="pull_quote">{(member.pull_quote ?? '').length} / 200</span>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">Save changes</button>
<a href="/admin?tab=participants" class="action-link label-sm">Cancel</a>
</div>
</form>
<p class="body-sm note">
Role transitions and deactivation live in the participants table.
A member-number is allocated the first time a user becomes CAB and is never reused.
</p>
</section>
</div>
<script>
// Tiny live counter for the 200-char pull-quote field — no framework.
document.querySelectorAll<HTMLTextAreaElement>('[data-counter]').forEach((el) => {
const counter = document.querySelector<HTMLElement>(`[data-counter-for="${el.id}"]`);
if (!counter) return;
const update = () => { counter.textContent = `${el.value.length} / 200`; };
el.addEventListener('input', update);
});
</script>
<style>
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.char-counter { color: var(--on-surface-muted); margin-top: var(--space-1); display: inline-block; }
.note {
color: var(--on-surface-muted);
margin-top: var(--space-4);
max-width: var(--reading-max);
}
.input:disabled {
color: var(--on-surface-muted);
background: var(--surface-container-low);
cursor: not-allowed;
}
</style>

View file

@ -12,6 +12,8 @@ const { title, user } = Astro.props;
const navLinks = [
{ href: '/pulse', label: 'Pulse' },
{ href: '/roadmap', label: 'Roadmap' },
{ href: '/members', label: 'Members' },
{ href: '/events', label: 'Events' },
];
const footerLinks = [
@ -28,8 +30,6 @@ const year = new Date().getFullYear();
<div class="nav-inner">
<a href="/" class="wordmark-link" aria-label="Project Bifrost — home">
<img src="/logo.svg" alt="Fenja AI" class="wordmark" />
<span class="wordmark-sep" aria-hidden="true">·</span>
<span class="wordmark-project">Project <em class="wordmark-bifrost">Bifrost</em></span>
</a>
<nav class="nav-right" aria-label="Main navigation">
@ -112,47 +112,16 @@ const year = new Date().getFullYear();
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--space-3);
border-bottom: none;
color: var(--on-surface);
}
.wordmark-link:hover {
border-bottom: none;
color: var(--on-surface);
}
.wordmark {
height: 22px;
width: auto;
display: block;
}
.wordmark-sep {
color: var(--on-surface-muted);
font-size: 1rem;
line-height: 1;
}
.wordmark-project {
font-family: var(--font-sans);
font-size: var(--text-body-md);
font-weight: 500;
color: var(--on-surface);
letter-spacing: 0;
}
.wordmark-bifrost {
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
background-image: linear-gradient(
90deg,
var(--pigment-terracotta) 0%,
var(--pigment-ochre) 28%,
var(--pigment-copper) 54%,
var(--pigment-indigo) 78%,
var(--pigment-heather) 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* ── Nav links ──────────────────────────────────────────────────── */
.nav-right {

Binary file not shown.

View file

@ -2,29 +2,24 @@
import AppLayout from '../../layouts/AppLayout.astro';
import {
getAllInvites, getAllUsersPublic, revokeInvite,
createInvite, updateUserRole, deactivateUser, updateUserAdminFields,
getUserPublicById, getAllJoinRequests,
createInvite, updateUserRole, deactivateUser,
getAllJoinRequests,
createPulse, updatePulse, publishPulse, closePulse, deletePulse,
getAllPulses, getPulseById, getPulseWithCounts,
createRoadmapItem, updateRoadmapItem, deleteRoadmapItem,
setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem,
createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug,
getEventRsvpCount, getEventById,
createDispatch, updateDispatch, publishDispatch, archiveDispatch,
deleteDispatch, getAllDispatchesForAdmin, getDispatchById,
recordActivity, getAllActivityForAdmin,
} from '../../lib/db';
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
import { fmtDate } from '../../lib/markdown';
import { parseFocusTags } from '../../lib/format';
import { notifyPulseOpened } from '../../lib/notify';
import PulsesTab from '../../components/admin/PulsesTab.astro';
import RoadmapTab from '../../components/admin/RoadmapTab.astro';
import EventsTab from '../../components/admin/EventsTab.astro';
import ActivityTab from '../../components/admin/ActivityTab.astro';
import DispatchesTab from '../../components/admin/DispatchesTab.astro';
import UserEditTab from '../../components/admin/UserEditTab.astro';
import type { Role, RoadmapStatus, EventKind, DispatchKind, DispatchStatus } from '../../lib/db';
import type { Role, RoadmapStatus, EventKind } from '../../lib/db';
const user = Astro.locals.user;
@ -80,50 +75,6 @@ if (Astro.request.method === 'POST') {
if (userId && userId !== user.id) deactivateUser(userId);
return Astro.redirect('/admin?tab=participants&msg=deactivated');
// ── User profile edit (title / pull_quote / focus_tags) ─────
} else if (action === 'update_user_admin') {
const userId = Number(data.get('user_id'));
if (userId) {
const title = String(data.get('title') ?? '').trim() || null;
const pullQuote = String(data.get('pull_quote') ?? '').trim() || null;
const tagsInput = String(data.get('focus_tags') ?? '');
const focusTags = parseFocusTags(tagsInput);
updateUserAdminFields(userId, { title, pull_quote: pullQuote, focus_tags: focusTags });
}
return Astro.redirect(`/admin?tab=participants&edit=${userId}&msg=user_updated`);
// ── Dispatches ───────────────────────────────────────────────
} else if (action === 'create_dispatch' || action === 'update_dispatch') {
const title = String(data.get('title') ?? '').trim();
const body = String(data.get('body') ?? '');
const excerpt = String(data.get('excerpt') ?? '').trim() || null;
const kind = String(data.get('kind') ?? '') as DispatchKind;
const authorId = Number(data.get('author_id'));
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
formError = 'Title, body, and a valid kind are required.';
} else if (action === 'create_dispatch') {
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status });
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
} else {
const id = Number(data.get('dispatch_id'));
if (id) updateDispatch(id, { title, body, excerpt, kind, author_id: authorId || user.id });
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
}
} else if (action === 'publish_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) publishDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_published');
} else if (action === 'archive_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) archiveDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_archived');
} else if (action === 'delete_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) deleteDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_deleted');
// ── Pulses ───────────────────────────────────────────────────
} else if (action === 'create_pulse' || action === 'update_pulse') {
const question = String(data.get('question') ?? '').trim();
@ -278,12 +229,6 @@ const joinRequests = getAllJoinRequests();
const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null;
const viewId = Number(Astro.url.searchParams.get('view') ?? 0) || null;
const fenjaUsers = users.filter(u => u.role === 'fenja');
const editingUser = tab === 'participants' && editId ? getUserPublicById(editId) : null;
const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : [];
const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
// Per-tab data
const pulses = tab === 'pulses' ? getAllPulses() : [];
const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null;
@ -306,7 +251,6 @@ const MSGS: Record<string, string> = {
revoked: 'Invite revoked.',
updated: 'Role updated.',
deactivated: 'User deactivated.',
user_updated: 'Member profile updated.',
pulse_created: 'Pulse saved.',
pulse_updated: 'Pulse updated.',
pulse_published: 'Pulse published — members notified.',
@ -319,11 +263,6 @@ const MSGS: Record<string, string> = {
event_created: 'Event saved.',
event_updated: 'Event updated.',
event_deleted: 'Event deleted.',
dispatch_created: 'Dispatch saved.',
dispatch_updated: 'Dispatch updated.',
dispatch_published: 'Dispatch published.',
dispatch_archived: 'Dispatch archived.',
dispatch_deleted: 'Dispatch deleted.',
};
actionMsg = Astro.url.searchParams.get('msg');
---
@ -340,7 +279,6 @@ actionMsg = Astro.url.searchParams.get('msg');
<a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</a>
<a href="/admin?tab=roadmap" class:list={['tab label-sm', { active: tab === 'roadmap' }]}>Roadmap</a>
<a href="/admin?tab=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a>
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a>
<a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a>
<a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
@ -459,11 +397,7 @@ actionMsg = Astro.url.searchParams.get('msg');
)}
<!-- Participants tab -->
{tab === 'participants' && editingUser && (
<UserEditTab member={editingUser} />
)}
{tab === 'participants' && !editingUser && (
{tab === 'participants' && (
<div class="tab-content">
<section class="section">
<h2 class="label-sm section-heading">All participants</h2>
@ -502,8 +436,7 @@ actionMsg = Astro.url.searchParams.get('msg');
<td class="body-sm muted">
{u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'}
</td>
<td class="action-cell">
<a href={`/admin?tab=participants&edit=${u.id}`} class="action-link label-sm">Edit</a>
<td>
{u.id !== user.id && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="deactivate_user" />
@ -576,10 +509,6 @@ actionMsg = Astro.url.searchParams.get('msg');
<ActivityTab rows={activityRows} />
)}
{tab === 'dispatches' && (
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} fenjaUsers={fenjaUsers} currentUserId={user.id} />
)}
</div>
</AppLayout>
@ -862,21 +791,4 @@ actionMsg = Astro.url.searchParams.get('msg');
.danger-btn:hover {
background: rgba(185, 107, 88, 0.08);
}
.action-cell { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
font-size: var(--text-label-md);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style>

View file

@ -1,246 +0,0 @@
---
import AppLayout from '../../layouts/AppLayout.astro';
import Avatar from '../../components/Avatar.astro';
import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
import {
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
dispatchKindPigment, roleLabel,
} from '../../lib/format';
import { renderMd } from '../../lib/markdown';
const user = Astro.locals.user;
const slugParam = Astro.params.slug ?? '';
const id = parseDispatchSlug(slugParam);
if (!id) return Astro.redirect('/dispatches');
const d = getDispatchById(id);
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
// Canonical-redirect when the slug changes after a rename — id is the authority
const canonical = dispatchSlug(d);
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
const { prev, next } = getAdjacentDispatches(d.id);
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(iso: string): string {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Copenhagen',
}).format(parseUtc(iso));
}
const bodyHtml = renderMd(d.body);
---
<AppLayout title={d.title} user={user}>
<article class="page">
<a href="/dispatches" class="section-link back-link">← All dispatches</a>
<header class="head">
<div class="head-meta">
<span class="kind-pill" style={`--pill: ${dispatchKindPigment(d.kind)}`}>
{dispatchKindLabel(d.kind)}
</span>
<time class="head-date label-sm" datetime={d.published_at ?? d.created_at}>
{fmt(d.published_at ?? d.created_at)}
</time>
</div>
<h1 class="title">{d.title}</h1>
<div class="byline">
<Avatar id={d.author_id} name={d.author_name} size={32} />
<div class="byline-text">
<span class="byline-name">{d.author_name}</span>
<span class="byline-role label-sm">{d.author_title ?? roleLabel(d.author_role)}</span>
</div>
</div>
</header>
<div class="body prose" set:html={bodyHtml} />
<hr class="divider" />
<nav class="adjacent" aria-label="Adjacent dispatches">
{prev ? (
<a class="adj-card adj-prev" href={`/dispatches/${dispatchSlug(prev)}`}>
<span class="adj-direction label-sm">← Previous</span>
<span class="adj-kind-pill" style={`--pill: ${dispatchKindPigment(prev.kind)}`}>
{dispatchKindLabel(prev.kind)}
</span>
<span class="adj-title">{prev.title}</span>
</a>
) : (
<span class="adj-empty"></span>
)}
{next ? (
<a class="adj-card adj-next" href={`/dispatches/${dispatchSlug(next)}`}>
<span class="adj-direction label-sm">Next →</span>
<span class="adj-kind-pill" style={`--pill: ${dispatchKindPigment(next.kind)}`}>
{dispatchKindLabel(next.kind)}
</span>
<span class="adj-title">{next.title}</span>
</a>
) : (
<span class="adj-empty"></span>
)}
</nav>
</article>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.back-link { align-self: flex-start; }
.head { display: flex; flex-direction: column; gap: var(--space-4); }
.head-meta {
display: flex;
align-items: center;
gap: var(--space-3);
}
.kind-pill {
background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill);
padding: 3px 12px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.head-date {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 2rem;
line-height: 1.2;
letter-spacing: var(--tracking-tight);
color: var(--on-surface);
margin: 0;
}
.byline {
display: flex;
align-items: center;
gap: var(--space-3);
}
.byline-text { display: flex; flex-direction: column; gap: 2px; }
.byline-name { font-weight: 600; color: var(--on-surface); }
.byline-role {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.body {
font-size: var(--text-body-lg);
line-height: 1.7;
color: var(--on-surface);
}
.body :global(p) { margin: 0 0 var(--space-4); }
.body :global(h2) {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.5rem;
margin: var(--space-6) 0 var(--space-3);
}
.body :global(h3) {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.25rem;
margin: var(--space-5) 0 var(--space-2);
}
.body :global(blockquote) {
border-left: 2px solid color-mix(in oklab, var(--pigment-terracotta) 40%, transparent);
padding-left: var(--space-4);
color: var(--on-surface-variant);
margin: var(--space-5) 0;
}
.body :global(code) {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--surface-container);
padding: 0.15em 0.4em;
border-radius: var(--radius-sm);
}
.body :global(ul), .body :global(ol) {
padding-left: var(--space-5);
margin: 0 0 var(--space-4);
}
.divider {
border: none;
height: 0.5px;
background: var(--surface-card-border);
margin: var(--space-6) 0 0;
}
.adjacent {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5);
}
.adj-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-5);
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
text-decoration: none;
border-bottom: 0.5px solid var(--surface-card-border);
color: inherit;
transition: transform 300ms var(--ease-standard);
}
.adj-card:hover { transform: translateY(-2px); border-bottom-color: var(--surface-card-border); }
.adj-next { text-align: right; align-items: flex-end; }
.adj-direction {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.adj-kind-pill {
align-self: flex-start;
background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill);
padding: 2px 9px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.adj-next .adj-kind-pill { align-self: flex-end; }
.adj-title {
font-family: var(--font-serif);
color: var(--on-surface);
}
.adj-empty {} /* placeholder for missing prev/next slot */
@media (max-width: 640px) {
.adjacent { grid-template-columns: 1fr; }
.adj-next { text-align: left; align-items: flex-start; }
.adj-next .adj-kind-pill { align-self: flex-start; }
}
</style>

View file

@ -1,179 +0,0 @@
---
import AppLayout from '../../layouts/AppLayout.astro';
import Avatar from '../../components/Avatar.astro';
import { getLatestPublishedDispatches } from '../../lib/db';
import {
dispatchSlug, dispatchKindLabel, dispatchKindPigment,
dispatchExcerptParas, roleLabel,
} from '../../lib/format';
const user = Astro.locals.user;
const dispatches = getLatestPublishedDispatches(200);
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(iso: string): string {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Copenhagen',
}).format(parseUtc(iso));
}
---
<AppLayout title="Dispatches" user={user}>
<div class="page">
<header class="head">
<h1 class="head-title">Notes from the studio.</h1>
<p class="head-sub">Decisions, half-built ideas, and things we've changed our mind about.</p>
</header>
{dispatches.length === 0 ? (
<p class="body-md empty">Nothing posted yet.</p>
) : (
<ul class="d-list">
{dispatches.map(d => (
<li class="d-row">
<a href={`/dispatches/${dispatchSlug(d)}`} class="d-link">
<div class="d-byline">
<Avatar id={d.author_id} name={d.author_name} size={28} />
<span class="d-author-text">
<span class="d-author-name">{d.author_name}</span>
<span class="d-author-role label-sm">{d.author_title ?? roleLabel(d.author_role)}</span>
</span>
</div>
<div class="d-body">
<header class="d-title-row">
<h2 class="d-title">{d.title}</h2>
<span class="d-kind-pill" style={`--pill: ${dispatchKindPigment(d.kind)}`}>
{dispatchKindLabel(d.kind)}
</span>
</header>
<p class="d-excerpt">{dispatchExcerptParas(d).lead}</p>
</div>
<time class="d-date label-sm" datetime={d.published_at ?? d.created_at}>
{fmt(d.published_at ?? d.created_at)}
</time>
</a>
</li>
))}
</ul>
)}
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.head { max-width: 46rem; }
.head-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-3);
}
.head-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: var(--text-display-md);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--on-surface);
margin: 0;
}
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
.empty { color: var(--on-surface-muted); }
.d-list { list-style: none; padding: 0; margin: 0; }
.d-row { border-bottom: 0.5px solid var(--surface-card-border); }
.d-row:last-child { border-bottom: none; }
.d-link {
display: grid;
grid-template-columns: 180px 1fr 130px;
gap: var(--space-5);
padding: var(--space-5) var(--space-3);
align-items: start;
text-decoration: none;
border-bottom: none;
color: inherit;
transition: background var(--duration-fast) var(--ease-standard);
}
.d-link:hover {
background: color-mix(in oklab, var(--surface-card) 60%, transparent);
border-bottom: none;
}
.d-byline { display: flex; align-items: center; gap: var(--space-3); min-width: 0; }
.d-author-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.d-author-name {
font-weight: 600;
color: var(--on-surface);
font-size: var(--text-body-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.d-author-role {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.d-body { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
.d-title-row {
display: flex;
align-items: baseline;
gap: var(--space-3);
flex-wrap: wrap;
}
.d-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.25rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.d-kind-pill {
background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill);
padding: 2px 9px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.d-excerpt {
color: var(--on-surface-variant);
margin: 0;
line-height: var(--leading-relaxed);
}
.d-date {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
justify-self: end;
white-space: nowrap;
}
@media (max-width: 720px) {
.d-link {
grid-template-columns: 1fr;
gap: var(--space-3);
}
.d-date { justify-self: start; }
}
</style>

View file

@ -1,513 +0,0 @@
---
import AppLayout from '../layouts/AppLayout.astro';
import AvatarPile from '../components/AvatarPile.astro';
import {
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
getEventRsvpCount, getUserRsvp, setEventRsvp, recordActivity,
} from '../lib/db';
import { eventKindLabel, defaultActionLabel, pigmentForId } from '../lib/format';
const user = Astro.locals.user;
// ── POST: RSVP ──────────────────────────────────────────────────────
if (Astro.request.method === 'POST') {
const data = await Astro.request.formData();
const action = String(data.get('action') ?? '');
if (action === 'rsvp') {
const slug = String(data.get('event_slug') ?? '');
const status = String(data.get('status') ?? '') as 'yes' | 'no' | 'interested';
if (slug && ['yes', 'no', 'interested'].includes(status)) {
const ev = getEventBySlug(slug);
if (ev) {
setEventRsvp(user.id, slug, status);
recordActivity(user.id, 'rsvped', 'event', ev.id);
}
}
return Astro.redirect('/events');
}
}
// ── Data ───────────────────────────────────────────────────────────
const upcoming = getUpcomingEvents(20);
const hero = upcoming.find(e => e.kind !== 'office_hours') ?? null;
const alsoUpcoming = upcoming.filter(e => e.id !== hero?.id);
const past = getPastEvents(8);
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
}
function dayNum(iso: string) { return fmt({ day: 'numeric' }, iso); }
function weekday(iso: string) { return fmt({ weekday: 'short' }, iso).toUpperCase(); }
function monthShort(iso: string) { return fmt({ month: 'short' }, iso).toUpperCase(); }
function timeStr(iso: string) { return fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso); }
const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
const heroConfirmedCount = heroAttendees.length;
const heroMyRsvp = hero ? getUserRsvp(user.id, hero.slug) : null;
const heroAudience = hero?.audience ?? 'Members only';
---
<AppLayout title="Events" user={user}>
<div class="page">
<header class="head">
<h1 class="head-title">Where the council gathers.</h1>
<p class="head-sub">Dinners, working sessions, the occasional summit. Always small, always off the record.</p>
</header>
<!-- ── Hero invitation ─────────────────────────────────────── -->
{hero ? (
<article class="hero" aria-label={`Next up: ${hero.title}`}>
<div class="hero-body">
<div class="hero-date">
<span class="hero-weekday">{weekday(hero.starts_at)}</span>
<span class="hero-day">{dayNum(hero.starts_at)}</span>
<span class="hero-month">{monthShort(hero.starts_at)}</span>
</div>
<div class="hero-detail">
<h2 class="hero-title">{hero.title}</h2>
<p class="hero-desc">{hero.description}</p>
<p class="hero-meta">{hero.location}{hero.location && ' · '}{timeStr(hero.starts_at)}{hero.duration_label ? ` · ${hero.duration_label}` : ''}</p>
</div>
</div>
<footer class="hero-foot">
<div class="hero-foot-left">
<span class="hero-foot-stat">
{hero.capacity ? `${hero.capacity} seats · ` : ''}{heroConfirmedCount} confirmed
</span>
{heroAttendees.length > 0 && (
<AvatarPile users={heroAttendees} max={5} size={22} borderColor="var(--ink)" />
)}
</div>
<form method="POST" class="hero-foot-right">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={hero.slug} />
{heroMyRsvp === 'yes' ? (
<>
<span class="hero-confirmed">You're confirmed ✓</span>
<button type="submit" name="status" value="no" class="hero-change">Change</button>
</>
) : (
<button type="submit" name="status" value="yes" class="hero-cta">Save your seat →</button>
)}
</form>
</footer>
</article>
) : (
<article class="hero hero--empty">
<p class="hero-empty-line">
Nothing scheduled yet — when we have something, you'll be the first to know.
</p>
</article>
)}
<!-- ── Also coming up ──────────────────────────────────────── -->
{alsoUpcoming.length > 0 && (
<section class="also">
<ul class="also-list">
{alsoUpcoming.map(ev => (
<li class="also-row">
<div class="also-date">
<span class="also-day">{dayNum(ev.starts_at)}</span>
<span class="also-month">{monthShort(ev.starts_at)}</span>
</div>
<div class="also-body">
<h3 class="also-title">{ev.title}</h3>
<p class="also-meta">
{[ev.duration_label, ev.audience, ev.location].filter(Boolean).join(' · ')
|| eventKindLabel(ev.kind)}
</p>
</div>
<form method="POST" class="also-action-form">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={ev.slug} />
<button type="submit" name="status" value="yes" class="also-action">
{ev.action_label ?? defaultActionLabel(ev.kind)}
</button>
</form>
</li>
))}
</ul>
</section>
)}
<!-- ── Past gatherings ─────────────────────────────────────── -->
{past.length > 0 && (
<section class="past">
<ul class="past-grid">
{past.map(ev => {
const monthCode = monthShort(ev.starts_at);
const attended = getEventRsvpCount(ev.slug).going;
const hasNotes = !!ev.notes_url;
const pigA = pigmentForId(ev.id);
const pigB = pigmentForId(ev.id + 1);
return (
<li class="past-card">
{ev.photo_url ? (
<img class="past-thumb" src={ev.photo_url} alt="" loading="lazy" />
) : hasNotes ? (
<a href={ev.notes_url!} class="past-thumb past-thumb--notes" aria-label="Read the notes">
<span class="past-thumb-month">{monthCode}</span>
</a>
) : (
<div
class="past-thumb past-thumb--gradient"
style={`background: linear-gradient(135deg, ${pigA.hex}, ${pigB.hex});`}
aria-hidden="true"
>
<span class="past-thumb-month">{monthCode}</span>
</div>
)}
<div class="past-text">
<h3 class="past-title">{ev.title}</h3>
<p class="past-meta">{fmt({ day: 'numeric', month: 'long', year: 'numeric' }, ev.starts_at)}{ev.location && ` · ${ev.location}`}</p>
<p class="past-foot label-sm">
{attended} attended · {hasNotes ? 'Notes shared' : 'No notes'}
</p>
</div>
</li>
);
})}
</ul>
<a href="/events/past" class="section-link">View all past gatherings →</a>
</section>
)}
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-10);
}
/* ── Head ─────────────────────────────────────────────────────── */
.head { max-width: 46rem; }
.head-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-3);
}
.head-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: var(--text-display-md);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--on-surface);
margin: 0;
}
.head-sub {
color: var(--on-surface-variant);
margin-top: var(--space-3);
max-width: 32rem;
}
/* ── Hero ─────────────────────────────────────────────────────── */
.hero {
background: var(--ink);
color: var(--ink-text);
border-radius: var(--radius-lg);
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.hero-top {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--ink-muted);
}
.hero-eyebrow {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.hero-body {
display: grid;
grid-template-columns: 100px 1fr;
gap: var(--space-6);
padding: var(--space-4) 0;
border-left: 0 solid transparent;
position: relative;
}
.hero-body::after {
content: '';
position: absolute;
left: 100px;
top: 0; bottom: 0;
width: 0.5px;
background: rgba(232, 224, 208, 0.2);
}
.hero-date { display: flex; flex-direction: column; gap: 2px; }
.hero-weekday, .hero-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--ink-text);
}
.hero-day {
font-family: var(--font-serif);
font-size: 2.75rem;
line-height: 1;
color: var(--ink-text);
}
.hero-detail { padding-left: var(--space-5); }
.hero-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.75rem;
line-height: 1.2;
color: var(--ink-text);
margin: 0 0 var(--space-3);
}
.hero-desc {
color: rgba(232, 224, 208, 0.85);
margin: 0 0 var(--space-3);
max-width: 40rem;
}
.hero-meta {
color: var(--ink-muted);
font-size: var(--text-body-sm);
margin: 0;
}
.hero-foot {
border-top: 0.5px solid rgba(232, 224, 208, 0.2);
padding-top: var(--space-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
.hero-foot-stat {
color: var(--ink-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
.hero-cta {
background: var(--ink-text);
color: var(--ink);
border: none;
padding: 10px 20px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.hero-cta:hover { opacity: 0.85; }
.hero-confirmed {
color: var(--ink-text);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 10px 16px;
border: 0.5px solid rgba(232, 224, 208, 0.4);
border-radius: 999px;
}
.hero-change {
background: transparent;
border: none;
color: var(--ink-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
text-decoration: underline;
}
.hero--empty {
align-items: stretch;
justify-content: center;
text-align: center;
min-height: 200px;
display: flex;
}
.hero-empty-line {
color: var(--ink-text);
font-family: var(--font-serif);
font-size: 1.25rem;
margin: auto;
max-width: 32rem;
}
/* ── Section eyebrow shared ──────────────────────────────────── */
.section-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-4);
}
/* ── Also coming up ──────────────────────────────────────────── */
.also-list { list-style: none; padding: 0; margin: 0; }
.also-row {
display: grid;
grid-template-columns: 70px 1fr 110px;
gap: var(--space-4);
align-items: center;
padding: var(--space-4) 0;
border-bottom: 0.5px solid var(--surface-card-border);
}
.also-row:last-child { border-bottom: none; }
.also-date {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.also-day {
font-family: var(--font-serif);
font-size: 1.5rem;
line-height: 1;
color: var(--on-surface);
}
.also-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin-top: 4px;
}
.also-body { display: flex; flex-direction: column; gap: 4px; }
.also-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.0625rem;
color: var(--on-surface);
margin: 0;
}
.also-meta { font-size: 0.75rem; color: var(--on-surface-variant); margin: 0; }
.also-action-form { justify-self: end; }
.also-action {
background: none;
border: none;
color: var(--pigment-terracotta);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
padding: 0;
}
.also-action:hover { opacity: 0.85; }
/* ── Past gatherings ─────────────────────────────────────────── */
.past-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-3);
}
.past-all {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
}
.past-all:hover { opacity: 0.85; border-bottom: none; }
.past-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5);
}
.past-card {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-4);
align-items: start;
}
.past-thumb {
width: 56px;
height: 56px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
text-decoration: none;
border: none;
}
.past-thumb--notes {
background: color-mix(in oklab, var(--pigment-copper) 18%, transparent);
color: var(--pigment-copper);
}
.past-thumb-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
font-weight: 600;
color: rgba(250, 246, 238, 0.6);
}
.past-thumb--notes .past-thumb-month { color: var(--pigment-copper); }
.past-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.past-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.0625rem;
color: var(--on-surface);
margin: 0;
}
.past-meta { font-size: 0.75rem; color: var(--on-surface-variant); margin: 0; }
.past-foot {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
margin: 0;
}
@media (max-width: 720px) {
.hero-body { grid-template-columns: 1fr; }
.hero-body::after { display: none; }
.past-grid { grid-template-columns: 1fr; }
.also-row { grid-template-columns: 60px 1fr; }
.also-action-form { grid-column: 1 / -1; justify-self: start; padding-top: var(--space-2); }
}
</style>

View file

@ -1,159 +0,0 @@
---
import AppLayout from '../../layouts/AppLayout.astro';
import { getPastEvents, getEventRsvpCount } from '../../lib/db';
import { pigmentForId } from '../../lib/format';
const user = Astro.locals.user;
const past = getPastEvents(500);
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
}
---
<AppLayout title="Past gatherings" user={user}>
<div class="page">
<header class="head">
<h1 class="head-title">The archive.</h1>
<p class="head-sub">Everything the council has gathered around so far.</p>
<a href="/events" class="section-link back-link">← Back to upcoming</a>
</header>
{past.length === 0 ? (
<p class="body-md empty">No past events yet.</p>
) : (
<ul class="past-list">
{past.map(ev => {
const monthCode = fmt({ month: 'short' }, ev.starts_at).toUpperCase();
const attended = getEventRsvpCount(ev.slug).going;
const hasNotes = !!ev.notes_url;
const pigA = pigmentForId(ev.id);
const pigB = pigmentForId(ev.id + 1);
return (
<li class="past-card">
{ev.photo_url ? (
<img class="past-thumb" src={ev.photo_url} alt="" loading="lazy" />
) : hasNotes ? (
<a href={ev.notes_url!} class="past-thumb past-thumb--notes" aria-label="Read the notes">
<span class="past-thumb-month">{monthCode}</span>
</a>
) : (
<div
class="past-thumb past-thumb--gradient"
style={`background: linear-gradient(135deg, ${pigA.hex}, ${pigB.hex});`}
aria-hidden="true"
>
<span class="past-thumb-month">{monthCode}</span>
</div>
)}
<div class="past-text">
<h3 class="past-title">{ev.title}</h3>
<p class="past-meta">
{fmt({ day: 'numeric', month: 'long', year: 'numeric' }, ev.starts_at)}
{ev.location && ` · ${ev.location}`}
</p>
<p class="past-foot label-sm">
{attended} attended · {hasNotes ? 'Notes shared' : 'No notes'}
</p>
</div>
</li>
);
})}
</ul>
)}
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.head { max-width: 46rem; }
.head-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-3);
}
.head-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: var(--text-display-md);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--on-surface);
margin: 0;
}
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
.back-link { margin-top: var(--space-4); }
.empty { color: var(--on-surface-muted); }
.past-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5);
}
.past-card {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-4);
align-items: start;
}
.past-thumb {
width: 56px;
height: 56px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
text-decoration: none;
border: none;
}
.past-thumb--notes {
background: color-mix(in oklab, var(--pigment-copper) 18%, transparent);
color: var(--pigment-copper);
}
.past-thumb-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
font-weight: 600;
color: rgba(250, 246, 238, 0.6);
}
.past-thumb--notes .past-thumb-month { color: var(--pigment-copper); }
.past-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.past-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.0625rem;
color: var(--on-surface);
margin: 0;
}
.past-meta { font-size: 0.75rem; color: var(--on-surface-variant); margin: 0; }
.past-foot {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
margin: 0;
}
@media (max-width: 720px) {
.past-list { grid-template-columns: 1fr; }
}
</style>

View file

@ -19,7 +19,8 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
<div class="page">
<header class="members-head">
<h1 class="members-title">The council.</h1>
<p class="label-sm members-eyebrow">Members</p>
<h1 class="members-title"><em>The council.</em></h1>
<p class="members-sub">
An invited circle of operators shaping what Project Bifrost becomes.
</p>
@ -81,6 +82,13 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
.members-head { margin-bottom: var(--space-6); max-width: 46rem; }
.members-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-3);
}
.members-title {
font-family: var(--font-serif);
font-weight: 400;
@ -90,6 +98,7 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
color: var(--on-surface);
margin: 0;
}
.members-title em { font-style: italic; }
.members-sub {
color: var(--on-surface-variant);
@ -131,6 +140,7 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
.m-name {
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
font-size: 1.375rem;
line-height: 1.2;
@ -164,7 +174,8 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
padding-left: 12px;
border-left: 2px solid color-mix(in oklab, var(--row-pigment) 40%, transparent);
font-family: var(--font-serif);
font-size: 0.9375rem;
font-style: italic;
font-size: 0.875rem;
line-height: 1.5;
color: var(--on-surface);
max-width: 38rem;

View file

@ -1,32 +1,31 @@
---
import AppLayout from '../layouts/AppLayout.astro';
import Avatar from '../components/Avatar.astro';
import AvatarPile from '../components/AvatarPile.astro';
import MembershipCard from '../components/MembershipCard.astro';
import DispatchesSection from '../components/DispatchesSection.astro';
import RecentlyFromTheCouncil from '../components/RecentlyFromTheCouncil.astro';
import {
getUpcomingEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity,
getAllRoadmapItems, getLatestPublishedDispatches, getAllCabMembers,
getOpenPulse, getPulseWithCounts, castVote, recordActivity,
getPulseById, getAllRoadmapItems, getUpcomingEvents,
countCabMembers, getUserVote,
} from '../lib/db';
import {
pulseDateLabel, timeOfDay, tenureSince, relativeTime,
eventKindLabel,
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
} from '../lib/format';
import { pulseDateLabel, timeOfDay, tenureSince, voteCountSentence } from '../lib/format';
const user = Astro.locals.user;
// ── POST: RSVP from the hero card ──────────────────────────────────
// ── POST: cast vote ────────────────────────────────────────────────
if (Astro.request.method === 'POST') {
const data = await Astro.request.formData();
const action = String(data.get('action') ?? '');
if (action === 'rsvp') {
const slug = String(data.get('event_slug') ?? '');
const status = String(data.get('status') ?? '') as 'yes' | 'no' | 'interested';
if (slug && ['yes', 'no', 'interested'].includes(status)) {
const ev = getEventBySlug(slug);
if (ev) {
setEventRsvp(user.id, slug, status);
recordActivity(user.id, 'rsvped', 'event', ev.id);
if (action === 'vote') {
const pulseId = Number(data.get('pulse_id'));
const optionIndex = Number(data.get('option_index'));
const target = getPulseById(pulseId);
if (target && target.status === 'open' && Number.isInteger(optionIndex)
&& optionIndex >= 0 && optionIndex < target.options.length) {
const existing = getUserVote(pulseId, user.id);
if (existing === null) {
castVote(pulseId, user.id, optionIndex);
recordActivity(user.id, 'voted', 'pulse', pulseId);
}
}
return Astro.redirect('/pulse');
@ -36,39 +35,40 @@ if (Astro.request.method === 'POST') {
// ── Greeting ───────────────────────────────────────────────────────
const firstName = user.name.split(' ')[0];
const greeting = `Good ${timeOfDay()}, ${firstName}.`;
// (date label dropped per the v3 eyebrow-removal pass; tenure line stays inline)
const dateLabel = pulseDateLabel();
const tenureAnchor = user.role === 'cab' && user.cab_joined_date
? user.cab_joined_date
: user.created_at;
const tenure = tenureSince(tenureAnchor);
// ── Events ─────────────────────────────────────────────────────────
const upcoming = getUpcomingEvents(20);
const hero = upcoming.find(e => e.kind !== 'office_hours') ?? upcoming[0] ?? null;
const comingUp = upcoming.filter(e => e.id !== hero?.id).slice(0, 4);
// ── This week's Pulse ──────────────────────────────────────────────
const openPulseRaw = getOpenPulse();
const totalMembers = countCabMembers();
const openPulse = openPulseRaw ? getPulseWithCounts(openPulseRaw.id, user.id) : null;
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
// Time-left label: "32 seconds" / "3 hours" / "2 days" — soft countdown
function timeLeftLabel(closesAt: string): string {
const ms = new Date(closesAt).getTime() - Date.now();
if (ms <= 0) return 'closing now';
const d = Math.floor(ms / 86400000);
if (d >= 1) return `${d} day${d === 1 ? '' : 's'}`;
const h = Math.floor(ms / 3600000);
if (h >= 1) return `${h} hour${h === 1 ? '' : 's'}`;
const m = Math.floor(ms / 60000);
if (m >= 1) return `${m} minute${m === 1 ? '' : 's'}`;
const s = Math.floor(ms / 1000);
return `${s} seconds`;
}
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
function closeDayLabel(closesAt: string): string {
const d = new Date(closesAt);
return new Intl.DateTimeFormat('en-GB', {
weekday: 'long', timeZone: 'Europe/Copenhagen',
}).format(d);
}
const dayNum = (iso: string) => fmt({ day: 'numeric' }, iso);
const weekday = (iso: string) => fmt({ weekday: 'short' }, iso).toUpperCase();
const monthShort = (iso: string) => fmt({ month: 'short' }, iso).toUpperCase();
const timeStr = (iso: string) => fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso);
const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
const heroConfirmedCount = heroAttendees.length;
const heroMyRsvp = hero ? getUserRsvp(user.id, hero.slug) : null;
// ── Latest from Fenja ──────────────────────────────────────────────
const [latestDispatch] = getLatestPublishedDispatches(1);
const latestPreview = latestDispatch ? dispatchLongPreview(latestDispatch, 520) : '';
// ── Roadmap preview (3 most-recently-updated items, horizontal) ────
// ── Roadmap preview (3 most-recently-updated items) ────────────────
const roadmapPreview = getAllRoadmapItems()
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1))
.slice(0, 3);
@ -80,7 +80,7 @@ function roadmapStatusDot(status: 'shipping' | 'beta' | 'exploring'): string {
exploring: 'var(--on-surface-muted)',
})[status];
}
function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; target: string | null }): string {
function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; target: string | null; attributed: unknown[] }): string {
const target = item.target ? ` · ${item.target}` : '';
switch (item.status) {
case 'shipping': return `Shipping${target}`;
@ -89,151 +89,149 @@ function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; t
}
}
// ── Council members ─────────────────────────────────────────────────
const members = getAllCabMembers();
// ── Events row ─────────────────────────────────────────────────────
const upcoming = getUpcomingEvents(20);
const nextExclusive = upcoming.find(e => e.kind === 'dinner' || e.kind === 'summit') ?? null;
const nextOfficeHours = upcoming.find(e => e.kind === 'office_hours') ?? null;
function formatEventDate(iso: string): string {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen',
}).format(new Date(iso)).toUpperCase();
}
---
<AppLayout title="Pulse" user={user}>
<div class="page">
<!-- ── Greeting ─────────────────────────────────────────────── -->
<section class="cascade greeting">
<h1 class="greeting-line">{greeting}</h1>
<p class="label-sm date-label">{dateLabel}</p>
<h1 class="greeting-line">
<span class="greeting-italic">{greeting}</span>
</h1>
<p class="greeting-sub body-md">
You've been a member for {tenure}. The team is reading every note you leave.
You've been a member for <em>{tenure}</em>. The team is reading every note you leave.
</p>
</section>
<!-- ── Events (--ink card with hero + bundled coming-up + see all) -->
{hero ? (
<section class="cascade events-card" aria-label="Events">
<!-- Hero -->
<div class="hero-body">
<div class="hero-date">
<span class="hero-weekday">{weekday(hero.starts_at)}</span>
<span class="hero-day">{dayNum(hero.starts_at)}</span>
<span class="hero-month">{monthShort(hero.starts_at)}</span>
</div>
<div class="hero-detail">
<h2 class="hero-title">{hero.title}</h2>
<p class="hero-desc">{hero.description}</p>
<p class="hero-meta">
{hero.location}{hero.location && ' · '}{timeStr(hero.starts_at)}{hero.duration_label ? ` · ${hero.duration_label}` : ''}
</p>
</div>
</div>
<footer class="hero-foot">
<div class="hero-foot-left">
<span class="hero-foot-stat">
{hero.capacity ? `${hero.capacity} seats · ` : ''}{heroConfirmedCount} confirmed
</span>
{heroAttendees.length > 0 && (
<AvatarPile users={heroAttendees} max={5} size={22} borderColor="var(--ink)" />
)}
</div>
<form method="POST" class="hero-foot-right">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={hero.slug} />
{heroMyRsvp === 'yes' ? (
<!-- ── This week's Pulse ────────────────────────────────────── -->
<section class="cascade pulse-card">
{openPulse ? (
<>
<span class="hero-confirmed">You're confirmed ✓</span>
<button type="submit" name="status" value="no" class="hero-change">Change</button>
<div class="pulse-meta">
<span class="live-dot" aria-hidden="true"></span>
<span class="label-sm pulse-label">This week's pulse · closes in {timeLeftLabel(openPulse.closes_at)}</span>
</div>
<p class="pulse-question">{openPulse.question}</p>
{openPulse.context && <p class="pulse-context body-md">{openPulse.context}</p>}
<form method="POST" class="pulse-options" novalidate>
<input type="hidden" name="action" value="vote" />
<input type="hidden" name="pulse_id" value={openPulse.id} />
{openPulse.options.map((opt, i) => {
const chosen = openPulse.my_vote === i;
const count = openPulse.votes_by_option[i] ?? 0;
const pct = openPulse.votes_total > 0 ? (count / openPulse.votes_total) * 100 : 0;
const locked = openPulse.my_vote !== null;
const letter = String.fromCharCode(65 + i); // A/B/C/D
return (
<button
type="submit"
name="option_index"
value={i}
class:list={['pulse-option', { chosen, locked }]}
disabled={locked && !chosen}
aria-pressed={chosen}
>
<span class="pulse-option-letter label-sm">{letter}</span>
<span class="pulse-option-text">{opt}</span>
{locked && (
<span class="pulse-option-bar" aria-hidden="true">
<span class="pulse-option-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
</span>
)}
</button>
);
})}
</form>
<p class="pulse-count body-sm">
{voteCountSentence(openPulse.votes_total, totalMembers)} Closes {closeDayLabel(openPulse.closes_at)}.
</p>
</>
) : (
<button type="submit" name="status" value="yes" class="hero-cta">Save your seat →</button>
)}
</form>
</footer>
<!-- Bundled coming-up sub-cards (no RSVP buttons) -->
{comingUp.length > 0 && (
<ul class="coming-up-grid">
{comingUp.map(ev => (
<li class="coming-up-card">
<div class="cu-date">
<span class="cu-day">{dayNum(ev.starts_at)}</span>
<span class="cu-month">{monthShort(ev.starts_at)}</span>
<div class="pulse-empty">
<span class="label-sm pulse-label-muted">This week's pulse</span>
<p class="pulse-empty-line">No pulse is open right now. The next one drops soon.</p>
</div>
<div class="cu-body">
<h3 class="cu-title">{ev.title}</h3>
<p class="cu-meta">{[ev.duration_label, ev.audience, eventKindLabel(ev.kind)].filter(Boolean).join(' · ')}</p>
</div>
</li>
))}
</ul>
)}
<a href="/events" class="section-link section-link--ink hero-see-all">See all events →</a>
</section>
<!-- ── Roadmap preview + Council mark ───────────────────────── -->
<section class="cascade preview-row">
<div class="roadmap-preview">
<p class="label-sm section-eyebrow">From the roadmap</p>
{roadmapPreview.length === 0 ? (
<p class="body-sm muted">No roadmap items yet.</p>
) : (
<section class="cascade events-card events-card--empty">
<p class="events-empty-line">Nothing scheduled yet — when we have something, you'll be the first to know.</p>
<a href="/events" class="section-link section-link--ink">See all events →</a>
</section>
)}
<!-- ── Latest from Fenja (unboxed, longer excerpt) ──────────── -->
{latestDispatch && (
<article class="cascade latest-article">
<header class="latest-byline">
<Avatar id={latestDispatch.author_id} name={latestDispatch.author_name} size={28} />
<span class="latest-byline-name">{latestDispatch.author_name}</span>
{latestDispatch.author_title && <span class="latest-byline-title">· {latestDispatch.author_title}</span>}
<span class="latest-byline-time">{relativeTime(latestDispatch.published_at ?? latestDispatch.created_at)}</span>
<span class="latest-kind-pill" style={`--pill: ${dispatchKindPigment(latestDispatch.kind)}`}>
{dispatchKindLabel(latestDispatch.kind)}
</span>
</header>
<h2 class="latest-title">{latestDispatch.title}</h2>
<p class="latest-body">{latestPreview}</p>
<a href={`/dispatches/${dispatchSlug(latestDispatch)}`} class="section-link">Read the full dispatch →</a>
</article>
)}
<!-- ── Roadmap — horizontal cards ───────────────────────────── -->
{roadmapPreview.length > 0 && (
<section class="cascade roadmap-section" aria-label="From the roadmap">
<ul class="roadmap-grid">
<ul class="roadmap-list">
{roadmapPreview.map(item => (
<li class="roadmap-card">
<li class="roadmap-row">
<span
class:list={['status-dot', { breathing: item.status === 'shipping' }]}
style={`background:${roadmapStatusDot(item.status)}`}
aria-hidden="true"
></span>
<div class="roadmap-card-text">
<h3 class="roadmap-card-title">{item.title}</h3>
<p class="roadmap-card-blurb">{roadmapStatusBlurb(item)}</p>
<div class="roadmap-row-text">
<p class="roadmap-row-title">{item.title}</p>
<p class="roadmap-row-blurb label-sm">{roadmapStatusBlurb(item)}</p>
</div>
</li>
))}
</ul>
<a href="/roadmap" class="section-link">See the full roadmap →</a>
</section>
)}
<!-- ── Council members — larger cards with company ──────────── -->
{members.length > 0 && (
<section class="cascade council-section" aria-label="The council">
<ul class="council-grid">
{members.map(m => (
<li class="council-card">
<Avatar id={m.id} name={m.name} size={56} />
<div class="council-card-text">
<span class="council-card-name">{m.name}</span>
{m.title && <span class="council-card-title">{m.title}</span>}
<span class="council-card-org">{m.organisation}</span>
<a href="/roadmap" class="see-all label-sm">See the full roadmap →</a>
</div>
</li>
))}
</ul>
<a href="/members" class="section-link">See who our council is made up of →</a>
<aside class="membership-slot">
<MembershipCard member={user} />
</aside>
</section>
<!-- ── Dispatches ───────────────────────────────────────────── -->
<section class="cascade">
<DispatchesSection limit={4} />
</section>
<!-- ── Recently from the council ────────────────────────────── -->
<section class="cascade">
<RecentlyFromTheCouncil />
</section>
<!-- ── Event row ────────────────────────────────────────────── -->
{(nextExclusive || nextOfficeHours) && (
<section class="cascade event-row">
{nextExclusive && (
<article class="event-card event-card--dark">
<p class="label-sm event-eyebrow event-eyebrow--light">
Members only · {formatEventDate(nextExclusive.starts_at)}
</p>
<h3 class="event-title">{nextExclusive.title}</h3>
<p class="event-desc">{nextExclusive.description}</p>
{nextExclusive.capacity && (
<p class="event-scarcity label-sm">{nextExclusive.capacity} seats · invitation by hand</p>
)}
</article>
)}
{nextOfficeHours && (
<article class="event-card event-card--light">
<p class="label-sm event-eyebrow">
Office hours · {formatEventDate(nextOfficeHours.starts_at)}
</p>
<h3 class="event-title">{nextOfficeHours.title}</h3>
<p class="event-desc">{nextOfficeHours.description}</p>
</article>
)}
</section>
)}
@ -261,13 +259,23 @@ const members = getAllCabMembers();
.cascade:nth-child(3) { animation-delay: 200ms; }
.cascade:nth-child(4) { animation-delay: 300ms; }
.cascade:nth-child(5) { animation-delay: 400ms; }
@keyframes cascade-in { to { opacity: 1; transform: translateY(0); } }
.cascade:nth-child(6) { animation-delay: 500ms; }
@keyframes cascade-in {
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.cascade { opacity: 1; transform: none; animation: none; }
}
/* ── Greeting ─────────────────────────────────────────────────── */
.greeting { display: flex; flex-direction: column; gap: var(--space-3); }
.date-label {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.greeting-line {
font-family: var(--font-serif);
font-weight: 400;
@ -277,347 +285,288 @@ const members = getAllCabMembers();
color: var(--on-surface);
margin: 0;
}
.greeting-italic { font-style: italic; }
.greeting-sub {
color: var(--on-surface-variant);
max-width: 48rem;
margin: 0;
}
.greeting-sub em { font-style: italic; color: var(--on-surface); }
/* ── Events card (--ink) ──────────────────────────────────────── */
.events-card {
background: var(--ink);
color: var(--ink-text);
/* ── Pulse card ───────────────────────────────────────────────── */
.pulse-card {
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
padding: var(--space-7) var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.events-card--empty {
align-items: flex-start;
text-align: left;
min-height: 160px;
justify-content: space-between;
}
.events-empty-line {
color: var(--ink-text);
font-family: var(--font-serif);
font-size: 1.25rem;
margin: 0;
max-width: 32rem;
opacity: 0.92;
gap: var(--space-4);
}
/* Hero (lighter, fewer italics) */
.hero-body {
display: grid;
grid-template-columns: 110px 1fr;
gap: var(--space-7);
position: relative;
.pulse-meta {
display: flex;
align-items: center;
gap: var(--space-3);
}
.hero-body::after {
content: '';
position: absolute;
left: 110px;
top: 0; bottom: 0;
width: 0.5px;
background: rgba(232, 224, 208, 0.18);
.live-dot {
width: 8px;
height: 8px;
background: var(--pigment-terracotta);
border-radius: 50%;
animation: breathe 2.4s ease-in-out infinite;
}
.hero-date { display: flex; flex-direction: column; gap: 4px; }
.hero-weekday, .hero-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.5; }
}
.pulse-label {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: rgba(232, 224, 208, 0.75);
color: var(--on-surface-variant);
font-weight: 500;
}
.hero-day {
.pulse-label-muted {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.pulse-question {
font-family: var(--font-serif);
font-weight: 400;
font-size: 2.75rem;
line-height: 1;
color: var(--ink-text);
}
.hero-detail { padding-left: var(--space-6); }
.hero-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.75rem;
line-height: 1.2;
color: var(--ink-text);
margin: 0 0 var(--space-3);
}
.hero-desc {
color: rgba(232, 224, 208, 0.92);
margin: 0 0 var(--space-3);
font-style: italic;
font-size: 1.375rem;
line-height: var(--leading-snug);
color: var(--on-surface);
margin: 0;
max-width: 50rem;
}
.hero-meta {
color: rgba(232, 224, 208, 0.7);
font-size: var(--text-body-sm);
.pulse-context {
color: var(--on-surface-variant);
margin: 0;
max-width: 50rem;
}
/* Hero foot */
.hero-foot {
border-top: 0.5px solid rgba(232, 224, 208, 0.18);
padding-top: var(--space-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
.hero-foot-stat {
color: rgba(232, 224, 208, 0.7);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
.hero-cta {
background: var(--ink-text);
color: var(--ink);
border: none;
padding: 10px 20px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.hero-cta:hover { opacity: 0.85; }
.hero-confirmed {
color: var(--ink-text);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 10px 16px;
border: 0.5px solid rgba(232, 224, 208, 0.4);
border-radius: 999px;
}
.hero-change {
background: transparent;
border: none;
color: rgba(232, 224, 208, 0.75);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
text-decoration: underline;
}
/* Bundled coming-up sub-cards (no RSVP buttons) */
.coming-up-grid {
list-style: none;
padding: 0;
margin: 0;
.pulse-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
margin-top: var(--space-2);
}
.coming-up-card {
background: rgba(232, 224, 208, 0.06);
border: 0.5px solid rgba(232, 224, 208, 0.14);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5);
.pulse-option {
position: relative;
display: flex;
gap: var(--space-4);
align-items: flex-start;
}
.cu-date { display: flex; flex-direction: column; gap: 2px; min-width: 36px; }
.cu-day {
font-family: var(--font-serif);
font-size: 1.5rem;
line-height: 1;
color: var(--ink-text);
}
.cu-month {
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: var(--background);
border: var(--ghost-border);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: rgba(232, 224, 208, 0.7);
font-size: var(--text-body-md);
color: var(--on-surface);
text-align: left;
cursor: pointer;
transition: transform 300ms var(--ease-standard),
border-color 300ms var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
overflow: hidden;
}
.cu-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.cu-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1rem;
line-height: 1.25;
color: var(--ink-text);
margin: 0;
.pulse-option:hover:not(.locked) {
transform: translateY(-2px);
border-color: var(--outline);
}
.cu-meta {
font-size: 0.75rem;
color: rgba(232, 224, 208, 0.65);
margin: 0;
.pulse-option.chosen {
border-color: var(--pigment-terracotta);
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
}
.pulse-option.locked:not(.chosen) {
cursor: default;
color: var(--on-surface-variant);
}
.pulse-option:disabled { opacity: 0.8; }
.pulse-option-letter {
font-weight: 600;
color: var(--on-surface-muted);
flex-shrink: 0;
}
.pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); }
.pulse-option-text { flex: 1; }
.pulse-option-bar {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: var(--surface-container);
}
.pulse-option-bar-fill {
display: block;
height: 100%;
background: var(--pigment-terracotta);
opacity: 0.6;
transition: width 600ms var(--ease-standard);
}
.hero-see-all { align-self: flex-start; }
.pulse-count {
color: var(--on-surface-variant);
margin: 0;
}
.pulse-count strong { color: var(--on-surface); font-weight: 600; }
/* ── Latest from Fenja (unboxed) ──────────────────────────────── */
.latest-article {
.pulse-empty {
display: flex;
flex-direction: column;
gap: var(--space-3);
max-width: 56rem;
}
.latest-byline {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
font-size: var(--text-body-sm);
}
.latest-byline-name { font-weight: 600; color: var(--on-surface); }
.latest-byline-title { color: var(--on-surface-variant); }
.latest-byline-time {
color: var(--on-surface-muted);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
margin-left: auto;
}
.latest-kind-pill {
background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill);
padding: 2px 9px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.latest-title {
.pulse-empty-line {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.625rem;
line-height: 1.25;
color: var(--on-surface);
font-style: italic;
font-size: 1.25rem;
color: var(--on-surface-variant);
margin: 0;
letter-spacing: var(--tracking-snug);
}
.latest-body {
color: var(--on-surface);
line-height: var(--leading-relaxed);
margin: 0;
font-size: var(--text-body-lg);
}
/* ── Roadmap horizontal cards ─────────────────────────────────── */
.roadmap-section {
/* ── Roadmap preview + Membership card ──────────────────────── */
.preview-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-6);
align-items: stretch;
}
.section-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-4);
}
.roadmap-preview {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.roadmap-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
.roadmap-card {
gap: var(--space-3);
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
padding: var(--space-5);
border-radius: var(--radius-lg);
padding: var(--space-6);
}
.membership-slot { display: flex; }
.membership-slot > * { flex: 1; }
.roadmap-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
min-height: 130px;
gap: 0;
}
.roadmap-row {
display: flex;
align-items: flex-start;
gap: var(--space-4);
padding: var(--space-4) 0;
border-top: var(--ghost-border);
}
.roadmap-row:last-child { border-bottom: var(--ghost-border); }
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.5; }
margin-top: 0.4em;
}
.status-dot.breathing { animation: breathe 2.4s ease-in-out infinite; }
.roadmap-card-text { display: flex; flex-direction: column; gap: var(--space-1); }
.roadmap-card-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.0625rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.roadmap-card-blurb {
.roadmap-row-text { flex: 1; display: flex; flex-direction: column; gap: var(--space-1); }
.roadmap-row-title { margin: 0; font-weight: 500; color: var(--on-surface); }
.roadmap-row-blurb {
color: var(--on-surface-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
margin: 0;
}
/* ── Council cards — larger, with company ─────────────────────── */
.council-section {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.council-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-4);
}
.council-card {
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
padding: var(--space-6) var(--space-6) var(--space-7);
display: flex;
align-items: center;
gap: var(--space-5);
}
.council-card-text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.council-card-name {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.125rem;
line-height: 1.2;
color: var(--on-surface);
}
.council-card-title {
font-family: var(--font-sans);
font-size: var(--text-body-sm);
.see-all {
color: var(--on-surface-variant);
}
.council-card-org {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
color: var(--on-surface-muted);
text-transform: uppercase;
margin-top: var(--space-3);
align-self: flex-start;
transition: color var(--duration-fast) var(--ease-standard);
}
.see-all:hover { color: var(--on-surface); border-bottom: none; }
/* ── Events row ──────────────────────────────────────────────── */
.event-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
}
/* ── Responsive ───────────────────────────────────────────────── */
.event-card {
padding: var(--space-8);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: var(--space-3);
transition: transform 300ms var(--ease-standard);
}
.event-card:hover { transform: translateY(-2px); }
.event-card--dark {
background: var(--ink);
color: var(--ink-text);
}
.event-card--dark .event-title,
.event-card--dark .event-desc,
.event-card--dark .event-scarcity {
color: var(--ink-text);
}
.event-card--light {
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
}
.event-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.event-eyebrow--light { color: var(--ink-muted); }
.event-title {
font-family: var(--font-serif);
font-size: 1.5rem;
line-height: var(--leading-snug);
margin: 0;
}
.event-desc { margin: 0; }
.event-scarcity {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
margin: 0;
}
/* ── Responsive: collapse 2-col rows on narrow widths ────────── */
@media (max-width: 880px) {
.roadmap-grid { grid-template-columns: 1fr; }
.hero-body { grid-template-columns: 1fr; }
.hero-body::after { display: none; }
.hero-detail { padding-left: 0; }
.preview-row, .event-row, .pulse-options { grid-template-columns: 1fr; }
}
</style>

View file

@ -52,8 +52,9 @@ const horizonColors: Record<string, string> = {
<div class="page">
<header class="page-header">
<p class="label-sm eyebrow">Roadmap</p>
<h1 class="display-md page-title">What we are building.</h1>
<p class="subtitle">
<p class="lead subtitle">
Three horizons. What is in progress now, what comes next,
and what is further out. This is the live picture.
</p>

View file

@ -232,31 +232,6 @@ a:hover {
.ghost-border { border: var(--ghost-border); }
.ghost-border-bottom { border-bottom: var(--ghost-border); }
/* --- Section link prominent italic serif, placed at the bottom of
its respective box or article. See points 8 + 10 in the v3 spec:
italics are reserved for links + the Bifrost wordmark. --- */
.section-link {
display: inline-block;
font-family: var(--font-serif);
font-style: italic;
font-size: var(--text-body-md);
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.section-link:hover {
border-bottom: none;
opacity: 0.78;
color: var(--pigment-terracotta);
}
.section-link--ink {
color: var(--ink-text);
}
.section-link--ink:hover {
color: var(--ink-text);
}
/* --- Focus ring --- */
:focus-visible {
outline: 2px solid var(--secondary);

View file

@ -129,6 +129,6 @@
--duration-slow: 420ms;
/* --- Layout --- */
--content-max: 83rem; /* 1328px — 15% wider than the original 72rem */
--content-max: 72rem; /* 1152px */
--reading-max: 42rem; /* 672px */
}

43
todo.md
View file

@ -1,43 +0,0 @@
# Todo
Deferred from the Phase 2 home-page simplification (commits after `ed2c272`).
## Recently from the council (the feed)
Removed from `/pulse` in this pass. Currently no surface for member-authored
contributions on the home page.
The underlying `contributions` table, the `RecentlyFromTheCouncil` component,
and the existing `/contribute` page all remain in place — only the home-page
embed is gone.
When we revisit this, options to consider:
- Inline a compact "What members are saying" row inside the
Roadmap + Latest from Fenja card — single quote, smallest possible
treatment, doesn't repeat dispatch real-estate.
- A dedicated Voices / Council notes section much further down the page
with a stronger editorial frame.
- Surface contributions only on the dedicated /contribute page (already
exists) and rely on dispatches as the primary "what's moving" signal.
## This week's pulse voting block
Removed from `/pulse` in this pass. The `pulses`, `votes`, vote-uniqueness
constraint, admin Pulses tab, and the activity `voted` kind all remain — only
the on-page voting card is gone.
**Idea worth trying:** rather than rebuild as its own block, fold polling
into the "Latest from Fenja" stream. A dispatch can be tagged as a poll
(one of the existing kinds, or a new `poll` kind) and rendered inline with
the 24 vote options + locked state + result bars. Single editorial channel
instead of two competing "what should I read first" surfaces on the home page.
If we go that way:
- Schema: extend `dispatches` with a nullable `pulse_id` foreign key (or
embed options on the dispatch directly).
- Render: when a dispatch is poll-shaped, its excerpt block becomes the
Pulse question + options; the "Read the full dispatch" affordance can
stay for context/comments.
- Admin: a checkbox on the dispatch form ("Attach an active poll") +
inline options inputs. No need for the standalone Pulses tab once this
lands — fold it in.