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(pnpm db:seed)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)",
"Bash(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}')"
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)"
]
}
}

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: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
{ 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(`
@ -78,15 +75,7 @@ for (const c of newCabs) {
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
const tenureWeeks = {
'lars@virk2.dk': 24,
'anna@virk3.dk': 14,
'soren@virk4.dk': 12,
'henriette@virk5.dk': 10,
'mads@virk6.dk': 8,
'camilla@virk7.dk': 6,
'frederik@virk8.dk': 3,
};
const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
const setCabMeta = db.prepare(`
UPDATE users
@ -119,21 +108,6 @@ const cabMeta = {
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
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) {
@ -169,17 +143,14 @@ const nowIso = (offsetSeconds = 0) => {
return d.toISOString().replace('T', ' ').slice(0, 19);
};
// ── Poll attached to a dispatch (the decision) — open, 2/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.
// ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
const pulseOptions = [
'Locking down on-prem deployment first',
'Pushing the traceability layer to GA',
'Going wide on document ingestion',
'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)
VALUES (?,?,?,?,?,?,?)
`).run(
@ -189,36 +160,27 @@ const decisionPulseId = db.prepare(`
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
).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 (?,?,?,?)')
.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 (?,?,?,?)')
.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
// 'shipping soon'. Items 1-2 are live in production; items 3-4 are in
// beta even if 'audit log export' has a near-term GA target. Travelled
// stop = (1 + 0.5) / 9 ≈ 0.17, putting the 'you are here' marker at
// the visible transition between travelled and ahead tones on the path.
// ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
const roadmap = [
{ title: 'Traceability layer', description: 'Every inference call writes a signed audit record. Shaped by Lars in our March session.', status: 'shipping', target: 'Live since March', display_order: 1, shipped_at: nowIso(-60 * 24 * 3600), attributed: [cabs[0].id], metadata_text: 'Shaped by Lars in our March session' },
{ title: 'Document ingestion', description: "Indexing PDF, Word, and plain text with proper chunking. Pilot-tested with Mette's team.", status: 'shipping', target: 'Live since late May', display_order: 2, shipped_at: nowIso(-7 * 24 * 3600), attributed: [cabs[1].id, cabs[2].id], metadata_text: "Pilot-tested with Mette's team" },
{ title: 'Audit log export', description: 'Stream the signed records to your own S3 or on-prem object store.', status: 'in_beta', target: 'GA next week', display_order: 3, shipped_at: null, attributed: [cabs[3].id], metadata_text: 'Builds on traceability layer' },
{ title: 'Agentic query mode', description: 'Multi-step retrieval over locked, on-prem document stores. Currently testing with two pilot organisations.', status: 'in_beta', target: 'July', display_order: 4, shipped_at: null, attributed: [cabs[1].id], metadata_text: 'Request beta access →' },
{ title: 'Contextual memory', description: 'Sessions that remember constraints between calls without leaking context across organisational boundaries.', status: 'exploring', target: 'Q3 2026', display_order: 5, shipped_at: null, attributed: [cabs[3].id], metadata_text: '2 council requests' },
{ title: 'Multi-organisation graphs', description: 'Permission-controlled knowledge spaces for departments within a single deployment.', status: 'exploring', target: 'Q3 2026', display_order: 6, shipped_at: null, attributed: [cabs[4].id], metadata_text: 'Open question on key custody' },
{ title: 'Multi-tenant isolation', description: 'Cryptographic separation between sub-organisations on shared infrastructure.', status: 'exploring', target: 'Q4 2026', display_order: 7, shipped_at: null, attributed: [cabs[5].id], metadata_text: null },
{ title: 'Federated learning hooks', description: 'Let aligned organisations train on shared signal without sharing the underlying data.', status: 'considering', target: '2027', display_order: 8, shipped_at: null, attributed: [], metadata_text: 'Council input wanted' },
{ title: 'Open evaluation framework', description: 'A public benchmark suite for compliant-AI use in regulated industries.', status: 'considering', target: '2027', display_order: 9, shipped_at: null, attributed: [], metadata_text: 'Long-term direction' },
{ title: 'Traceability layer', description: 'Every response cites its sources with structured provenance.', status: 'shipping', target: 'Live now', display_order: 10, shipped_at: nowIso(-2 * 24 * 3600), attributed: [cabs[0].id] },
{ title: 'Document ingestion pipeline', description: 'Upload PDF, Word, plain text. Chunked, indexed, retrievable.', status: 'beta', target: null, display_order: 10, shipped_at: null, attributed: [cabs[1].id, cabs[2].id] },
{ title: 'Contextual memory', description: 'The system learns the regulatory and organisational context over time.', status: 'exploring', target: 'Q3 2026', display_order: 10, shipped_at: null, attributed: [cabs[3].id] },
{ title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, shipped_at: null, attributed: [] },
];
const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?)
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
VALUES (?,?,?,?,?,?)
`);
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
for (const r of roadmap) {
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, 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);
}
@ -247,9 +209,7 @@ for (const c of contribs) {
const dispatchSeed = [
{ kind: 'decision', ageDays: 2,
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.
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.`,
excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.',
body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction.
@ -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 insertDispatch = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id)
VALUES (?,?,?,?,?,'published',?,?,?,?)
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
VALUES (?,?,?,?,?,'published',?,?,?)
`);
for (let i = 0; i < dispatchSeed.length; i += 1) {
const d = dispatchSeed[i];
const when = nowIso(-d.ageDays * 24 * 60 * 60);
const authorId = fenjas[i % fenjas.length].id;
// Attach the decision-pulse to the decision dispatch — this is the demo
// 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);
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
}
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
@ -372,13 +329,13 @@ const insertActivity = db.prepare(`
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
VALUES (?,?,?,?,?)
`);
insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600));
insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600));
insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60));
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60));
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`);
console.log(' roadmap: 9 items (2 shipping / 2 in_beta / 3 exploring / 2 considering)');
console.log(' pulse #' + pulseId + ' open, 2 of 4 voted');
console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
console.log(' events: dinner + studio hours + working session, 2 past');

View file

@ -30,7 +30,7 @@ const md = readFileSync(mdPath, 'utf8');
// schema's three statuses. In-progress items are actively being built and
// tested with pilots → beta. Next/Later are roadmap intent, not started → exploring.
const SECTION_STATUS = {
'In progress': { status: 'in_beta', target: null },
'In progress': { status: 'beta', target: null },
'Next': { status: 'exploring', target: 'Next quarter' },
'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>
.m-card {
background: var(--ink);
color: var(--on-ink);
color: var(--ink-text);
border-radius: var(--radius-lg);
padding: 1.25rem;
display: flex;
@ -70,7 +70,7 @@ const tags = readFocusTags(member.focus_tags);
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--on-ink);
background: var(--ink-text);
color: var(--ink);
display: inline-flex;
align-items: center;
@ -89,7 +89,7 @@ const tags = readFocusTags(member.focus_tags);
font-weight: 500;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
color: var(--ink-muted);
}
.m-name {
@ -99,7 +99,7 @@ const tags = readFocusTags(member.focus_tags);
font-weight: 400;
font-size: 1.5rem;
line-height: 1.15;
color: var(--on-ink);
color: var(--ink-text);
display: flex;
flex-direction: column;
}
@ -116,12 +116,12 @@ const tags = readFocusTags(member.focus_tags);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
color: var(--ink-muted);
}
.m-since-value {
font-family: var(--font-sans);
font-size: var(--text-body-sm);
color: var(--on-ink);
color: var(--ink-text);
}
.m-tags {
@ -133,8 +133,8 @@ const tags = readFocusTags(member.focus_tags);
gap: 6px;
}
.m-tag {
border: 0.5px solid rgba(255, 252, 247, 0.3);
color: var(--on-ink);
border: 0.5px solid rgba(232, 224, 208, 0.3);
color: var(--ink-text);
padding: 3px 8px;
border-radius: 999px;
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 { dispatchKindLabel } from '../../lib/format';
interface Props {
dispatches: DispatchWithAuthor[];
editing: DispatchWithAuthor | null;
editingPoll: PulseRow | null;
fenjaUsers: UserPublic[];
currentUserId: number;
}
const { dispatches, editing, editingPoll, 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 { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
const STATUS_LABEL: Record<string, string> = {
draft: 'Draft',
@ -66,9 +57,8 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
</div>
<div class="field">
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional)</label>
<textarea id="d-excerpt" name="excerpt" class="input body-md" rows="4">{editing?.excerpt ?? ''}</textarea>
<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>
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional — falls back to first ~200 chars of body)</label>
<input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
</div>
<div class="field">
@ -88,65 +78,6 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
</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">
<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>}
@ -213,30 +144,6 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
.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 {
display: inline-block;
padding: 0.15em var(--space-3);

View file

@ -9,23 +9,17 @@ interface Props {
const { items, editing, cabUsers } = Astro.props;
const STATUS_LABEL = {
shipping: 'Shipping',
in_beta: 'In beta',
exploring: 'Exploring',
considering: 'Considering',
} as const;
const STATUS_LABEL = { shipping: 'Shipping', beta: 'Beta', exploring: 'Exploring' } as const;
const formAction = editing ? 'update_roadmap' : 'create_roadmap';
const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id));
// Group items by status for display
type Status = 'shipping' | 'in_beta' | 'exploring' | 'considering';
type Status = 'shipping' | 'beta' | 'exploring';
const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
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),
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),
shipping: items.filter(i => i.status === 'shipping' ).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),
};
---
<div class="tab-content">
@ -45,10 +39,9 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
<div class="field">
<label for="status" class="label-sm field-label">Status</label>
<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="in_beta" selected={editing?.status === 'in_beta'}>In beta</option>
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option>
<option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
<option value="beta" selected={editing?.status === 'beta'}>Beta</option>
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option>
</select>
</div>
<div class="field">
@ -66,20 +59,6 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
</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">
<legend class="label-sm field-label">Attributed members (who shaped this)</legend>
{cabUsers.map(u => (
@ -99,7 +78,7 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
</section>
<!-- ── List by status ────────────────────────────────────────── -->
{(['shipping','in_beta','exploring','considering'] as const).map(status => (
{(['shipping','beta','exploring'] as const).map(status => (
<section class="section">
<h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2>
{grouped[status].length === 0 ? (
@ -197,6 +176,4 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
.muted { color: var(--on-surface-muted); }
</style>

View file

@ -15,7 +15,8 @@ const navLinks = [
];
const footerLinks = [
{ href: '/vision', label: 'Vision' },
{ href: '/vision', label: 'Vision' },
{ href: '/council-manifesto', label: 'Council manifesto' },
];
const currentPath = Astro.url.pathname;
@ -111,51 +112,35 @@ const year = new Date().getFullYear();
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
gap: var(--space-3);
border-bottom: none;
color: var(--on-surface);
line-height: 1; /* belt + braces — no nav-row leading on the lockup */
}
.wordmark-link:hover {
border-bottom: none;
color: var(--on-surface);
}
.wordmark {
height: 20px;
height: 22px;
width: auto;
display: block;
}
.wordmark-sep {
color: var(--on-surface-muted);
font-family: var(--font-serif);
font-size: 18px;
font-size: 1rem;
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 {
font-size: 18px;
font-family: var(--font-sans);
font-size: var(--text-body-md);
font-weight: 500;
color: var(--on-surface);
letter-spacing: 0;
}
.wordmark-bifrost {
display: inline-block;
font-size: 16px;
font-family: var(--font-serif);
font-style: italic;
padding: 3px 0 1px;
vertical-align: baseline;
font-weight: 400;
background-image: linear-gradient(
90deg,
var(--pigment-terracotta) 0%,
@ -180,15 +165,14 @@ const year = new Date().getFullYear();
display: inline-block;
width: 1px;
height: 18px;
background: rgba(0, 0, 0, 0.15);
margin: 0 18px;
background: var(--ghost-border-color);
margin: 0 var(--space-2);
transform: scaleX(0.5);
transform-origin: center;
}
.nav-logout-form { display: inline-flex; }
.nav-link {
position: relative;
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
@ -199,17 +183,17 @@ const year = new Date().getFullYear();
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
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 {
color: var(--on-surface);
background: var(--surface-container-low);
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 {
color: var(--pigment-terracotta);
font-weight: 500;
color: var(--on-surface);
background: var(--surface-container);
}
/* ── User zone ──────────────────────────────────────────────────── */

View file

@ -634,21 +634,6 @@ export function castVote(pulseId: number, userId: number, optionIndex: number):
).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 {
const r = db.prepare(
'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?'
@ -665,7 +650,7 @@ export function countPulseParticipants(pulseId: number): number {
// ── Roadmap items ────────────────────────────────────────────────
export type RoadmapStatus = 'shipping' | 'in_beta' | 'exploring' | 'considering';
export type RoadmapStatus = 'shipping' | 'beta' | 'exploring';
export interface RoadmapItem {
id: number;
@ -675,7 +660,6 @@ export interface RoadmapItem {
target: string | null;
display_order: number;
shipped_at: string | null;
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
created_at: string;
updated_at: string;
}
@ -690,12 +674,11 @@ export function createRoadmapItem(data: {
status: RoadmapStatus;
target?: string | null;
display_order?: number;
metadata_text?: string | null;
}): number {
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?)
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
VALUES (?,?,?,?,?,?)
`).run(
data.title,
data.description,
@ -703,7 +686,6 @@ export function createRoadmapItem(data: {
data.target ?? null,
data.display_order ?? 0,
shipped_at,
data.metadata_text ?? null,
);
return Number(r.lastInsertRowid);
}
@ -718,7 +700,6 @@ export function updateRoadmapItem(id: number, data: {
status: RoadmapStatus;
target: string | null;
display_order: number;
metadata_text?: string | null;
}): { shippedNow: boolean } {
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
@ -732,9 +713,9 @@ export function updateRoadmapItem(id: number, data: {
db.prepare(`
UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
shipped_at = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, id);
return { shippedNow };
}
@ -1023,7 +1004,6 @@ export interface Dispatch {
published_at: string | null;
created_at: string;
updated_at: string;
pulse_id: number | null; // attached poll, if any
}
export interface DispatchWithAuthor extends Dispatch {
@ -1032,18 +1012,6 @@ export interface DispatchWithAuthor extends Dispatch {
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: {
title: string;
body: string;
@ -1051,119 +1019,46 @@ export function createDispatch(data: {
kind: DispatchKind;
author_id: number;
status: DispatchStatus;
poll?: DispatchPollInput | null;
}): number {
const published_at = data.status === 'published'
? new Date().toISOString().slice(0, 19).replace('T', ' ')
: 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(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, pulse_id)
VALUES (?,?,?,?,?,?,?,?)
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at, pulseId);
return Number(r.lastInsertRowid);
})();
const r = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at)
VALUES (?,?,?,?,?,?,?)
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at);
return Number(r.lastInsertRowid);
}
/** Update a dispatch and, optionally, manage its attached poll. */
export function updateDispatch(id: number, data: {
title: string;
body: string;
excerpt: string | null;
kind: DispatchKind;
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 {
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(`
UPDATE dispatches
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?,
pulse_id = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, pulseId, 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 };
db.prepare(`
UPDATE dispatches
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, id);
}
/** Promote draft published, stamping published_at = now() on first publish.
* Idempotent: if already published, published_at is preserved. Also opens
* any attached draft poll so members can start voting. */
* Idempotent: if already published, published_at is preserved. */
export function publishDispatch(id: number): void {
db.transaction(() => {
db.prepare(`
UPDATE dispatches
SET status = 'published',
published_at = COALESCE(published_at, datetime('now')),
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) publishPulse(row.pulse_id);
})();
db.prepare(`
UPDATE dispatches
SET status = 'published',
published_at = COALESCE(published_at, datetime('now')),
updated_at = datetime('now')
WHERE id = ?
`).run(id);
}
/** Archive a dispatch. Leaves published_at intact for history. Closes any
* attached open poll so the bar charts read final. */
/** Archive a dispatch. Leaves published_at intact for history. */
export function archiveDispatch(id: number): void {
db.transaction(() => {
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);
})();
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
}
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 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)) {
formError = 'Title, body, and a valid kind are required.';
} else if (action === 'create_dispatch') {
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status, poll: pollInput });
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status });
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
} else {
const id = Number(data.get('dispatch_id'));
if (id) updateDispatch(id, {
title, body, excerpt, kind, author_id: authorId || user.id,
poll: pollInput, pollExplicit,
});
if (id) updateDispatch(id, { title, body, excerpt, kind, author_id: authorId || user.id });
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
}
} else if (action === 'publish_dispatch') {
@ -195,18 +174,17 @@ if (Astro.request.method === 'POST') {
// ── Roadmap ──────────────────────────────────────────────────
} else if (action === 'create_roadmap' || action === 'update_roadmap') {
const title = String(data.get('title') ?? '').trim();
const description = String(data.get('description') ?? '').trim();
const status = String(data.get('status') ?? '') as RoadmapStatus;
const target = String(data.get('target') ?? '').trim() || null;
const displayOrder = Number(data.get('display_order') ?? 0);
const metadataText = String(data.get('metadata_text') ?? '').trim() || null;
const title = String(data.get('title') ?? '').trim();
const description = String(data.get('description') ?? '').trim();
const status = String(data.get('status') ?? '') as RoadmapStatus;
const target = String(data.get('target') ?? '').trim() || null;
const displayOrder = Number(data.get('display_order') ?? 0);
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.';
} 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);
if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
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'));
if (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);
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];
updateRoadmapItem(item.id, {
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, {
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 dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
const dispatchEditingPoll = dispatchEditing?.pulse_id ? getPulseById(dispatchEditing.pulse_id) : null;
// Per-tab data
const pulses = tab === 'pulses' ? getAllPulses() : [];
@ -358,11 +335,12 @@ actionMsg = Astro.url.searchParams.get('msg');
<h1 class="display-md page-title">Control panel.</h1>
</header>
<!-- Tabs (Pulses entity merged into Dispatches — polls now attach to articles) -->
<!-- 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=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a>
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a>
<a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a>
<a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
@ -599,7 +577,7 @@ actionMsg = Astro.url.searchParams.get('msg');
)}
{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>

View file

@ -1,10 +1,7 @@
---
import AppLayout from '../../layouts/AppLayout.astro';
import Avatar from '../../components/Avatar.astro';
import {
getDispatchWithPoll, getAdjacentDispatches,
getPulseById, castOrChangeVote, recordActivity, countCabMembers,
} from '../../lib/db';
import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
import {
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
dispatchKindPigment, roleLabel,
@ -17,38 +14,15 @@ const id = parseDispatchSlug(slugParam);
if (!id) return Astro.redirect('/dispatches');
// Vote POST — handled before main render so we can refresh state
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);
const d = getDispatchById(id);
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
// Canonical-redirect when the slug changes after a rename — id is the authority
const canonical = dispatchSlug(d);
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
const totalMembers = countCabMembers();
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 {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
@ -89,51 +63,6 @@ const bodyHtml = renderMd(d.body);
<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" />
<nav class="adjacent" aria-label="Adjacent dispatches">
@ -265,95 +194,6 @@ const bodyHtml = renderMd(d.body);
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 {
display: grid;
grid-template-columns: 1fr 1fr;

View file

@ -220,7 +220,7 @@ const heroAudience = hero?.audience ?? 'Members only';
/* ── Hero ─────────────────────────────────────────────────────── */
.hero {
background: var(--ink);
color: var(--on-ink);
color: var(--ink-text);
border-radius: var(--radius-lg);
padding: 1.75rem;
display: flex;
@ -232,7 +232,7 @@ const heroAudience = hero?.audience ?? 'Members only';
display: flex;
justify-content: space-between;
align-items: center;
color: var(--on-ink-muted);
color: var(--ink-muted);
}
.hero-eyebrow {
font-family: var(--font-sans);
@ -255,7 +255,7 @@ const heroAudience = hero?.audience ?? 'Members only';
left: 100px;
top: 0; bottom: 0;
width: 0.5px;
background: var(--ink-divider);
background: rgba(232, 224, 208, 0.2);
}
.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);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink);
color: var(--ink-text);
}
.hero-day {
font-family: var(--font-serif);
font-size: 2.75rem;
line-height: 1;
color: var(--on-ink);
color: var(--ink-text);
}
.hero-detail { padding-left: var(--space-5); }
@ -279,22 +279,22 @@ const heroAudience = hero?.audience ?? 'Members only';
font-weight: 400;
font-size: 1.75rem;
line-height: 1.2;
color: var(--on-ink);
color: var(--ink-text);
margin: 0 0 var(--space-3);
}
.hero-desc {
color: var(--on-ink-body);
color: rgba(232, 224, 208, 0.85);
margin: 0 0 var(--space-3);
max-width: 40rem;
}
.hero-meta {
color: var(--on-ink-muted);
color: var(--ink-muted);
font-size: var(--text-body-sm);
margin: 0;
}
.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);
display: flex;
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-stat {
color: var(--on-ink-muted);
color: var(--ink-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
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-cta {
background: var(--on-ink);
background: var(--ink-text);
color: var(--ink);
border: none;
padding: 10px 20px;
@ -329,20 +329,20 @@ const heroAudience = hero?.audience ?? 'Members only';
.hero-cta:hover { opacity: 0.85; }
.hero-confirmed {
color: var(--on-ink);
color: var(--ink-text);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 10px 16px;
border: 0.5px solid rgba(255, 252, 247, 0.4);
border: 0.5px solid rgba(232, 224, 208, 0.4);
border-radius: 999px;
}
.hero-change {
background: transparent;
border: none;
color: var(--on-ink-muted);
color: var(--ink-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
@ -359,7 +359,7 @@ const heroAudience = hero?.audience ?? 'Members only';
display: flex;
}
.hero-empty-line {
color: var(--on-ink);
color: var(--ink-text);
font-family: var(--font-serif);
font-size: 1.25rem;
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 LatestDispatchBanner from '../components/LatestDispatchBanner.astro';
import RoadmapRoute from '../components/RoadmapRoute.astro';
import { getAllRoadmapItems } from '../lib/db';
import { marked } from 'marked';
const user = Astro.locals.user;
const items = getAllRoadmapItems()
.sort((a, b) => a.display_order - b.display_order || a.id - b.id);
// Single-file roadmap — not a content collection
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}>
<article class="roadmap-page">
<div class="page">
<!-- Single centred header — merges the page lead with the route's
interaction hints. -->
<header class="roadmap-header">
<h1 class="roadmap-title">Roadmap</h1>
<p class="roadmap-sub">
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.
<header class="page-header">
<h1 class="display-md page-title">What we are building.</h1>
<p class="subtitle">
Three horizons. What is in progress now, what comes next,
and what is further out. This is the live picture.
</p>
</header>
<!-- Legend lives above the route now — reads as a key the eye picks
up just before walking the path. -->
<div class="roadmap-legend" aria-label="Status legend">
<span><i style="background:#6d8c7c"></i>Shipping</span>
<span><i style="background:#b96b58"></i>In beta</span>
<span><i style="background:#b4b2a9"></i>Exploring</span>
<span><i style="background:#d4d2c8"></i>Considering</span>
<div class="horizons">
{sections.map((section) => (
<section class="horizon">
<div class="horizon-header">
<span
class="horizon-dot"
style={`background: ${horizonColors[section.title] ?? 'var(--on-surface-muted)'}`}
aria-hidden="true"
/>
<h2 class="headline-sm horizon-title">{section.title}</h2>
</div>
<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>
<RoadmapRoute items={items} />
<!-- Latest dispatch sits at the foot of the page with generous
space above so it reads as a separate beat, not a continuation
of the route. -->
<LatestDispatchBanner />
</article>
</div>
</AppLayout>
<style>
.roadmap-page {
padding: 0 36px 80px;
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
}
/* ── Centred header ──────────────────────────────────────────── */
.roadmap-header {
text-align: center;
max-width: 640px;
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;
/* ── Header ──────────────────────────────────────────────────────── */
.page-header {
max-width: 44rem;
margin-bottom: var(--space-12);
}
/* ── Legend (above the route, key-style) ─────────────────────── */
.roadmap-legend {
display: flex;
justify-content: center;
gap: 24px;
margin: 0 auto 14px; /* tight to the route — they're paired */
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;
.eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
color: var(--on-surface-muted);
margin-bottom: var(--space-3);
}
.roadmap-legend i {
width: 8px;
height: 8px;
border-radius: 50%;
.page-title {
margin-bottom: var(--space-5);
}
.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;
}
/* ── Dispatch banner (foot of page, generous breathing room) ── */
.roadmap-page :global(.rr-dispatch) { margin-top: 112px; }
.horizon-title {
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) {
.roadmap-page { padding: 0 24px 64px; }
.roadmap-header { padding-top: 72px; margin-bottom: 40px; }
.roadmap-title { font-size: 36px; }
.roadmap-legend { margin-bottom: 12px; }
.roadmap-page :global(.rr-dispatch) { margin-top: 72px; }
/* ── Items ───────────────────────────────────────────────────────── */
.item-list {
list-style: none;
padding: 0;
margin: 0;
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>

View file

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

View file

@ -45,14 +45,8 @@
--surface-card: #ffffff;
--surface-card-border: rgba(0, 0, 0, 0.08);
--ink: #2c3a52; /* deep indigo — membership card + event hero */
--ink-text: #e8e0d0; /* legacy warm cream — superseded by --on-ink */
--ink-muted: #b8a989; /* legacy tan — superseded by --on-ink-muted */
/* --- 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 */
--ink-text: #e8e0d0; /* readable cream on --ink */
--ink-muted: #b8a989; /* muted label tone on --ink */
/* --- Semantic state mappings --- */
--color-success: var(--pigment-copper);
@ -135,6 +129,6 @@
--duration-slow: 420ms;
/* --- Layout --- */
--content-max: 72rem; /* 1152px */
--content-max: 83rem; /* 1328px — 15% wider than the original 72rem */
--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.');
});
});