Compare commits
54 commits
66c3f6492f
...
65191256ec
| Author | SHA1 | Date | |
|---|---|---|---|
| 65191256ec | |||
| 0ea7e3fd96 | |||
| 407e7bc378 | |||
| 4009d5b711 | |||
| b2e0e8f518 | |||
| f90480bc8b | |||
| acbb722a0a | |||
| 22a55aa073 | |||
| 1c020f191c | |||
| 941d2a1557 | |||
| 1f95a6579d | |||
| d7c13d3c99 | |||
| 73dc656257 | |||
| 3917070dab | |||
| c0592f7ca5 | |||
| 8ca5e88618 | |||
| d85583b4a3 | |||
| 83503fe7a3 | |||
| b4df8e10f1 | |||
| b76e1fc5c4 | |||
| 788989fe35 | |||
| 1ec01a2257 | |||
| ac52e97c28 | |||
| f8d88ed760 | |||
| fde07b1f11 | |||
| 33a21735e6 | |||
| 1a169f3ac6 | |||
| 0fde7e493b | |||
| 16938026bc | |||
| 1325422056 | |||
| d49882b3f9 | |||
| 7bd4902b9d | |||
| 884cca85f1 | |||
| 66b460c35f | |||
| d17d9b93a7 | |||
| f659b70814 | |||
| 9c00087c50 | |||
| f8ecad4433 | |||
| 255ed76bbd | |||
| 7403d805cd | |||
| 39996ab93e | |||
| 5ddaad3da3 | |||
| 89688d605d | |||
| cde98f9454 | |||
| 7bd3997564 | |||
| 4219cda7b6 | |||
| 29fe1b7c92 | |||
| 096cdb00b6 | |||
| a4df2b4982 | |||
| 9ae8422527 | |||
| cb2efa70f3 | |||
| 9800d0a448 | |||
| 867661ee3d | |||
| cafbcf8b74 |
26 changed files with 3004 additions and 748 deletions
|
|
@ -44,7 +44,10 @@
|
||||||
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches/1-we-are-deprioritising-public-cloud-parity-for-q3)",
|
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches/1-we-are-deprioritising-public-cloud-parity-for-q3)",
|
||||||
"Bash(pnpm db:seed)",
|
"Bash(pnpm db:seed)",
|
||||||
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
|
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
|
||||||
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)"
|
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)",
|
||||||
|
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/roadmap)",
|
||||||
|
"Bash(grep -nE \"\\\\.rr-fade-left, \\\\.rr-fade-right|rr-fade-left \\\\{|rr-fade-right \\\\{\" src/components/RoadmapRoute.astro)",
|
||||||
|
"Bash(awk -F: '{print $1}')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
migrations/0005_polls_on_dispatches.sql
Normal file
10
migrations/0005_polls_on_dispatches.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- 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;
|
||||||
40
migrations/0006_roadmap_considering.sql
Normal file
40
migrations/0006_roadmap_considering.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- 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;
|
||||||
6
migrations/0007_roadmap_metadata.sql
Normal file
6
migrations/0007_roadmap_metadata.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- 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,6 +61,9 @@ const newCabs = [
|
||||||
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
|
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
|
||||||
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
|
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
|
||||||
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
|
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
|
||||||
|
{ name: 'Mads Lindberg', email: 'mads@virk6.dk', org: 'Virksomhed 6' },
|
||||||
|
{ name: 'Camilla Storm', email: 'camilla@virk7.dk', org: 'Virksomhed 7' },
|
||||||
|
{ name: 'Frederik Lund', email: 'frederik@virk8.dk', org: 'Virksomhed 8' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const insertUser = db.prepare(`
|
const insertUser = db.prepare(`
|
||||||
|
|
@ -75,7 +78,15 @@ for (const c of newCabs) {
|
||||||
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
|
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
|
||||||
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
|
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
|
||||||
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
|
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
|
||||||
const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
|
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 setCabMeta = db.prepare(`
|
const setCabMeta = db.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
|
|
@ -108,6 +119,21 @@ const cabMeta = {
|
||||||
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
|
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
|
||||||
focus_tags: ['Legal', 'Policy', 'EU AI Act'],
|
focus_tags: ['Legal', 'Policy', 'EU AI Act'],
|
||||||
},
|
},
|
||||||
|
'mads@virk6.dk': {
|
||||||
|
title: 'Chief Strategy Officer',
|
||||||
|
pull_quote: 'Healthcare runs on consent — and consent runs on trust.',
|
||||||
|
focus_tags: ['Healthcare', 'Consent', 'Governance'],
|
||||||
|
},
|
||||||
|
'camilla@virk7.dk': {
|
||||||
|
title: 'Head of Cyber Resilience',
|
||||||
|
pull_quote: 'Cyber resilience is not a feature — it is the substrate.',
|
||||||
|
focus_tags: ['Defence', 'Resilience'],
|
||||||
|
},
|
||||||
|
'frederik@virk8.dk': {
|
||||||
|
title: 'Director of Public Innovation',
|
||||||
|
pull_quote: 'Public innovation succeeds when it is measurably better, not just newer.',
|
||||||
|
focus_tags: ['Public sector', 'Measurement'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const u of cabRows) {
|
for (const u of cabRows) {
|
||||||
|
|
@ -143,14 +169,17 @@ const nowIso = (offsetSeconds = 0) => {
|
||||||
return d.toISOString().replace('T', ' ').slice(0, 19);
|
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
|
// ── 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.
|
||||||
const pulseOptions = [
|
const pulseOptions = [
|
||||||
'Locking down on-prem deployment first',
|
'Locking down on-prem deployment first',
|
||||||
'Pushing the traceability layer to GA',
|
'Pushing the traceability layer to GA',
|
||||||
'Going wide on document ingestion',
|
'Going wide on document ingestion',
|
||||||
'Building the agentic query loop',
|
'Building the agentic query loop',
|
||||||
];
|
];
|
||||||
const pulseId = db.prepare(`
|
const decisionPulseId = db.prepare(`
|
||||||
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
|
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
|
||||||
VALUES (?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?)
|
||||||
`).run(
|
`).run(
|
||||||
|
|
@ -160,27 +189,36 @@ const pulseId = db.prepare(`
|
||||||
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
|
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
|
||||||
).lastInsertRowid;
|
).lastInsertRowid;
|
||||||
|
|
||||||
// 2 votes from cabs[0] and cabs[1]
|
// 2 votes — Lars and Anna
|
||||||
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
||||||
.run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600));
|
.run(decisionPulseId, cabs[0].id, 1, nowIso(-2 * 3600));
|
||||||
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
||||||
.run(pulseId, cabs[1].id, 1, nowIso(-30 * 60));
|
.run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60));
|
||||||
|
|
||||||
// ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
|
// ── 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.
|
||||||
const roadmap = [
|
const roadmap = [
|
||||||
{ title: 'Traceability layer', description: 'Every response cites its sources with structured provenance.', status: 'shipping', target: 'Live now', display_order: 10, shipped_at: nowIso(-2 * 24 * 3600), attributed: [cabs[0].id] },
|
{ title: '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 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: '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: '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: '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 and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, shipped_at: null, attributed: [] },
|
{ 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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const insertRoad = db.prepare(`
|
const insertRoad = db.prepare(`
|
||||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
|
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||||
VALUES (?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?)
|
||||||
`);
|
`);
|
||||||
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
|
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
|
||||||
for (const r of roadmap) {
|
for (const r of roadmap) {
|
||||||
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at).lastInsertRowid);
|
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, r.metadata_text).lastInsertRowid);
|
||||||
for (const uid of r.attributed) insertAttr.run(id, uid);
|
for (const uid of r.attributed) insertAttr.run(id, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,7 +247,9 @@ for (const c of contribs) {
|
||||||
const dispatchSeed = [
|
const dispatchSeed = [
|
||||||
{ kind: 'decision', ageDays: 2,
|
{ kind: 'decision', ageDays: 2,
|
||||||
title: 'We are deprioritising public-cloud parity for Q3',
|
title: 'We are deprioritising public-cloud parity for Q3',
|
||||||
excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.',
|
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.`,
|
||||||
body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
|
body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
|
||||||
|
|
||||||
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction.
|
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction.
|
||||||
|
|
@ -249,14 +289,17 @@ It is not a blog. It is the studio talking to the room — short, dated, signed.
|
||||||
|
|
||||||
const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
|
const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
|
||||||
const insertDispatch = db.prepare(`
|
const insertDispatch = db.prepare(`
|
||||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
|
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id)
|
||||||
VALUES (?,?,?,?,?,'published',?,?,?)
|
VALUES (?,?,?,?,?,'published',?,?,?,?)
|
||||||
`);
|
`);
|
||||||
for (let i = 0; i < dispatchSeed.length; i += 1) {
|
for (let i = 0; i < dispatchSeed.length; i += 1) {
|
||||||
const d = dispatchSeed[i];
|
const d = dispatchSeed[i];
|
||||||
const when = nowIso(-d.ageDays * 24 * 60 * 60);
|
const when = nowIso(-d.ageDays * 24 * 60 * 60);
|
||||||
const authorId = fenjas[i % fenjas.length].id;
|
const authorId = fenjas[i % fenjas.length].id;
|
||||||
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
|
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
|
||||||
|
|
@ -329,13 +372,13 @@ const insertActivity = db.prepare(`
|
||||||
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
|
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
|
||||||
VALUES (?,?,?,?,?)
|
VALUES (?,?,?,?,?)
|
||||||
`);
|
`);
|
||||||
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
|
insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600));
|
||||||
insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
|
insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600));
|
||||||
insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60));
|
insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60));
|
||||||
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
|
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
|
||||||
|
|
||||||
console.log(' pulse #' + pulseId + ' open, 2 of 4 voted');
|
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`);
|
||||||
console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
|
console.log(' roadmap: 9 items (2 shipping / 2 in_beta / 3 exploring / 2 considering)');
|
||||||
console.log(' contributions: 3 (most recent has 3 reactions)');
|
console.log(' contributions: 3 (most recent has 3 reactions)');
|
||||||
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
|
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
|
||||||
console.log(' events: dinner + studio hours + working session, 2 past');
|
console.log(' events: dinner + studio hours + working session, 2 past');
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ const md = readFileSync(mdPath, 'utf8');
|
||||||
// schema's three statuses. In-progress items are actively being built and
|
// schema's three statuses. In-progress items are actively being built and
|
||||||
// tested with pilots → beta. Next/Later are roadmap intent, not started → exploring.
|
// tested with pilots → beta. Next/Later are roadmap intent, not started → exploring.
|
||||||
const SECTION_STATUS = {
|
const SECTION_STATUS = {
|
||||||
'In progress': { status: 'beta', target: null },
|
'In progress': { status: 'in_beta', target: null },
|
||||||
'Next': { status: 'exploring', target: 'Next quarter' },
|
'Next': { status: 'exploring', target: 'Next quarter' },
|
||||||
'Later': { status: 'exploring', target: 'Later this year' },
|
'Later': { status: 'exploring', target: 'Later this year' },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
330
src/components/EventHeroCard.astro
Normal file
330
src/components/EventHeroCard.astro
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
---
|
||||||
|
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>
|
||||||
208
src/components/LatestDispatchBanner.astro
Normal file
208
src/components/LatestDispatchBanner.astro
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
---
|
||||||
|
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>
|
<style>
|
||||||
.m-card {
|
.m-card {
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -70,7 +70,7 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--ink-text);
|
background: var(--on-ink);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -89,7 +89,7 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-name {
|
.m-name {
|
||||||
|
|
@ -99,7 +99,7 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
@ -116,12 +116,12 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
font-size: var(--text-label-sm);
|
font-size: var(--text-label-sm);
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
}
|
}
|
||||||
.m-since-value {
|
.m-since-value {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-body-sm);
|
font-size: var(--text-body-sm);
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-tags {
|
.m-tags {
|
||||||
|
|
@ -133,8 +133,8 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.m-tag {
|
.m-tag {
|
||||||
border: 0.5px solid rgba(232, 224, 208, 0.3);
|
border: 0.5px solid rgba(255, 252, 247, 0.3);
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|
|
||||||
261
src/components/RoadmapCarousel.astro
Normal file
261
src/components/RoadmapCarousel.astro
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
---
|
||||||
|
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>
|
||||||
680
src/components/RoadmapRoute.astro
Normal file
680
src/components/RoadmapRoute.astro
Normal file
|
|
@ -0,0 +1,680 @@
|
||||||
|
---
|
||||||
|
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,16 +1,25 @@
|
||||||
---
|
---
|
||||||
import type { DispatchWithAuthor, UserPublic } from '../../lib/db';
|
import type { DispatchWithAuthor, UserPublic, PulseRow } from '../../lib/db';
|
||||||
import { fmtDateTime } from '../../lib/markdown';
|
import { fmtDateTime } from '../../lib/markdown';
|
||||||
import { dispatchKindLabel } from '../../lib/format';
|
import { dispatchKindLabel } from '../../lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dispatches: DispatchWithAuthor[];
|
dispatches: DispatchWithAuthor[];
|
||||||
editing: DispatchWithAuthor | null;
|
editing: DispatchWithAuthor | null;
|
||||||
|
editingPoll: PulseRow | null;
|
||||||
fenjaUsers: UserPublic[];
|
fenjaUsers: UserPublic[];
|
||||||
currentUserId: number;
|
currentUserId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
|
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 STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
draft: 'Draft',
|
draft: 'Draft',
|
||||||
|
|
@ -57,8 +66,9 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional — falls back to first ~200 chars of body)</label>
|
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional)</label>
|
||||||
<input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -78,6 +88,65 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<!-- ── Attached poll (optional) ────────────────────────────── -->
|
||||||
|
<fieldset class="poll-fieldset">
|
||||||
|
<legend class="label-sm field-label">Attach a poll (optional)</legend>
|
||||||
|
<input type="hidden" name="poll_explicit" value="1" />
|
||||||
|
|
||||||
|
<p class="body-sm muted poll-help">
|
||||||
|
Fill in a question and at least two options to attach a poll. Leave them all blank
|
||||||
|
to {editingPoll ? 'detach the existing poll' : 'skip'}.
|
||||||
|
{editingPoll && <span class="poll-existing-flag"> · Currently attached: pulse #{editingPoll.id}, status {editingPoll.status}.</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-poll-question" class="label-sm field-label">Poll question</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="d-poll-question"
|
||||||
|
name="poll_question"
|
||||||
|
class="input body-md"
|
||||||
|
value={editingPoll?.question ?? ''}
|
||||||
|
placeholder={editing ? editing.title : 'A question for the council'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="poll-options-grid">
|
||||||
|
{pollOptionsForForm.map((val, i) => (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`poll_option_${i}`}
|
||||||
|
placeholder={`Option ${String.fromCharCode(65 + i)}${i < 2 ? '' : ' (optional)'}`}
|
||||||
|
class="input body-md"
|
||||||
|
value={val}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-poll-opens" class="label-sm field-label">Poll opens at (UTC)</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="d-poll-opens"
|
||||||
|
name="poll_opens_at"
|
||||||
|
class="input body-md"
|
||||||
|
value={toInputValue(editingPoll?.opens_at)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-poll-closes" class="label-sm field-label">Poll closes at (UTC)</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="d-poll-closes"
|
||||||
|
name="poll_closes_at"
|
||||||
|
class="input body-md"
|
||||||
|
value={toInputValue(editingPoll?.closes_at)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
|
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
|
||||||
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
|
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
|
||||||
|
|
@ -144,6 +213,30 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
|
||||||
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
|
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
|
||||||
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
||||||
|
|
||||||
|
.poll-fieldset {
|
||||||
|
border: 0.5px solid var(--surface-card-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.poll-fieldset legend {
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
.poll-help { color: var(--on-surface-muted); margin: 0; }
|
||||||
|
.poll-existing-flag { color: var(--pigment-terracotta); }
|
||||||
|
.poll-options-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.muted { color: var(--on-surface-muted); }
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.15em var(--space-3);
|
padding: 0.15em var(--space-3);
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,23 @@ interface Props {
|
||||||
|
|
||||||
const { items, editing, cabUsers } = Astro.props;
|
const { items, editing, cabUsers } = Astro.props;
|
||||||
|
|
||||||
const STATUS_LABEL = { shipping: 'Shipping', beta: 'Beta', exploring: 'Exploring' } as const;
|
const STATUS_LABEL = {
|
||||||
|
shipping: 'Shipping',
|
||||||
|
in_beta: 'In beta',
|
||||||
|
exploring: 'Exploring',
|
||||||
|
considering: 'Considering',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const formAction = editing ? 'update_roadmap' : 'create_roadmap';
|
const formAction = editing ? 'update_roadmap' : 'create_roadmap';
|
||||||
const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id));
|
const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id));
|
||||||
|
|
||||||
// Group items by status for display
|
// Group items by status for display
|
||||||
type Status = 'shipping' | 'beta' | 'exploring';
|
type Status = 'shipping' | 'in_beta' | 'exploring' | 'considering';
|
||||||
const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
|
const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
|
||||||
shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order),
|
shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order),
|
||||||
beta: items.filter(i => i.status === 'beta' ).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),
|
exploring: items.filter(i => i.status === 'exploring' ).sort((a,b) => a.display_order - b.display_order),
|
||||||
|
considering: items.filter(i => i.status === 'considering').sort((a,b) => a.display_order - b.display_order),
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
|
|
@ -39,8 +45,9 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="status" class="label-sm field-label">Status</label>
|
<label for="status" class="label-sm field-label">Status</label>
|
||||||
<select id="status" name="status" class="select body-md" required>
|
<select id="status" name="status" class="select body-md" required>
|
||||||
|
<option value="considering" selected={editing?.status === 'considering'}>Considering</option>
|
||||||
<option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
|
<option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
|
||||||
<option value="beta" selected={editing?.status === 'beta'}>Beta</option>
|
<option value="in_beta" selected={editing?.status === 'in_beta'}>In beta</option>
|
||||||
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option>
|
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -59,6 +66,20 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
|
||||||
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
|
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="metadata_text" class="label-sm field-label">Hover note (~60 chars)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="metadata_text"
|
||||||
|
name="metadata_text"
|
||||||
|
class="input body-md"
|
||||||
|
value={editing?.metadata_text ?? ''}
|
||||||
|
placeholder="e.g. Open question on key custody · Council input wanted"
|
||||||
|
maxlength="120"
|
||||||
|
/>
|
||||||
|
<span class="body-sm muted">A short narrative cue shown on hover in /roadmap. Optional.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset class="attribution-grid">
|
<fieldset class="attribution-grid">
|
||||||
<legend class="label-sm field-label">Attributed members (who shaped this)</legend>
|
<legend class="label-sm field-label">Attributed members (who shaped this)</legend>
|
||||||
{cabUsers.map(u => (
|
{cabUsers.map(u => (
|
||||||
|
|
@ -78,7 +99,7 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── List by status ────────────────────────────────────────── -->
|
<!-- ── List by status ────────────────────────────────────────── -->
|
||||||
{(['shipping','beta','exploring'] as const).map(status => (
|
{(['shipping','in_beta','exploring','considering'] as const).map(status => (
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2>
|
<h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2>
|
||||||
{grouped[status].length === 0 ? (
|
{grouped[status].length === 0 ? (
|
||||||
|
|
@ -176,4 +197,6 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
||||||
|
|
||||||
|
.muted { color: var(--on-surface-muted); }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ const navLinks = [
|
||||||
|
|
||||||
const footerLinks = [
|
const footerLinks = [
|
||||||
{ href: '/vision', label: 'Vision' },
|
{ href: '/vision', label: 'Vision' },
|
||||||
{ href: '/council-manifesto', label: 'Council manifesto' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const currentPath = Astro.url.pathname;
|
const currentPath = Astro.url.pathname;
|
||||||
|
|
@ -112,35 +111,51 @@ const year = new Date().getFullYear();
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: 12px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
|
line-height: 1; /* belt + braces — no nav-row leading on the lockup */
|
||||||
}
|
}
|
||||||
.wordmark-link:hover {
|
.wordmark-link:hover {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
}
|
}
|
||||||
.wordmark {
|
.wordmark {
|
||||||
height: 22px;
|
height: 20px;
|
||||||
width: auto;
|
width: auto;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.wordmark-sep {
|
.wordmark-sep {
|
||||||
color: var(--on-surface-muted);
|
color: var(--on-surface-muted);
|
||||||
font-size: 1rem;
|
font-family: var(--font-serif);
|
||||||
|
font-size: 18px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
/* Optical kern — the bullet's typographic centre sits slightly above
|
||||||
|
its baseline in Newsreader; this nudges it onto the visual midline. */
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
.wordmark-project {
|
/* Italic Newsreader renders ~10% visually taller than regular at the
|
||||||
font-family: var(--font-sans);
|
same font-size — the cursive B has a flourish extending past the
|
||||||
font-size: var(--text-body-md);
|
cap line. Drop Bifrost to 16px so its cap+flourish optical height
|
||||||
font-weight: 500;
|
matches Project's 18px cap, and use inline-block + tiny vertical
|
||||||
color: var(--on-surface);
|
padding so the gradient-clip bbox doesn't chop the flourish off. */
|
||||||
letter-spacing: 0;
|
.wordmark-project,
|
||||||
}
|
|
||||||
.wordmark-bifrost {
|
.wordmark-bifrost {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
letter-spacing: var(--tracking-snug);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.wordmark-project {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
.wordmark-bifrost {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 3px 0 1px;
|
||||||
|
vertical-align: baseline;
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--pigment-terracotta) 0%,
|
var(--pigment-terracotta) 0%,
|
||||||
|
|
@ -165,14 +180,15 @@ const year = new Date().getFullYear();
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: var(--ghost-border-color);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
margin: 0 var(--space-2);
|
margin: 0 18px;
|
||||||
transform: scaleX(0.5);
|
transform: scaleX(0.5);
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
.nav-logout-form { display: inline-flex; }
|
.nav-logout-form { display: inline-flex; }
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
|
position: relative;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-label-md);
|
font-size: var(--text-label-md);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -183,17 +199,17 @@ const year = new Date().getFullYear();
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
transition: color var(--duration-fast) var(--ease-standard),
|
transition: color var(--duration-fast) var(--ease-standard);
|
||||||
background var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
}
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
background: var(--surface-container-low);
|
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
/* Active nav link: terracotta + slightly heavier weight. Colour alone
|
||||||
|
is the indicator — no badge, bullet, italic, or family swap. */
|
||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
color: var(--on-surface);
|
color: var(--pigment-terracotta);
|
||||||
background: var(--surface-container);
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── User zone ──────────────────────────────────────────────────── */
|
/* ── User zone ──────────────────────────────────────────────────── */
|
||||||
|
|
|
||||||
129
src/lib/db.ts
129
src/lib/db.ts
|
|
@ -634,6 +634,21 @@ export function castVote(pulseId: number, userId: number, optionIndex: number):
|
||||||
).run(pulseId, userId, optionIndex);
|
).run(pulseId, userId, optionIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** UPSERT — first vote inserts, subsequent ones update option_index + voted_at.
|
||||||
|
* Use this when the UI allows members to change their pick while the pulse is
|
||||||
|
* still open. Returns true if this was a brand-new vote (so callers can
|
||||||
|
* record activity once), false if it changed an existing vote. */
|
||||||
|
export function castOrChangeVote(pulseId: number, userId: number, optionIndex: number): boolean {
|
||||||
|
const existing = getUserVote(pulseId, userId);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO votes (pulse_id, user_id, option_index, voted_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(pulse_id, user_id) DO UPDATE
|
||||||
|
SET option_index = excluded.option_index, voted_at = excluded.voted_at
|
||||||
|
`).run(pulseId, userId, optionIndex);
|
||||||
|
return existing === null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserVote(pulseId: number, userId: number): number | null {
|
export function getUserVote(pulseId: number, userId: number): number | null {
|
||||||
const r = db.prepare(
|
const r = db.prepare(
|
||||||
'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?'
|
'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?'
|
||||||
|
|
@ -650,7 +665,7 @@ export function countPulseParticipants(pulseId: number): number {
|
||||||
|
|
||||||
// ── Roadmap items ────────────────────────────────────────────────
|
// ── Roadmap items ────────────────────────────────────────────────
|
||||||
|
|
||||||
export type RoadmapStatus = 'shipping' | 'beta' | 'exploring';
|
export type RoadmapStatus = 'shipping' | 'in_beta' | 'exploring' | 'considering';
|
||||||
|
|
||||||
export interface RoadmapItem {
|
export interface RoadmapItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -660,6 +675,7 @@ export interface RoadmapItem {
|
||||||
target: string | null;
|
target: string | null;
|
||||||
display_order: number;
|
display_order: number;
|
||||||
shipped_at: string | null;
|
shipped_at: string | null;
|
||||||
|
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
@ -674,11 +690,12 @@ export function createRoadmapItem(data: {
|
||||||
status: RoadmapStatus;
|
status: RoadmapStatus;
|
||||||
target?: string | null;
|
target?: string | null;
|
||||||
display_order?: number;
|
display_order?: number;
|
||||||
|
metadata_text?: string | null;
|
||||||
}): number {
|
}): number {
|
||||||
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
|
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
|
||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
|
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||||
VALUES (?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?)
|
||||||
`).run(
|
`).run(
|
||||||
data.title,
|
data.title,
|
||||||
data.description,
|
data.description,
|
||||||
|
|
@ -686,6 +703,7 @@ export function createRoadmapItem(data: {
|
||||||
data.target ?? null,
|
data.target ?? null,
|
||||||
data.display_order ?? 0,
|
data.display_order ?? 0,
|
||||||
shipped_at,
|
shipped_at,
|
||||||
|
data.metadata_text ?? null,
|
||||||
);
|
);
|
||||||
return Number(r.lastInsertRowid);
|
return Number(r.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|
@ -700,6 +718,7 @@ export function updateRoadmapItem(id: number, data: {
|
||||||
status: RoadmapStatus;
|
status: RoadmapStatus;
|
||||||
target: string | null;
|
target: string | null;
|
||||||
display_order: number;
|
display_order: number;
|
||||||
|
metadata_text?: string | null;
|
||||||
}): { shippedNow: boolean } {
|
}): { shippedNow: boolean } {
|
||||||
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
|
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
|
||||||
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
|
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
|
||||||
|
|
@ -713,9 +732,9 @@ export function updateRoadmapItem(id: number, data: {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE roadmap_items
|
UPDATE roadmap_items
|
||||||
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
||||||
shipped_at = ?, updated_at = datetime('now')
|
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, id);
|
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
|
||||||
|
|
||||||
return { shippedNow };
|
return { shippedNow };
|
||||||
}
|
}
|
||||||
|
|
@ -1004,6 +1023,7 @@ export interface Dispatch {
|
||||||
published_at: string | null;
|
published_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
pulse_id: number | null; // attached poll, if any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DispatchWithAuthor extends Dispatch {
|
export interface DispatchWithAuthor extends Dispatch {
|
||||||
|
|
@ -1012,6 +1032,18 @@ export interface DispatchWithAuthor extends Dispatch {
|
||||||
author_role: Role;
|
author_role: Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DispatchWithPoll extends DispatchWithAuthor {
|
||||||
|
poll: PulseWithCounts | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional poll attachment used when creating/updating a dispatch. */
|
||||||
|
export interface DispatchPollInput {
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
opens_at: string;
|
||||||
|
closes_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function createDispatch(data: {
|
export function createDispatch(data: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
|
@ -1019,34 +1051,99 @@ export function createDispatch(data: {
|
||||||
kind: DispatchKind;
|
kind: DispatchKind;
|
||||||
author_id: number;
|
author_id: number;
|
||||||
status: DispatchStatus;
|
status: DispatchStatus;
|
||||||
|
poll?: DispatchPollInput | null;
|
||||||
}): number {
|
}): number {
|
||||||
const published_at = data.status === 'published'
|
const published_at = data.status === 'published'
|
||||||
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||||
: null;
|
: null;
|
||||||
|
return db.transaction(() => {
|
||||||
|
let pulseId: number | null = null;
|
||||||
|
if (data.poll && data.poll.options.length >= 2) {
|
||||||
|
pulseId = createPulse({
|
||||||
|
question: data.poll.question,
|
||||||
|
context: null,
|
||||||
|
options: data.poll.options,
|
||||||
|
opens_at: data.poll.opens_at,
|
||||||
|
closes_at: data.poll.closes_at,
|
||||||
|
status: data.status === 'published' ? 'open' : 'draft',
|
||||||
|
created_by: data.author_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at)
|
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, pulse_id)
|
||||||
VALUES (?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at);
|
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at, pulseId);
|
||||||
return Number(r.lastInsertRowid);
|
return Number(r.lastInsertRowid);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update a dispatch and, optionally, manage its attached poll. */
|
||||||
export function updateDispatch(id: number, data: {
|
export function updateDispatch(id: number, data: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
excerpt: string | null;
|
excerpt: string | null;
|
||||||
kind: DispatchKind;
|
kind: DispatchKind;
|
||||||
author_id: number;
|
author_id: number;
|
||||||
|
poll?: DispatchPollInput | null; // present + has options ⇒ attach/update; explicit null ⇒ detach
|
||||||
|
pollExplicit?: boolean; // distinguishes "leave poll alone" (undefined) from "detach" (null + flag)
|
||||||
}): void {
|
}): void {
|
||||||
|
db.transaction(() => {
|
||||||
|
const cur = db.prepare('SELECT pulse_id, status FROM dispatches WHERE id = ?')
|
||||||
|
.get(id) as { pulse_id: number | null; status: DispatchStatus } | undefined;
|
||||||
|
if (!cur) return;
|
||||||
|
|
||||||
|
let pulseId: number | null = cur.pulse_id;
|
||||||
|
|
||||||
|
if (data.pollExplicit) {
|
||||||
|
if (data.poll && data.poll.options.length >= 2) {
|
||||||
|
if (cur.pulse_id) {
|
||||||
|
// update the existing pulse in place
|
||||||
|
updatePulse(cur.pulse_id, {
|
||||||
|
question: data.poll.question,
|
||||||
|
context: null,
|
||||||
|
options: data.poll.options,
|
||||||
|
opens_at: data.poll.opens_at,
|
||||||
|
closes_at: data.poll.closes_at,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pulseId = createPulse({
|
||||||
|
question: data.poll.question,
|
||||||
|
context: null,
|
||||||
|
options: data.poll.options,
|
||||||
|
opens_at: data.poll.opens_at,
|
||||||
|
closes_at: data.poll.closes_at,
|
||||||
|
status: cur.status === 'published' ? 'open' : 'draft',
|
||||||
|
created_by: data.author_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// explicit detach
|
||||||
|
pulseId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE dispatches
|
UPDATE dispatches
|
||||||
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now')
|
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?,
|
||||||
|
pulse_id = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Promote draft → published, stamping published_at = now() on first publish.
|
/** Promote draft → published, stamping published_at = now() on first publish.
|
||||||
* Idempotent: if already published, published_at is preserved. */
|
* Idempotent: if already published, published_at is preserved. Also opens
|
||||||
|
* any attached draft poll so members can start voting. */
|
||||||
export function publishDispatch(id: number): void {
|
export function publishDispatch(id: number): void {
|
||||||
|
db.transaction(() => {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE dispatches
|
UPDATE dispatches
|
||||||
SET status = 'published',
|
SET status = 'published',
|
||||||
|
|
@ -1054,11 +1151,19 @@ export function publishDispatch(id: number): void {
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(id);
|
`).run(id);
|
||||||
|
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
|
||||||
|
if (row?.pulse_id) publishPulse(row.pulse_id);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Archive a dispatch. Leaves published_at intact for history. */
|
/** Archive a dispatch. Leaves published_at intact for history. Closes any
|
||||||
|
* attached open poll so the bar charts read final. */
|
||||||
export function archiveDispatch(id: number): void {
|
export function archiveDispatch(id: number): void {
|
||||||
|
db.transaction(() => {
|
||||||
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
|
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
|
||||||
|
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
|
||||||
|
if (row?.pulse_id) closePulse(row.pulse_id);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteDispatch(id: number): void {
|
export function deleteDispatch(id: number): void {
|
||||||
|
|
|
||||||
Binary file not shown.
119
src/lib/roadmap-layout.ts
Normal file
119
src/lib/roadmap-layout.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
/**
|
||||||
|
* 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,14 +101,35 @@ if (Astro.request.method === 'POST') {
|
||||||
const authorId = Number(data.get('author_id'));
|
const authorId = Number(data.get('author_id'));
|
||||||
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
|
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
|
||||||
|
|
||||||
|
// Parse optional poll attachment fields.
|
||||||
|
const pollExplicit = String(data.get('poll_explicit') ?? '') === '1';
|
||||||
|
const pollQuestion = String(data.get('poll_question') ?? '').trim();
|
||||||
|
const pollOpts = [0, 1, 2, 3]
|
||||||
|
.map(i => String(data.get(`poll_option_${i}`) ?? '').trim())
|
||||||
|
.filter(s => s.length > 0);
|
||||||
|
const pollOpens = String(data.get('poll_opens_at') ?? '');
|
||||||
|
const pollCloses = String(data.get('poll_closes_at') ?? '');
|
||||||
|
let pollInput: { question: string; options: string[]; opens_at: string; closes_at: string } | null = null;
|
||||||
|
if (pollQuestion && pollOpts.length >= 2 && pollOpens && pollCloses) {
|
||||||
|
pollInput = {
|
||||||
|
question: pollQuestion,
|
||||||
|
options: pollOpts,
|
||||||
|
opens_at: toSqlDate(pollOpens),
|
||||||
|
closes_at: toSqlDate(pollCloses),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
|
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
|
||||||
formError = 'Title, body, and a valid kind are required.';
|
formError = 'Title, body, and a valid kind are required.';
|
||||||
} else if (action === 'create_dispatch') {
|
} else if (action === 'create_dispatch') {
|
||||||
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status });
|
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status, poll: pollInput });
|
||||||
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
|
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
|
||||||
} else {
|
} else {
|
||||||
const id = Number(data.get('dispatch_id'));
|
const id = Number(data.get('dispatch_id'));
|
||||||
if (id) updateDispatch(id, { title, body, excerpt, kind, author_id: authorId || user.id });
|
if (id) updateDispatch(id, {
|
||||||
|
title, body, excerpt, kind, author_id: authorId || user.id,
|
||||||
|
poll: pollInput, pollExplicit,
|
||||||
|
});
|
||||||
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
|
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
|
||||||
}
|
}
|
||||||
} else if (action === 'publish_dispatch') {
|
} else if (action === 'publish_dispatch') {
|
||||||
|
|
@ -179,12 +200,13 @@ if (Astro.request.method === 'POST') {
|
||||||
const status = String(data.get('status') ?? '') as RoadmapStatus;
|
const status = String(data.get('status') ?? '') as RoadmapStatus;
|
||||||
const target = String(data.get('target') ?? '').trim() || null;
|
const target = String(data.get('target') ?? '').trim() || null;
|
||||||
const displayOrder = Number(data.get('display_order') ?? 0);
|
const displayOrder = Number(data.get('display_order') ?? 0);
|
||||||
|
const metadataText = String(data.get('metadata_text') ?? '').trim() || null;
|
||||||
const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean);
|
const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean);
|
||||||
|
|
||||||
if (!title || !['shipping','beta','exploring'].includes(status)) {
|
if (!title || !['shipping','in_beta','exploring','considering'].includes(status)) {
|
||||||
formError = 'Title and status are required.';
|
formError = 'Title and status are required.';
|
||||||
} else if (action === 'create_roadmap') {
|
} else if (action === 'create_roadmap') {
|
||||||
const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder });
|
const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder, metadata_text: metadataText });
|
||||||
setRoadmapAttributions(id, attributedIds);
|
setRoadmapAttributions(id, attributedIds);
|
||||||
if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
|
if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
|
||||||
return Astro.redirect('/admin?tab=roadmap&msg=roadmap_created');
|
return Astro.redirect('/admin?tab=roadmap&msg=roadmap_created');
|
||||||
|
|
@ -192,7 +214,7 @@ if (Astro.request.method === 'POST') {
|
||||||
const id = Number(data.get('roadmap_id'));
|
const id = Number(data.get('roadmap_id'));
|
||||||
if (id) {
|
if (id) {
|
||||||
const { shippedNow } = updateRoadmapItem(id, {
|
const { shippedNow } = updateRoadmapItem(id, {
|
||||||
title, description, status, target, display_order: displayOrder,
|
title, description, status, target, display_order: displayOrder, metadata_text: metadataText,
|
||||||
});
|
});
|
||||||
setRoadmapAttributions(id, attributedIds);
|
setRoadmapAttributions(id, attributedIds);
|
||||||
if (shippedNow) recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
|
if (shippedNow) recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
|
||||||
|
|
@ -263,11 +285,11 @@ function moveRoadmapItem(id: number, dir: 'up' | 'down'): void {
|
||||||
const other = sameStatus[swapIdx];
|
const other = sameStatus[swapIdx];
|
||||||
updateRoadmapItem(item.id, {
|
updateRoadmapItem(item.id, {
|
||||||
title: item.title, description: item.description, status: item.status,
|
title: item.title, description: item.description, status: item.status,
|
||||||
target: item.target, display_order: other.display_order,
|
target: item.target, display_order: other.display_order, metadata_text: item.metadata_text,
|
||||||
});
|
});
|
||||||
updateRoadmapItem(other.id, {
|
updateRoadmapItem(other.id, {
|
||||||
title: other.title, description: other.description, status: other.status,
|
title: other.title, description: other.description, status: other.status,
|
||||||
target: other.target, display_order: item.display_order,
|
target: other.target, display_order: item.display_order, metadata_text: other.metadata_text,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,6 +305,7 @@ const editingUser = tab === 'participants' && editId ? getUserPublicById(editId)
|
||||||
|
|
||||||
const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : [];
|
const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : [];
|
||||||
const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
|
const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
|
||||||
|
const dispatchEditingPoll = dispatchEditing?.pulse_id ? getPulseById(dispatchEditing.pulse_id) : null;
|
||||||
|
|
||||||
// Per-tab data
|
// Per-tab data
|
||||||
const pulses = tab === 'pulses' ? getAllPulses() : [];
|
const pulses = tab === 'pulses' ? getAllPulses() : [];
|
||||||
|
|
@ -335,12 +358,11 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
<h1 class="display-md page-title">Control panel.</h1>
|
<h1 class="display-md page-title">Control panel.</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs (Pulses entity merged into Dispatches — polls now attach to articles) -->
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</a>
|
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a>
|
||||||
<a href="/admin?tab=roadmap" class:list={['tab label-sm', { active: tab === 'roadmap' }]}>Roadmap</a>
|
<a href="/admin?tab=roadmap" class:list={['tab label-sm', { active: tab === 'roadmap' }]}>Roadmap</a>
|
||||||
<a href="/admin?tab=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a>
|
<a href="/admin?tab=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a>
|
||||||
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a>
|
|
||||||
<a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a>
|
<a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a>
|
||||||
<a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
|
<a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
|
||||||
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
|
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
|
||||||
|
|
@ -577,7 +599,7 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'dispatches' && (
|
{tab === 'dispatches' && (
|
||||||
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} fenjaUsers={fenjaUsers} currentUserId={user.id} />
|
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} editingPoll={dispatchEditingPoll} fenjaUsers={fenjaUsers} currentUserId={user.id} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
---
|
---
|
||||||
import AppLayout from '../../layouts/AppLayout.astro';
|
import AppLayout from '../../layouts/AppLayout.astro';
|
||||||
import Avatar from '../../components/Avatar.astro';
|
import Avatar from '../../components/Avatar.astro';
|
||||||
import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
|
import {
|
||||||
|
getDispatchWithPoll, getAdjacentDispatches,
|
||||||
|
getPulseById, castOrChangeVote, recordActivity, countCabMembers,
|
||||||
|
} from '../../lib/db';
|
||||||
import {
|
import {
|
||||||
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
|
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
|
||||||
dispatchKindPigment, roleLabel,
|
dispatchKindPigment, roleLabel,
|
||||||
|
|
@ -14,15 +17,38 @@ const id = parseDispatchSlug(slugParam);
|
||||||
|
|
||||||
if (!id) return Astro.redirect('/dispatches');
|
if (!id) return Astro.redirect('/dispatches');
|
||||||
|
|
||||||
const d = getDispatchById(id);
|
// 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);
|
||||||
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
|
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
|
||||||
|
|
||||||
// Canonical-redirect when the slug changes after a rename — id is the authority
|
// Canonical-redirect when the slug changes after a rename — id is the authority
|
||||||
const canonical = dispatchSlug(d);
|
const canonical = dispatchSlug(d);
|
||||||
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
|
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
|
||||||
|
|
||||||
|
const totalMembers = countCabMembers();
|
||||||
const { prev, next } = getAdjacentDispatches(d.id);
|
const { prev, next } = getAdjacentDispatches(d.id);
|
||||||
|
|
||||||
|
function closeDayLabel(closesAt: string): string {
|
||||||
|
const parsed = closesAt.includes('T') ? new Date(closesAt) : new Date(closesAt.replace(' ', 'T') + 'Z');
|
||||||
|
return new Intl.DateTimeFormat('en-GB', { weekday: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function parseUtc(s: string): Date {
|
function parseUtc(s: string): Date {
|
||||||
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
||||||
return new Date(s.replace(' ', 'T') + 'Z');
|
return new Date(s.replace(' ', 'T') + 'Z');
|
||||||
|
|
@ -63,6 +89,51 @@ const bodyHtml = renderMd(d.body);
|
||||||
|
|
||||||
<div class="body prose" set:html={bodyHtml} />
|
<div class="body prose" set:html={bodyHtml} />
|
||||||
|
|
||||||
|
{d.poll && (
|
||||||
|
<aside class="inline-poll" aria-label="Poll attached to this dispatch">
|
||||||
|
<p class="inline-poll-question">{d.poll.question}</p>
|
||||||
|
<form method="POST" class="inline-poll-options" novalidate>
|
||||||
|
<input type="hidden" name="action" value="vote" />
|
||||||
|
<input type="hidden" name="pulse_id" value={d.poll.id} />
|
||||||
|
{d.poll.options.map((opt, i) => {
|
||||||
|
const hasVoted = d.poll!.my_vote !== null;
|
||||||
|
const chosen = d.poll!.my_vote === i;
|
||||||
|
const closed = d.poll!.status !== 'open';
|
||||||
|
const count = d.poll!.votes_by_option[i] ?? 0;
|
||||||
|
const pct = d.poll!.votes_total > 0 ? (count / d.poll!.votes_total) * 100 : 0;
|
||||||
|
const letter = String.fromCharCode(65 + i);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="option_index"
|
||||||
|
value={i}
|
||||||
|
class:list={['inline-poll-option', { chosen, closed }]}
|
||||||
|
disabled={closed && !chosen}
|
||||||
|
aria-pressed={chosen}
|
||||||
|
>
|
||||||
|
<span class="inline-poll-letter">{letter}</span>
|
||||||
|
<span class="inline-poll-text">{opt}</span>
|
||||||
|
{hasVoted && (
|
||||||
|
<span class="inline-poll-pct">{pct.toFixed(0)}%</span>
|
||||||
|
)}
|
||||||
|
{hasVoted && (
|
||||||
|
<span class="inline-poll-bar" aria-hidden="true">
|
||||||
|
<span class="inline-poll-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</form>
|
||||||
|
<p class="inline-poll-count">
|
||||||
|
{d.poll.votes_total} of {totalMembers} have weighed in
|
||||||
|
{d.poll.status === 'open'
|
||||||
|
? ` · closes ${closeDayLabel(d.poll.closes_at)}`
|
||||||
|
: ' · closed'}
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
<hr class="divider" />
|
<hr class="divider" />
|
||||||
|
|
||||||
<nav class="adjacent" aria-label="Adjacent dispatches">
|
<nav class="adjacent" aria-label="Adjacent dispatches">
|
||||||
|
|
@ -194,6 +265,95 @@ const bodyHtml = renderMd(d.body);
|
||||||
margin: var(--space-6) 0 0;
|
margin: var(--space-6) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Inline poll attached to the dispatch ──────────────────────── */
|
||||||
|
.inline-poll {
|
||||||
|
margin-top: var(--space-7);
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 0.5px solid var(--surface-card-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.inline-poll-question {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--on-surface);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.inline-poll-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.inline-poll-option {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--background);
|
||||||
|
border: 0.5px solid var(--surface-card-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
color: var(--on-surface);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-standard),
|
||||||
|
background var(--duration-fast) var(--ease-standard);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.inline-poll-option:hover:not(.closed) { border-color: var(--outline); }
|
||||||
|
.inline-poll-option.chosen {
|
||||||
|
border-color: var(--pigment-terracotta);
|
||||||
|
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
|
||||||
|
}
|
||||||
|
.inline-poll-option.closed:not(.chosen) {
|
||||||
|
cursor: default;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
.inline-poll-pct {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-label-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.inline-poll-option.chosen .inline-poll-pct { color: var(--pigment-terracotta); }
|
||||||
|
.inline-poll-option:disabled { opacity: 0.85; }
|
||||||
|
.inline-poll-letter {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.inline-poll-option.chosen .inline-poll-letter { color: var(--pigment-terracotta); }
|
||||||
|
.inline-poll-text { flex: 1; }
|
||||||
|
.inline-poll-bar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--surface-container);
|
||||||
|
}
|
||||||
|
.inline-poll-bar-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--pigment-terracotta);
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: width 600ms var(--ease-standard);
|
||||||
|
}
|
||||||
|
.inline-poll-count {
|
||||||
|
color: var(--on-surface-muted);
|
||||||
|
font-size: var(--text-label-sm);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.adjacent {
|
.adjacent {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
/* ── Hero ─────────────────────────────────────────────────────── */
|
/* ── Hero ─────────────────────────────────────────────────────── */
|
||||||
.hero {
|
.hero {
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.75rem;
|
padding: 1.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -232,7 +232,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
}
|
}
|
||||||
.hero-eyebrow {
|
.hero-eyebrow {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|
@ -255,7 +255,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
left: 100px;
|
left: 100px;
|
||||||
top: 0; bottom: 0;
|
top: 0; bottom: 0;
|
||||||
width: 0.5px;
|
width: 0.5px;
|
||||||
background: rgba(232, 224, 208, 0.2);
|
background: var(--ink-divider);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-date { display: flex; flex-direction: column; gap: 2px; }
|
.hero-date { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
@ -264,13 +264,13 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
font-size: var(--text-label-sm);
|
font-size: var(--text-label-sm);
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
}
|
}
|
||||||
.hero-day {
|
.hero-day {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 2.75rem;
|
font-size: 2.75rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-detail { padding-left: var(--space-5); }
|
.hero-detail { padding-left: var(--space-5); }
|
||||||
|
|
@ -279,22 +279,22 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
margin: 0 0 var(--space-3);
|
margin: 0 0 var(--space-3);
|
||||||
}
|
}
|
||||||
.hero-desc {
|
.hero-desc {
|
||||||
color: rgba(232, 224, 208, 0.85);
|
color: var(--on-ink-body);
|
||||||
margin: 0 0 var(--space-3);
|
margin: 0 0 var(--space-3);
|
||||||
max-width: 40rem;
|
max-width: 40rem;
|
||||||
}
|
}
|
||||||
.hero-meta {
|
.hero-meta {
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
font-size: var(--text-body-sm);
|
font-size: var(--text-body-sm);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-foot {
|
.hero-foot {
|
||||||
border-top: 0.5px solid rgba(232, 224, 208, 0.2);
|
border-top: 0.5px solid var(--ink-divider);
|
||||||
padding-top: var(--space-4);
|
padding-top: var(--space-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -304,7 +304,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
}
|
}
|
||||||
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
|
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
|
||||||
.hero-foot-stat {
|
.hero-foot-stat {
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-label-sm);
|
font-size: var(--text-label-sm);
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
|
|
@ -313,7 +313,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
|
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
|
||||||
|
|
||||||
.hero-cta {
|
.hero-cta {
|
||||||
background: var(--ink-text);
|
background: var(--on-ink);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
|
|
@ -329,20 +329,20 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
.hero-cta:hover { opacity: 0.85; }
|
.hero-cta:hover { opacity: 0.85; }
|
||||||
|
|
||||||
.hero-confirmed {
|
.hero-confirmed {
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-label-md);
|
font-size: var(--text-label-md);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border: 0.5px solid rgba(232, 224, 208, 0.4);
|
border: 0.5px solid rgba(255, 252, 247, 0.4);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
.hero-change {
|
.hero-change {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-label-sm);
|
font-size: var(--text-label-sm);
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
|
|
@ -359,7 +359,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
.hero-empty-line {
|
.hero-empty-line {
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,212 +1,111 @@
|
||||||
---
|
---
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
import AppLayout from '../layouts/AppLayout.astro';
|
import AppLayout from '../layouts/AppLayout.astro';
|
||||||
import { marked } from 'marked';
|
import LatestDispatchBanner from '../components/LatestDispatchBanner.astro';
|
||||||
|
import RoadmapRoute from '../components/RoadmapRoute.astro';
|
||||||
|
import { getAllRoadmapItems } from '../lib/db';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
|
|
||||||
// Single-file roadmap — not a content collection
|
const items = getAllRoadmapItems()
|
||||||
const raw = readFileSync(join(process.cwd(), 'content/roadmap.md'), 'utf-8');
|
.sort((a, b) => a.display_order - b.display_order || a.id - b.id);
|
||||||
|
|
||||||
// Strip YAML frontmatter
|
|
||||||
const body = raw.replace(/^---[\s\S]*?---\n/, '');
|
|
||||||
|
|
||||||
// Parse sections by ## headings
|
|
||||||
function parseSections(md: string) {
|
|
||||||
const sectionRe = /^## (.+)$/gm;
|
|
||||||
const sections: { title: string; items: { title: string; body: string; pilotOnly: boolean }[] }[] = [];
|
|
||||||
const matches = [...md.matchAll(sectionRe)];
|
|
||||||
|
|
||||||
for (let i = 0; i < matches.length; i++) {
|
|
||||||
const m = matches[i];
|
|
||||||
const start = m.index! + m[0].length;
|
|
||||||
const end = matches[i + 1]?.index ?? md.length;
|
|
||||||
const sectionBody = md.slice(start, end).trim();
|
|
||||||
|
|
||||||
// Each item starts with **Title** — description
|
|
||||||
const itemRe = /\*\*([^*]+)\*\*\s*—\s*([\s\S]*?)(?=\n\n\*\*|\n\n##|$)/g;
|
|
||||||
const items: { title: string; body: string; pilotOnly: boolean }[] = [];
|
|
||||||
let itemMatch: RegExpExecArray | null;
|
|
||||||
while ((itemMatch = itemRe.exec(sectionBody)) !== null) {
|
|
||||||
const rawBody = itemMatch[2].trim();
|
|
||||||
const pilotOnly = rawBody.includes('`pilot-only`');
|
|
||||||
const cleanBody = rawBody.replace(/`pilot-only`/g, '').trim();
|
|
||||||
items.push({ title: itemMatch[1], body: cleanBody, pilotOnly });
|
|
||||||
}
|
|
||||||
|
|
||||||
sections.push({ title: m[1], items });
|
|
||||||
}
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = parseSections(body);
|
|
||||||
|
|
||||||
const horizonColors: Record<string, string> = {
|
|
||||||
'In progress': 'var(--pigment-copper)',
|
|
||||||
'Next': 'var(--pigment-ochre)',
|
|
||||||
'Later': 'var(--pigment-indigo)',
|
|
||||||
};
|
|
||||||
---
|
---
|
||||||
<AppLayout title="Roadmap" user={user}>
|
<AppLayout title="Roadmap" user={user}>
|
||||||
<div class="page">
|
<article class="roadmap-page">
|
||||||
|
|
||||||
<header class="page-header">
|
<!-- Single centred header — merges the page lead with the route's
|
||||||
<h1 class="display-md page-title">What we are building.</h1>
|
interaction hints. -->
|
||||||
<p class="subtitle">
|
<header class="roadmap-header">
|
||||||
Three horizons. What is in progress now, what comes next,
|
<h1 class="roadmap-title">Roadmap</h1>
|
||||||
and what is further out. This is the live picture.
|
<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.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="horizons">
|
<!-- Legend lives above the route now — reads as a key the eye picks
|
||||||
{sections.map((section) => (
|
up just before walking the path. -->
|
||||||
<section class="horizon">
|
<div class="roadmap-legend" aria-label="Status legend">
|
||||||
<div class="horizon-header">
|
<span><i style="background:#6d8c7c"></i>Shipping</span>
|
||||||
<span
|
<span><i style="background:#b96b58"></i>In beta</span>
|
||||||
class="horizon-dot"
|
<span><i style="background:#b4b2a9"></i>Exploring</span>
|
||||||
style={`background: ${horizonColors[section.title] ?? 'var(--on-surface-muted)'}`}
|
<span><i style="background:#d4d2c8"></i>Considering</span>
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<h2 class="headline-sm horizon-title">{section.title}</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="item-list">
|
<RoadmapRoute items={items} />
|
||||||
{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>
|
|
||||||
|
|
||||||
</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>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.roadmap-page {
|
||||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
padding: 0 36px 80px;
|
||||||
max-width: var(--content-max);
|
max-width: var(--content-max);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ──────────────────────────────────────────────────────── */
|
/* ── Centred header ──────────────────────────────────────────── */
|
||||||
.page-header {
|
.roadmap-header {
|
||||||
max-width: 44rem;
|
text-align: center;
|
||||||
margin-bottom: var(--space-12);
|
max-width: 640px;
|
||||||
|
margin: 0 auto 56px; /* generous gap to the legend */
|
||||||
|
padding-top: 96px;
|
||||||
}
|
}
|
||||||
|
.roadmap-title {
|
||||||
.eyebrow {
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--on-surface-muted);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 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-family: var(--font-serif);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
letter-spacing: var(--tracking-snug);
|
font-size: 48px;
|
||||||
margin: 0;
|
line-height: 1.05;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
flex: 1;
|
margin: 0 0 14px;
|
||||||
}
|
}
|
||||||
|
.roadmap-sub {
|
||||||
.pilot-badge {
|
font-size: 14px;
|
||||||
flex-shrink: 0;
|
line-height: 1.65;
|
||||||
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);
|
color: var(--on-surface-variant);
|
||||||
line-height: var(--leading-relaxed);
|
margin: 0 auto;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
.roadmap-legend i {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dispatch banner (foot of page, generous breathing room) ── */
|
||||||
|
.roadmap-page :global(.rr-dispatch) { margin-top: 112px; }
|
||||||
|
|
||||||
|
@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; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -232,23 +232,27 @@ a:hover {
|
||||||
.ghost-border { border: var(--ghost-border); }
|
.ghost-border { border: var(--ghost-border); }
|
||||||
.ghost-border-bottom { border-bottom: var(--ghost-border); }
|
.ghost-border-bottom { border-bottom: var(--ghost-border); }
|
||||||
|
|
||||||
/* --- Section link — prominent italic serif, placed at the bottom of
|
/* --- Section link — black serif italic, underlined, larger.
|
||||||
its respective box or article. See points 8 + 10 in the v3 spec:
|
Placed at the bottom of its respective box or article.
|
||||||
italics are reserved for links + the Bifrost wordmark. --- */
|
Italics are reserved for links + the Bifrost wordmark. --- */
|
||||||
.section-link {
|
.section-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: var(--text-body-md);
|
font-size: var(--text-title-lg); /* 1.125rem — larger than body */
|
||||||
color: var(--pigment-terracotta);
|
color: var(--on-surface);
|
||||||
text-decoration: none;
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 0.5px;
|
||||||
|
text-underline-offset: 4px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||||
}
|
}
|
||||||
.section-link:hover {
|
.section-link:hover {
|
||||||
|
color: var(--on-surface);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
color: var(--pigment-terracotta);
|
|
||||||
}
|
}
|
||||||
.section-link--ink {
|
.section-link--ink {
|
||||||
color: var(--ink-text);
|
color: var(--ink-text);
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,14 @@
|
||||||
--surface-card: #ffffff;
|
--surface-card: #ffffff;
|
||||||
--surface-card-border: rgba(0, 0, 0, 0.08);
|
--surface-card-border: rgba(0, 0, 0, 0.08);
|
||||||
--ink: #2c3a52; /* deep indigo — membership card + event hero */
|
--ink: #2c3a52; /* deep indigo — membership card + event hero */
|
||||||
--ink-text: #e8e0d0; /* readable cream on --ink */
|
--ink-text: #e8e0d0; /* legacy warm cream — superseded by --on-ink */
|
||||||
--ink-muted: #b8a989; /* muted label tone 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 */
|
||||||
|
|
||||||
/* --- Semantic state mappings --- */
|
/* --- Semantic state mappings --- */
|
||||||
--color-success: var(--pigment-copper);
|
--color-success: var(--pigment-copper);
|
||||||
|
|
@ -129,6 +135,6 @@
|
||||||
--duration-slow: 420ms;
|
--duration-slow: 420ms;
|
||||||
|
|
||||||
/* --- Layout --- */
|
/* --- Layout --- */
|
||||||
--content-max: 83rem; /* 1328px — 15% wider than the original 72rem */
|
--content-max: 72rem; /* 1152px */
|
||||||
--reading-max: 42rem; /* 672px */
|
--reading-max: 42rem; /* 672px */
|
||||||
}
|
}
|
||||||
|
|
|
||||||
94
tests/roadmap-layout.test.ts
Normal file
94
tests/roadmap-layout.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
tests/tenure-milestone.test.ts
Normal file
33
tests/tenure-milestone.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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