Compare commits

..

No commits in common. "65191256ec97f983536b50a9368a7a03e5537c48" and "66c3f6492f9bf4f3cb533007c566e8f17b4eaf83" have entirely different histories.

26 changed files with 754 additions and 3010 deletions

View file

@ -44,10 +44,7 @@
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches/1-we-are-deprioritising-public-cloud-parity-for-q3)", "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(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 -i)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)", "Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)"
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/roadmap)",
"Bash(grep -nE \"\\\\.rr-fade-left, \\\\.rr-fade-right|rr-fade-left \\\\{|rr-fade-right \\\\{\" src/components/RoadmapRoute.astro)",
"Bash(awk -F: '{print $1}')"
] ]
} }
} }

View file

@ -1,10 +0,0 @@
-- Polls are no longer a standalone entity in the UX: every poll is attached
-- to a dispatch. We keep the pulses + votes tables (vote uniqueness, status
-- derivation, admin history) and add a nullable FK from dispatches.
--
-- ON DELETE SET NULL — if an attached pulse is hard-deleted, the dispatch
-- survives without a poll rather than vanishing with it.
ALTER TABLE dispatches ADD COLUMN pulse_id INTEGER REFERENCES pulses(id) ON DELETE SET NULL;
CREATE INDEX idx_dispatches_pulse ON dispatches(pulse_id) WHERE pulse_id IS NOT NULL;

View file

@ -1,40 +0,0 @@
-- Roadmap status enum gains a fourth value `considering` for items that are
-- under discussion but not yet committed to. Same migration also renames
-- the existing `beta` value to `in_beta` so the canonical names line up
-- with the v4 spec (no second display label layer needed).
--
-- SQLite can't widen a CHECK constraint in place, so this is a full table
-- rebuild. roadmap_attributions has an ON DELETE CASCADE FK to
-- roadmap_items(id), so foreign keys are toggled off around the rebuild to
-- preserve attribution rows across the DROP/RENAME.
PRAGMA foreign_keys = OFF;
CREATE TABLE roadmap_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'exploring'
CHECK(status IN ('shipping','in_beta','exploring','considering')),
target TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
shipped_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO roadmap_items_new
(id, title, description, status, target, display_order, shipped_at, created_at, updated_at)
SELECT
id, title, description,
CASE status WHEN 'beta' THEN 'in_beta' ELSE status END,
target, display_order, shipped_at, created_at, updated_at
FROM roadmap_items;
DROP TABLE roadmap_items;
ALTER TABLE roadmap_items_new RENAME TO roadmap_items;
CREATE INDEX idx_roadmap_status ON roadmap_items(status, display_order);
CREATE INDEX idx_roadmap_shipped ON roadmap_items(shipped_at);
PRAGMA foreign_keys = ON;

View file

@ -1,6 +0,0 @@
-- Roadmap items gain an optional metadata_text field — a short admin-set
-- narrative cue shown in the route card's hover expansion. Free-form,
-- ~60 chars suggested in admin helper text. NULL when not set; UI hides
-- the line in that case.
ALTER TABLE roadmap_items ADD COLUMN metadata_text TEXT;

View file

@ -61,9 +61,6 @@ const newCabs = [
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' }, { name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' }, { name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' }, { name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
{ name: 'Mads Lindberg', email: 'mads@virk6.dk', org: 'Virksomhed 6' },
{ name: 'Camilla Storm', email: 'camilla@virk7.dk', org: 'Virksomhed 7' },
{ name: 'Frederik Lund', email: 'frederik@virk8.dk', org: 'Virksomhed 8' },
]; ];
const insertUser = db.prepare(` const insertUser = db.prepare(`
@ -78,15 +75,7 @@ for (const c of newCabs) {
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up. // 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. // 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 cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
const tenureWeeks = { const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
'lars@virk2.dk': 24,
'anna@virk3.dk': 14,
'soren@virk4.dk': 12,
'henriette@virk5.dk': 10,
'mads@virk6.dk': 8,
'camilla@virk7.dk': 6,
'frederik@virk8.dk': 3,
};
const setCabMeta = db.prepare(` const setCabMeta = db.prepare(`
UPDATE users UPDATE users
@ -119,21 +108,6 @@ const cabMeta = {
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.', pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
focus_tags: ['Legal', 'Policy', 'EU AI Act'], focus_tags: ['Legal', 'Policy', 'EU AI Act'],
}, },
'mads@virk6.dk': {
title: 'Chief Strategy Officer',
pull_quote: 'Healthcare runs on consent — and consent runs on trust.',
focus_tags: ['Healthcare', 'Consent', 'Governance'],
},
'camilla@virk7.dk': {
title: 'Head of Cyber Resilience',
pull_quote: 'Cyber resilience is not a feature — it is the substrate.',
focus_tags: ['Defence', 'Resilience'],
},
'frederik@virk8.dk': {
title: 'Director of Public Innovation',
pull_quote: 'Public innovation succeeds when it is measurably better, not just newer.',
focus_tags: ['Public sector', 'Measurement'],
},
}; };
for (const u of cabRows) { for (const u of cabRows) {
@ -169,17 +143,14 @@ const nowIso = (offsetSeconds = 0) => {
return d.toISOString().replace('T', ' ').slice(0, 19); return d.toISOString().replace('T', ' ').slice(0, 19);
}; };
// ── Poll attached to a dispatch (the decision) — open, 2/4 voted ── // ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
// Polls are no longer standalone; they attach to a dispatch via pulse_id.
// We create the pulse first, capture its id, and stamp it on the dispatch
// when we INSERT it further down.
const pulseOptions = [ const pulseOptions = [
'Locking down on-prem deployment first', 'Locking down on-prem deployment first',
'Pushing the traceability layer to GA', 'Pushing the traceability layer to GA',
'Going wide on document ingestion', 'Going wide on document ingestion',
'Building the agentic query loop', 'Building the agentic query loop',
]; ];
const decisionPulseId = db.prepare(` const pulseId = db.prepare(`
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by) INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
VALUES (?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?)
`).run( `).run(
@ -189,36 +160,27 @@ const decisionPulseId = db.prepare(`
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id, nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
).lastInsertRowid; ).lastInsertRowid;
// 2 votes — Lars and Anna // 2 votes from cabs[0] and cabs[1]
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(decisionPulseId, cabs[0].id, 1, nowIso(-2 * 3600)); .run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600));
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60)); .run(pulseId, cabs[1].id, 1, nowIso(-30 * 60));
// ── Roadmap: 9 items, status meaning 'currently live' rather than // ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
// 'shipping soon'. Items 1-2 are live in production; items 3-4 are in
// beta even if 'audit log export' has a near-term GA target. Travelled
// stop = (1 + 0.5) / 9 ≈ 0.17, putting the 'you are here' marker at
// the visible transition between travelled and ahead tones on the path.
const roadmap = [ const roadmap = [
{ title: 'Traceability layer', description: 'Every inference call writes a signed audit record. Shaped by Lars in our March session.', status: 'shipping', target: 'Live since March', display_order: 1, shipped_at: nowIso(-60 * 24 * 3600), attributed: [cabs[0].id], metadata_text: 'Shaped by Lars in our March session' }, { title: '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', description: "Indexing PDF, Word, and plain text with proper chunking. Pilot-tested with Mette's team.", status: 'shipping', target: 'Live since late May', display_order: 2, shipped_at: nowIso(-7 * 24 * 3600), attributed: [cabs[1].id, cabs[2].id], metadata_text: "Pilot-tested with Mette's team" }, { title: '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: 'Audit log export', description: 'Stream the signed records to your own S3 or on-prem object store.', status: 'in_beta', target: 'GA next week', display_order: 3, shipped_at: null, attributed: [cabs[3].id], metadata_text: 'Builds on traceability layer' }, { title: '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 over locked, on-prem document stores. Currently testing with two pilot organisations.', status: 'in_beta', target: 'July', display_order: 4, shipped_at: null, attributed: [cabs[1].id], metadata_text: 'Request beta access →' }, { title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, shipped_at: null, attributed: [] },
{ title: 'Contextual memory', description: 'Sessions that remember constraints between calls without leaking context across organisational boundaries.', status: 'exploring', target: 'Q3 2026', display_order: 5, shipped_at: null, attributed: [cabs[3].id], metadata_text: '2 council requests' },
{ title: 'Multi-organisation graphs', description: 'Permission-controlled knowledge spaces for departments within a single deployment.', status: 'exploring', target: 'Q3 2026', display_order: 6, shipped_at: null, attributed: [cabs[4].id], metadata_text: 'Open question on key custody' },
{ title: 'Multi-tenant isolation', description: 'Cryptographic separation between sub-organisations on shared infrastructure.', status: 'exploring', target: 'Q4 2026', display_order: 7, shipped_at: null, attributed: [cabs[5].id], metadata_text: null },
{ title: 'Federated learning hooks', description: 'Let aligned organisations train on shared signal without sharing the underlying data.', status: 'considering', target: '2027', display_order: 8, shipped_at: null, attributed: [], metadata_text: 'Council input wanted' },
{ title: 'Open evaluation framework', description: 'A public benchmark suite for compliant-AI use in regulated industries.', status: 'considering', target: '2027', display_order: 9, shipped_at: null, attributed: [], metadata_text: 'Long-term direction' },
]; ];
const insertRoad = db.prepare(` const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text) INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
VALUES (?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?)
`); `);
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)'); const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
for (const r of roadmap) { for (const r of roadmap) {
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, r.metadata_text).lastInsertRowid); const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at).lastInsertRowid);
for (const uid of r.attributed) insertAttr.run(id, uid); for (const uid of r.attributed) insertAttr.run(id, uid);
} }
@ -247,9 +209,7 @@ for (const c of contribs) {
const dispatchSeed = [ const dispatchSeed = [
{ kind: 'decision', ageDays: 2, { kind: 'decision', ageDays: 2,
title: 'We are deprioritising public-cloud parity for Q3', title: 'We are deprioritising public-cloud parity for Q3',
excerpt: `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. excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.',
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.`,
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. 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. 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.
@ -289,17 +249,14 @@ 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 fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
const insertDispatch = db.prepare(` const insertDispatch = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id) INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
VALUES (?,?,?,?,?,'published',?,?,?,?) VALUES (?,?,?,?,?,'published',?,?,?)
`); `);
for (let i = 0; i < dispatchSeed.length; i += 1) { for (let i = 0; i < dispatchSeed.length; i += 1) {
const d = dispatchSeed[i]; const d = dispatchSeed[i];
const when = nowIso(-d.ageDays * 24 * 60 * 60); const when = nowIso(-d.ageDays * 24 * 60 * 60);
const authorId = fenjas[i % fenjas.length].id; const authorId = fenjas[i % fenjas.length].id;
// Attach the decision-pulse to the decision dispatch — this is the demo insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
// case for polls-as-articles. Other dispatches stay poll-free.
const attachedPulse = d.kind === 'decision' ? decisionPulseId : null;
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when, attachedPulse);
} }
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past // ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
@ -372,13 +329,13 @@ const insertActivity = db.prepare(`
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at) INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
VALUES (?,?,?,?,?) VALUES (?,?,?,?,?)
`); `);
insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600)); insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600)); insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60)); 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)); insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`); console.log(' pulse #' + pulseId + ' open, 2 of 4 voted');
console.log(' roadmap: 9 items (2 shipping / 2 in_beta / 3 exploring / 2 considering)'); console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
console.log(' contributions: 3 (most recent has 3 reactions)'); console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)'); console.log(' dispatches: 4 published (2/5/9/12 days ago)');
console.log(' events: dinner + studio hours + working session, 2 past'); console.log(' events: dinner + studio hours + working session, 2 past');

View file

@ -30,7 +30,7 @@ const md = readFileSync(mdPath, 'utf8');
// schema's three statuses. In-progress items are actively being built and // schema's three statuses. In-progress items are actively being built and
// tested with pilots → beta. Next/Later are roadmap intent, not started → exploring. // tested with pilots → beta. Next/Later are roadmap intent, not started → exploring.
const SECTION_STATUS = { const SECTION_STATUS = {
'In progress': { status: 'in_beta', target: null }, 'In progress': { status: 'beta', target: null },
'Next': { status: 'exploring', target: 'Next quarter' }, 'Next': { status: 'exploring', target: 'Next quarter' },
'Later': { status: 'exploring', target: 'Later this year' }, 'Later': { status: 'exploring', target: 'Later this year' },
}; };

View file

@ -1,330 +0,0 @@
---
import Avatar from './Avatar.astro';
import type { Event, UserPublic } from '../lib/db';
import { eventKindLabel, redactName } from '../lib/format';
interface Props {
event: Event | null;
attendees: UserPublic[]; // confirmed (status='yes')
confirmedCount: number;
myRsvp: 'yes' | 'no' | 'interested' | null;
}
const { event, attendees, confirmedCount, myRsvp } = Astro.props;
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));
}
const dayPadded = event ? String(parseUtc(event.starts_at).getUTCDate()).padStart(2, '0') : '';
const weekday = event ? fmt({ weekday: 'long' }, event.starts_at).toUpperCase() : '';
const monthShort = event ? fmt({ month: 'short' }, event.starts_at).toUpperCase() : '';
const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, event.starts_at) : '';
const visibleAttendees = attendees.slice(0, 3);
const overflow = Math.max(0, attendees.length - visibleAttendees.length);
---
{event ? (
<article class="hero" aria-label={`Next gathering: ${event.title}`}>
<div class="hero-top">
<div class="hero-date">
<span class="hero-weekday">{weekday}</span>
<span class="hero-day">{dayPadded}</span>
<span class="hero-month-time">{monthShort} · {startTime}</span>
</div>
<div class="hero-mid">
<p class="hero-eyebrow">Next gathering · {eventKindLabel(event.kind).toUpperCase()}</p>
<h2 class="hero-title">{event.title}</h2>
<p class="hero-desc">{event.description}</p>
<p class="hero-location">{event.location}</p>
</div>
<div class="hero-meta">
{event.duration_label && (
<p class="hero-duration">{event.duration_label.toUpperCase()}</p>
)}
{visibleAttendees.length > 0 && (
<ul class="hero-attendees" aria-label="Confirmed attendees">
{visibleAttendees.map(u => (
<li class="hero-attendee">
<span class="hero-attendee-name">{redactName(u.name)}</span>
<Avatar id={u.id} name={u.name} size={18} />
</li>
))}
{overflow > 0 && (
<li class="hero-attendee">
<span class="hero-attendee-name">+{overflow} more</span>
<span class="hero-attendee-overflow" aria-hidden="true">+{overflow}</span>
</li>
)}
</ul>
)}
</div>
</div>
<footer class="hero-foot">
<p class="hero-status">
{event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED
</p>
<form method="POST" class="hero-actions">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={event.slug} />
{myRsvp === '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="no" class="hero-decline">Can't make it</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"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
</article>
)}
<style>
.hero {
background: var(--ink);
color: var(--on-ink);
border-radius: 14px;
padding: 32px 36px 28px;
display: flex;
flex-direction: column;
gap: 22px;
}
.hero-top {
display: grid;
grid-template-columns: 140px 1fr auto;
gap: 32px;
align-items: start;
}
/* ── Date column ─────────────────────────────────────────────── */
.hero-date {
display: flex;
flex-direction: column;
gap: 6px;
}
.hero-weekday {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
}
.hero-day {
font-family: var(--font-serif);
font-weight: 400;
font-size: 88px;
line-height: 0.85;
color: var(--on-ink);
}
.hero-month-time {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
}
/* ── Mid column ──────────────────────────────────────────────── */
.hero-mid {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.hero-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
margin: 0;
}
.hero-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 26px;
line-height: 1.15;
color: var(--on-ink);
margin: 0;
}
.hero-desc {
font-size: 13px;
line-height: 1.55;
color: var(--on-ink-body);
margin: 0;
max-width: 380px;
}
.hero-location {
font-size: 12px;
color: var(--on-ink-muted);
margin: 0;
}
/* ── Right meta column ───────────────────────────────────────── */
.hero-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
min-width: 140px;
}
.hero-duration {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
text-align: right;
margin: 0;
}
.hero-attendees {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
align-items: stretch;
}
.hero-attendee {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.hero-attendee-name {
font-family: var(--font-sans);
font-size: 11px;
color: var(--on-ink-muted);
}
.hero-attendee-overflow {
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 252, 247, 0.15);
color: var(--on-ink);
font-family: var(--font-sans);
font-size: 9px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* ── Bottom strip ────────────────────────────────────────────── */
.hero-foot {
border-top: 0.5px solid var(--ink-divider);
padding-top: 22px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.hero-status {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
margin: 0;
}
.hero-actions {
display: flex;
align-items: center;
gap: 16px;
}
.hero-decline {
background: none;
border: none;
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
padding: 0;
}
.hero-decline:hover { color: var(--on-ink); }
.hero-cta {
background: var(--on-ink);
color: var(--ink);
border: none;
padding: 9px 22px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.hero-cta:hover { opacity: 0.88; }
.hero-confirmed {
color: var(--on-ink);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 9px 22px;
border: 0.5px solid rgba(255, 252, 247, 0.3);
border-radius: 999px;
}
.hero-change {
background: transparent;
border: none;
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
text-decoration: underline;
}
.hero-change:hover { color: var(--on-ink); }
/* ── Empty state ─────────────────────────────────────────────── */
.hero--empty {
align-items: flex-start;
min-height: 200px;
justify-content: center;
}
.hero-empty {
font-family: var(--font-serif);
font-size: 20px;
color: var(--on-ink-body);
margin: 0;
max-width: 32rem;
}
.hero-empty em { font-style: italic; }
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) {
.hero-top { grid-template-columns: 1fr; }
.hero-meta { align-items: flex-start; }
.hero-duration, .hero-attendee { justify-content: flex-start; }
}
</style>

View file

@ -1,208 +0,0 @@
---
import { getLatestPublishedDispatches } from '../lib/db';
import {
dispatchSlug, dispatchKindLabel, splitExcerpt, relativeTime,
} from '../lib/format';
const [latest] = getLatestPublishedDispatches(1);
const [p1, p2] = latest
? splitExcerpt(latest.excerpt || latest.body)
: ['', null];
// Mark p2 with an ellipsis when the source extends beyond what we used —
// i.e. the body is longer than excerpt + paragraph break.
const sourceLen = latest ? (latest.excerpt || latest.body).trim().length : 0;
const usedLen = p1.length + (p2 ? p2.length + 2 : 0);
const truncated = sourceLen > usedLen + 4;
const authorFirstName = latest ? latest.author_name.split(' ')[0] : '';
const authorInitial = authorFirstName ? authorFirstName[0].toUpperCase() : '';
const authorRole = latest?.author_title ?? 'team';
---
{latest && (
<div class="rr-dispatch">
<div class="rr-dispatch-meta">
<div class="rr-dispatch-meta-left">
<span class="rr-dispatch-eyebrow">
Latest dispatch · {relativeTime(latest.published_at ?? latest.created_at)}
</span>
<span class:list={['rr-dispatch-kind', `rr-dispatch-kind-${latest.kind}`]}>
{dispatchKindLabel(latest.kind)}
</span>
</div>
<a class="rr-dispatch-all" href="/dispatches">All dispatches →</a>
</div>
<h2 class="rr-dispatch-title">{latest.title}</h2>
<div class="rr-dispatch-body">
<div class="rr-dispatch-text">
<p class="rr-dispatch-p1">{p1}</p>
{p2 && (
<p class="rr-dispatch-p2">{p2}{truncated ? '…' : ''}</p>
)}
</div>
<div class="rr-dispatch-author">
<div class="rr-dispatch-author-row">
<div class="rr-dispatch-author-text">
<p class="rr-dispatch-author-name">{authorFirstName}</p>
<p class="rr-dispatch-author-role">{authorRole}</p>
</div>
<div class="rr-dispatch-author-avatar">{authorInitial}</div>
</div>
<a class="rr-dispatch-cta" href={`/dispatches/${dispatchSlug(latest)}`}>
Read full dispatch →
</a>
</div>
</div>
</div>
)}
<style>
.rr-dispatch {
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: 14px;
padding: 36px 40px;
}
.rr-dispatch-meta {
display: grid;
grid-template-columns: 1fr auto;
gap: 24px;
align-items: baseline;
margin-bottom: 22px;
}
.rr-dispatch-meta-left {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.rr-dispatch-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 1.6px;
text-transform: uppercase;
color: var(--on-surface-variant);
}
.rr-dispatch-kind {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 0.8px;
padding: 2px 8px;
border-radius: 3px;
text-transform: uppercase;
font-weight: 500;
}
.rr-dispatch-kind-decision { background: rgba(44,58,82,0.10); color: #2c3a52; }
.rr-dispatch-kind-update { background: rgba(109,140,124,0.12);color: #6d8c7c; }
.rr-dispatch-kind-behind_the_scenes { background: rgba(120,95,83,0.12); color: #785f53; }
.rr-dispatch-kind-note { background: rgba(185,107,88,0.10); color: #b96b58; }
.rr-dispatch-all {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: 1px;
color: var(--on-surface-variant);
text-transform: uppercase;
text-decoration: none;
border-bottom: none;
}
.rr-dispatch-all:hover { color: var(--on-surface); border-bottom: none; }
.rr-dispatch-title {
font-family: var(--font-serif);
font-size: 30px;
line-height: 1.2;
color: var(--on-surface);
margin: 0 0 22px;
max-width: 720px;
}
.rr-dispatch-body {
display: grid;
grid-template-columns: 1fr auto;
gap: 40px;
align-items: end;
}
.rr-dispatch-text { max-width: 720px; }
.rr-dispatch-p1 {
font-size: 14px;
line-height: 1.7;
color: var(--on-surface);
margin: 0 0 10px;
}
.rr-dispatch-p2 {
font-size: 14px;
line-height: 1.7;
color: var(--on-surface-variant);
margin: 0;
}
.rr-dispatch-author {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 14px;
padding-bottom: 4px;
}
.rr-dispatch-author-row {
display: flex;
align-items: center;
gap: 10px;
}
.rr-dispatch-author-text { text-align: right; }
.rr-dispatch-author-name {
font-size: 13px;
margin: 0;
color: var(--on-surface);
}
.rr-dispatch-author-role {
font-size: 11px;
margin: 1px 0 0;
color: var(--on-surface-variant);
}
.rr-dispatch-author-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--ink);
color: #fffcf7;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-serif);
font-style: italic;
font-size: 14px;
flex-shrink: 0;
}
.rr-dispatch-cta {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: 1.2px;
color: var(--pigment-terracotta);
text-transform: uppercase;
text-decoration: none;
padding-bottom: 2px;
border-bottom: 1px solid var(--pigment-terracotta);
white-space: nowrap;
}
.rr-dispatch-cta:hover { opacity: 0.78; }
@media (max-width: 767px) {
.rr-dispatch { padding: 28px 24px; }
.rr-dispatch-title { font-size: 24px; }
.rr-dispatch-body { grid-template-columns: 1fr; gap: 22px; }
.rr-dispatch-author {
flex-direction: row;
justify-content: space-between;
align-items: center;
order: -1;
}
}
</style>

View file

@ -51,7 +51,7 @@ const tags = readFocusTags(member.focus_tags);
<style> <style>
.m-card { .m-card {
background: var(--ink); background: var(--ink);
color: var(--on-ink); color: var(--ink-text);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
display: flex; display: flex;
@ -70,7 +70,7 @@ const tags = readFocusTags(member.focus_tags);
width: 22px; width: 22px;
height: 22px; height: 22px;
border-radius: 50%; border-radius: 50%;
background: var(--on-ink); background: var(--ink-text);
color: var(--ink); color: var(--ink);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -89,7 +89,7 @@ const tags = readFocusTags(member.focus_tags);
font-weight: 500; font-weight: 500;
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--on-ink-muted); color: var(--ink-muted);
} }
.m-name { .m-name {
@ -99,7 +99,7 @@ const tags = readFocusTags(member.focus_tags);
font-weight: 400; font-weight: 400;
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1.15; line-height: 1.15;
color: var(--on-ink); color: var(--ink-text);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -116,12 +116,12 @@ const tags = readFocusTags(member.focus_tags);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--on-ink-muted); color: var(--ink-muted);
} }
.m-since-value { .m-since-value {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--on-ink); color: var(--ink-text);
} }
.m-tags { .m-tags {
@ -133,8 +133,8 @@ const tags = readFocusTags(member.focus_tags);
gap: 6px; gap: 6px;
} }
.m-tag { .m-tag {
border: 0.5px solid rgba(255, 252, 247, 0.3); border: 0.5px solid rgba(232, 224, 208, 0.3);
color: var(--on-ink); color: var(--ink-text);
padding: 3px 8px; padding: 3px 8px;
border-radius: 999px; border-radius: 999px;
font-family: var(--font-sans); font-family: var(--font-sans);

View file

@ -1,261 +0,0 @@
---
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
interface Props {
items: RoadmapItemWithAttribution[];
}
const { items } = Astro.props;
const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING',
in_beta: 'IN BETA',
exploring: 'EXPLORING',
considering: 'CONSIDERING',
};
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
shipping: 'var(--pigment-copper)',
in_beta: 'var(--pigment-terracotta)',
exploring: '#b4b2a9',
considering: '#b4b2a9',
};
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
shipping: 'var(--pigment-copper)',
in_beta: 'var(--pigment-terracotta)',
exploring: '#b4b2a9',
considering: '#d4d2c8',
};
/** First-names-only attribution string. Empty when no attribution exists. */
function attributionLine(attributed: { name: string }[]): string {
if (!attributed.length) return '';
const names = attributed.map(a => a.name.split(' ')[0]);
if (names.length === 1) return `Shaped by ${names[0]}.`;
if (names.length === 2) return `Shaped by ${names[0]} and ${names[1]}.`;
return `Shaped by ${names.slice(0, -1).join(', ')} and ${names.at(-1)}.`;
}
const hasArrows = items.length > 3;
---
<section class="roadmap-section" aria-label="On the roadmap">
<header class="roadmap-header">
<h2 class="roadmap-title">On the <em>roadmap.</em></h2>
<div class="roadmap-actions">
<a href="/roadmap" class="roadmap-all">See the full roadmap →</a>
{hasArrows && (
<div class="roadmap-arrows" role="group" aria-label="Scroll controls">
<button type="button" class="roadmap-arrow" data-dir="prev" aria-label="Previous" disabled>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M9 2 L4 7 L9 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button type="button" class="roadmap-arrow" data-dir="next" aria-label="Next">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M5 2 L10 7 L5 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
)}
</div>
</header>
<div class="carousel-wrap">
<div class="carousel-scroll" data-carousel-scroll>
<div class="carousel-strip">
{items.map(item => (
<article class="carousel-card">
<header class="card-status">
<span class="card-dot" style={`background:${STATUS_DOT_COLOR[item.status]}`} aria-hidden="true"></span>
<span class="card-status-label" style={`color:${STATUS_LABEL_COLOR[item.status]}`}>
{STATUS_LABEL[item.status]}{item.target ? ` · ${item.target.toUpperCase()}` : ''}
</span>
</header>
<h3 class="card-title">{item.title}</h3>
<p class="card-desc">
{item.description}
{item.attributed.length > 0 && (
<span class="card-attribution"> {attributionLine(item.attributed)}</span>
)}
</p>
</article>
))}
</div>
</div>
{hasArrows && <div class="carousel-fade-right" data-carousel-fade></div>}
</div>
</section>
<script>
// Vanilla carousel — scroll by card width, update arrow disabled state,
// fade the right gradient when scrolled to the end.
document.querySelectorAll<HTMLElement>('.roadmap-section').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('[data-carousel-scroll]');
const fade = section.querySelector<HTMLElement>('[data-carousel-fade]');
if (!scroll) return;
const prev = section.querySelector<HTMLButtonElement>('.roadmap-arrow[data-dir="prev"]');
const next = section.querySelector<HTMLButtonElement>('.roadmap-arrow[data-dir="next"]');
function cardWidth() {
const card = scroll!.querySelector<HTMLElement>('.carousel-card');
return card ? card.getBoundingClientRect().width : scroll!.clientWidth;
}
function update() {
const max = scroll!.scrollWidth - scroll!.clientWidth;
const atStart = scroll!.scrollLeft <= 1;
const atEnd = scroll!.scrollLeft >= max - 1;
if (prev) prev.disabled = atStart;
if (next) next.disabled = atEnd;
if (fade) fade.style.opacity = atEnd ? '0' : '1';
}
prev?.addEventListener('click', () => scroll.scrollBy({ left: -cardWidth(), behavior: 'smooth' }));
next?.addEventListener('click', () => scroll.scrollBy({ left: cardWidth(), behavior: 'smooth' }));
scroll.addEventListener('scroll', update, { passive: true });
update();
});
</script>
<style>
.roadmap-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.roadmap-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.roadmap-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 22px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.roadmap-title em { font-style: italic; }
.roadmap-actions {
display: flex;
align-items: baseline;
gap: 18px;
}
.roadmap-all {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
}
.roadmap-all:hover { opacity: 0.8; border-bottom: none; }
.roadmap-arrows {
display: flex;
gap: 8px;
align-self: center;
}
.roadmap-arrow {
width: 30px;
height: 30px;
border-radius: 50%;
border: 0.5px solid rgba(0, 0, 0, 0.15);
background: var(--background);
color: var(--on-surface);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: opacity var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.roadmap-arrow:hover:not(:disabled) { background: var(--surface-container-low); }
.roadmap-arrow:disabled { opacity: 0.25; cursor: default; }
.carousel-wrap { position: relative; }
.carousel-scroll {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
border-top: 0.5px solid rgba(0, 0, 0, 0.08);
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
}
.carousel-scroll::-webkit-scrollbar { display: none; }
.carousel-strip {
display: flex;
}
.carousel-card {
flex: 0 0 calc((100% - 2px) / 3);
scroll-snap-align: start;
background: var(--background);
padding: 24px 26px;
box-sizing: border-box;
border-right: 0.5px solid rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
gap: 12px;
min-height: 168px;
}
.carousel-card:last-child { border-right: none; }
.carousel-fade-right {
position: absolute;
right: 0; top: 0; bottom: 0;
width: 80px;
background: linear-gradient(to right, transparent, var(--background));
pointer-events: none;
transition: opacity 0.2s ease;
}
/* Card contents */
.card-status {
display: flex;
align-items: center;
gap: 8px;
}
.card-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.card-status-label {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
font-weight: 600;
}
.card-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 19px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.card-desc {
font-size: 12px;
line-height: 1.55;
color: var(--on-surface-variant);
margin: 0;
}
.card-attribution {
color: var(--on-surface-muted);
}
/* Mobile: one card per view */
@media (max-width: 768px) {
.carousel-card { flex: 0 0 88%; }
.roadmap-arrows { display: none; }
.carousel-fade-right { display: none; }
}
</style>

View file

@ -1,680 +0,0 @@
---
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
import { computeRouteLayout, travelledStopFor } from '../lib/roadmap-layout';
interface Props {
items: RoadmapItemWithAttribution[];
viewportWidth?: number; // SSR fallback for the layout math
}
const { items, viewportWidth = 1100 } = Astro.props;
const layout = computeRouteLayout({ itemCount: items.length, viewportWidth });
const travelledStop = travelledStopFor(items.map(i => i.status));
const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING',
in_beta: 'IN BETA',
exploring: 'EXPLORING',
considering: 'CONSIDERING',
};
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
shipping: '#6d8c7c',
in_beta: '#b96b58',
exploring: '#b4b2a9',
considering: '#b4b2a9',
};
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
shipping: '#6d8c7c',
in_beta: '#b96b58',
exploring: '#b4b2a9',
considering: '#d4d2c8',
};
// "You are here" — the most recent shipping item. -1 if nothing has shipped yet.
let lastShippingIndex = -1;
items.forEach((it, i) => { if (it.status === 'shipping') lastShippingIndex = i; });
function trailingLine(item: RoadmapItemWithAttribution): string | null {
if (item.metadata_text && item.metadata_text.trim().length > 0) return item.metadata_text;
if (item.attributed.length > 0) {
const names = item.attributed.map(a => a.name.split(' ')[0]);
if (names.length === 1) return `Shaped by ${names[0]}`;
if (names.length === 2) return `Shaped by ${names[0]} and ${names[1]}`;
return `Shaped by ${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
}
return null;
}
// Stringified x position of the 'you are here' milestone for the
// initial-scroll logic in the nav script. -1 → 0 (no scroll offset).
const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex] : 0;
---
<section class="route" aria-label="Roadmap route" data-initial-x={initialShippingX}>
<!-- The route — desktop horizontal. .rr-fullbleed escapes the parent
.page max-width so the route can span the actual viewport while
the header above and legend below stay centred in the content
column. -->
<div class="rr-wrap rr-fullbleed rr-desktop" data-item-count={items.length}>
<div class="rr-scroll" id="rr-scroll">
<div class="rr-scroll-inner">
<div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
<svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="420" aria-hidden="true">
<defs>
<linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/>
<stop offset={String(travelledStop)} stop-color="#2a2520" stop-opacity="0.55"/>
<stop offset={String(Math.min(1, travelledStop + 0.06))} stop-color="#2a2520" stop-opacity="0.15"/>
<stop offset="1" stop-color="#2a2520" stop-opacity="0.15"/>
</linearGradient>
</defs>
{layout.pathD && (
<path id="rr-path-d" d={layout.pathD} fill="none" stroke="url(#rr-path-gradient)" stroke-width="1.25" stroke-linecap="round"/>
)}
</svg>
{items.map((item, i) => (
<div
class="rr-milestone"
data-y={layout.itemY[i]}
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
>
<div
class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]}
style={`background:${STATUS_DOT_COLOR[item.status]};`}
aria-hidden="true"
></div>
<div class:list={['rr-attach', `rr-attach-${layout.cardSide[i]}`]}>
<div class="rr-connector" aria-hidden="true"></div>
<a class="rr-card" tabindex="0" href={`#item-${item.id}`} id={`item-${item.id}`}>
<p class="rr-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
</p>
<p class="rr-card-title">{item.title}</p>
<div class="rr-more">
{item.description && <p class="rr-desc">{item.description}</p>}
{trailingLine(item) && <p class="rr-trail">{trailingLine(item)}</p>}
</div>
</a>
</div>
</div>
))}
</div>
</div>
</div>
<div class="rr-fade-left" id="rr-fade-l" aria-hidden="true"></div>
<div class="rr-fade-right" id="rr-fade-r" aria-hidden="true"></div>
<!-- Single forward-only advance affordance anchored to the right
viewport edge. There's no left arrow on purpose — the path
reads left-to-right and the user's instinct after looking at
a milestone is 'what's next?', not 'what came before?'. -->
<button
type="button"
class="rr-advance"
id="rr-advance"
aria-label="Further along the route"
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<!-- Legend lives in /roadmap.astro now so it returns to centred
content-column width below the full-bleed route. -->
<!-- Mobile vertical timeline -->
<ol class="rr-mobile" aria-label="Roadmap timeline">
{items.map((item, i) => (
<li class="rrm-row">
<div class="rrm-track-col" aria-hidden="true">
<span class="rrm-dot" style={`background:${STATUS_DOT_COLOR[item.status]};`}></span>
{i < items.length - 1 && <span class="rrm-line"></span>}
</div>
<div class="rrm-body">
<p class="rrm-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
</p>
<p class="rrm-title">{item.title}</p>
{item.description && <p class="rrm-desc">{item.description}</p>}
{trailingLine(item) && <p class="rrm-trail">{trailingLine(item)}</p>}
</div>
</li>
))}
</ol>
</section>
<script>
// Vanilla route runtime. Two concerns:
// 1. Nav: arrow buttons, edge fades, initial-scroll into shipping
// 2. Viewport-aware layout — SSR uses a 1100px fallback for the math;
// on the client we know the real viewport, so we recompute itemX
// positions + SVG path d + track width on mount and on (debounced)
// resize. itemY values come from data-y on each milestone (path
// amplitude doesn't change with viewport, only the horizontal spread).
const MIN_SPACING = 320;
const PADDING_X = 60;
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
const wrap = section.querySelector<HTMLElement>('.rr-wrap');
const track = section.querySelector<HTMLElement>('#rr-track');
const svg = section.querySelector<SVGSVGElement>('#rr-path-svg');
const pathD = section.querySelector<SVGPathElement>('#rr-path-d');
const milestones = Array.from(section.querySelectorAll<HTMLElement>('.rr-milestone'));
const fadeL = section.querySelector<HTMLElement>('#rr-fade-l');
const fadeR = section.querySelector<HTMLElement>('#rr-fade-r');
const advance = section.querySelector<HTMLButtonElement>('#rr-advance');
if (!scroll || !track || !svg) return;
const itemCount = milestones.length;
const itemY: number[] = milestones.map(m => Number(m.dataset.y ?? 0));
/** Recompute trackWidth + itemX[] + pathD using the live viewport. */
function recompute() {
const vw = window.innerWidth;
const targetUsableWidth = vw * 0.80;
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
const trackWidth = usableWidth + PADDING_X * 2;
const itemX: number[] = [];
for (let i = 0; i < itemCount; i += 1) {
itemX.push(
itemCount === 1
? PADDING_X + usableWidth / 2
: PADDING_X + (i / (itemCount - 1)) * usableWidth,
);
}
// Bezier path: control points at the segment midpoint x with control
// y values matching the prior and next milestone (keeps the tangent
// flat at each dot — the "river" feel from the layout helper).
let d = '';
if (itemCount > 0) {
d = `M ${itemX[0]} ${itemY[0]}`;
for (let i = 1; i < itemCount; i += 1) {
const cx = (itemX[i - 1] + itemX[i]) / 2;
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
}
}
// Apply.
track!.style.width = `${trackWidth}px`;
svg!.setAttribute('width', String(trackWidth));
if (pathD && d) pathD.setAttribute('d', d);
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
}
/* Edge state — fades + advance disable. */
function updateNav() {
const max = scroll!.scrollWidth - scroll!.clientWidth;
const atStart = scroll!.scrollLeft <= 2;
const atEnd = scroll!.scrollLeft >= max - 2;
if (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
if (advance) advance.classList.toggle('rr-at-end', atEnd);
}
/* ── Unified scroll handling: wheel, drag, animated glide. ──
No CSS scroll-snap and no scroll-behavior: smooth — both fight
the JS-driven smooth motion. Drag has momentum; wheel translates
vertical to horizontal; arrow click runs a cubic-ease animation. */
let isDragging = false;
let dragStartX = 0;
let dragStartScrollLeft = 0;
let dragTotalMovement = 0;
let lastMoveX = 0;
let lastMoveTime = 0;
let velocity = 0; // px/ms, signed (positive = pointer moving right)
let momentumRAF: number | null = null;
let animateRAF: number | null = null;
function cancelAnims() {
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
}
function animateScrollTo(target: number, durationMs: number) {
cancelAnims();
const start = scroll!.scrollLeft;
const delta = target - start;
const startTime = performance.now();
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
const step = () => {
const t = Math.min(1, (performance.now() - startTime) / durationMs);
scroll!.scrollLeft = start + delta * easeOut(t);
updateNav();
if (t < 1) animateRAF = requestAnimationFrame(step);
else animateRAF = null;
};
animateRAF = requestAnimationFrame(step);
}
// Wheel — vertical wheel becomes horizontal scroll on this element.
// Trackpads sending horizontal deltaX go through unchanged (1:1, no scaling).
scroll.addEventListener('wheel', (e) => {
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (dx === 0) return;
e.preventDefault();
cancelAnims();
scroll!.scrollLeft += dx;
updateNav();
}, { passive: false });
// Drag — pointer events; momentum on release.
scroll.addEventListener('pointerdown', (e) => {
if (e.button !== undefined && e.button !== 0) return;
// Don't start a drag when the click target is the advance button.
if (advance && advance.contains(e.target as Node)) return;
isDragging = true;
dragStartX = e.pageX;
dragStartScrollLeft = scroll!.scrollLeft;
dragTotalMovement = 0;
lastMoveX = e.pageX;
lastMoveTime = performance.now();
velocity = 0;
cancelAnims();
try { scroll!.setPointerCapture(e.pointerId); } catch { /* not all envs */ }
scroll!.classList.add('rr-dragging');
});
scroll.addEventListener('pointermove', (e) => {
if (!isDragging) return;
const dx = e.pageX - dragStartX;
scroll!.scrollLeft = dragStartScrollLeft - dx;
dragTotalMovement = Math.max(dragTotalMovement, Math.abs(dx));
const now = performance.now();
const dt = now - lastMoveTime;
if (dt > 0) velocity = (e.pageX - lastMoveX) / dt;
lastMoveX = e.pageX;
lastMoveTime = now;
updateNav();
});
function endDrag() {
if (!isDragging) return;
isDragging = false;
scroll!.classList.remove('rr-dragging');
// Click vs drag: anything under 5px total movement is a click —
// skip momentum and let the underlying card's <a> handle the click.
if (dragTotalMovement < 5) return;
// Otherwise it's a real drag — suppress the synthetic click that
// follows so a drag-then-release-over-a-card doesn't navigate.
const suppressClick = (ev: Event) => {
ev.stopPropagation();
ev.preventDefault();
scroll!.removeEventListener('click', suppressClick, true);
};
scroll!.addEventListener('click', suppressClick, true);
// Momentum: signed velocity, decay 0.93 per frame, stop under 0.4 px/frame.
// Direction inverted because dragging right moves scrollLeft left.
let v = -velocity * 16;
const step = () => {
if (Math.abs(v) < 0.4) { momentumRAF = null; return; }
scroll!.scrollLeft += v;
v *= 0.93;
updateNav();
momentumRAF = requestAnimationFrame(step);
};
momentumRAF = requestAnimationFrame(step);
}
scroll.addEventListener('pointerup', endDrag);
scroll.addEventListener('pointercancel', endDrag);
// Advance arrow — animated glide of 60% viewport width.
advance?.addEventListener('click', () => {
const target = Math.min(
scroll!.scrollLeft + scroll!.clientWidth * 0.6,
scroll!.scrollWidth - scroll!.clientWidth,
);
animateScrollTo(target, 480);
});
scroll.addEventListener('scroll', updateNav, { passive: true });
// Debounced resize → recompute layout + refresh nav state. 120ms is
// long enough to coalesce drag-resize events without feeling laggy.
let resizeTimer: number | undefined;
window.addEventListener('resize', () => {
if (resizeTimer !== undefined) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
});
// Initial mount: recompute with the real viewport, then scroll the
// 'you are here' milestone roughly 25% from the left.
recompute();
const initialX = milestones.find(m => m.querySelector('.rr-dot.rr-current'));
if (initialX) {
const x = parseFloat(initialX.style.left) || 0;
const max = scroll.scrollWidth - scroll.clientWidth;
const target = Math.max(0, Math.min(max, x - scroll.clientWidth * 0.25));
scroll.scrollLeft = target;
}
setTimeout(updateNav, 50);
updateNav();
// Three-pulse hint on the advance arrow ~100ms after layout settles
// so the user notices the affordance once and then it sits quietly.
setTimeout(() => advance?.classList.add('rr-hint'), 100);
});
</script>
<style>
/* ── Desktop route ──────────────────────────────────────────────── */
.rr-wrap { position: relative; }
/* Escape the parent .page max-width so the route can use the actual
viewport width. The headline, dispatch banner, section header, and
legend all stay centred at content width — only the route widens. */
.rr-fullbleed {
width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
}
.rr-scroll {
/* overflow-x: auto + overflow-y: visible lets hovered cards expand
above/below the track without being clipped. .rr-scroll-inner is
the spec-recommended belt-and-braces wrapper in case a browser
misbehaves on the combination.
NO scroll-snap-type and NO scroll-behavior: smooth — both fight
the JS drag-momentum + animated-glide implementation below. The
path is meant to glide continuously, not click into fixed
positions. */
overflow-x: auto;
overflow-y: visible;
scrollbar-width: none;
padding: 60px 80px 80px;
/* Drag affordance: cursor + suppress native horizontal swipe so
horizontal drag triggers our handler while vertical drag still
scrolls the page. user-select stops drag from selecting text. */
cursor: grab;
touch-action: pan-y;
user-select: none;
}
.rr-scroll::-webkit-scrollbar { display: none; }
.rr-scroll.rr-dragging { cursor: grabbing; }
/* Pointer-events off the cards mid-drag — prevents accidental hover
reveal while the track is being dragged past. */
.rr-scroll.rr-dragging .rr-card { pointer-events: none; }
.rr-scroll-inner { /* structural — keeps the track on its own layer */ }
.rr-track { position: relative; }
.rr-path { position: absolute; top: 0; left: 0; pointer-events: none; }
.rr-milestone {
position: absolute;
transform: translate(-50%, -50%);
}
.rr-dot {
width: 14px;
height: 14px;
border-radius: 50%;
box-shadow: 0 0 0 5px var(--background); /* halo cuts the path under the dot */
transition: transform .25s ease, box-shadow .25s ease;
}
.rr-dot.rr-current {
transform: scale(1.3);
box-shadow:
0 0 0 5px var(--background), /* cream halo */
0 0 0 6px rgba(185, 107, 88, 0.45); /* terracotta ring outside */
}
/* Hover-on-card animates the sibling dot too. :has() is fine on every
evergreen browser we target; older Firefox just doesn't grow the dot. */
.rr-milestone:has(.rr-card:hover) .rr-dot,
.rr-milestone:has(.rr-card:focus-visible) .rr-dot {
transform: scale(1.15);
}
.rr-milestone:has(.rr-card:hover) .rr-dot.rr-current,
.rr-milestone:has(.rr-card:focus-visible) .rr-dot.rr-current {
transform: scale(1.4);
}
.rr-attach {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
}
.rr-attach-below { top: 7px; } /* hangs down from the dot */
.rr-attach-above { bottom: 7px; flex-direction: column-reverse; }
.rr-connector {
width: 1px;
height: 30px;
background: rgba(0, 0, 0, 0.18);
}
.rr-card {
display: block;
width: 220px;
padding: 12px 14px;
border-radius: 10px;
background: transparent;
color: inherit;
text-decoration: none;
border-bottom: none;
transition:
transform .35s cubic-bezier(.2,.7,.3,1),
box-shadow .35s ease,
background .25s ease;
cursor: pointer;
}
.rr-card:hover,
.rr-card:focus-visible {
background: var(--surface-card);
box-shadow:
0 12px 32px -16px rgba(42, 37, 32, 0.25),
0 0 0 0.5px var(--surface-card-border);
transform: translateY(-2px);
z-index: 10;
border-bottom: none;
outline: none;
}
.rr-eyebrow {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 1.4px;
text-transform: uppercase;
margin: 0 0 6px;
font-weight: 600;
}
.rr-card-title {
font-family: var(--font-serif);
font-size: 16px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.rr-more {
max-height: 0;
opacity: 0;
overflow: hidden;
transition:
max-height .35s ease,
opacity .25s ease,
margin-top .35s ease;
margin-top: 0;
}
.rr-card:hover .rr-more,
.rr-card:focus-visible .rr-more {
max-height: 280px;
opacity: 1;
margin-top: 10px;
}
.rr-desc {
font-family: var(--font-sans);
font-size: 12px;
line-height: 1.55;
color: var(--on-surface-variant);
margin: 0 0 10px;
}
.rr-trail {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);
margin: 0;
}
/* ── Advance arrow ─────────────────────────────────────────────── */
.rr-advance {
position: absolute;
right: 32px;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--pigment-terracotta);
background: var(--background);
color: var(--pigment-terracotta);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
z-index: 5;
transition: background .2s ease,
color .2s ease,
opacity .25s ease,
transform .25s ease;
}
.rr-advance:hover,
.rr-advance:focus-visible {
background: var(--pigment-terracotta);
color: var(--background);
outline: none;
transform: translateY(-50%) scale(1.06);
}
.rr-advance[disabled],
.rr-advance.rr-at-end {
opacity: 0.25;
pointer-events: none;
}
/* Three-pulse hint on first load — fires once, then stops. */
@keyframes rr-advance-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(185, 107, 88, 0); }
50% { box-shadow: 0 0 0 8px rgba(185, 107, 88, 0.15); }
}
.rr-advance.rr-hint {
animation: rr-advance-pulse 1.4s ease-in-out 3;
}
/* Edge fades cover only the track itself — the top/bottom padding
zones (60/80) on .rr-scroll exist so hover cards can overflow there
without clipping, so the fades shouldn't paint over them. */
.rr-fade-left, .rr-fade-right {
position: absolute;
top: 60px;
bottom: 80px;
pointer-events: none;
transition: opacity .25s ease;
}
.rr-fade-left {
left: 0; width: 60px;
background: linear-gradient(to left, transparent, var(--background));
opacity: 0;
}
.rr-fade-right {
right: 0; width: 90px;
background: linear-gradient(to right, transparent, var(--background));
}
/* ── Mobile vertical timeline ──────────────────────────────────── */
.rr-mobile { display: none; }
@media (max-width: 767px) {
.rr-desktop { display: none; }
.rr-mobile {
display: block;
list-style: none;
padding: 0;
margin: 0;
}
.rrm-row {
display: grid;
grid-template-columns: 32px 1fr;
gap: 16px;
align-items: start;
}
.rrm-track-col {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
min-height: 100%;
padding-top: 6px;
}
.rrm-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.rrm-line {
width: 1px;
flex: 1;
min-height: 28px;
background: rgba(0, 0, 0, 0.18);
margin-top: 4px;
}
.rrm-body {
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 28px;
}
.rrm-eyebrow {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 1.4px;
text-transform: uppercase;
margin: 0;
font-weight: 600;
}
.rrm-title {
font-family: var(--font-serif);
font-size: 18px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.rrm-desc {
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.55;
color: var(--on-surface-variant);
margin: 0;
}
.rrm-trail {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);
margin: 0;
}
}
</style>

View file

@ -1,25 +1,16 @@
--- ---
import type { DispatchWithAuthor, UserPublic, PulseRow } from '../../lib/db'; import type { DispatchWithAuthor, UserPublic } from '../../lib/db';
import { fmtDateTime } from '../../lib/markdown'; import { fmtDateTime } from '../../lib/markdown';
import { dispatchKindLabel } from '../../lib/format'; import { dispatchKindLabel } from '../../lib/format';
interface Props { interface Props {
dispatches: DispatchWithAuthor[]; dispatches: DispatchWithAuthor[];
editing: DispatchWithAuthor | null; editing: DispatchWithAuthor | null;
editingPoll: PulseRow | null;
fenjaUsers: UserPublic[]; fenjaUsers: UserPublic[];
currentUserId: number; currentUserId: number;
} }
const { dispatches, editing, editingPoll, fenjaUsers, currentUserId } = Astro.props; const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
function toInputValue(sql: string | null | undefined): string {
if (!sql) return '';
return sql.replace(' ', 'T').slice(0, 16);
}
const pollOptionsForForm: string[] = editingPoll ? [...editingPoll.options] : [];
while (pollOptionsForForm.length < 4) pollOptionsForForm.push('');
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
draft: 'Draft', draft: 'Draft',
@ -66,9 +57,8 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
</div> </div>
<div class="field"> <div class="field">
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional)</label> <label for="d-excerpt" class="label-sm field-label">Excerpt (optional — falls back to first ~200 chars of body)</label>
<textarea id="d-excerpt" name="excerpt" class="input body-md" rows="4">{editing?.excerpt ?? ''}</textarea> <input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
<span class="body-sm muted">Write 24 sentences. The first sentence becomes the lead paragraph on the /roadmap dispatch banner; the rest follows in muted text. Use a blank line to control the paragraph break. Falls back to the first ~200 chars of the body if empty.</span>
</div> </div>
<div class="field"> <div class="field">
@ -88,65 +78,6 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
</div> </div>
)} )}
<!-- ── Attached poll (optional) ────────────────────────────── -->
<fieldset class="poll-fieldset">
<legend class="label-sm field-label">Attach a poll (optional)</legend>
<input type="hidden" name="poll_explicit" value="1" />
<p class="body-sm muted poll-help">
Fill in a question and at least two options to attach a poll. Leave them all blank
to {editingPoll ? 'detach the existing poll' : 'skip'}.
{editingPoll && <span class="poll-existing-flag"> · Currently attached: pulse #{editingPoll.id}, status {editingPoll.status}.</span>}
</p>
<div class="field">
<label for="d-poll-question" class="label-sm field-label">Poll question</label>
<input
type="text"
id="d-poll-question"
name="poll_question"
class="input body-md"
value={editingPoll?.question ?? ''}
placeholder={editing ? editing.title : 'A question for the council'}
/>
</div>
<div class="poll-options-grid">
{pollOptionsForForm.map((val, i) => (
<input
type="text"
name={`poll_option_${i}`}
placeholder={`Option ${String.fromCharCode(65 + i)}${i < 2 ? '' : ' (optional)'}`}
class="input body-md"
value={val}
/>
))}
</div>
<div class="form-grid">
<div class="field">
<label for="d-poll-opens" class="label-sm field-label">Poll opens at (UTC)</label>
<input
type="datetime-local"
id="d-poll-opens"
name="poll_opens_at"
class="input body-md"
value={toInputValue(editingPoll?.opens_at)}
/>
</div>
<div class="field">
<label for="d-poll-closes" class="label-sm field-label">Poll closes at (UTC)</label>
<input
type="datetime-local"
id="d-poll-closes"
name="poll_closes_at"
class="input body-md"
value={toInputValue(editingPoll?.closes_at)}
/>
</div>
</div>
</fieldset>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button> <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>} {editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
@ -213,30 +144,6 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); } .mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
.form-actions { display: flex; gap: var(--space-3); align-items: center; } .form-actions { display: flex; gap: var(--space-3); align-items: center; }
.poll-fieldset {
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
margin: 0;
}
.poll-fieldset legend {
padding: 0 var(--space-2);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.poll-help { color: var(--on-surface-muted); margin: 0; }
.poll-existing-flag { color: var(--pigment-terracotta); }
.poll-options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.muted { color: var(--on-surface-muted); }
.status-pill { .status-pill {
display: inline-block; display: inline-block;
padding: 0.15em var(--space-3); padding: 0.15em var(--space-3);

View file

@ -9,23 +9,17 @@ interface Props {
const { items, editing, cabUsers } = Astro.props; const { items, editing, cabUsers } = Astro.props;
const STATUS_LABEL = { const STATUS_LABEL = { shipping: 'Shipping', beta: 'Beta', exploring: 'Exploring' } as const;
shipping: 'Shipping',
in_beta: 'In beta',
exploring: 'Exploring',
considering: 'Considering',
} as const;
const formAction = editing ? 'update_roadmap' : 'create_roadmap'; const formAction = editing ? 'update_roadmap' : 'create_roadmap';
const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id)); const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id));
// Group items by status for display // Group items by status for display
type Status = 'shipping' | 'in_beta' | 'exploring' | 'considering'; type Status = 'shipping' | 'beta' | 'exploring';
const grouped: Record<Status, RoadmapItemWithAttribution[]> = { const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order), shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order),
in_beta: items.filter(i => i.status === 'in_beta' ).sort((a,b) => a.display_order - b.display_order), beta: items.filter(i => i.status === 'beta' ).sort((a,b) => a.display_order - b.display_order),
exploring: items.filter(i => i.status === 'exploring' ).sort((a,b) => a.display_order - b.display_order), exploring: items.filter(i => i.status === 'exploring').sort((a,b) => a.display_order - b.display_order),
considering: items.filter(i => i.status === 'considering').sort((a,b) => a.display_order - b.display_order),
}; };
--- ---
<div class="tab-content"> <div class="tab-content">
@ -45,9 +39,8 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
<div class="field"> <div class="field">
<label for="status" class="label-sm field-label">Status</label> <label for="status" class="label-sm field-label">Status</label>
<select id="status" name="status" class="select body-md" required> <select id="status" name="status" class="select body-md" required>
<option value="considering" selected={editing?.status === 'considering'}>Considering</option>
<option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option> <option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
<option value="in_beta" selected={editing?.status === 'in_beta'}>In beta</option> <option value="beta" selected={editing?.status === 'beta'}>Beta</option>
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option> <option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option>
</select> </select>
</div> </div>
@ -66,20 +59,6 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea> <textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
</div> </div>
<div class="field">
<label for="metadata_text" class="label-sm field-label">Hover note (~60 chars)</label>
<input
type="text"
id="metadata_text"
name="metadata_text"
class="input body-md"
value={editing?.metadata_text ?? ''}
placeholder="e.g. Open question on key custody · Council input wanted"
maxlength="120"
/>
<span class="body-sm muted">A short narrative cue shown on hover in /roadmap. Optional.</span>
</div>
<fieldset class="attribution-grid"> <fieldset class="attribution-grid">
<legend class="label-sm field-label">Attributed members (who shaped this)</legend> <legend class="label-sm field-label">Attributed members (who shaped this)</legend>
{cabUsers.map(u => ( {cabUsers.map(u => (
@ -99,7 +78,7 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
</section> </section>
<!-- ── List by status ────────────────────────────────────────── --> <!-- ── List by status ────────────────────────────────────────── -->
{(['shipping','in_beta','exploring','considering'] as const).map(status => ( {(['shipping','beta','exploring'] as const).map(status => (
<section class="section"> <section class="section">
<h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2> <h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2>
{grouped[status].length === 0 ? ( {grouped[status].length === 0 ? (
@ -197,6 +176,4 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
padding: 0; padding: 0;
} }
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; } .action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
.muted { color: var(--on-surface-muted); }
</style> </style>

View file

@ -16,6 +16,7 @@ const navLinks = [
const footerLinks = [ const footerLinks = [
{ href: '/vision', label: 'Vision' }, { href: '/vision', label: 'Vision' },
{ href: '/council-manifesto', label: 'Council manifesto' },
]; ];
const currentPath = Astro.url.pathname; const currentPath = Astro.url.pathname;
@ -111,51 +112,35 @@ const year = new Date().getFullYear();
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: var(--space-3);
border-bottom: none; border-bottom: none;
color: var(--on-surface); color: var(--on-surface);
line-height: 1; /* belt + braces — no nav-row leading on the lockup */
} }
.wordmark-link:hover { .wordmark-link:hover {
border-bottom: none; border-bottom: none;
color: var(--on-surface); color: var(--on-surface);
} }
.wordmark { .wordmark {
height: 20px; height: 22px;
width: auto; width: auto;
display: block; display: block;
} }
.wordmark-sep { .wordmark-sep {
color: var(--on-surface-muted); color: var(--on-surface-muted);
font-family: var(--font-serif); font-size: 1rem;
font-size: 18px;
line-height: 1; line-height: 1;
/* Optical kern — the bullet's typographic centre sits slightly above
its baseline in Newsreader; this nudges it onto the visual midline. */
transform: translateY(-2px);
}
/* Italic Newsreader renders ~10% visually taller than regular at the
same font-size — the cursive B has a flourish extending past the
cap line. Drop Bifrost to 16px so its cap+flourish optical height
matches Project's 18px cap, and use inline-block + tiny vertical
padding so the gradient-clip bbox doesn't chop the flourish off. */
.wordmark-project,
.wordmark-bifrost {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: var(--tracking-snug);
line-height: 1.4;
} }
.wordmark-project { .wordmark-project {
font-size: 18px; font-family: var(--font-sans);
font-size: var(--text-body-md);
font-weight: 500;
color: var(--on-surface); color: var(--on-surface);
letter-spacing: 0;
} }
.wordmark-bifrost { .wordmark-bifrost {
display: inline-block; font-family: var(--font-serif);
font-size: 16px;
font-style: italic; font-style: italic;
padding: 3px 0 1px; font-weight: 400;
vertical-align: baseline;
background-image: linear-gradient( background-image: linear-gradient(
90deg, 90deg,
var(--pigment-terracotta) 0%, var(--pigment-terracotta) 0%,
@ -180,15 +165,14 @@ const year = new Date().getFullYear();
display: inline-block; display: inline-block;
width: 1px; width: 1px;
height: 18px; height: 18px;
background: rgba(0, 0, 0, 0.15); background: var(--ghost-border-color);
margin: 0 18px; margin: 0 var(--space-2);
transform: scaleX(0.5); transform: scaleX(0.5);
transform-origin: center; transform-origin: center;
} }
.nav-logout-form { display: inline-flex; } .nav-logout-form { display: inline-flex; }
.nav-link { .nav-link {
position: relative;
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-md); font-size: var(--text-label-md);
font-weight: 500; font-weight: 500;
@ -199,17 +183,17 @@ const year = new Date().getFullYear();
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border-bottom: none; border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard); transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
} }
.nav-link:hover { .nav-link:hover {
color: var(--on-surface); color: var(--on-surface);
background: var(--surface-container-low);
border-bottom: none; border-bottom: none;
} }
/* Active nav link: terracotta + slightly heavier weight. Colour alone
is the indicator — no badge, bullet, italic, or family swap. */
.nav-link.active { .nav-link.active {
color: var(--pigment-terracotta); color: var(--on-surface);
font-weight: 500; background: var(--surface-container);
} }
/* ── User zone ──────────────────────────────────────────────────── */ /* ── User zone ──────────────────────────────────────────────────── */

View file

@ -634,21 +634,6 @@ export function castVote(pulseId: number, userId: number, optionIndex: number):
).run(pulseId, userId, optionIndex); ).run(pulseId, userId, optionIndex);
} }
/** UPSERT first vote inserts, subsequent ones update option_index + voted_at.
* Use this when the UI allows members to change their pick while the pulse is
* still open. Returns true if this was a brand-new vote (so callers can
* record activity once), false if it changed an existing vote. */
export function castOrChangeVote(pulseId: number, userId: number, optionIndex: number): boolean {
const existing = getUserVote(pulseId, userId);
db.prepare(`
INSERT INTO votes (pulse_id, user_id, option_index, voted_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(pulse_id, user_id) DO UPDATE
SET option_index = excluded.option_index, voted_at = excluded.voted_at
`).run(pulseId, userId, optionIndex);
return existing === null;
}
export function getUserVote(pulseId: number, userId: number): number | null { export function getUserVote(pulseId: number, userId: number): number | null {
const r = db.prepare( const r = db.prepare(
'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?' 'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?'
@ -665,7 +650,7 @@ export function countPulseParticipants(pulseId: number): number {
// ── Roadmap items ──────────────────────────────────────────────── // ── Roadmap items ────────────────────────────────────────────────
export type RoadmapStatus = 'shipping' | 'in_beta' | 'exploring' | 'considering'; export type RoadmapStatus = 'shipping' | 'beta' | 'exploring';
export interface RoadmapItem { export interface RoadmapItem {
id: number; id: number;
@ -675,7 +660,6 @@ export interface RoadmapItem {
target: string | null; target: string | null;
display_order: number; display_order: number;
shipped_at: string | null; shipped_at: string | null;
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -690,12 +674,11 @@ export function createRoadmapItem(data: {
status: RoadmapStatus; status: RoadmapStatus;
target?: string | null; target?: string | null;
display_order?: number; display_order?: number;
metadata_text?: string | null;
}): number { }): number {
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
const r = db.prepare(` const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text) INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
VALUES (?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?)
`).run( `).run(
data.title, data.title,
data.description, data.description,
@ -703,7 +686,6 @@ export function createRoadmapItem(data: {
data.target ?? null, data.target ?? null,
data.display_order ?? 0, data.display_order ?? 0,
shipped_at, shipped_at,
data.metadata_text ?? null,
); );
return Number(r.lastInsertRowid); return Number(r.lastInsertRowid);
} }
@ -718,7 +700,6 @@ export function updateRoadmapItem(id: number, data: {
status: RoadmapStatus; status: RoadmapStatus;
target: string | null; target: string | null;
display_order: number; display_order: number;
metadata_text?: string | null;
}): { shippedNow: boolean } { }): { shippedNow: boolean } {
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?') const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined; .get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
@ -732,9 +713,9 @@ export function updateRoadmapItem(id: number, data: {
db.prepare(` db.prepare(`
UPDATE roadmap_items UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?, SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
shipped_at = ?, metadata_text = ?, updated_at = datetime('now') shipped_at = ?, updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id); `).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, id);
return { shippedNow }; return { shippedNow };
} }
@ -1023,7 +1004,6 @@ export interface Dispatch {
published_at: string | null; published_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
pulse_id: number | null; // attached poll, if any
} }
export interface DispatchWithAuthor extends Dispatch { export interface DispatchWithAuthor extends Dispatch {
@ -1032,18 +1012,6 @@ export interface DispatchWithAuthor extends Dispatch {
author_role: Role; author_role: Role;
} }
export interface DispatchWithPoll extends DispatchWithAuthor {
poll: PulseWithCounts | null;
}
/** Optional poll attachment used when creating/updating a dispatch. */
export interface DispatchPollInput {
question: string;
options: string[];
opens_at: string;
closes_at: string;
}
export function createDispatch(data: { export function createDispatch(data: {
title: string; title: string;
body: string; body: string;
@ -1051,99 +1019,34 @@ export function createDispatch(data: {
kind: DispatchKind; kind: DispatchKind;
author_id: number; author_id: number;
status: DispatchStatus; status: DispatchStatus;
poll?: DispatchPollInput | null;
}): number { }): number {
const published_at = data.status === 'published' const published_at = data.status === 'published'
? new Date().toISOString().slice(0, 19).replace('T', ' ') ? new Date().toISOString().slice(0, 19).replace('T', ' ')
: null; : null;
return db.transaction(() => {
let pulseId: number | null = null;
if (data.poll && data.poll.options.length >= 2) {
pulseId = createPulse({
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
status: data.status === 'published' ? 'open' : 'draft',
created_by: data.author_id,
});
}
const r = db.prepare(` const r = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, pulse_id) INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at)
VALUES (?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?)
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at, pulseId); `).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at);
return Number(r.lastInsertRowid); return Number(r.lastInsertRowid);
})();
} }
/** Update a dispatch and, optionally, manage its attached poll. */
export function updateDispatch(id: number, data: { export function updateDispatch(id: number, data: {
title: string; title: string;
body: string; body: string;
excerpt: string | null; excerpt: string | null;
kind: DispatchKind; kind: DispatchKind;
author_id: number; author_id: number;
poll?: DispatchPollInput | null; // present + has options ⇒ attach/update; explicit null ⇒ detach
pollExplicit?: boolean; // distinguishes "leave poll alone" (undefined) from "detach" (null + flag)
}): void { }): void {
db.transaction(() => {
const cur = db.prepare('SELECT pulse_id, status FROM dispatches WHERE id = ?')
.get(id) as { pulse_id: number | null; status: DispatchStatus } | undefined;
if (!cur) return;
let pulseId: number | null = cur.pulse_id;
if (data.pollExplicit) {
if (data.poll && data.poll.options.length >= 2) {
if (cur.pulse_id) {
// update the existing pulse in place
updatePulse(cur.pulse_id, {
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
});
} else {
pulseId = createPulse({
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
status: cur.status === 'published' ? 'open' : 'draft',
created_by: data.author_id,
});
}
} else {
// explicit detach
pulseId = null;
}
}
db.prepare(` db.prepare(`
UPDATE dispatches UPDATE dispatches
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now')
pulse_id = ?, updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, pulseId, id); `).run(data.title, data.body, data.excerpt, data.kind, data.author_id, id);
})();
}
/** Dispatch + its attached poll (with counts + this viewer's vote). */
export function getDispatchWithPoll(dispatchId: number, viewerId: number): DispatchWithPoll | null {
const d = getDispatchById(dispatchId);
if (!d) return null;
const poll = d.pulse_id ? getPulseWithCounts(d.pulse_id, viewerId) : null;
return { ...d, poll };
} }
/** Promote draft published, stamping published_at = now() on first publish. /** Promote draft published, stamping published_at = now() on first publish.
* Idempotent: if already published, published_at is preserved. Also opens * Idempotent: if already published, published_at is preserved. */
* any attached draft poll so members can start voting. */
export function publishDispatch(id: number): void { export function publishDispatch(id: number): void {
db.transaction(() => {
db.prepare(` db.prepare(`
UPDATE dispatches UPDATE dispatches
SET status = 'published', SET status = 'published',
@ -1151,19 +1054,11 @@ export function publishDispatch(id: number): void {
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run(id); `).run(id);
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
if (row?.pulse_id) publishPulse(row.pulse_id);
})();
} }
/** Archive a dispatch. Leaves published_at intact for history. Closes any /** Archive a dispatch. Leaves published_at intact for history. */
* attached open poll so the bar charts read final. */
export function archiveDispatch(id: number): void { export function archiveDispatch(id: number): void {
db.transaction(() => {
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id); db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
if (row?.pulse_id) closePulse(row.pulse_id);
})();
} }
export function deleteDispatch(id: number): void { export function deleteDispatch(id: number): void {

Binary file not shown.

View file

@ -1,119 +0,0 @@
/**
* Coordinate-generation for the /roadmap horizontal route component.
*
* Given an itemCount + viewport width, produces:
* - itemX / itemY: position of each milestone dot on the SVG canvas
* - cardSide: which side ('below' or 'above') the milestone's card hangs on
* - pathD: a smooth cubic-bezier SVG path string snaking through all dots
* - trackWidth: total scroll width of the track ( viewportWidth so the
* page always offers visible content + scroll affordance)
* - midY: vertical centreline of the track, returned for callers that want
* to place additional decoration relative to the centre
*
* No DOM access here pure math. Tested directly in roadmap-layout.test.ts.
*/
export interface LayoutOpts {
itemCount: number;
viewportWidth: number;
minSpacingX?: number; // default 320
trackHeight?: number; // default 460
amplitude?: number; // default 120
paddingX?: number; // default 60
}
export interface LayoutResult {
trackWidth: number;
pathD: string;
itemX: number[];
itemY: number[];
cardSide: ('above' | 'below')[];
midY: number;
}
export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
const minSpacing = opts.minSpacingX ?? 320;
const trackHeight = opts.trackHeight ?? 420;
const amplitude = opts.amplitude ?? 120;
const padding = opts.paddingX ?? 60;
const midY = trackHeight / 2;
const itemCount = Math.max(0, opts.itemCount);
if (itemCount === 0) {
return {
trackWidth: opts.viewportWidth,
pathD: '',
itemX: [],
itemY: [],
cardSide: [],
midY,
};
}
// Aim for ~80% of viewport for low item counts; data-driven minimum
// takes over once items × minSpacing exceeds that target (the carousel
// case — track extends past viewport).
const targetUsableWidth = opts.viewportWidth * 0.80;
const dataDrivenWidth = (itemCount - 1) * minSpacing;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
const trackWidth = usableWidth + padding * 2;
const itemX: number[] = Array.from({ length: itemCount }, (_, i) =>
itemCount === 1
? padding + usableWidth / 2
: padding + (i / (itemCount - 1)) * usableWidth,
);
// First item on the centreline; subsequent items alternate up/down with
// a varying amplitude so the path feels hand-planned rather than purely
// sinusoidal. Multiplier ramps 0.78 (first off-axis) → ~1.18 (last item)
// — closer items swing less, further items swing more.
const denom = Math.max(1, itemCount - 1);
const itemY: number[] = itemX.map((_, i) => {
if (i === 0) return midY;
const direction = i % 2 === 1 ? -1 : 1;
const multiplier = 0.78 + (i / denom) * 0.4;
return midY + direction * amplitude * multiplier;
});
// Cards hang TOWARD the centreline rather than away from it. A dot above
// centre (odd index) gets a card below; a dot below centre (even index >0)
// gets a card above. This keeps every card growing into the track height
// rather than out the top or bottom of the scroll container — which the
// CSS spec clips regardless of overflow-y: visible (browser computes
// overflow-y to auto whenever overflow-x is auto). i=0 sits on the
// centreline, no clipping risk either way; defaulting to below.
const cardSide: ('above' | 'below')[] = itemX.map((_, i) => {
if (i === 0) return 'below';
return i % 2 === 1 ? 'below' : 'above';
});
// Smooth cubic-bezier path. Control points use the midpoint x of each
// segment, with control-y values matching the prior and next item
// respectively — this keeps the curve tangent flat at each milestone
// (the "river" feel rather than the "zigzag" feel).
let d = `M ${itemX[0]} ${itemY[0]}`;
for (let i = 1; i < itemCount; i += 1) {
const cx = (itemX[i - 1] + itemX[i]) / 2;
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
}
return { trackWidth, pathD: d, itemX, itemY, cardSide, midY };
}
/**
* The travelled-portion stop position on the path stroke gradient.
* - No shipping items: 0 (path is entirely "ahead" tone)
* - Some shipping items: (lastShippingIndex + 0.5) / itemCount
* - Clamped to [0, 0.98] so the fade-to-ahead is always visible
*/
export function travelledStopFor(
statuses: ReadonlyArray<'shipping' | 'in_beta' | 'exploring' | 'considering'>,
): number {
if (statuses.length === 0) return 0;
let last = -1;
statuses.forEach((s, i) => { if (s === 'shipping') last = i; });
if (last < 0) return 0;
return Math.min(0.98, (last + 0.5) / statuses.length);
}

View file

@ -101,35 +101,14 @@ if (Astro.request.method === 'POST') {
const authorId = Number(data.get('author_id')); const authorId = Number(data.get('author_id'));
const status = String(data.get('status') ?? 'draft') as DispatchStatus; const status = String(data.get('status') ?? 'draft') as DispatchStatus;
// Parse optional poll attachment fields.
const pollExplicit = String(data.get('poll_explicit') ?? '') === '1';
const pollQuestion = String(data.get('poll_question') ?? '').trim();
const pollOpts = [0, 1, 2, 3]
.map(i => String(data.get(`poll_option_${i}`) ?? '').trim())
.filter(s => s.length > 0);
const pollOpens = String(data.get('poll_opens_at') ?? '');
const pollCloses = String(data.get('poll_closes_at') ?? '');
let pollInput: { question: string; options: string[]; opens_at: string; closes_at: string } | null = null;
if (pollQuestion && pollOpts.length >= 2 && pollOpens && pollCloses) {
pollInput = {
question: pollQuestion,
options: pollOpts,
opens_at: toSqlDate(pollOpens),
closes_at: toSqlDate(pollCloses),
};
}
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) { if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
formError = 'Title, body, and a valid kind are required.'; formError = 'Title, body, and a valid kind are required.';
} else if (action === 'create_dispatch') { } else if (action === 'create_dispatch') {
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status, poll: pollInput }); createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status });
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created'); return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
} else { } else {
const id = Number(data.get('dispatch_id')); const id = Number(data.get('dispatch_id'));
if (id) updateDispatch(id, { if (id) updateDispatch(id, { title, body, excerpt, kind, author_id: authorId || user.id });
title, body, excerpt, kind, author_id: authorId || user.id,
poll: pollInput, pollExplicit,
});
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`); return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
} }
} else if (action === 'publish_dispatch') { } else if (action === 'publish_dispatch') {
@ -200,13 +179,12 @@ if (Astro.request.method === 'POST') {
const status = String(data.get('status') ?? '') as RoadmapStatus; const status = String(data.get('status') ?? '') as RoadmapStatus;
const target = String(data.get('target') ?? '').trim() || null; const target = String(data.get('target') ?? '').trim() || null;
const displayOrder = Number(data.get('display_order') ?? 0); const displayOrder = Number(data.get('display_order') ?? 0);
const metadataText = String(data.get('metadata_text') ?? '').trim() || null;
const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean); const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean);
if (!title || !['shipping','in_beta','exploring','considering'].includes(status)) { if (!title || !['shipping','beta','exploring'].includes(status)) {
formError = 'Title and status are required.'; formError = 'Title and status are required.';
} else if (action === 'create_roadmap') { } else if (action === 'create_roadmap') {
const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder, metadata_text: metadataText }); const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder });
setRoadmapAttributions(id, attributedIds); setRoadmapAttributions(id, attributedIds);
if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id); if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
return Astro.redirect('/admin?tab=roadmap&msg=roadmap_created'); return Astro.redirect('/admin?tab=roadmap&msg=roadmap_created');
@ -214,7 +192,7 @@ if (Astro.request.method === 'POST') {
const id = Number(data.get('roadmap_id')); const id = Number(data.get('roadmap_id'));
if (id) { if (id) {
const { shippedNow } = updateRoadmapItem(id, { const { shippedNow } = updateRoadmapItem(id, {
title, description, status, target, display_order: displayOrder, metadata_text: metadataText, title, description, status, target, display_order: displayOrder,
}); });
setRoadmapAttributions(id, attributedIds); setRoadmapAttributions(id, attributedIds);
if (shippedNow) recordActivity(user.id, 'roadmap_shipped', 'roadmap', id); if (shippedNow) recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
@ -285,11 +263,11 @@ function moveRoadmapItem(id: number, dir: 'up' | 'down'): void {
const other = sameStatus[swapIdx]; const other = sameStatus[swapIdx];
updateRoadmapItem(item.id, { updateRoadmapItem(item.id, {
title: item.title, description: item.description, status: item.status, title: item.title, description: item.description, status: item.status,
target: item.target, display_order: other.display_order, metadata_text: item.metadata_text, target: item.target, display_order: other.display_order,
}); });
updateRoadmapItem(other.id, { updateRoadmapItem(other.id, {
title: other.title, description: other.description, status: other.status, title: other.title, description: other.description, status: other.status,
target: other.target, display_order: item.display_order, metadata_text: other.metadata_text, target: other.target, display_order: item.display_order,
}); });
} }
@ -305,7 +283,6 @@ const editingUser = tab === 'participants' && editId ? getUserPublicById(editId)
const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : []; const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : [];
const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null; const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
const dispatchEditingPoll = dispatchEditing?.pulse_id ? getPulseById(dispatchEditing.pulse_id) : null;
// Per-tab data // Per-tab data
const pulses = tab === 'pulses' ? getAllPulses() : []; const pulses = tab === 'pulses' ? getAllPulses() : [];
@ -358,11 +335,12 @@ actionMsg = Astro.url.searchParams.get('msg');
<h1 class="display-md page-title">Control panel.</h1> <h1 class="display-md page-title">Control panel.</h1>
</header> </header>
<!-- Tabs (Pulses entity merged into Dispatches — polls now attach to articles) --> <!-- Tabs -->
<div class="tabs"> <div class="tabs">
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a> <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=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=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=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=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}> <a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
@ -599,7 +577,7 @@ actionMsg = Astro.url.searchParams.get('msg');
)} )}
{tab === 'dispatches' && ( {tab === 'dispatches' && (
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} editingPoll={dispatchEditingPoll} fenjaUsers={fenjaUsers} currentUserId={user.id} /> <DispatchesTab dispatches={dispatches} editing={dispatchEditing} fenjaUsers={fenjaUsers} currentUserId={user.id} />
)} )}
</div> </div>

View file

@ -1,10 +1,7 @@
--- ---
import AppLayout from '../../layouts/AppLayout.astro'; import AppLayout from '../../layouts/AppLayout.astro';
import Avatar from '../../components/Avatar.astro'; import Avatar from '../../components/Avatar.astro';
import { import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
getDispatchWithPoll, getAdjacentDispatches,
getPulseById, castOrChangeVote, recordActivity, countCabMembers,
} from '../../lib/db';
import { import {
parseDispatchSlug, dispatchSlug, dispatchKindLabel, parseDispatchSlug, dispatchSlug, dispatchKindLabel,
dispatchKindPigment, roleLabel, dispatchKindPigment, roleLabel,
@ -17,38 +14,15 @@ const id = parseDispatchSlug(slugParam);
if (!id) return Astro.redirect('/dispatches'); if (!id) return Astro.redirect('/dispatches');
// Vote POST — handled before main render so we can refresh state const d = getDispatchById(id);
if (Astro.request.method === 'POST') {
const data = await Astro.request.formData();
const action = String(data.get('action') ?? '');
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 wasNew = castOrChangeVote(pulseId, user.id, optionIndex);
if (wasNew) recordActivity(user.id, 'voted', 'pulse', pulseId);
}
return Astro.redirect(Astro.url.pathname);
}
}
const d = getDispatchWithPoll(id, user.id);
if (!d || d.status !== 'published') return Astro.redirect('/dispatches'); if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
// Canonical-redirect when the slug changes after a rename — id is the authority // Canonical-redirect when the slug changes after a rename — id is the authority
const canonical = dispatchSlug(d); const canonical = dispatchSlug(d);
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`); if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
const totalMembers = countCabMembers();
const { prev, next } = getAdjacentDispatches(d.id); const { prev, next } = getAdjacentDispatches(d.id);
function closeDayLabel(closesAt: string): string {
const parsed = closesAt.includes('T') ? new Date(closesAt) : new Date(closesAt.replace(' ', 'T') + 'Z');
return new Intl.DateTimeFormat('en-GB', { weekday: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed);
}
function parseUtc(s: string): Date { function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s); if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z'); return new Date(s.replace(' ', 'T') + 'Z');
@ -89,51 +63,6 @@ const bodyHtml = renderMd(d.body);
<div class="body prose" set:html={bodyHtml} /> <div class="body prose" set:html={bodyHtml} />
{d.poll && (
<aside class="inline-poll" aria-label="Poll attached to this dispatch">
<p class="inline-poll-question">{d.poll.question}</p>
<form method="POST" class="inline-poll-options" novalidate>
<input type="hidden" name="action" value="vote" />
<input type="hidden" name="pulse_id" value={d.poll.id} />
{d.poll.options.map((opt, i) => {
const hasVoted = d.poll!.my_vote !== null;
const chosen = d.poll!.my_vote === i;
const closed = d.poll!.status !== 'open';
const count = d.poll!.votes_by_option[i] ?? 0;
const pct = d.poll!.votes_total > 0 ? (count / d.poll!.votes_total) * 100 : 0;
const letter = String.fromCharCode(65 + i);
return (
<button
type="submit"
name="option_index"
value={i}
class:list={['inline-poll-option', { chosen, closed }]}
disabled={closed && !chosen}
aria-pressed={chosen}
>
<span class="inline-poll-letter">{letter}</span>
<span class="inline-poll-text">{opt}</span>
{hasVoted && (
<span class="inline-poll-pct">{pct.toFixed(0)}%</span>
)}
{hasVoted && (
<span class="inline-poll-bar" aria-hidden="true">
<span class="inline-poll-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
</span>
)}
</button>
);
})}
</form>
<p class="inline-poll-count">
{d.poll.votes_total} of {totalMembers} have weighed in
{d.poll.status === 'open'
? ` · closes ${closeDayLabel(d.poll.closes_at)}`
: ' · closed'}
</p>
</aside>
)}
<hr class="divider" /> <hr class="divider" />
<nav class="adjacent" aria-label="Adjacent dispatches"> <nav class="adjacent" aria-label="Adjacent dispatches">
@ -265,95 +194,6 @@ const bodyHtml = renderMd(d.body);
margin: var(--space-6) 0 0; margin: var(--space-6) 0 0;
} }
/* ── Inline poll attached to the dispatch ──────────────────────── */
.inline-poll {
margin-top: var(--space-7);
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.inline-poll-question {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.25rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.inline-poll-options {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.inline-poll-option {
position: relative;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--background);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-body-md);
color: var(--on-surface);
text-align: left;
cursor: pointer;
transition: border-color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
overflow: hidden;
}
.inline-poll-option:hover:not(.closed) { border-color: var(--outline); }
.inline-poll-option.chosen {
border-color: var(--pigment-terracotta);
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
}
.inline-poll-option.closed:not(.chosen) {
cursor: default;
color: var(--on-surface-variant);
}
.inline-poll-pct {
margin-left: auto;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
font-weight: 600;
letter-spacing: var(--tracking-wider);
color: var(--on-surface-variant);
font-variant-numeric: tabular-nums;
}
.inline-poll-option.chosen .inline-poll-pct { color: var(--pigment-terracotta); }
.inline-poll-option:disabled { opacity: 0.85; }
.inline-poll-letter {
font-weight: 600;
color: var(--on-surface-muted);
flex-shrink: 0;
}
.inline-poll-option.chosen .inline-poll-letter { color: var(--pigment-terracotta); }
.inline-poll-text { flex: 1; }
.inline-poll-bar {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: var(--surface-container);
}
.inline-poll-bar-fill {
display: block;
height: 100%;
background: var(--pigment-terracotta);
opacity: 0.55;
transition: width 600ms var(--ease-standard);
}
.inline-poll-count {
color: var(--on-surface-muted);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
margin: 0;
}
.adjacent { .adjacent {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;

View file

@ -220,7 +220,7 @@ const heroAudience = hero?.audience ?? 'Members only';
/* ── Hero ─────────────────────────────────────────────────────── */ /* ── Hero ─────────────────────────────────────────────────────── */
.hero { .hero {
background: var(--ink); background: var(--ink);
color: var(--on-ink); color: var(--ink-text);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.75rem; padding: 1.75rem;
display: flex; display: flex;
@ -232,7 +232,7 @@ const heroAudience = hero?.audience ?? 'Members only';
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
color: var(--on-ink-muted); color: var(--ink-muted);
} }
.hero-eyebrow { .hero-eyebrow {
font-family: var(--font-sans); font-family: var(--font-sans);
@ -255,7 +255,7 @@ const heroAudience = hero?.audience ?? 'Members only';
left: 100px; left: 100px;
top: 0; bottom: 0; top: 0; bottom: 0;
width: 0.5px; width: 0.5px;
background: var(--ink-divider); background: rgba(232, 224, 208, 0.2);
} }
.hero-date { display: flex; flex-direction: column; gap: 2px; } .hero-date { display: flex; flex-direction: column; gap: 2px; }
@ -264,13 +264,13 @@ const heroAudience = hero?.audience ?? 'Members only';
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--on-ink); color: var(--ink-text);
} }
.hero-day { .hero-day {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 2.75rem; font-size: 2.75rem;
line-height: 1; line-height: 1;
color: var(--on-ink); color: var(--ink-text);
} }
.hero-detail { padding-left: var(--space-5); } .hero-detail { padding-left: var(--space-5); }
@ -279,22 +279,22 @@ const heroAudience = hero?.audience ?? 'Members only';
font-weight: 400; font-weight: 400;
font-size: 1.75rem; font-size: 1.75rem;
line-height: 1.2; line-height: 1.2;
color: var(--on-ink); color: var(--ink-text);
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
} }
.hero-desc { .hero-desc {
color: var(--on-ink-body); color: rgba(232, 224, 208, 0.85);
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
max-width: 40rem; max-width: 40rem;
} }
.hero-meta { .hero-meta {
color: var(--on-ink-muted); color: var(--ink-muted);
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
margin: 0; margin: 0;
} }
.hero-foot { .hero-foot {
border-top: 0.5px solid var(--ink-divider); border-top: 0.5px solid rgba(232, 224, 208, 0.2);
padding-top: var(--space-4); padding-top: var(--space-4);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -304,7 +304,7 @@ const heroAudience = hero?.audience ?? 'Members only';
} }
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); } .hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
.hero-foot-stat { .hero-foot-stat {
color: var(--on-ink-muted); color: var(--ink-muted);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
@ -313,7 +313,7 @@ const heroAudience = hero?.audience ?? 'Members only';
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); } .hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
.hero-cta { .hero-cta {
background: var(--on-ink); background: var(--ink-text);
color: var(--ink); color: var(--ink);
border: none; border: none;
padding: 10px 20px; padding: 10px 20px;
@ -329,20 +329,20 @@ const heroAudience = hero?.audience ?? 'Members only';
.hero-cta:hover { opacity: 0.85; } .hero-cta:hover { opacity: 0.85; }
.hero-confirmed { .hero-confirmed {
color: var(--on-ink); color: var(--ink-text);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-md); font-size: var(--text-label-md);
font-weight: 600; font-weight: 600;
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase; text-transform: uppercase;
padding: 10px 16px; padding: 10px 16px;
border: 0.5px solid rgba(255, 252, 247, 0.4); border: 0.5px solid rgba(232, 224, 208, 0.4);
border-radius: 999px; border-radius: 999px;
} }
.hero-change { .hero-change {
background: transparent; background: transparent;
border: none; border: none;
color: var(--on-ink-muted); color: var(--ink-muted);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
@ -359,7 +359,7 @@ const heroAudience = hero?.audience ?? 'Members only';
display: flex; display: flex;
} }
.hero-empty-line { .hero-empty-line {
color: var(--on-ink); color: var(--ink-text);
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 1.25rem; font-size: 1.25rem;
margin: auto; margin: auto;

File diff suppressed because it is too large Load diff

View file

@ -1,111 +1,212 @@
--- ---
import { readFileSync } from 'fs';
import { join } from 'path';
import AppLayout from '../layouts/AppLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
import LatestDispatchBanner from '../components/LatestDispatchBanner.astro'; import { marked } from 'marked';
import RoadmapRoute from '../components/RoadmapRoute.astro';
import { getAllRoadmapItems } from '../lib/db';
const user = Astro.locals.user; const user = Astro.locals.user;
const items = getAllRoadmapItems() // Single-file roadmap — not a content collection
.sort((a, b) => a.display_order - b.display_order || a.id - b.id); const raw = readFileSync(join(process.cwd(), 'content/roadmap.md'), 'utf-8');
// Strip YAML frontmatter
const body = raw.replace(/^---[\s\S]*?---\n/, '');
// Parse sections by ## headings
function parseSections(md: string) {
const sectionRe = /^## (.+)$/gm;
const sections: { title: string; items: { title: string; body: string; pilotOnly: boolean }[] }[] = [];
const matches = [...md.matchAll(sectionRe)];
for (let i = 0; i < matches.length; i++) {
const m = matches[i];
const start = m.index! + m[0].length;
const end = matches[i + 1]?.index ?? md.length;
const sectionBody = md.slice(start, end).trim();
// Each item starts with **Title** — description
const itemRe = /\*\*([^*]+)\*\*\s*—\s*([\s\S]*?)(?=\n\n\*\*|\n\n##|$)/g;
const items: { title: string; body: string; pilotOnly: boolean }[] = [];
let itemMatch: RegExpExecArray | null;
while ((itemMatch = itemRe.exec(sectionBody)) !== null) {
const rawBody = itemMatch[2].trim();
const pilotOnly = rawBody.includes('`pilot-only`');
const cleanBody = rawBody.replace(/`pilot-only`/g, '').trim();
items.push({ title: itemMatch[1], body: cleanBody, pilotOnly });
}
sections.push({ title: m[1], items });
}
return sections;
}
const sections = parseSections(body);
const horizonColors: Record<string, string> = {
'In progress': 'var(--pigment-copper)',
'Next': 'var(--pigment-ochre)',
'Later': 'var(--pigment-indigo)',
};
--- ---
<AppLayout title="Roadmap" user={user}> <AppLayout title="Roadmap" user={user}>
<article class="roadmap-page"> <div class="page">
<!-- Single centred header — merges the page lead with the route's <header class="page-header">
interaction hints. --> <h1 class="display-md page-title">What we are building.</h1>
<header class="roadmap-header"> <p class="subtitle">
<h1 class="roadmap-title">Roadmap</h1> Three horizons. What is in progress now, what comes next,
<p class="roadmap-sub"> and what is further out. This is the live picture.
A live picture of the work. What's in motion, what's queued,
what we're still thinking about. Tap or hover any milestone
for the full story. Drag or scroll to move.
</p> </p>
</header> </header>
<!-- Legend lives above the route now — reads as a key the eye picks <div class="horizons">
up just before walking the path. --> {sections.map((section) => (
<div class="roadmap-legend" aria-label="Status legend"> <section class="horizon">
<span><i style="background:#6d8c7c"></i>Shipping</span> <div class="horizon-header">
<span><i style="background:#b96b58"></i>In beta</span> <span
<span><i style="background:#b4b2a9"></i>Exploring</span> class="horizon-dot"
<span><i style="background:#d4d2c8"></i>Considering</span> style={`background: ${horizonColors[section.title] ?? 'var(--on-surface-muted)'}`}
aria-hidden="true"
/>
<h2 class="headline-sm horizon-title">{section.title}</h2>
</div> </div>
<RoadmapRoute items={items} /> <ul class="item-list">
{section.items.map((item) => (
<li class="item">
<div class="item-header">
<h3 class="item-title body-lg">{item.title}</h3>
{item.pilotOnly && (
<span class="pilot-badge label-sm" title="Available to pilot participants only">
Pilot
</span>
)}
</div>
<p class="body-md item-body">{item.body}</p>
</li>
))}
</ul>
</section>
))}
</div>
<!-- Latest dispatch sits at the foot of the page with generous </div>
space above so it reads as a separate beat, not a continuation
of the route. -->
<LatestDispatchBanner />
</article>
</AppLayout> </AppLayout>
<style> <style>
.roadmap-page { .page {
padding: 0 36px 80px; padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max); max-width: var(--content-max);
margin: 0 auto; margin: 0 auto;
} }
/* ── Centred header ──────────────────────────────────────────── */ /* ── Header ──────────────────────────────────────────────────────── */
.roadmap-header { .page-header {
text-align: center; max-width: 44rem;
max-width: 640px; margin-bottom: var(--space-12);
margin: 0 auto 56px; /* generous gap to the legend */
padding-top: 96px;
}
.roadmap-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 48px;
line-height: 1.05;
letter-spacing: var(--tracking-tight);
color: var(--on-surface);
margin: 0 0 14px;
}
.roadmap-sub {
font-size: 14px;
line-height: 1.65;
color: var(--on-surface-variant);
margin: 0 auto;
max-width: 520px;
} }
/* ── Legend (above the route, key-style) ─────────────────────── */ .eyebrow {
.roadmap-legend { letter-spacing: var(--tracking-wider);
display: flex;
justify-content: center;
gap: 24px;
margin: 0 auto 14px; /* tight to the route — they're paired */
flex-wrap: wrap;
}
.roadmap-legend span {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-variant); color: var(--on-surface-muted);
margin-bottom: var(--space-3);
} }
.roadmap-legend i {
width: 8px; .page-title {
height: 8px; margin-bottom: var(--space-5);
border-radius: 50%; }
.subtitle {
color: var(--on-surface-variant);
max-width: var(--reading-max);
margin: 0;
}
/* ── Horizons ────────────────────────────────────────────────────── */
.horizons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-8);
align-items: start;
}
.horizon {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.horizon-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding-bottom: var(--space-4);
border-bottom: var(--ghost-border);
}
.horizon-dot {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Dispatch banner (foot of page, generous breathing room) ── */ .horizon-title {
.roadmap-page :global(.rr-dispatch) { margin-top: 112px; } font-family: var(--font-sans);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
font-size: var(--text-label-md);
margin: 0;
color: var(--on-surface-variant);
}
@media (max-width: 767px) { /* ── Items ───────────────────────────────────────────────────────── */
.roadmap-page { padding: 0 24px 64px; } .item-list {
.roadmap-header { padding-top: 72px; margin-bottom: 40px; } list-style: none;
.roadmap-title { font-size: 36px; } padding: 0;
.roadmap-legend { margin-bottom: 12px; } margin: 0;
.roadmap-page :global(.rr-dispatch) { margin-top: 72px; } display: flex;
flex-direction: column;
gap: var(--space-6);
}
.item {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.item-header {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.item-title {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: var(--tracking-snug);
margin: 0;
color: var(--on-surface);
flex: 1;
}
.pilot-badge {
flex-shrink: 0;
padding: 0.2em var(--space-2);
background: var(--surface-container);
border-radius: var(--radius-full);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
white-space: nowrap;
}
.item-body {
margin: 0;
color: var(--on-surface-variant);
line-height: var(--leading-relaxed);
} }
</style> </style>

View file

@ -232,27 +232,23 @@ a:hover {
.ghost-border { border: var(--ghost-border); } .ghost-border { border: var(--ghost-border); }
.ghost-border-bottom { border-bottom: var(--ghost-border); } .ghost-border-bottom { border-bottom: var(--ghost-border); }
/* --- Section link black serif italic, underlined, larger. /* --- Section link prominent italic serif, placed at the bottom of
Placed at the bottom of its respective box or article. its respective box or article. See points 8 + 10 in the v3 spec:
Italics are reserved for links + the Bifrost wordmark. --- */ italics are reserved for links + the Bifrost wordmark. --- */
.section-link { .section-link {
display: inline-block; display: inline-block;
font-family: var(--font-serif); font-family: var(--font-serif);
font-style: italic; font-style: italic;
font-size: var(--text-title-lg); /* 1.125rem — larger than body */ font-size: var(--text-body-md);
color: var(--on-surface); color: var(--pigment-terracotta);
text-decoration: underline; text-decoration: none;
text-decoration-thickness: 0.5px;
text-underline-offset: 4px;
border-bottom: none; border-bottom: none;
transition: opacity var(--duration-fast) var(--ease-standard); transition: opacity var(--duration-fast) var(--ease-standard);
} }
.section-link:hover { .section-link:hover {
color: var(--on-surface);
text-decoration: underline;
text-decoration-thickness: 1px;
border-bottom: none; border-bottom: none;
opacity: 0.78; opacity: 0.78;
color: var(--pigment-terracotta);
} }
.section-link--ink { .section-link--ink {
color: var(--ink-text); color: var(--ink-text);

View file

@ -45,14 +45,8 @@
--surface-card: #ffffff; --surface-card: #ffffff;
--surface-card-border: rgba(0, 0, 0, 0.08); --surface-card-border: rgba(0, 0, 0, 0.08);
--ink: #2c3a52; /* deep indigo — membership card + event hero */ --ink: #2c3a52; /* deep indigo — membership card + event hero */
--ink-text: #e8e0d0; /* legacy warm cream — superseded by --on-ink */ --ink-text: #e8e0d0; /* readable cream on --ink */
--ink-muted: #b8a989; /* legacy tan — superseded by --on-ink-muted */ --ink-muted: #b8a989; /* muted label tone on --ink */
/* --- v4: bleached cream on indigo surfaces (replaces --ink-text) --- */
--on-ink: #fffcf7; /* primary text on --ink */
--on-ink-body: rgba(255, 252, 247, 0.85); /* body copy */
--on-ink-muted: rgba(255, 252, 247, 0.65); /* tracked labels */
--ink-divider: rgba(255, 252, 247, 0.18); /* 0.5px lines on --ink */
/* --- Semantic state mappings --- */ /* --- Semantic state mappings --- */
--color-success: var(--pigment-copper); --color-success: var(--pigment-copper);
@ -135,6 +129,6 @@
--duration-slow: 420ms; --duration-slow: 420ms;
/* --- Layout --- */ /* --- Layout --- */
--content-max: 72rem; /* 1152px */ --content-max: 83rem; /* 1328px — 15% wider than the original 72rem */
--reading-max: 42rem; /* 672px */ --reading-max: 42rem; /* 672px */
} }

View file

@ -1,94 +0,0 @@
import { describe, it, expect } from 'vitest';
import { computeRouteLayout, travelledStopFor } from '../src/lib/roadmap-layout.js';
function isStrictlyIncreasing(xs: number[]): boolean {
for (let i = 1; i < xs.length; i += 1) if (xs[i] <= xs[i - 1]) return false;
return true;
}
describe('computeRouteLayout', () => {
it('1 item — produces a valid single-point M path on the centreline', () => {
const out = computeRouteLayout({ itemCount: 1, viewportWidth: 1000 });
expect(out.itemX).toHaveLength(1);
expect(out.midY).toBe(210); // trackHeight 420 / 2
expect(out.itemY).toEqual([out.midY]);
expect(out.cardSide).toEqual(['below']);
expect(out.pathD.startsWith('M ')).toBe(true);
expect(out.pathD).not.toContain('C ');
// Target usable width = 1000 * 0.8 = 800; trackWidth = 800 + 60*2 = 920
expect(out.trackWidth).toBe(920);
});
it('2 items — cards hang toward centreline (both below: i=0 centre, i=1 above-centre)', () => {
const out = computeRouteLayout({ itemCount: 2, viewportWidth: 1000 });
expect(out.itemX).toHaveLength(2);
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
// i=0: centreline (below by convention). i=1: dot above centre → card below.
expect(out.cardSide).toEqual(['below', 'below']);
expect(out.pathD.startsWith('M ')).toBe(true);
expect((out.pathD.match(/C /g) ?? []).length).toBe(1);
});
it('3 items — cards toward centreline; amplitude multiplier ramps', () => {
const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1000 });
// i=0 centre (below), i=1 above-centre (card below), i=2 below-centre (card above).
expect(out.cardSide).toEqual(['below', 'below', 'above']);
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
// First item on centreline, second above (smaller y), third below.
expect(out.itemY[0]).toBe(out.midY);
expect(out.itemY[1]).toBeLessThan(out.midY);
expect(out.itemY[2]).toBeGreaterThan(out.midY);
expect(Math.abs(out.itemY[2] - out.midY)).toBeGreaterThan(Math.abs(out.itemY[1] - out.midY));
});
it('7 items — every card grows toward the centreline, never away from it', () => {
const out = computeRouteLayout({ itemCount: 7, viewportWidth: 1200 });
expect(out.itemX).toHaveLength(7);
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
// i: 0 1 2 3 4 5 6 — odd indices dot-above-centre (card below), even
// indices >0 dot-below-centre (card above). i=0 default below.
expect(out.cardSide).toEqual(['below', 'below', 'above', 'below', 'above', 'below', 'above']);
// Spot-check: every non-i=0 card's side is opposite to its dot's
// offset from centre — i.e. cards always shrink toward midY.
for (let i = 1; i < out.itemX.length; i += 1) {
const dotAbove = out.itemY[i] < out.midY;
if (dotAbove) expect(out.cardSide[i]).toBe('below');
else expect(out.cardSide[i]).toBe('above');
}
expect(out.pathD.startsWith('M ')).toBe(true);
expect((out.pathD.match(/C /g) ?? []).length).toBe(6);
});
it('20 items — data-driven width wins over the 80% target', () => {
const out = computeRouteLayout({ itemCount: 20, viewportWidth: 800 });
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
// (20 - 1) * 320 + 60 * 2 = 6200; clearly beats 800 * 0.8 + 120 = 760.
expect(out.trackWidth).toBe(6200);
});
it('few items on a wide viewport — track ≈ 80% of viewport + padding', () => {
// 3 items at viewport 1400. Data-driven = 2 * 320 + 120 = 760.
// 80% target = 1400 * 0.8 + 120 = 1240 — should win.
const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1400 });
expect(out.trackWidth).toBe(1240);
});
});
describe('travelledStopFor', () => {
it('returns 0 when no items have shipped', () => {
expect(travelledStopFor(['exploring', 'considering'])).toBe(0);
expect(travelledStopFor([])).toBe(0);
});
it('returns (lastShippingIndex + 0.5) / itemCount', () => {
// [shipping, shipping, in_beta, exploring] → lastShipping = 1 → (1.5)/4 = 0.375
expect(travelledStopFor(['shipping', 'shipping', 'in_beta', 'exploring'])).toBeCloseTo(0.375, 5);
});
it('clamps to 0.98 when every item has shipped', () => {
expect(travelledStopFor(['shipping', 'shipping', 'shipping'])).toBeCloseTo(0.833, 2);
// even with 100 items all shipping, clamps to 0.98
const allShipping = Array(100).fill('shipping') as ('shipping')[];
expect(travelledStopFor(allShipping)).toBeLessThanOrEqual(0.98);
});
});

View file

@ -1,33 +0,0 @@
import { describe, it, expect } from 'vitest';
import { tenureMilestone } from '../src/lib/format.js';
describe('tenureMilestone — copy variants by day count', () => {
it('0 days reads "Day one."', () => {
expect(tenureMilestone(0)).toBe('Day one. The team is reading every note you leave.');
});
it('1 day reads "Day 2." (off-by-one — day 1 is the first 24h after joining)', () => {
expect(tenureMilestone(1)).toBe('Day 2. The team is reading every note you leave.');
});
it('7 days enters the "{n} days in" bucket', () => {
expect(tenureMilestone(7)).toBe('7 days in. The team is reading every note you leave.');
});
it('22 days reads "A few weeks in."', () => {
expect(tenureMilestone(22)).toBe('A few weeks in. The team is reading every note you leave.');
});
it('60 days reads "{n_months} months in." (months = floor(days/30))', () => {
expect(tenureMilestone(60)).toBe('2 months in. The team is reading every note you leave.');
});
it('200 days reads "Almost a year in." (switches to "Still" suffix)', () => {
expect(tenureMilestone(200)).toBe('Almost a year in. Still reading every note you leave.');
});
it('400 days reads "{n_years} year(s) in."', () => {
expect(tenureMilestone(400)).toBe('1 year in. Still reading every note you leave.');
expect(tenureMilestone(730)).toBe('2 years in. Still reading every note you leave.');
});
});