diff --git a/migrations/0009_roadmap_features.sql b/migrations/0009_roadmap_features.sql new file mode 100644 index 0000000..b38948d --- /dev/null +++ b/migrations/0009_roadmap_features.sql @@ -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; diff --git a/scripts/seed-demo.js b/scripts/seed-demo.js index abcf664..5daae61 100644 --- a/scripts/seed-demo.js +++ b/scripts/seed-demo.js @@ -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'); diff --git a/src/admin/resources/roadmap.ts b/src/admin/resources/roadmap.ts index 8b32e61..4b2d498 100644 --- a/src/admin/resources/roadmap.ts +++ b/src/admin/resources/roadmap.ts @@ -47,8 +47,8 @@ export const roadmapResource: Resource = { 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 = { 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 = { 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 = { 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 = { 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 = { 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) diff --git a/src/components/RoadmapCarousel.astro b/src/components/RoadmapCarousel.astro index bc9b360..20cc087 100644 --- a/src/components/RoadmapCarousel.astro +++ b/src/components/RoadmapCarousel.astro @@ -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 = { 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()}` : ''} -

{item.title}

+

{(() => { + const sp = splitStageSuffix(item.title); + return sp.stage ? {sp.base} — {sp.stage} : item.title; + })()}

{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; diff --git a/src/components/RoadmapRoute.astro b/src/components/RoadmapRoute.astro index 54053a9..11ed77b 100644 --- a/src/components/RoadmapRoute.astro +++ b/src/components/RoadmapRoute.astro @@ -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 = { 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

-
-
+
= 0 ? layout.itemX[lastShippingIndex

{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}

-

{item.title}

+

{(() => { + const sp = splitStageSuffix(item.title); + return sp.stage ? {sp.base} — {sp.stage} : item.title; + })()}

{item.description &&

{item.description}

} + {item.features.length > 0 && ( +
    + {item.features.map(f => ( +
  • + + {f} +
  • + ))} +
+ )} {trailingLine(item) &&

{trailingLine(item)}

}
@@ -160,8 +179,23 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex

{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}

-

{item.title}

+

{(() => { + const sp = splitStageSuffix(item.title); + return sp.stage ? {sp.base} — {sp.stage} : item.title; + })()}

{item.description &&

{item.description}

} + {item.features.length > 0 && ( +
    + {item.features.map(f => ( +
  • + + {f} +
  • + ))} +
+ )} {trailingLine(item) &&

{trailingLine(item)}

}
@@ -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('.route').forEach((section) => { const scroll = section.querySelector('#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; } diff --git a/src/lib/db.ts b/src/lib/db.ts index a551bc3..f2f0232 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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 & { 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 diff --git a/src/lib/format.ts b/src/lib/format.ts index 2b58881..4258bc9 100644 Binary files a/src/lib/format.ts and b/src/lib/format.ts differ diff --git a/src/pages/roadmap.astro b/src/pages/roadmap.astro index 6b74c1e..a65269b 100644 --- a/src/pages/roadmap.astro +++ b/src/pages/roadmap.astro @@ -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. -->
Shipping - In beta - Planned + In dev + Planning Exploring Considering
- - - + + +