Compare commits
No commits in common. "65191256ec97f983536b50a9368a7a03e5537c48" and "66c3f6492f9bf4f3cb533007c566e8f17b4eaf83" have entirely different histories.
65191256ec
...
66c3f6492f
26 changed files with 754 additions and 3010 deletions
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 2–4 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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
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,9 +39,8 @@ 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="beta" selected={editing?.status === 'beta'}>Beta</option>
|
||||
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option>
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const navLinks = [
|
|||
|
||||
const footerLinks = [
|
||||
{ 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 ──────────────────────────────────────────────────── */
|
||||
|
|
|
|||
129
src/lib/db.ts
129
src/lib/db.ts
|
|
@ -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,99 +1019,34 @@ 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);
|
||||
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')
|
||||
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, 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 };
|
||||
`).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',
|
||||
|
|
@ -1151,19 +1054,11 @@ export function publishDispatch(id: number): void {
|
|||
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);
|
||||
})();
|
||||
}
|
||||
|
||||
/** 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);
|
||||
})();
|
||||
}
|
||||
|
||||
export function deleteDispatch(id: number): void {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
@ -200,13 +179,12 @@ if (Astro.request.method === 'POST') {
|
|||
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 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
||||
<RoadmapRoute items={items} />
|
||||
<ul class="item-list">
|
||||
{section.items.map((item) => (
|
||||
<li class="item">
|
||||
<div class="item-header">
|
||||
<h3 class="item-title body-lg">{item.title}</h3>
|
||||
{item.pilotOnly && (
|
||||
<span class="pilot-badge label-sm" title="Available to pilot participants only">
|
||||
Pilot
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p class="body-md item-body">{item.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Latest dispatch sits at the foot of the page with generous
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue