feat(roadmap): feature bullets, live items, larger scroll-locked route

- Add per-item feature bullets (features column, migration 0009, db helpers,
  admin field) rendered as plus-icon lists on desktop + mobile.
- Reseed with the live roadmap items; status labels renamed
  (In dev / Planning) and "— Alpha" suffix dropped from titles.
- Enlarge the route and lock the roadmap page to sideways-only scroll so the
  timeline stays on screen; full-bleed edge-to-edge width; nudge the header
  down toward the page centre.
- Small-caps stage suffix helper (splitStageSuffix) in format.ts.
This commit is contained in:
Arlind 2026-06-18 16:04:56 +02:00
parent 29b30b27e6
commit a9e8a57642
8 changed files with 281 additions and 119 deletions

View file

@ -0,0 +1,6 @@
-- Roadmap items gain an optional `features` column — a JSON array of short
-- strings, each rendered as a plus-icon bullet in the route card's hover
-- expansion (and the mobile timeline). Lists the concrete capabilities an
-- item ships. NULL / '[]' when not set; the UI hides the list in that case.
ALTER TABLE roadmap_items ADD COLUMN features TEXT;

View file

@ -195,30 +195,50 @@ db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60));
// ── Roadmap: 9 items, status meaning 'currently live' rather than
// 'shipping soon'. Items 1-2 are live in production; items 3-4 are in
// beta even if 'audit log export' has a near-term GA target. Travelled
// stop = (1 + 0.5) / 9 ≈ 0.17, putting the 'you are here' marker at
// the visible transition between travelled and ahead tones on the path.
// ── Roadmap: mirrors the live bifrost-portal.fenja.ai/roadmap items
// (titles, statuses, target months, order). Descriptions are completed from
// the truncated live text; the `features` plus-icon bullets are drafted from
// each item's description.
const roadmap = [
{ title: 'Traceability layer', description: 'Every inference call writes a signed audit record. Shaped by Lars in our March session.', status: 'shipping', target: 'Live since March', display_order: 1, shipped_at: nowIso(-60 * 24 * 3600), attributed: [cabs[0].id], metadata_text: 'Shaped by Lars in our March session' },
{ title: 'Document ingestion', description: "Indexing PDF, Word, and plain text with proper chunking. Pilot-tested with Mette's team.", status: 'shipping', target: 'Live since late May', display_order: 2, shipped_at: nowIso(-7 * 24 * 3600), attributed: [cabs[1].id, cabs[2].id], metadata_text: "Pilot-tested with Mette's team" },
{ title: 'Audit log export', description: 'Stream the signed records to your own S3 or on-prem object store.', status: 'in_beta', target: 'GA next week', display_order: 3, shipped_at: null, attributed: [cabs[3].id], metadata_text: 'Builds on traceability layer' },
{ title: 'Agentic query mode', description: 'Multi-step retrieval over locked, on-prem document stores. Currently testing with two pilot organisations.', status: 'in_beta', target: 'July', display_order: 4, shipped_at: null, attributed: [cabs[1].id], metadata_text: 'Request beta access →' },
{ title: 'Contextual memory', description: 'Sessions that remember constraints between calls without leaking context across organisational boundaries.', status: 'exploring', target: 'Q3 2026', display_order: 5, shipped_at: null, attributed: [cabs[3].id], metadata_text: '2 council requests' },
{ title: 'Multi-organisation graphs', description: 'Permission-controlled knowledge spaces for departments within a single deployment.', status: 'exploring', target: 'Q3 2026', display_order: 6, shipped_at: null, attributed: [cabs[4].id], metadata_text: 'Open question on key custody' },
{ title: 'Multi-tenant isolation', description: 'Cryptographic separation between sub-organisations on shared infrastructure.', status: 'exploring', target: 'Q4 2026', display_order: 7, shipped_at: null, attributed: [cabs[5].id], metadata_text: null },
{ title: 'Federated learning hooks', description: 'Let aligned organisations train on shared signal without sharing the underlying data.', status: 'considering', target: '2027', display_order: 8, shipped_at: null, attributed: [], metadata_text: 'Council input wanted' },
{ title: 'Open evaluation framework', description: 'A public benchmark suite for compliant-AI use in regulated industries.', status: 'considering', target: '2027', display_order: 9, shipped_at: null, attributed: [], metadata_text: 'Long-term direction' },
{ title: 'MCP Usage', status: 'shipping', target: 'July 2026', display_order: 1, shipped_at: nowIso(-3 * 24 * 3600), attributed: [], metadata_text: null,
description: 'Connect Fenja to external MCP servers — databases, internal tools, and other software — so it can read from and act across your systems.',
features: ['Connect external MCP servers', 'Reach databases, internal tools & software', 'Per-connection credentials & scoping'] },
{ title: 'Extended Logging', status: 'shipping', target: 'July 2026', display_order: 2, shipped_at: nowIso(-2 * 24 * 3600), attributed: [], metadata_text: null,
description: 'Granular logging and audit controls, capturing usage down to the individual user, request, and document.',
features: ['Granular usage & audit logging', 'Per-user, per-request, per-document trails', 'Exportable audit records'] },
{ title: 'Wiki 2.0', status: 'in_beta', target: 'July 2026', display_order: 3, shipped_at: null, attributed: [], metadata_text: null,
description: 'A major wiki upgrade: version history with diffs between revisions, admin-locked pages, and a richer page structure.',
features: ['Version history with revision diffs', 'Admin-locked pages', 'Richer page structure'] },
{ title: 'Interview 2.0', status: 'planned', target: 'July 2026', display_order: 4, shipped_at: null, attributed: [], metadata_text: null,
description: 'A major Interviews upgrade: build and manage interview scripts, send invites, and track responses in one place.',
features: ['Build & manage interview scripts', 'Send invites & track responses', 'Centralised results view'] },
{ title: 'Fenja Analyze', status: 'planned', target: 'August 2026', display_order: 5, shipped_at: null, attributed: [], metadata_text: null,
description: 'The first release of Fenja Analyze: ask a question in plain language, run it as a query against your data, and get an answer with sources.',
features: ['Plain-language questions → queries', 'Answers grounded in your data', 'Source citations'] },
{ title: 'Fenja Agentic', status: 'planned', target: 'September 2026', display_order: 6, shipped_at: null, attributed: [], metadata_text: null,
description: 'The first release of Agents: create and orchestrate agents from an admin panel, wiring them to the tools and data they need.',
features: ['Create agents from an admin panel', 'Orchestrate multi-agent workflows', 'Wire agents to tools & data'] },
{ title: 'Fenja Dev', status: 'planned', target: 'September 2026', display_order: 7, shipped_at: null, attributed: [], metadata_text: null,
description: 'The first release of Fenja Dev: a sovereign IDE with terminal and git integration, running entirely inside your environment.',
features: ['Sovereign in-environment IDE', 'Integrated terminal', 'Git integration'] },
{ title: 'HTML Reports', status: 'planned', target: 'September 2026', display_order: 8, shipped_at: null, attributed: [], metadata_text: null,
description: 'Structured reporting for Fenja Analyze: admins define report templates so generated analyses render as clean, shareable HTML.',
features: ['Admin-defined report templates', 'Analyses rendered as clean HTML', 'Shareable, structured output'] },
{ title: 'Self-Service Agents', status: 'planned', target: 'October 2026', display_order: 9, shipped_at: null, attributed: [], metadata_text: null,
description: 'A major expansion of the agent experience, letting users create and run their own agents without admin involvement.',
features: ['User-created agents (no admin needed)', 'Run & manage your own agents', 'Reuse across tasks'] },
{ title: 'Self-service Routines & Skills', status: 'exploring', target: 'October 2026', display_order: 10, shipped_at: null, attributed: [], metadata_text: null,
description: "Personal and domain-level routines and skills: users build reusable, tailored workflows for their own and their team's recurring tasks.",
features: ['Personal & domain-level routines', 'Reusable, tailored workflows', 'Built for recurring team tasks'] },
];
const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?)
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
VALUES (?,?,?,?,?,?,?,?)
`);
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
for (const r of roadmap) {
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, r.metadata_text).lastInsertRowid);
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, r.metadata_text, JSON.stringify(r.features ?? [])).lastInsertRowid);
for (const uid of r.attributed) insertAttr.run(id, uid);
}
@ -378,7 +398,7 @@ insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, no
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`);
console.log(' roadmap: 9 items (2 shipping / 2 in_beta / 3 exploring / 2 considering)');
console.log(' roadmap: 10 items (2 shipping / 1 in_beta / 6 planned / 1 exploring)');
console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
console.log(' events: dinner + studio hours + working session, 2 past');

View file

@ -47,8 +47,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
width: '120px',
pillVariants: {
shipping: { label: 'Shipping', class: 'pill-shipping' },
in_beta: { label: 'In beta', class: 'pill-in-beta' },
planned: { label: 'Planned', class: 'pill-planned' },
in_beta: { label: 'In dev', class: 'pill-in-beta' },
planned: { label: 'Planning', class: 'pill-planned' },
exploring: { label: 'Exploring', class: 'pill-exploring' },
considering: { label: 'Considering', class: 'pill-considering' },
},
@ -69,8 +69,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
filters: [
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
{ key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' },
{ key: 'in_beta', label: 'In beta', predicate: (i) => i.status === 'in_beta' },
{ key: 'planned', label: 'Planned', predicate: (i) => i.status === 'planned' },
{ key: 'in_beta', label: 'In dev', predicate: (i) => i.status === 'in_beta' },
{ key: 'planned', label: 'Planning', predicate: (i) => i.status === 'planned' },
{ key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' },
{ key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' },
],
@ -100,8 +100,8 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
required: true,
options: [
{ value: 'shipping', label: 'Shipping' },
{ value: 'in_beta', label: 'In beta' },
{ value: 'planned', label: 'Planned' },
{ value: 'in_beta', label: 'In dev' },
{ value: 'planned', label: 'Planning' },
{ value: 'exploring', label: 'Exploring' },
{ value: 'considering', label: 'Considering' },
],
@ -123,6 +123,14 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
defaultValue: 0,
helperText: 'Lower numbers appear earlier in the route. Items with the same number fall back to creation order.',
},
{
key: 'features',
label: 'Features',
kind: 'multi-text',
maxItems: 8,
placeholderEach: 'e.g. Streaming responses with citations',
helperText: 'Bullet-point capabilities this item ships. Shown as a plus-icon list on hover in the /roadmap route.',
},
{
key: 'metadata_text',
label: 'Hover metadata',
@ -160,6 +168,9 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
target: ((data.target as string) ?? '').trim() || null,
display_order: Number(data.display_order ?? 0),
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
features: Array.isArray(data.features)
? (data.features as unknown[]).map((v) => String(v)).filter((v) => v.trim() !== '')
: [],
});
const userIds = Array.isArray(data.attributed_members)
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
@ -176,6 +187,9 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
target: ((data.target as string) ?? '').trim() || null,
display_order: Number(data.display_order ?? 0),
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
features: Array.isArray(data.features)
? (data.features as unknown[]).map((v) => String(v)).filter((v) => v.trim() !== '')
: [],
});
const userIds = Array.isArray(data.attributed_members)
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)

View file

@ -1,5 +1,6 @@
---
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
import { splitStageSuffix } from '../lib/format';
interface Props {
items: RoadmapItemWithAttribution[];
@ -9,8 +10,8 @@ const { items } = Astro.props;
const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING',
in_beta: 'IN BETA',
planned: 'PLANNED',
in_beta: 'IN DEV',
planned: 'PLANNING',
exploring: 'EXPLORING',
considering: 'CONSIDERING',
};
@ -75,7 +76,10 @@ const hasArrows = items.length > 3;
{STATUS_LABEL[item.status]}{item.target ? ` · ${item.target.toUpperCase()}` : ''}
</span>
</header>
<h3 class="card-title">{item.title}</h3>
<h3 class="card-title">{(() => {
const sp = splitStageSuffix(item.title);
return sp.stage ? <Fragment>{sp.base} — <span class="card-stage">{sp.stage}</span></Fragment> : item.title;
})()}</h3>
<p class="card-desc">
{item.description}
{item.attributed.length > 0 && (
@ -245,6 +249,12 @@ const hasArrows = items.length > 3;
color: var(--on-surface);
margin: 0;
}
.card-stage {
font-variant: small-caps;
text-transform: lowercase;
letter-spacing: 0.04em;
color: var(--on-surface-variant);
}
.card-desc {
font-size: 13px;
line-height: 1.55;

View file

@ -1,6 +1,7 @@
---
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
import { computeRouteLayout, travelledStopFor } from '../lib/roadmap-layout';
import { splitStageSuffix } from '../lib/format';
interface Props {
items: RoadmapItemWithAttribution[];
@ -27,13 +28,16 @@ const layout = computeRouteLayout({
paddingLeft,
paddingRight: trailing,
tailLength: trailing,
trackHeight: 480,
amplitude: 140,
minSpacingX: 360,
});
const travelledStop = travelledStopFor(items.map(i => i.status));
const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING',
in_beta: 'IN BETA',
planned: 'PLANNED',
in_beta: 'IN DEV',
planned: 'PLANNING',
exploring: 'EXPLORING',
considering: 'CONSIDERING',
};
@ -80,8 +84,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
<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">
<div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 480px;`}>
<svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="480" 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"/>
@ -98,8 +102,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
{items.map((item, i) => (
<div
class="rr-milestone"
data-y={layout.itemY[i]}
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
data-y={Math.round(layout.itemY[i])}
style={`left: ${Math.round(layout.itemX[i])}px; top: ${Math.round(layout.itemY[i])}px;`}
>
<div
class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]}
@ -113,9 +117,24 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
<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>
<p class="rr-card-title">{(() => {
const sp = splitStageSuffix(item.title);
return sp.stage ? <Fragment>{sp.base} — <span class="rr-stage">{sp.stage}</span></Fragment> : item.title;
})()}</p>
<div class="rr-more">
{item.description && <p class="rr-desc">{item.description}</p>}
{item.features.length > 0 && (
<ul class="rr-features">
{item.features.map(f => (
<li class="rr-feature">
<svg class="rr-feature-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
<path d="M8 3.5v9M3.5 8h9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>{f}</span>
</li>
))}
</ul>
)}
{trailingLine(item) && <p class="rr-trail">{trailingLine(item)}</p>}
</div>
</a>
@ -160,8 +179,23 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
<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>
<p class="rrm-title">{(() => {
const sp = splitStageSuffix(item.title);
return sp.stage ? <Fragment>{sp.base} — <span class="rr-stage">{sp.stage}</span></Fragment> : item.title;
})()}</p>
{item.description && <p class="rrm-desc">{item.description}</p>}
{item.features.length > 0 && (
<ul class="rr-features">
{item.features.map(f => (
<li class="rr-feature">
<svg class="rr-feature-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
<path d="M8 3.5v9M3.5 8h9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>{f}</span>
</li>
))}
</ul>
)}
{trailingLine(item) && <p class="rrm-trail">{trailingLine(item)}</p>}
</div>
</li>
@ -177,10 +211,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
// 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 MIN_SPACING = 360;
const PADDING_X = 60;
const CONTENT_MAX = 1152; // matches --content-max (72rem)
const MID_Y = 210; // vertical centreline = track height (420) / 2
const MID_Y = 240; // vertical centreline = track height (480) / 2
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
@ -214,13 +248,16 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const trailing = Math.round(vw * 0.25);
const trackWidth = paddingLeft + usableWidth + trailing;
// Round to whole pixels — a milestone left-positioned on a fractional
// pixel and then translate(-50%)'d renders its text on half-pixels,
// which the browser antialiases into a soft/blurry edge.
const itemX: number[] = [];
for (let i = 0; i < itemCount; i += 1) {
itemX.push(
itemX.push(Math.round(
itemCount === 1
? paddingLeft + usableWidth / 2
: paddingLeft + (i / (itemCount - 1)) * usableWidth,
);
));
}
// Bezier path: control points at the segment midpoint x with control
@ -253,18 +290,16 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
/* Scroll-proximity focus: emphasise the milestone nearest the centre
of the viewport and let those toward the edges recede + dim. Driven
every frame that the track moves (via updateNav), so movement feels
alive rather than a flat pan. Not parallax — every milestone still
tracks the scroll 1:1; only scale + opacity shift with position. */
alive rather than a flat pan. Only OPACITY shifts with position — we
deliberately don't scale, because scaling rasterised card text makes
it render blurry. */
function updateFocus() {
if (!scroll || itemXs.length === 0) return;
const center = scroll.scrollLeft + scroll.clientWidth / 2;
const half = Math.max(1, scroll.clientWidth / 2);
milestones.forEach((m, i) => {
const t = Math.min(1, Math.abs((itemXs[i] ?? 0) - center) / half);
const scale = (1 - 0.10 * t).toFixed(3);
const op = (1 - 0.42 * t).toFixed(3);
m.style.transform = `translate(-50%, -50%) scale(${scale})`;
m.style.opacity = op;
m.style.opacity = (1 - 0.42 * t).toFixed(3);
});
}
@ -469,18 +504,19 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
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.
/* Fills the available height on the roadmap page (which is locked to the
viewport) and centres the track vertically via .rr-scroll-inner, so the
expanding cards have the full half-height above/below to grow into
without the page scrolling. Scrolls horizontally only.
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. */
the JS drag-momentum + animated-glide implementation below. */
/* Height is set by the page (roadmap.astro) to a definite viewport-based
value on desktop; defaults to the track's own height elsewhere. */
box-sizing: border-box;
overflow-x: auto;
overflow-y: visible;
overflow-y: hidden;
scrollbar-width: none;
padding: 60px 80px 80px;
padding: 24px 80px;
/* Drag affordance: cursor + suppress native horizontal swipe so
horizontal drag triggers our handler while vertical drag still
@ -494,19 +530,28 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
/* 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; }
/* Centre the track vertically in the (viewport-tall) scroller so card
expansion has the full half-height to grow into, both up and down. The
track is wider than the viewport and overflows horizontally → scrolls. */
.rr-scroll-inner {
min-height: 100%;
display: flex;
align-items: center;
}
/* flex: none so the track keeps its explicit (wide) width and doesn't get
shrunk by the flex parent down to its ~0 min-content width — its children
are absolutely positioned, so without this it collapses to a tiny box. */
.rr-track { position: relative; flex: none; }
.rr-path { position: absolute; top: 0; left: 0; pointer-events: none; }
.rr-milestone {
position: absolute;
/* Inline transform/opacity are driven per-frame from JS based on each
milestone's distance from the viewport centre, so the track comes
alive as you move it (centre milestone emphasised, edges recede).
The short ease softens the per-frame updates into a glide. */
/* Centred on its dot. Only opacity is animated per-frame from JS (centre
milestone bright, edges recede) — no scaling, and no `will-change` on
transform, so card text is rasterised natively (crisp) instead of being
baked into a transformed compositing layer (which looked blurry). */
transform: translate(-50%, -50%);
transition: transform .2s ease-out, opacity .2s ease-out;
will-change: transform, opacity;
transition: opacity .2s ease-out;
}
/* A hovered/focused card always reads at full size and brightness,
regardless of where it sits along the route — overrides the inline
@ -519,10 +564,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
}
.rr-dot {
width: 14px;
height: 14px;
width: 16px;
height: 16px;
border-radius: 50%;
box-shadow: 0 0 0 5px var(--background); /* halo cuts the path under the dot */
box-shadow: 0 0 0 6px var(--background); /* halo cuts the path under the dot */
transition: transform .25s ease, box-shadow .25s ease;
}
.rr-dot.rr-current {
@ -556,15 +601,15 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-connector {
width: 1px;
height: 30px;
height: 36px;
background: rgba(0, 0, 0, 0.18);
}
.rr-card {
display: block;
width: 240px;
padding: 14px 16px;
border-radius: 10px;
width: 288px;
padding: 18px 20px;
border-radius: 12px;
background: transparent;
color: inherit;
text-decoration: none;
@ -589,19 +634,26 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-eyebrow {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: 1.4px;
font-size: 12.5px;
letter-spacing: 1.5px;
text-transform: uppercase;
margin: 0 0 7px;
margin: 0 0 8px;
font-weight: 600;
}
.rr-card-title {
font-family: var(--font-serif);
font-size: 20px;
font-size: 23px;
line-height: 1.25;
color: var(--on-surface);
margin: 0;
}
/* Stage suffix (e.g. "alpha") rendered in small capitals. */
.rr-stage {
font-variant: small-caps;
text-transform: lowercase;
letter-spacing: 0.04em;
color: var(--on-surface-variant);
}
.rr-more {
max-height: 0;
opacity: 0;
@ -614,20 +666,44 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
}
.rr-card:hover .rr-more,
.rr-card:focus-visible .rr-more {
max-height: 340px;
max-height: 520px;
opacity: 1;
margin-top: 12px;
margin-top: 14px;
}
.rr-desc {
font-family: var(--font-sans);
font-size: 14px;
font-size: 15px;
line-height: 1.6;
color: var(--on-surface-variant);
margin: 0 0 10px;
margin: 0 0 12px;
}
/* ── Feature bullets (plus-icon list) ──────────────────────────── */
.rr-features {
list-style: none;
padding: 0;
margin: 0 0 12px;
display: flex;
flex-direction: column;
gap: 7px;
}
.rr-feature {
display: flex;
align-items: flex-start;
gap: 9px;
font-family: var(--font-sans);
font-size: 13.5px;
line-height: 1.45;
color: var(--on-surface-variant);
}
.rr-feature-icon {
flex-shrink: 0;
margin-top: 2px;
color: var(--pigment-terracotta);
}
.rr-trail {
font-family: var(--font-sans);
font-size: 10px;
font-size: 11px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);
@ -638,6 +714,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-advance {
position: absolute;
right: 32px;
/* The track is vertically centred in the scroller, so 50% lines up. */
top: 50%;
transform: translateY(-50%);
width: 48px;
@ -678,13 +755,12 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
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. */
/* Edge fades run the full height of the (viewport-tall) scroller — they're
just horizontal gradients, so full height reads fine. */
.rr-fade-left, .rr-fade-right {
position: absolute;
top: 60px;
bottom: 80px;
top: 0;
bottom: 0;
pointer-events: none;
transition: opacity .25s ease;
}

View file

@ -714,6 +714,7 @@ export interface RoadmapItem {
display_order: number;
shipped_at: string | null;
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
features: string[]; // bullet-point capabilities, rendered as a plus-icon list
created_at: string;
updated_at: string;
}
@ -722,6 +723,23 @@ export interface RoadmapItemWithAttribution extends RoadmapItem {
attributed: { id: number; name: string; slug: string | null }[];
}
/** The `features` column is stored as a JSON array of strings (or NULL). */
type RoadmapItemRow = Omit<RoadmapItem, 'features'> & { features: string | null };
function parseFeatures(raw: string | null): string[] {
if (!raw || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
} catch {
return [];
}
}
function rowToRoadmapItem(row: RoadmapItemRow): RoadmapItem {
return { ...row, features: parseFeatures(row.features) };
}
export function createRoadmapItem(data: {
title: string;
description: string;
@ -729,9 +747,11 @@ export function createRoadmapItem(data: {
target?: string | null;
display_order?: number;
metadata_text?: string | null;
features?: string[];
}): number {
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
const requestedOrder = data.display_order ?? 0;
const features = JSON.stringify((data.features ?? []).filter((f) => f.trim() !== ''));
return db.transaction(() => {
// Cascade: insert at position N shifts every existing item at or after N
@ -741,8 +761,8 @@ export function createRoadmapItem(data: {
).run(requestedOrder);
const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?)
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
VALUES (?,?,?,?,?,?,?,?)
`).run(
data.title,
data.description,
@ -751,6 +771,7 @@ export function createRoadmapItem(data: {
requestedOrder,
shipped_at,
data.metadata_text ?? null,
features,
);
return Number(r.lastInsertRowid);
})();
@ -767,6 +788,7 @@ export function updateRoadmapItem(id: number, data: {
target: string | null;
display_order: number;
metadata_text?: string | null;
features?: string[];
}): { shippedNow: boolean } {
const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?')
.get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined;
@ -793,12 +815,13 @@ export function updateRoadmapItem(id: number, data: {
).run(id, to, from);
}
const features = JSON.stringify((data.features ?? []).filter((f) => f.trim() !== ''));
db.prepare(`
UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
shipped_at = ?, metadata_text = ?, features = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, features, id);
return { shippedNow };
})();
@ -819,21 +842,22 @@ export function deleteRoadmapItem(id: number): void {
}
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
const item = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItem | undefined;
if (!item) return null;
const row = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItemRow | undefined;
if (!row) return null;
const attributed = db.prepare(`
SELECT u.id, u.name, u.slug FROM roadmap_attributions ra
JOIN users u ON u.id = ra.user_id
WHERE ra.roadmap_item_id = ?
ORDER BY u.name
`).all(id) as { id: number; name: string; slug: string | null }[];
return { ...item, attributed };
return { ...rowToRoadmapItem(row), attributed };
}
export function getAllRoadmapItems(): RoadmapItemWithAttribution[] {
const items = db.prepare(
const rows = db.prepare(
'SELECT * FROM roadmap_items ORDER BY status, display_order, created_at'
).all() as RoadmapItem[];
).all() as RoadmapItemRow[];
const items = rows.map(rowToRoadmapItem);
const attribs = db.prepare(`
SELECT ra.roadmap_item_id, u.id, u.name, u.slug
FROM roadmap_attributions ra

Binary file not shown.

View file

@ -1,6 +1,5 @@
---
import AppLayout from '../layouts/AppLayout.astro';
import LatestDispatchBanner from '../components/LatestDispatchBanner.astro';
import RoadmapRoute from '../components/RoadmapRoute.astro';
import { getAllRoadmapItems } from '../lib/db';
@ -27,86 +26,99 @@ const items = getAllRoadmapItems()
up just before walking the path. -->
<div class="roadmap-legend" aria-label="Status legend">
<span><i style="background:#6d8c7c"></i>Shipping</span>
<span><i style="background:#b96b58"></i>In beta</span>
<span><i style="background:#5a6d83"></i>Planned</span>
<span><i style="background:#b96b58"></i>In dev</span>
<span><i style="background:#5a6d83"></i>Planning</span>
<span><i style="background:#b4b2a9"></i>Exploring</span>
<span><i style="background:#d4d2c8"></i>Considering</span>
</div>
<RoadmapRoute items={items} />
<!-- Latest dispatch sits at the foot of the page with generous
space above so it reads as a separate beat, not a continuation
of the route. -->
<LatestDispatchBanner />
</article>
</AppLayout>
<!-- Desktop: the roadmap is a fixed, full-viewport view. Lock page scrolling
so the wheel only pans the route sideways and the route stays on screen.
Mobile keeps normal vertical scrolling for the timeline list. -->
<style is:global>
@media (min-width: 768px) {
html, body { overflow: hidden; }
.app > .footer { display: none; }
}
</style>
<style>
.roadmap-page {
padding: 0 36px 80px;
padding: 0 36px;
max-width: var(--content-max);
margin: 0 auto;
}
/* Desktop: give the route a definite, viewport-based height (no fragile
percentage-height chain) so it fills the screen and the track stays
centred and fully visible while scrolling sideways only. The subtracted
amount ≈ nav + the header/legend block above the route. */
@media (min-width: 768px) {
/* NB: no overflow:hidden here — it would clip the full-bleed route back
to the content column. Page scrolling is locked via body instead. */
.roadmap-page :global(.rr-scroll) { height: calc(100vh - 310px); }
}
/* ── Centred header ──────────────────────────────────────────── */
.roadmap-header {
text-align: center;
max-width: 640px;
margin: 0 auto 56px; /* generous gap to the legend */
padding-top: 96px;
margin: 0 auto 18px; /* gap to the legend */
padding-top: 88px; /* nudged down toward the page's vertical centre */
flex-shrink: 0;
}
.roadmap-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 48px;
font-size: 58px;
line-height: 1.05;
letter-spacing: var(--tracking-tight);
color: var(--on-surface);
margin: 0 0 14px;
margin: 0 0 18px;
}
.roadmap-sub {
font-size: 14px;
font-size: 18px;
line-height: 1.65;
color: var(--on-surface-variant);
margin: 0 auto;
max-width: 520px;
max-width: 600px;
}
/* ── 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 */
gap: 28px;
margin: 0 auto 18px; /* tight to the route — they're paired */
flex-wrap: wrap;
flex-shrink: 0;
}
.roadmap-legend span {
display: inline-flex;
align-items: center;
gap: 7px;
gap: 9px;
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 1px;
font-size: 13px;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--on-surface-variant);
}
.roadmap-legend i {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
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-title { font-size: 42px; }
.roadmap-legend { margin-bottom: 12px; }
.roadmap-page :global(.rr-dispatch) { margin-top: 72px; }
}
</style>