Compare commits

...

54 commits

Author SHA1 Message Date
65191256ec style(roadmap): more air between nav and title
Triple the top padding above the header (32→96px desktop, 24→72px
mobile) so the title sits with more breathing room below the nav.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:32:02 +02:00
0ea7e3fd96 style(roadmap): more air title→legend, tighter legend→route
Pair the legend visually with the route by widening the gap above it
and narrowing the gap below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:31:41 +02:00
407e7bc378 refactor(roadmap): move legend above the route + breathe room before banner
Legend swaps position with the route. Old order: header → route →
legend → banner. New order: header → legend → route → banner.

The legend now reads as a key the eye picks up before walking the
path, instead of an after-the-fact reveal of what the colours meant.

Spacing tuned to match the new rhythm:
  header → legend: 28px (header.margin-bottom)
  legend → route:  32px (legend.margin-bottom; was 28px top margin)
  route → banner:  112px (banner.margin-top; was 64px)

The 112px before the dispatch banner gives the route room to land
visually before the editorial block at the foot starts. Mobile gets
72px instead of 112px since the page is more compressed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:29:13 +02:00
4009d5b711 refactor(roadmap): single centred header + dispatch banner below the route
Order on the page:
  1. Centred header — 48px serif 'Roadmap' h1, one merged subtitle that
     combines the original page lead ('A live picture of the work…')
     with both interaction hints (tap-or-hover, drag-or-scroll).
  2. <RoadmapRoute> — full-bleed track + right-edge advance arrow.
  3. Legend — centred in content column.
  4. <LatestDispatchBanner> — moved from above the route to below the
     legend. The dispatch becomes supplementary editorial that sits at
     the foot of the page; the route is the primary surface and now
     leads.

Banner spacing flipped: was margin-bottom 56px above the route, now
margin-top 64px below the legend.

The previous left-aligned 'What we are building.' top header and the
secondary centred 'Roadmap' route-intro block are both gone — merged
into one centred header at the top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:27:29 +02:00
b2e0e8f518 fix(roadmap): restore original top header + add centred route-intro below banner
The previous pass replaced the top page header with a single centred
'Roadmap' block — that was wrong. The intent was: keep the original
left-aligned editorial lead at the very top of the page, and insert a
new smaller centred section between the dispatch banner and the route
that carries the interaction hints.

Top of page (restored):
  Roadmap            ← 11px tracked eyebrow
  What we are building.   ← 48px serif h1, left-aligned, max-width 540
  A live picture of the work…   ← 14px subtitle, no hover-hint copy

Between LatestDispatchBanner and RoadmapRoute (new):
  Roadmap            ← 22px serif title, centred
  Tap or hover any milestone for the full story. Drag or scroll to move.
                     ← 12px tracked muted hint, centred

The hover/tap hint moves out of the top subtitle and into the route-
intro block, where the drag/scroll hint joins it. Both interaction
modalities sit together right above the surface they describe — closer
to where the reader needs them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:23:48 +02:00
f90480bc8b feat(route): unified scroll — drag with momentum + wheel + animated glide
Replaces the snap-based scroll-by-arrow model with one that handles
three input modalities feeding the same scroll state. Result: the
track glides continuously instead of clicking into milestones.

Stripped:
  - scroll-snap-type: x mandatory on .rr-scroll
  - scroll-behavior: smooth on .rr-scroll
  - scroll-snap-align: center on .rr-dot
  - The fades-only updateNav stub from commit 2

Added on .rr-scroll:
  - cursor: grab → grabbing on .rr-dragging
  - touch-action: pan-y so vertical page-scroll passes through on mobile
    while horizontal drags activate the route's own drag handler
  - user-select: none stops text selection mid-drag
  - .rr-dragging .rr-card { pointer-events: none } so a hover-reveal
    can't pop open while the track is being dragged

Script (vanilla, ~140 lines):
  - animateScrollTo(target, durationMs): cubic-ease-out via RAF.
    Cancels any existing momentum or animation before starting.
  - Wheel handler: vertical deltaY translates to horizontal scrollLeft
    when |deltaX| < |deltaY|; horizontal trackpad gestures pass through
    1:1 unscaled. preventDefault on this scroll element only — vertical
    wheel elsewhere on the page scrolls the page as normal.
  - Pointer-drag: pointerdown captures the start position + scrollLeft;
    pointermove updates scrollLeft and tracks velocity in px/ms over
    the most recent sample. setPointerCapture for cross-element drag.
  - Momentum on release: signed velocity × 16ms decays at 0.93 per
    frame, stops below 0.4 px/frame. Direction inverted because
    dragging right moves scrollLeft left.
  - Click vs drag discrimination at 5px total movement: under 5px,
    the synthetic click passes through (card navigates); over 5px,
    a capturing-phase click suppressor on the scroll element eats
    the next click so a drag-then-release-over-a-card doesn't
    accidentally navigate.
  - Advance arrow click now runs animateScrollTo(scrollLeft + 60% of
    viewport, 480ms) instead of the placeholder native scrollBy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:19:13 +02:00
acbb722a0a feat(route): single right-edge advance arrow — forward-only affordance
Replaces the prev/next button pair removed last commit with a single
viewport-anchored circular control.

  - 48px diameter, 1px terracotta border, terracotta chevron on cream.
  - Hover / focus-visible fills to terracotta with cream chevron and a
    1.06× scale-up.
  - Anchored absolute inside .rr-wrap (already position: relative):
    right: 32px / top: 50% / translateY(-50%).
  - Toggles .rr-at-end (opacity 0.25, pointer-events: none) when the
    scroll container reaches its right edge.
  - First-load hint: .rr-hint class added 100ms after mount fires a
    rr-advance-pulse keyframe three times (iteration-count: 3) — soft
    8px shadow ring in 15% terracotta pulses out and back. Animation
    stops naturally; no JS cleanup needed.

No left arrow on purpose — the path reads past → future, and the
user's instinct at any milestone is 'what's next?' The right arrow
earns its keep by hinting the existence of more track beyond the
visible window. A symmetric left arrow would just be noise.

Click handler today: scrollBy({behavior: 'smooth'}) by 60% of viewport
width. Step 4 replaces this with a custom-animated glide and adds the
drag + wheel scroll modalities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:17:26 +02:00
22a55aa073 feat(roadmap): centred 'Roadmap' page header + legend escapes route component
Page-level rebuild. /roadmap.astro renders a centred header block that
reads as one calm vertical stack:

  Roadmap        ← 11px tracked uppercase eyebrow (--on-surface-variant)
  Roadmap        ← 48px serif h1, single word
  A live picture of the work…   ← 14px subtitle, 480px max-width

The eyebrow + h1 read 'Roadmap → Roadmap' on purpose — the tracked
uppercase eyebrow primes the eye for the serif headline, and the
repetition feels confident rather than redundant. If it starts to grate
in practice, the eyebrow's the easy drop.

'What we are building.' is gone. The earlier 'The route' sub-header
inside <RoadmapRoute> is gone. The two prev/next arrow buttons in that
sub-header are gone (the single right-edge advance arrow lands in the
next commit).

Legend moves out of <RoadmapRoute> and into /roadmap.astro as the page's
final block. With the route still .rr-fullbleed, this lets the legend
return to centred content-column width — exactly the spec's
'header-centred / route-wide / legend-centred' rhythm.

Mid-state in this commit: the advance arrow doesn't exist yet, so
there's no in-page scroll affordance beyond the still-active scroll-snap
behaviour. Step 3 adds the arrow; step 4 strips the snap and adds drag
+ wheel + animated glide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:15:47 +02:00
1c020f191c docs: Phase 0.5 audit — /roadmap final layout pass preflight
Confirmed before any code:
- /roadmap top→bottom: <header class="page-header"> with left-aligned
  'What we are building.' h1 → <LatestDispatchBanner /> →
  <RoadmapRoute /> (which renders the route header + arrows + legend
  internally; the page doesn't see them).
- .rr-scroll has scroll-snap-type: x mandatory + scroll-behavior:
  smooth. Dots carry scroll-snap-align: center. Both arrows are
  scrollBy({behavior: 'smooth'}). No drag, no wheel, no momentum.
- Arrows live in the section header inside RoadmapRoute, not as a
  floating viewport-anchored control. The whole route-header /
  route-arrows construction comes out in commit 2; commit 3 replaces
  it with the right-edge advance button anchored to .rr-region.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:12:59 +02:00
941d2a1557 fix(nav): scale italic Bifrost down to optically match regular Project
Italic Newsreader at the same font-size renders visibly taller than its
regular sibling — the cursive 'B' has a flourish that extends above the
cap-line. Matching font-sizes meant Bifrost always looked larger, and
trying to fit that flourish inside the line-box / gradient-clip
either made the wordmark cramped or clipped the top off.

Asymmetric sizes for optical match:
  Project — 18px regular Newsreader
  Bifrost — 16px italic Newsreader

At those sizes, Project's 18px cap-height roughly equals Bifrost's
16px cap + flourish-ascender, so the two words read as the same
visual height.

inline-block + padding 3px top / 1px bottom on Bifrost keeps the
gradient-clip bbox tall enough to contain the flourish without
clipping, while vertical-align: baseline keeps the typographic baseline
aligned with Project's baseline so the wordmark sits on one line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:10:55 +02:00
1f95a6579d style: equalise wordmark heights, drop event 'closes' line, rename pulse eyebrow
Three small tweaks:

- AppLayout wordmark: 'Project' and 'Bifrost' now share font-size 18 +
  line-height 1.4 + tracking-snug via a combined selector. The previous
  asymmetry (Bifrost had display: inline-block + padding 4/0/2 +
  line-height: 1 to lift the gradient-clip box for the italic ascender)
  was visibly making Bifrost render slightly off-height vs Project. With
  parent line-height 1.4 the gradient-clip bbox is tall enough on its
  own; padding hack removed.
- EventHeroCard footer status line drops the ' | CLOSES TUESDAY'
  trailer. The line now reads simply '{capacity} SEATS · {n} CONFIRMED'.
  closesDay variable and .hero-status-divider style both gone.
- /pulse pulse-col eyebrow: 'This week's' → 'Pulse Vote'. Matches the
  more direct labelling everywhere else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:59:30 +02:00
d7c13d3c99 feat(route): full-bleed escape + client recompute on real viewport width
Two coupled changes so the route actually uses the page width:

.rr-wrap.rr-desktop gains the .rr-fullbleed class which uses the canonical
calc(50% - 50vw) margin trick to break out of the parent .page max-width.
The headline, dispatch banner, route section header, and legend all stay
inside the content column at 72rem; only the route itself widens to the
viewport. Visually reads as a magazine spread — the section header lands
centred, then the path spreads outward beneath.

Viewport-aware layout: SSR still uses the 1100 default (we can't know
the client viewport server-side), but a new mount script on
RoadmapRoute recomputes the layout against window.innerWidth and
updates:
  - .rr-track width (via inline style)
  - .rr-path-svg width attribute
  - .rr-path-d d attribute (rebuilt from the same cubic-bezier
    formula the SSR helper uses, with the live itemX values; itemY
    comes from per-milestone data-y attributes since amplitude
    doesn't change with viewport)
  - .rr-milestone left positions

Resize: 120ms debounced handler runs the same recompute + refreshes
the arrow/fade nav state. Each milestone keeps its same data-y, so
only the horizontal spread changes — the river's vertical shape is
preserved on resize.

Initial-scroll into shipping rewired to read the .rr-current
milestone's live `style.left` after recompute, not the SSR-computed
data-initial-x value (which is now stale once the client redoes the
math).

.rr-scroll horizontal padding 60 → 80 + scroll-padding-{left,right}
60 → 80 so first/last cards have breathing room inside the now-
viewport-wide container.

Smoke as Lars: rr-fullbleed class on the wrap, data-y attributes on
each milestone, rr-path-svg id present. The SVG width and itemX
positions land at viewport-derived values after mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:56:41 +02:00
73dc656257 style(nav): active link is colour + weight only — drop bullet, italic, case swap
.nav-link.active was carrying four overlapping cues from earlier passes:
terracotta colour, serif italic, sentence case, leading '·' bullet, and
a 15px size bump. The spec's 'option 4' is colour-only. Strip the rest:

  .nav-link.active {
    color: var(--pigment-terracotta);
    font-weight: 500;
  }

That's it. Same sans uppercase as inactive links, just terracotta with
a touch more weight. The bullet ::before is gone; nothing floats below
the link.

Sign out (.logout-btn) is already --on-surface-muted with --on-surface
on hover from a prior pass — exactly the muted treatment the new spec
asks for, so no change needed. The 'Sign out is currently terracotta'
line in the spec didn't match the codebase; flagged in the audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:53:11 +02:00
3917070dab docs: Phase 0.5 audit — active nav option 4 + route full-bleed preflight
Findings before any code:
- .nav-link.active already terracotta from a prior pass, but with extra
  serif italic + sentence case + larger font + leading '·' bullet. The
  new spec wants colour-only + weight 500. Strip the rest.
- Wrapper chain to /roadmap: <main class="main-content"> (no max-width)
  → <div class="page"> with max-width: var(--content-max) = 72rem
  (1152px). Single constraining wrapper. calc(50% - 50vw) escape works.
- Sign out (.logout-btn) is already --on-surface-muted with --on-surface
  on hover — exactly where the spec wants it to land. Skip that
  paragraph in commit 2. The spec's claim 'Sign out is currently
  terracotta' doesn't match the code.
- RoadmapRoute SSR default viewportWidth is 1100 and /roadmap.astro
  doesn't override. Switching to window.innerWidth requires a mount-
  time + resize-debounced client recompute of SVG width / path d /
  itemX dot positions. Heavier lift, but the dots really do spread
  with viewport (no scaleX cheat available).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:49:29 +02:00
c0592f7ca5 fix(route): cards grow toward centreline — fixes top/bottom clipping at the source
The actual cause of the persistent top/bottom card clipping wasn't the
track height or the padding — it's that the CSS spec forces
overflow-y: visible to compute as auto whenever overflow-x is auto.
Browsers clip the scroll container on both axes regardless of how we
declare overflow-y. Every previous fix was band-aiding the same
underlying problem.

Geometric fix: flip cardSide so cards hang toward the centreline
instead of away from it.

  - i=0 (dot on centreline)         → card below (default, no clip risk)
  - i=1 (dot above-centre, odd)     → card below (grows toward midY)
  - i=2 (dot below-centre, even >0) → card above (grows toward midY)
  - …alternating thereafter

Cards now always grow into the track, never out of it. Both axes are
naturally bounded by the track's height. Hover-expanded cards stay
inside the scroll container's clip box, so the browser-forced clipping
has nothing to remove.

Tests updated to expect the new pattern. The 7-item case carries an
extra spot-check that every card's side is opposite to its dot's
offset from the centreline — i.e. the geometric invariant the fix
relies on.

Visual rhythm: cards still alternate above/below as the path swings
up and down; the wave reads the same. What changes is which milestones
have cards above vs below — and only at the visual top of the page
where it improves things by stopping the clipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:43:41 +02:00
8ca5e88618 feat(banner): editorial dispatch banner — title + 2-paragraph excerpt + author block
LatestDispatchBanner rebuilt from the three-column horizontal row into
an editorial card that reads as something to engage with, not a
database row.

New layout:
  - Meta row (1fr / auto): tracked 'LATEST DISPATCH · {relative}' +
    kind pill on the left; 'All dispatches →' tracked link on the right.
  - 30px serif headline beneath, max-width 720px so a real title can
    breathe across two lines if needed.
  - Body grid (1fr / auto): the prose on the left, an author block on
    the right. Prose splits into two paragraphs — p1 in primary text,
    p2 in muted (--on-surface-variant) ending in an ellipsis if the
    source extends beyond what was rendered. Author block has a small
    'Jonathan / team' stack alongside a 36px serif italic initial in
    an ink-coloured circle, plus a terracotta 'Read full dispatch →'
    CTA with a 1px terracotta bottom border.

Kind pills get per-kind tinted backgrounds (decision → indigo, update
→ copper, behind_the_scenes → walnut, note → terracotta) matching the
established kind-pigment mapping.

splitExcerpt helper added to src/lib/format.ts:
  - Prefers a markdown \\n\\n paragraph break (admin-controllable);
  - Falls back to the first sentence boundary past character 120;
  - Returns [first, null] when no good split exists — banner renders
    just p1 in that case and skips p2 entirely.

Admin: the excerpt field on /admin?tab=dispatches grows from a
single-line input to a 4-row textarea with the spec'd helper text
nudging admins to write 2-4 sentences with a blank-line break.

Seed: the decision dispatch's excerpt rewritten as the spec's
two-paragraph block so the new layout has real content to render.
Body stays unchanged.

Mobile: the body collapses to single-column; the author block jumps
above the prose with order: -1, so the byline reads first on small
screens and the text flows freely below it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:38:51 +02:00
d85583b4a3 chore: remove 'In motion right now' strip — one signal, one place
The .rr-current marker on the current shipping milestone already says
'this is where we are' through colour + ring + dot scale. A second
surface saying the same thing in different words diluted both. One
signal, one place.

Removed:
- src/components/RoadmapInMotion.astro (component file)
- The import and render slot in /roadmap.astro
- The 'For shipping items: the first sentence appears on /roadmap as
  the "In motion right now" line' helper text on the admin description
  field (the field stays; only the helper copy goes).

The firstSentenceOf helper lived inside RoadmapInMotion only and was
never re-exported, so it dies with the file.

Banner margin-bottom restores from 40 → 56px now that nothing sits
between the dispatch banner and the route header. The route flows
directly out of the dispatch beat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:36:33 +02:00
83503fe7a3 fix(route): overflow-y: visible — hover cards never clip vertically again
The root cause of the hover-clipping we kept band-aiding with larger
track-heights: a scroll container with overflow-x: auto implicitly
clips on the perpendicular axis too. Explicit overflow-y: visible
lets cards expand above and below the track freely.

Implementation matches the spec's belt-and-braces pattern. New layered
markup:

  .rr-wrap          → position: relative, anchors fades
  .rr-scroll        → overflow-x: auto + overflow-y: visible,
                      padding 60/60/80, scroll-padding 60/60 sides
  .rr-scroll-inner  → structural, no layout effect
  .rr-track         → positioned at the inner wrapper

The padding-top: 60px / padding-bottom: 80px on .rr-scroll gives cards
room to grow above and below the track without ever hitting a clip
boundary, even on browsers that mis-handle the overflow-x/y mix.

Edge fades reposition: top: 60px / bottom: 80px (was 0 / 16) so they
only cover the track itself, not the overflow padding zones above and
below where hover-expanded cards now live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:35:02 +02:00
b4df8e10f1 fix(route): span ~80% of viewport + scroll padding back to 60 + track 420
Three coupled fixes to the route's geometry:

- computeRouteLayout's width calculation flipped to Math.max(viewport
  * 0.80, itemCount * minSpacing + padding * 2). On a wide screen with
  few items the 80% target wins and the path stretches across the
  page; once item count makes the data-driven width exceed 80% (the
  carousel case), the data-driven value wins and the track extends
  past the viewport unchanged.
- .rr-scroll horizontal padding 140 → 60 each side. The previous 140
  was overcompensating; with the new 80% target the milestones already
  sit inside their own breathing room. 60 is card-half + 30px buffer,
  enough for a 220px card centred under a dot 60px from the edge.
  scroll-padding kept in sync at 60 for snap-stop landings.
- trackHeight default 580 → 420; midY 290 → 210. The 580 was bandaging
  the vertical-clipping issue — that fix lands in the next commit. With
  the clip properly addressed, 420 fits the path's amplitude 120 swing
  cleanly with no wasted vertical space.

Tests rewritten to match the new width semantics: 1 item @ 1000 →
920px (0.8 * 1000 + 120); 3 items @ 1400 → 1240px; 20 items @ 800 →
6200px (data-driven wins). midY assertion 290 → 210.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:32:29 +02:00
b76e1fc5c4 docs: Phase 0.5 audit — /roadmap polish pass v6 preflight
Confirmed before any code:
- .rr-scroll: padding 0/140/8, scroll-padding-{left,right} 140,
  overflow-x: auto only — overflow-y is implicit (the spec's known
  flaky combination). paddingX default in computeRouteLayout is 60.
- <RoadmapInMotion> exists at src/components/RoadmapInMotion.astro
  and is mounted at /roadmap.astro:29. firstSentenceOf helper is
  local to that file (not exported, no other callers — safe to delete
  outright).
- <LatestDispatchBanner> uses the three-column row grid (auto 1fr auto)
  from the prior pass — that whole layout is what step 5 rebuilds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:31:04 +02:00
788989fe35 chore(seed): roadmap copy refresh — status reflects 'currently live', not 'shipping date'
The previous seed conflated status='shipping' with 'about to ship' —
'Audit log export' was status=shipping target='Next week' which is
actually a queued release, not a live one. Refined so:

  status='shipping' → live in production now
  status='in_beta'  → not yet live, GA target set
  status='exploring' → on the long horizon
  status='considering' → not committed

Updated distribution: 2 shipping / 2 in_beta / 3 exploring / 2
considering. travelledStop = (1 + 0.5) / 9 ≈ 0.17, so the gradient
visibly transitions from travelled to ahead right at the 'you are
here' marker — the visual story matches the data.

Targets rewritten to read in this new register:
  - Traceability layer       Live since March
  - Document ingestion       Live since late May  ← .rr-current
  - Audit log export         GA next week (now in_beta)
  - Agentic query mode       July
  - Contextual memory        Q3 2026
  - Multi-organisation graphs Q3 2026
  - Multi-tenant isolation   Q4 2026
  - Federated learning hooks 2027 (considering)
  - Open evaluation framework 2027 (considering)

Descriptions rewritten so the In motion strip pulls a meaningful first
sentence from item #2 — 'Indexing PDF, Word, and plain text with
proper chunking.'

shipped_at backdated on items 1-2 only (60 days / 7 days ago), so the
.rr-current marker lands on the most recently-shipped item (Document
ingestion), not the about-to-GA in_beta item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:01:31 +02:00
1ec01a2257 feat(roadmap): 'In motion right now' strip + subtitle copy + admin helper
New <RoadmapInMotion> component renders between the dispatch banner
and the route's section header. Pulls the most-recent shipping item
(same selection rule the .rr-current marker uses) and prints the
first sentence of its description as a 18px serif italic line
preceded by an 'IN MOTION RIGHT NOW' tracked eyebrow.

A member who only spends 5 seconds on /roadmap now still walks away
with a sentence about what just shipped — no scroll, no hover.

firstSentenceOf() is the obvious regex against the first
[.!?](?=\s|$). Bails to the 200-char slice if no sentence boundary
fits (covers 'Dr.' / 'e.g.' confusables). Returns '' on null. The
strip hides itself entirely when there's no shipping item, or when
the shipping item has no description text.

Page subtitle: 'Hover any milestone for the full story.' →
'Tap or hover any milestone for the full story.' — touch devices
don't have hover, and the kind of detail that says we're paying
attention.

Admin description-field gains a helper note: 'For shipping items:
the first sentence appears on /roadmap as the "In motion right now"
line. Make it count.' Nudges good first-sentence writing without
adding a new field to maintain.

Banner margin under the dispatch banner reduces 56 → 40px because
the in-motion strip carries its own 36px bottom margin to the route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:00:14 +02:00
ac52e97c28 feat(route): drop progress dots, move legend below, vary path amplitude
Three coupled changes that all serve the same goal — less furniture
above the route, more honest information below it.

Progress dots gone. At 5 pills × ~400px per pill the strip was too
coarse to feel meaningful; the arrows + edge fades already
communicate scroll position. .rr-progress markup, the script logic
that updated the .active class, and the .rr-progress / .rr-progress-dot
styles are all deleted.

Legend moves from beside 'The route' in the section header to below
the track, centred. Reading order is now title → walk the path → key,
which is the order it makes sense in. The header collapses to just
the title on the left and the two arrow buttons on the right.

Path amplitude is no longer constant. computeRouteLayout multiplies
the base amplitude (120) by a per-item factor that ramps 0.78 (first
off-axis item) → 1.18 (last item), so closer-in items swing tighter
and further-out items swing wider. The visual effect is subtle but
the path now feels hand-planned instead of strictly sinusoidal.

Test updated to verify the multiplier — |itemY[2] - midY| now exceeds
|itemY[1] - midY| in the 3-item case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:58:59 +02:00
f8d88ed760 fix(route): 'you are here' marker is unmistakable + dot animates with hover
The previous .rr-current was a 1.15× scale on top of an animated pulsing
::after ring — subtle, and the pulse was easy to miss against the cream
ground. Replaced with a static box-shadow ring at 6px offset in 45%
terracotta, plus a 1.3× scale on the dot itself. The pulse is gone;
the ring is now visible at rest, which is what the marker needs to do.

Hover/focus on a milestone card now scales its sibling dot via :has():
  - any card hover/focus → its dot 1.15
  - the current-shipping card hover/focus → its dot 1.4
The dot acknowledges that you've engaged with its card. Cleaner than
tying scroll position or click state.

:has() ships in every evergreen browser since 2023; older Firefox just
won't grow the dot, which degrades to no harm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:56:49 +02:00
fde07b1f11 fix(route): track 580 + 140px scroll-container padding — no more clipping
The route was clipping at three places: top and bottom of hovered
cards (the track was only 460 tall) and at the left/right viewport
edges (first card half-off-screen at scrollLeft 0, last card off the
right at scrollEnd).

Track height: default trackHeight in roadmap-layout 460 → 580; .rr-track
inline-style and the SVG height matched. midY now 290. Path centreline
stays in the visual centre and gains 60px breathing room above + 60px
below — which is exactly the room a hovered card needs to expand into.

Scroll-container padding: .rr-scroll gains 140px of horizontal padding
plus matching scroll-padding-left/right so snap-stops land cleanly.
The 140 figure is 220px card-width / 2 + 30px buffer, so the first and
last cards have a full card-width of clear space inside the viewport
at the scroll extremes.

Layout helper test verifies midY === 290.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:56:01 +02:00
33a21735e6 style(banner): give the dispatch banner room to read as an invitation
Three changes to <LatestDispatchBanner>:

- Padding: 22/26 → 28/26 — extra top weight stops the eyebrow + title
  feeling stuck to the card edge.
- Excerpt: the previous nowrap + text-overflow: ellipsis clamp was
  truncating mid-sentence. Replaced with -webkit-line-clamp: 2 so a
  real sentence (and a half) renders. line-height 1.55, font-size
  bumped 12 → 13 to match the longer reading rhythm.
- Kind pill next to the title, same pattern as the home-page dispatch
  byline pill: 9px tracked uppercase, 2/8 padding, 3px radius, kind-
  pigment-tinted background at 10% opacity. Wraps below the title on
  narrow widths via flex-wrap on .b-title-row.

Banner margin-bottom (set on /roadmap.astro) stays at 56px for now;
the spec's reduction to 40px is conditional on the In motion strip
landing, which is the commit after next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:54:59 +02:00
1a169f3ac6 docs: Phase 0.5 audit — /roadmap polish pass preflight
Confirmed before changing anything:
- trackHeight default = 460 in roadmap-layout.ts (midY 230). .rr-track
  inline-styled at 460px. .rr-scroll has only padding-bottom: 8px — no
  horizontal padding, no scroll-padding-{left,right}.
- .rr-current class is being applied to the correct dot:
  class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]}.
  lastShippingIndex computed via the spec's pop-of-shipping-indices.
- Progress dots row is rendered as <div class="rr-progress"> with
  Math.max(2, Math.min(6, ceil(items.length/2))) .rr-progress-dot
  children. Nav script toggles .active.
- Card eyebrow reads `${item.target.toUpperCase()} · ${STATUS_LABEL[
  item.status]}` (or the bare label when target is null) — matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:54:05 +02:00
0fde7e493b chore(seed): /roadmap demo — 9 items spanning shipping → considering
Replaces the 7-item roadmap seed with the 9-item layout from the v5
spec. Distribution: 3 shipping → 1 in_beta → 3 exploring → 2 considering.
travelledStop computes to (2 + 0.5) / 9 ≈ 0.28, so the route gradient
visibly reads as 'travelled-then-ahead' rather than one solid tone.

Each item gets a target string and either a metadata_text (8 of 9) or
a fresh attribution (the one without metadata_text, 'Multi-tenant
isolation', attributed to Camilla — so the route card surfaces the
'Shaped by Camilla' trailing line via the fallback path).

metadata_text varies across the spec'd cues — 'Shaped by Lars in our
March session' / 'Pilot-tested with Mette's team' / 'Builds on
traceability layer' / 'Request beta access →' / '2 council requests' /
'Open question on key custody' / 'Council input wanted' / 'Long-term
direction'.

Attribution coverage now spans 6 of the 7 cab members so multiple
'Shaped by ...' trailers exist if metadata_text were ever cleared.

The first three shipping items get realistic shipped_at backdates
(-21 / -7 / -1 days) so the 'most recent shipping' detection lands on
'Audit log export' — which becomes the pulsed 'you are here' dot on
the route.

Smoke as Lars: /roadmap header reads 'What we are building.',
LatestDispatchBanner shows the deprioritising-public-cloud decision,
all nine route titles render, metadata_text trailing lines present in
the DOM, .rr-current marker on the most recent shipping milestone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:45:27 +02:00
16938026bc feat(page): /roadmap rebuild — header + dispatch banner + route
Body content of /roadmap is fully replaced. The previous implementation
parsed content/roadmap.md with a hand-written regex into three flat
columns (IN PROGRESS / NEXT / LATER) — gone in its entirety, including
the parseSections helper and horizonColors map (page-local, not exported,
so nothing else broke).

New layout:
  1. Page header — tracked 'ROADMAP' eyebrow + 48px serif 'What we
     are building.' + a 14px sub line up to ~540px wide that explains
     the hover affordance. 36px margin below.
  2. <LatestDispatchBanner /> — renders nothing if zero dispatches.
     56px below before the route's section header.
  3. <RoadmapRoute items={items} /> — pulls all roadmap_items ordered
     by display_order asc, falls back to id asc on ties.

Page padding 40/36/80 desktop, 32/24/64 mobile. h1 drops to 36px on
phones; banner gap collapses to 36px.

content/roadmap.md is no longer read; admin manages everything via
/admin?tab=roadmap. The markdown file stays in the repo as the seed
source for fresh databases (still consumed by scripts/seed-roadmap.js)
but the live page is database-driven.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:44:08 +02:00
1325422056 feat(route): mobile vertical timeline at <768px
The treasure-map metaphor is a desktop affordance — on phones it
becomes a vertical timeline instead. Markup was rendered alongside the
desktop track in commit RM.5; this commit adds the styles to actually
show it and hides the desktop bits.

Per-row layout — 32px / 1fr grid:
- Left column holds the 12px round status-coloured dot and a 1px
  rgba(0,0,0,0.18) vertical line continuing down to the next dot. The
  last row has no line (rendered conditionally in the markup), so the
  trail ends cleanly at the final milestone.
- Right column holds the same eyebrow / serif title / description /
  trailing line — but always visible. No hover, no reveal. Reading is
  the only interaction.

Arrow buttons and progress dots both hidden at this breakpoint. The
mobile timeline needs no JS — pure markup + CSS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:43:35 +02:00
d49882b3f9 feat(route): nav script — arrows, fades, progress dots, initial scroll
Vanilla TS script at the bottom of RoadmapRoute.astro. No library.

- Arrows scrollBy ±72% of the scroll-container's clientWidth, smooth
  behaviour. Disabled at scroll start/end.
- Edge fades (.rr-fade-left / -right) flip opacity 0↔1 at scroll start /
  end so the affordance disappears when there's nowhere further to go.
- Progress dots track scrollLeft/(scrollWidth-clientWidth) percentage,
  bucketing into dots.length slots. Active dot gets .active (themed in
  CSS as --on-surface).
- On mount, the script reads section.data-initial-x — the SVG x position
  of the most recent shipping milestone (computed server-side from the
  layout helper) — and scrolls so that x sits ~25% from the left edge
  of the viewport. Clamped to [0, scrollWidth-clientWidth]. Member opens
  /roadmap and immediately sees one shipped item + several ahead-of-them
  items, not the very start of history.
- setTimeout(update, 50) re-measures after first paint settles
  (especially relevant when SVG fonts or other late-arriving assets
  shift the trackWidth by a couple of px).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:43:07 +02:00
7bd4902b9d feat(component): RoadmapRoute — SVG path + milestones + hover-reveal cards (static)
The treasure-map. Static render only; nav script lands in the next commit.

Section header: serif 'The route' + tracked-uppercase legend
(Shipping / In beta / Exploring / Considering) on the left; two 32px
round arrow buttons on the right (matching the /pulse RoadmapCarousel
chrome).

Body — desktop layout (.rr-desktop):
- Outer .rr-wrap holds an overflow-x: auto .rr-scroll with snap-x.
- Track is sized to layout.trackWidth × 460. Cubic-bezier SVG path
  rendered behind milestones, stroked with a horizontal gradient that
  fades from #2a2520 / 0.55 alpha through to #2a2520 / 0.15 at the
  travelled-stop position (computed by travelledStopFor in step 3).
- Each milestone is a 14px round dot in its status colour, with a 5px
  cream halo cutting the path beneath. The 'you are here' marker (most
  recent shipping item) gets a 1.15× scale + a quiet 2.4s pulse ring.
- Cards hang from each dot via a 1px / 30px vertical connector, on the
  alternating cardSide returned by layout. .rr-card is the anchor target;
  hover and :focus-visible both reveal the description + trailing line
  via max-height + opacity transitions, so keyboard tab is a first-class
  interaction (no mouse required).
- Trailing line: item.metadata_text if set, else 'Shaped by {first
  names}' if attributed_members non-empty, else nothing.
- Edge fades on both sides for scroll affordance (left fade hidden when
  at scrollLeft 0; right fade hides when at scrollEnd — the JS in step 6
  will toggle their opacity).

Progress dots row underneath — count = max(2, min(6, ceil(items/2))).
First dot starts active; nav script will move it.

Mobile vertical fallback (.rr-mobile) markup is included now but kept
display:none on desktop. Step 7 turns it on at the (max-width: 767px)
breakpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:42:32 +02:00
884cca85f1 feat(component): LatestDispatchBanner — slim single-row card
White card with 0.5px border, 12px radius, 22px/26px padding. Three-
column grid (auto / 1fr / auto):
  - Left: 30px deterministic-pigment avatar + tracked 'LATEST DISPATCH ·
    {relative}' eyebrow + 11px '{first_name} · {title || team}' line.
  - Middle: 19px serif title + 12px single-line ellipsis excerpt.
  - Right: 11px terracotta 'READ DISPATCH' link (1px terracotta bottom
    border) + 10px muted 'ALL DISPATCHES →' below it.

The whole card is one <a> targeting the dispatch slug — hover lifts it
1px and tints the surface; entire surface is the click target.

Hidden entirely when getLatestPublishedDispatches(1) returns []. The
banner doesn't render an empty-state placeholder — the /roadmap page
just starts with the route in that case.

At <768px the grid collapses to a single column and the excerpt wraps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:41:00 +02:00
66b460c35f feat(lib): roadmap-layout — coordinate generation for the route component
Pure math, no DOM. computeRouteLayout(opts) takes itemCount + viewport
width and returns trackWidth, pathD, itemX, itemY, cardSide, midY:

- itemX evenly distributes items between padding and viewport-padding,
  expanding the canvas beyond the viewport when itemCount * minSpacingX
  exceeds the available width. Single-item case centres the dot.
- itemY puts the first item on the centreline; subsequent items
  alternate +amplitude / -amplitude so the path snakes gently up and
  down. The route reads as a river rather than a saw-tooth because the
  cubic-bezier control points use the segment midpoint x — that holds
  the tangent flat at each milestone.
- cardSide alternates 'below' / 'above' starting from 'below' on item 0.
  Cards hang from their dot via a thin vertical connector in the
  consuming component.

Also adds travelledStopFor(statuses) — the stop position on the path
stroke gradient where 'travelled' fades into 'ahead'. Clamps to 0.98
even when every item is shipping so the fade is always visible.

9 unit tests cover itemCount 1/2/3/7/20 plus the travelledStop edge
cases (no shipping → 0; all shipping → ≤ 0.98; mixed → exact midpoint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:40:26 +02:00
d17d9b93a7 feat(db): roadmap_items.metadata_text + admin field
Migration 0007 (spec said 0006 but 0006 was already roadmap_considering)
adds a single nullable metadata_text column to roadmap_items — a short
admin-set narrative cue shown on hover in the route cards. ~60 chars
suggested in admin helper text. Hidden in the UI when NULL.

db.ts: RoadmapItem type gains the field. createRoadmapItem + updateRoadmapItem
accept an optional metadata_text parameter. moveRoadmapItem passes it through
when swapping display_order between siblings so the helper preserves it.

Admin: /admin?tab=roadmap edit form gets a new 'Hover note' input under
the description, with the helper text and a 120-char hard cap. Empty
string saves as NULL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:39:18 +02:00
f659b70814 docs: Phase 0.5 audit — /roadmap redesign preflight
Confirmed before writing code:
  - Current /roadmap reads content/roadmap.md via a hand-written
    parseSections regex into three flat columns (IN PROGRESS / NEXT /
    LATER) — it doesn't query roadmap_items at all. Full body replace
    in step 8; AppLayout + new editorial page header stay.
  - roadmap_items.status enum already covers 'considering' from 0006.
    No need to fold the enum extension into this migration.
  - getLatestPublishedDispatches(limit: number) is in db.ts (line 1177);
    pass 1 for the banner. No new helper.
  - No tests reference /roadmap markup. The route layout helper is the
    only new unit-test surface.

Divergence: spec asks for migration 0006_roadmap_metadata.sql but that
number's already used by roadmap_considering. Going with 0007.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:36:58 +02:00
9c00087c50 fix(nav): widen 'Bifrost' gradient-clip box so the italic cap renders fully
The italic 'B' on 'Bifrost' was being clipped at the top because
background-clip: text only paints inside the inline element's content
box — which at the previous tight line-height didn't include the
ascender flourish.

Two cooperating fixes:
- .wordmark-project gets line-height: 1.5 so the parent line-box has
  enough vertical room for the italic ascender to live in.
- .wordmark-bifrost becomes display: inline-block with 4px top / 2px
  bottom padding. That extends the element's content box vertically so
  the gradient-clip mask covers the full italic glyph including the
  serif curl above the cap-height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:35:19 +02:00
f8ecad4433 style(nav): wordmark — give italic ascenders room, share visual midline
Two fixes for the previous baseline-align attempt:

- 'Bifrost' was getting its italic ascender clipped because line-height
  was pinned at 1. Switched to line-height: 1.3 on .wordmark-project so
  the swash on the italic 'B' / 'f' has room to render.
- The lockup was reading low overall because align-items: baseline +
  translateY(3px) on the SVG together shifted the SVG below the text
  baseline. Reverted to align-items: center for reliable cross-browser
  centring, dropped the SVG transform, and pulled the SVG height from
  22 → 20 so the visible glyph height of the wordmark matches the cap-
  height of Newsreader at 18px more closely. The '·' bullet keeps a
  -2px nudge because Newsreader's bullet sits typographically above its
  baseline at this size.
- 2px padding-top on .wordmark-project compensates for the small bit
  of descender slack at the bottom of the Fenja SVG — caps now sit on
  the same visible cap-line as the wordmark glyphs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:33:36 +02:00
255ed76bbd style(nav): align Fenja logo + 'Project Bifrost' on a shared baseline
Three small adjustments so the lockup reads as one mark, not a logo
next to a label:

- align-items: baseline on .wordmark-link so the typographic baseline
  drives vertical alignment (instead of bounding-box centres).
- 'Project' + 'Bifrost' bumped from text-body-md (16px) to 18px with
  line-height: 1 and tracking-snug. The bigger weight matches the
  visible glyph height of the Fenja SVG and pulls the bullet into
  conversation with the wordmark instead of floating between them.
- The Fenja SVG gets a translateY(3px) nudge to compensate for the
  descender slack in its 265×101 viewBox — the SVG's actual baseline
  sits a couple of px above its bounding-box bottom, so the flex
  baseline-align would otherwise place 'fenja' above the text baseline.
- The '·' separator drops 1px and grows to 18px so it sits between the
  two marks rather than floating above them.

The exact numbers are empirical — eyeballed in dev — but the rationale
each pin to a baseline relationship rather than 'magic px offset'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:31:56 +02:00
7403d805cd style(nav): wordmark 'Project' in Newsreader to match 'Bifrost'
.wordmark-project was Manrope 500 — visually a sans-serif label next to
a serif-italic gradient noun. Switches to Newsreader 400 so the
wordmark reads as one continuous editorial mark, with the italic +
gradient on 'Bifrost' doing the visual lift instead of a family contrast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:30:10 +02:00
39996ab93e feat(pulse): council marquee auto-scrolls all 7 members across the page
Council section gets a proper carousel — a horizontal marquee that
moves continuously across the page, listing every cab member in turn
rather than a fixed-size grid.

Implementation:
- Members rendered twice in a single flex track; CSS keyframe translates
  from translateX(0) to translateX(-50%) over 40s+ (duration scales with
  member count via the --marquee-duration inline custom prop, capped at
  6 sec per member or 28 sec minimum). At -50% the first copy is fully
  offscreen and the second copy occupies the visible window seamlessly;
  the loop resets without a visible jump.
- aria-hidden on the duplicated copies so screen readers don't double-
  announce.
- mask-image fades both edges so members slide in and out softly rather
  than clipping at the container edge.
- Paused on hover so a reader can stop and parse a tile.
- prefers-reduced-motion: animation off and the strip becomes a quietly
  scrollable horizontal list — keyboard / trackpad users can pan
  manually instead of relying on the animation.

Seed adds 3 more cab members for a total of 7 (Mads Lindberg, Camilla
Storm, Frederik Lund) with backdated cab_joined_date so member_numbers
allocate 5/6/7. Each gets title + pull_quote + focus_tags consistent
with the existing four. Tenure spread is now 3 → 24 weeks across the
seven members so /members renders meaningfully varied 'member since'
dates.

The previous 4-tile grid + 5th-tile-as-link case is gone; the marquee
loops the full set so no truncation is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:21:53 +02:00
5ddaad3da3 feat(vote): show percentages after voting + allow change-of-mind
The pulse vote widget on /pulse and on /dispatches/[slug] now behaves
the way the v5 spec asks:

- Eyebrow shortens: 'This week's pulse' → 'This week's'.
- Status line copy changes shape depending on whether the viewer has
  voted yet. Pre-vote: 'Vote to see how the council weighs in · Closes
  TUESDAY' — sets expectations that the percentages reveal after a
  vote. Post-vote: '2 of 7 voted · Closes TUESDAY · Click to change' —
  tells the viewer they can change their mind.
- Each option now renders a right-aligned tabular-nums percentage badge,
  but only after the viewer has voted. Pre-vote there's no percentage
  on screen at all — voting is a commitment, not a peek.
- Options stay clickable after voting (no `disabled`). Re-clicking a
  different option changes the vote.

DB: new helper castOrChangeVote(pulseId, userId, optionIndex) does an
UPSERT — INSERT on first vote, UPDATE option_index + voted_at on
subsequent. Returns true if this was the brand-new vote, so the caller
can write the 'voted' activity row exactly once and not double-count
changes-of-mind in the feed. castVote(...) (INSERT OR IGNORE) stays in
db.ts for callers that explicitly want first-vote-wins semantics.

Status-class rename: .locked → .closed on both pulse-option and
inline-poll-option. The class now reflects what it actually represents
(the pulse is closed) rather than the false invariant 'the user has
voted and can't change'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:20:16 +02:00
89688d605d style(pulse): drop date + tenure line, italic-noun section titles, more air, new active nav cue
Greeting (1, 2): the tracked date label above the greeting and the
milestone tenure line below it both come out. The greeting is now just
'Good morning, Jonathan.' alone on the left, with the MEMBER · NNN /
'Founding circle' stamp on the right. tenureMilestone helper + test stay
in place — they're still useful for future surfaces but no longer
rendered on /pulse.

Section titles (3): the noun in each section title gets serif italic +
a trailing period — 'On the <em>roadmap.</em>' and 'The <em>council.</em>'.
The two-word framing reads better visually than the previous flat
sans-serif title.

Spacing (6): bumped the major section transitions. Greeting → hero now
80px (was 56), also-coming-up → editorial 96px, editorial → roadmap
96px, roadmap → council 96px. The hero → also-coming-up gap stays at
the deliberate 18px because they're a pair, and the dispatch column's
internal margin-top 48px to its 'Earlier' list stays unchanged because
they belong to the same story.

Footer (7): 'Council manifesto' link removed from the footer for all
pages. The standalone /council-manifesto route stays in the codebase
(orphaned, not linked) so it isn't a 404 when someone has the URL.

Active nav link (8): the previous 4px terracotta dot below the active
link is gone. The active link now reads in a different typographic
register entirely:
  - terracotta colour
  - serif italic Newsreader (vs sans uppercase for inactive)
  - sentence case (text-transform: none — the label appears as 'Pulse'
    instead of 'PULSE')
  - leading '· ' prefix dot for an additional 'you are here' marker
Four cues at once, no positioned overlay, no floating elements. The
typography shift alone tells you which page you're on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:16:11 +02:00
cde98f9454 feat(pulse): spacing pass + council section header + 7-item roadmap seed
Spacing — explicit per-section margins on /pulse rather than a single
gap. Page is padding: 40px 36px 80px now. Transitions match the spec:

  greeting   ─ 48px ─ below nav
  greeting   ─ 56px ─ hero
  hero       ─ 18px ─ also coming up    (intentionally tight; related)
  also       ─ 72px ─ editorial row
  editorial  ─ 72px ─ roadmap
  roadmap    ─ 72px ─ council

The hero, editorial, roadmap and council transitions all sit at 72px so
the page reads as four distinct registers rather than a slab stack. The
hero → also-coming-up gap stays deliberately tight at 18px because the
two are a pair (the strip is the lighter outro to the indigo card).

Council section restructured to match the roadmap carousel framing:
  - Outer card chrome dropped — no more single white surface wrapping the
    grid. Section is just a header row + a 4-column grid of tiles.
  - Header row: 22px serif 'The council' on the left, 11px terracotta
    tracked uppercase 'See who our council is made up of →' on the right.
    Same pattern as the roadmap header.
  - Tiles: 38px avatar (down from 56), 15px serif name, 11px title,
    10px tracked organisation. No background, no border. 24px grid gap.
  - First 4 members render; if more, a 5th tile replaces the would-be
    fifth member with a right-aligned 'See all N council members →' link.
    With the current 4-member seed this case isn't exercised but the
    branch is in place for when the council grows.
  - 2-up on tablets, 1-up below 520px.

Seed update: roadmap now has 7 items spanning all four statuses (2
shipping / 1 in_beta / 2 exploring / 2 considering) ordered by
display_order 1..7. Traceability layer carries the 'Shaped by Lars'
attribution; Agentic query mode is attributed to Anna; Contextual memory
to Henriette. The rest are unattributed so the attribution trailer's
hidden case is exercised too. With 7 items the carousel arrows engage
and the right-edge fade is visible at start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:58:34 +02:00
7bd3997564 feat(component): RoadmapCarousel — snap-scrolling horizontal strip
Replaces the 3-column grid on /pulse with a CSS scroll-snap carousel.
3 cards per view on desktop (flex 0 0 calc((100% - 2px) / 3)), one full
card with a slice of the next at 88% on mobile.

Header row: serif 22px 'On the roadmap' title on the left; right side has
the 'See the full roadmap →' section link plus two 30px round arrow
buttons. Arrows disable (opacity 0.25) when at start/end of scroll. Hidden
on mobile — touch swipe is the affordance.

Carousel-scroll has 0.5px top + bottom borders in rgba(0,0,0,0.08).
Scrollbar hidden cross-browser. Each card has a 0.5px right border (last
card excluded), 24/26 padding, background --background so it sits on the
cream rather than introducing a white surface.

Card contents:
  - 6px status dot in the status colour + tracked 10px label
    '{STATUS_LABEL} · {TARGET}' in the same family colour. The
    considering tier uses the lighter #d4d2c8 dot with the #b4b2a9 label
    to distinguish it from exploring.
  - 19px serif title, line-height 1.2.
  - 12px description, line-height 1.55, --on-surface-variant. If the
    item has attributions, an attributionLine() trailer appends 'Shaped
    by Lars.' / 'Shaped by Lars and Anna.' / etc. in --on-surface-muted.

Right-edge fade gradient (80px) fades to opacity 0 at scrollEnd via a
small vanilla script. The script also handles arrow disabled state and
scrollBy ±cardWidth on click. No library.

Items are loaded with the full set ordered by display_order ASC (then
id ASC tiebreak) — admin orders chronologically nearest-to-furthest and
the carousel just consumes that.

If items.length < 4 the arrows + fade are hidden; the cards still flex
naturally and don't actually need to scroll.

/pulse: dropped the old .roadmap-section/.roadmap-grid/.roadmap-card +
status-dot/breathe styles. Carousel does its own.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:55:44 +02:00
4219cda7b6 feat(pulse): 'also coming up' strip + editorial row (dispatch + pulse)
Below the indigo hero, two new sections replace the previous fenja-section
two-box layout.

ALSO-COMING-UP STRIP — plain text on cream, not a card. Flex
justify-between, 4px vertical padding. Up to 2 upcoming events on the
left, each rendered as a 22px serif day number beside a stacked
column: 9px tracked 'MONTH · KIND' label on top, 13px event title on
bottom. A 1px × 28px vertical divider in rgba(0,0,0,0.08) between the
two items. Right side carries the lone 'All gatherings →' link in 11px
terracotta tracked uppercase.

EDITORIAL ROW — 1.7fr / 1fr grid with a 56px column gap. Asymmetric
on purpose; the dispatch column carries the weight.

Dispatch column (left, wider, no card chrome):
  - Byline row: 26px deterministic-pigment avatar + 12px author first
    name + 11px relative time + kind pill (9px tracked, 2/8 padding,
    3px radius, pigment-tinted background, margin-left: auto).
  - 32px serif headline, line-height 1.15.
  - 14px lead paragraph, line-height 1.7 (via dispatchLongPreview).
  - 'Read the full dispatch' link — 11px terracotta tracked uppercase
    with a hard 1px terracotta underline (not the global .section-link
    italic treatment — this one's a button-ish affordance).
  - Margin-top 48px, padding-top 28px, 0.5px on-surface-border line,
    then the EARLIER list. Rows are 15px serif title left, 10px tracked
    '{KIND} · {N}D' right. Day count is rounded UTC delta.

Pulse column (right, narrower, no card chrome):
  - 10px tracked 'this week's pulse' eyebrow with a 5px terracotta dot
    prefix. The only return of an eyebrow on /pulse.
  - 22px serif question.
  - 11px tracked status line: 'CLOSES {DAY} · {N} OF {TOTAL} VOTED'.
  - Options stack with 6px gap. Each option has no top/right/bottom
    border, only a 2px left border. Default left-border is rgba(0,0,0,
    0.1); selected option flips to terracotta and gains a 5% terracotta
    background tint. Letter prefix (A/B/C/D) is tracked, 10px, muted
    by default and terracotta when chosen.
  - Vote action POST handler unchanged — same /pulse?action=vote path
    introduced in v2.

If the featured dispatch has no attached poll, the right column
doesn't render and the dispatch column takes the full row (the spec's
single-column collapse case).

Stale CSS for the old events-card / hero-* / coming-up-* / fenja-* /
fenja-poll-* class names all deleted in the same pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:53:39 +02:00
29fe1b7c92 feat(component): EventHeroCard — the indigo card that carries /pulse
Self-contained component for the next gathering. Three-column grid:
  - Left (140px): 10px tracked weekday · 88px serif day numeral (zero-
    padded, line-height 0.85) · 11px tracked MONTH · HH:MM line. All on
    the bleached --on-ink cream.
  - Middle (1fr): 10px tracked NEXT GATHERING · KIND eyebrow · 26px serif
    title · 13px description (--on-ink-body @ 85%) · 12px location line
    (--on-ink-muted @ 65%).
  - Right (auto, min 140px): tracked duration label right-aligned, then
    up to 3 confirmed attendees as 'name + 18px avatar' rows, with a
    +N overflow chip beneath if more.
Bottom strip after a 0.5px --ink-divider top border, padding-top 22px:
  - Left: tracked '{capacity} SEATS · {n} CONFIRMED | CLOSES {DAY}' status
    line, with a low-opacity '|' divider character between the halves.
  - Right: form actions — 'Can't make it' link + cream pill 'Save your
    seat →'. If already confirmed, swaps to outlined 'You're confirmed
    ✓' + a small 'Change' link.
Empty state collapses the action strip and renders a serif italic
'Nothing scheduled yet — when we have something, you'll be the first to
know.' (the only italic on the card, mirroring the spec).

/pulse swap: the previous inline events-card markup (hero body + bundled
coming-up grid + see-all link) is replaced by a single <EventHeroCard>
slot. The 'Also coming up' strip lives outside the indigo card now and
will be built in step 6; for this commit a temporary comment marker
holds its slot. AvatarPile import is dropped from /pulse since
EventHeroCard renders the attendees list inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:50:43 +02:00
096cdb00b6 feat(pulse): two-column greeting + tenure-milestone copy
Replaces the single-column greeting block with a 1fr / 1fr row. The left
side holds the date label (MONDAY, 11 MAY in tracked uppercase 11px,
swapped from the bullet separator to comma per spec), the serif 36px
greeting line ('Good morning, Jonathan.') with the first name italic,
and a milestone-aware tenure line. The right side (only rendered when
the user has a member_number — i.e. cab members) shows 'MEMBER · NNN'
zero-padded above 'Founding circle' in serif.

tenureMilestone(days) is the new helper that produces the milestone copy
through seven buckets: 0 / 1–6 / 7–20 / 21–55 / 56–180 / 181–364 / 365+.
The 1–6 bucket renders 'Day 2.', 'Day 3.' etc. (day count is days-since-
join; day one is the first 24 hours after joining). The months bucket
uses Math.floor(days/30) with a min of 2 to avoid a one-day-after-21
reading '1 months in.'. Years pluralise normally.

daysSince(iso) — small wrapper that handles SQL date strings ('YYYY-MM-DD
HH:MM:SS' or pure dates) and returns whole-day UTC delta. Same UTC
coercion the rest of the page uses.

Tests: 7 cases for tenureMilestone at the boundary days 0/1/7/22/60/200/
400 (plus an extra 730 to exercise the year plural). 43/43 passing.

This block replaces the in-line <MembershipCard> idea that was floating
around in earlier passes; the dark indigo card stays in src/components
for /members/:slug, but on /pulse the two-line stamp is enough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:48:50 +02:00
a4df2b4982 style(nav): active-link dot + 0.5px vertical divider before user name
- Active link no longer carries a background fill or pill. Instead, a
  4px terracotta dot is centred 22px below the link via ::after. Quieter,
  more confident, and survives the cream/white surface change without
  needing a hover-area redesign.
- Vertical divider before the user's name shifts from the existing ghost-
  border colour to a solid rgba(0,0,0,0.15) line, 18px tall with 18px
  padding either side (replacing the prior --space-2 / 8px). Still a
  scaled-1px element rather than a pipe character.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:47:10 +02:00
9ae8422527 feat(db): roadmap_items gains 'considering' + 'in_beta' rename, --on-ink tokens
Migration 0006 (the spec said 0005 but that number was already taken by
polls_on_dispatches from the previous session): rebuilds the
roadmap_items CHECK to ('shipping','in_beta','exploring','considering')
and renames any existing 'beta' rows to 'in_beta' in-place. FKs from
roadmap_attributions are preserved across the DROP/RENAME by toggling
PRAGMA foreign_keys off around the rebuild — attribution count unchanged
after migrate (verified 4 rows survive on the demo DB).

Tokens (src/styles/tokens.css): adds --on-ink, --on-ink-body,
--on-ink-muted, --ink-divider. The bleached #fffcf7 cream replaces the
warm #e8e0d0 --ink-text wherever it sits on indigo. Legacy --ink-text /
--ink-muted stay in tokens.css for now — if any later commit references
them they remain defined; the migration of existing call sites is
covered here.

Migrated to the new tokens in this pass:
  - src/components/MembershipCard.astro (members/:slug card)
  - src/pages/events.astro (hero invitation card)
  Both render with cleaner whites on indigo as a side effect.

Code updates for the new status enum:
  - db.ts: RoadmapStatus = shipping | in_beta | exploring | considering
  - admin/RoadmapTab.astro: Status select gains Considering + In beta;
    grouped section iteration covers all four
  - admin/index.astro: validation list updated
  - scripts/seed-roadmap.js: 'In progress' markdown bucket → 'in_beta'
  - pulse.astro: roadmapStatusDot + roadmapStatusBlurb temporarily widened
    (full rewrite of that section lands in step 7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:46:39 +02:00
cb2efa70f3 docs: Phase 0.5 audit — /pulse visual refinement preflight
Confirmed before writing code:
  - /pulse render order: greeting · events-card · fenja-section (article +
    inline poll) · roadmap-section (3-col grid) · council-section.
  - <MembershipCard> already absent from /pulse (removed in v2). The
    component stays in src/components for /members/:slug use.
  - events.kind: ('dinner','office_hours','summit','virtual','working_session')
  - roadmap_items.status: ('shipping','beta','exploring') — needs widening.
  - tokens.css: --background #faf6ee, --ink #2c3a52, --ink-text #e8e0d0,
    --ink-muted #b8a989. No --on-ink* tokens yet.

Divergences resolved with the user before step 2:
  1. Migration is 0006_roadmap_considering.sql, not 0005 — that number is
     already taken by polls_on_dispatches from the previous session.
  2. roadmap_items.status will rename existing 'beta' rows to 'in_beta'
     (matching the spec's canonical name) in the same migration.
  3. --on-ink, --on-ink-muted, --on-ink-body, --ink-divider added; existing
     --ink-text / --ink-muted references on indigo surfaces (MembershipCard,
     /events hero, /pulse hero) migrate in the same pass so old tokens
     aren't left half-used.
  4. Eyebrow uppercase labels return on /pulse only (NEXT GATHERING ·
     KIND, EARLIER, THIS WEEK'S PULSE, MEMBER · NNN). /members /events
     /dispatches keep the eyebrowless treatment.
  5. Pulse column shows the featured dispatch's attached poll. No attached
     poll ⇒ editorial row collapses to one column. Aligns with the
     polls-attached-to-articles model from the previous pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:43:26 +02:00
9800d0a448 feat(pulse): two-box Fenja+poll, prominent hero, single-bg council, more air
Layout (per the v4 follow-up spec):

1b. Latest from Fenja is now a two-box layout when there's an attached
    poll: article on the left (wider), poll widget on the right. Without
    a poll, the article box takes the full row. Both boxes are surfaced
    on --surface-card with the same generous padding so they read as
    sibling pieces.

1c. Featured excerpt is extended to ~720 chars (was ~520) via a wider
    threshold on dispatchLongPreview. Below the article+poll row, the
    next two most-recent published dispatches render as minimalist rows
    — just title + kind + relative time, separated by ghost borders.

2.  Hero event: date column is now 150px wide (was 110px); grid uses
    align-items: center so the date+detail columns are vertically aligned
    rather than top-stuck. Day number scaled up to 3.5rem (was 2.75).
    Outer card padding bumped from --space-7 to --space-10. Hero title
    bumped to 2rem.

3.  More air: page-level section gap --space-10 → --space-12. Each
    on-page card has been re-padded; outer page horizontal padding goes
    down to --space-16 from --space-20 to match the narrower canvas.

6.  Council members no longer have individual card chrome. One outer
    --surface-card wraps the whole grid; each member cell is just an
    avatar + name + title + company stack with no background or border.
    Cells use a larger 6/8 grid gap so they don't crowd each other.

Inline poll widget on /dispatches/[slug]: when a dispatch has an
attached pulse, the article body is followed by a compact poll card
matching the /pulse-side widget. Vote POST handled inline; the page
re-renders with the locked + result-bar state.

scripts/seed-demo.js: the existing 'Which milestone should we anchor Q3
around?' pulse now attaches to the decision dispatch ('We are
deprioritising public-cloud parity for Q3') via pulse_id. Other
dispatches stay poll-free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:19:00 +02:00
867661ee3d feat(polls): polls attach to dispatches — standalone Pulses entity retired
Schema (migration 0005): dispatches gains a nullable pulse_id FK to
pulses(id) ON DELETE SET NULL. Partial index on the populated rows.
The pulses + votes tables themselves are unchanged — vote uniqueness,
status derivation, and the existing tests still hold; only the entity
relationship changes.

db.ts:
- Dispatch type gains pulse_id. New DispatchWithPoll = DispatchWithAuthor
  + a hydrated poll (pulse + counts + viewer's vote).
- createDispatch accepts an optional poll input — if provided, creates the
  pulse first in the same transaction and stamps dispatches.pulse_id.
- updateDispatch grows two new arguments: poll (input or null) and a
  pollExplicit flag. The flag distinguishes "leave the existing poll
  alone" (undefined) from "the admin actively chose to detach / replace
  it" (true). The detach path nulls pulse_id; the replace path mutates
  the existing pulse in place via updatePulse so vote history survives.
- publishDispatch / archiveDispatch are now wrappers that also publishPulse
  / closePulse on the attached pulse. Dispatch state drives poll state.
- getDispatchWithPoll(dispatchId, viewerId) — single call for the page
  renderers.

Admin:
- The Pulses tab is removed from the admin tab nav. The route + POST
  handlers stay in place so existing draft pulses aren't orphaned, but
  the entity is no longer a place admins go to think.
- DispatchesTab form gains a poll fieldset: question + 4 option inputs
  (first two required if any are filled) + opens_at + closes_at. A
  hidden poll_explicit flag tells the server the form intentionally
  asserted the poll state (so leaving the fields blank during edit
  detaches rather than no-ops). On edit, fields prefill from the
  attached pulse if present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:14:50 +02:00
cafbcf8b74 style: site back to 72rem + section-link is black/underlined/larger
- --content-max: 83rem → 72rem (back to the original width — the wider
  canvas tested less well in practice; sections felt under-furnished).
- .section-link: italic serif retained, but now black (--on-surface)
  instead of terracotta, underlined with a fine 0.5px stroke and 4px
  text-underline-offset, font-size bumped from text-body-md to
  text-title-lg (1.125rem). Hover slightly thickens the underline rather
  than swapping colour, so the link still reads as a link at rest.
- .section-link--ink modifier swaps to cream on the dark events card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:12:01 +02:00
26 changed files with 3004 additions and 748 deletions

View file

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

View 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;

View 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;

View 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;

View file

@ -61,6 +61,9 @@ const newCabs = [
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
{ name: 'Mads Lindberg', email: 'mads@virk6.dk', org: 'Virksomhed 6' },
{ name: 'Camilla Storm', email: 'camilla@virk7.dk', org: 'Virksomhed 7' },
{ name: 'Frederik Lund', email: 'frederik@virk8.dk', org: 'Virksomhed 8' },
];
const insertUser = db.prepare(`
@ -75,7 +78,15 @@ for (const c of newCabs) {
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 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(`
UPDATE users
@ -108,6 +119,21 @@ const cabMeta = {
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
focus_tags: ['Legal', 'Policy', 'EU AI Act'],
},
'mads@virk6.dk': {
title: 'Chief Strategy Officer',
pull_quote: 'Healthcare runs on consent — and consent runs on trust.',
focus_tags: ['Healthcare', 'Consent', 'Governance'],
},
'camilla@virk7.dk': {
title: 'Head of Cyber Resilience',
pull_quote: 'Cyber resilience is not a feature — it is the substrate.',
focus_tags: ['Defence', 'Resilience'],
},
'frederik@virk8.dk': {
title: 'Director of Public Innovation',
pull_quote: 'Public innovation succeeds when it is measurably better, not just newer.',
focus_tags: ['Public sector', 'Measurement'],
},
};
for (const u of cabRows) {
@ -143,14 +169,17 @@ const nowIso = (offsetSeconds = 0) => {
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 = [
'Locking down on-prem deployment first',
'Pushing the traceability layer to GA',
'Going wide on document ingestion',
'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)
VALUES (?,?,?,?,?,?,?)
`).run(
@ -160,27 +189,36 @@ const pulseId = db.prepare(`
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
).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 (?,?,?,?)')
.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 (?,?,?,?)')
.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 = [
{ title: 'Traceability layer', description: 'Every response cites its sources with structured provenance.', status: 'shipping', target: 'Live now', display_order: 10, shipped_at: nowIso(-2 * 24 * 3600), attributed: [cabs[0].id] },
{ title: 'Document ingestion pipeline', description: 'Upload PDF, Word, plain text. Chunked, indexed, retrievable.', status: 'beta', target: null, display_order: 10, shipped_at: null, attributed: [cabs[1].id, cabs[2].id] },
{ title: 'Contextual memory', description: 'The system learns the regulatory and organisational context over time.', status: 'exploring', target: 'Q3 2026', display_order: 10, shipped_at: null, attributed: [cabs[3].id] },
{ title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, shipped_at: null, attributed: [] },
{ 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' },
];
const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
VALUES (?,?,?,?,?,?)
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
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).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);
}
@ -209,7 +247,9 @@ for (const c of contribs) {
const dispatchSeed = [
{ kind: 'decision', ageDays: 2,
title: 'We are deprioritising public-cloud parity for Q3',
excerpt: 'After three weeks of pilot feedback, the 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.
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 insertDispatch = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
VALUES (?,?,?,?,?,'published',?,?,?)
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id)
VALUES (?,?,?,?,?,'published',?,?,?,?)
`);
for (let i = 0; i < dispatchSeed.length; i += 1) {
const d = dispatchSeed[i];
const when = nowIso(-d.ageDays * 24 * 60 * 60);
const authorId = fenjas[i % fenjas.length].id;
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
@ -329,13 +372,13 @@ const insertActivity = db.prepare(`
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
VALUES (?,?,?,?,?)
`);
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60));
insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600));
insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600));
insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60));
insertActivity.run(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(' roadmap: 1 shipping / 1 beta / 2 exploring');
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(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
console.log(' events: dinner + studio hours + working session, 2 past');

View file

@ -30,7 +30,7 @@ const md = readFileSync(mdPath, 'utf8');
// schema's three statuses. In-progress items are actively being built and
// tested with pilots → beta. Next/Later are roadmap intent, not started → exploring.
const SECTION_STATUS = {
'In progress': { status: 'beta', target: null },
'In progress': { status: 'in_beta', target: null },
'Next': { status: 'exploring', target: 'Next quarter' },
'Later': { status: 'exploring', target: 'Later this year' },
};

View 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>

View 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>

View file

@ -51,7 +51,7 @@ const tags = readFocusTags(member.focus_tags);
<style>
.m-card {
background: var(--ink);
color: var(--ink-text);
color: var(--on-ink);
border-radius: var(--radius-lg);
padding: 1.25rem;
display: flex;
@ -70,7 +70,7 @@ const tags = readFocusTags(member.focus_tags);
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--ink-text);
background: var(--on-ink);
color: var(--ink);
display: inline-flex;
align-items: center;
@ -89,7 +89,7 @@ const tags = readFocusTags(member.focus_tags);
font-weight: 500;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--ink-muted);
color: var(--on-ink-muted);
}
.m-name {
@ -99,7 +99,7 @@ const tags = readFocusTags(member.focus_tags);
font-weight: 400;
font-size: 1.5rem;
line-height: 1.15;
color: var(--ink-text);
color: var(--on-ink);
display: flex;
flex-direction: column;
}
@ -116,12 +116,12 @@ const tags = readFocusTags(member.focus_tags);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--ink-muted);
color: var(--on-ink-muted);
}
.m-since-value {
font-family: var(--font-sans);
font-size: var(--text-body-sm);
color: var(--ink-text);
color: var(--on-ink);
}
.m-tags {
@ -133,8 +133,8 @@ const tags = readFocusTags(member.focus_tags);
gap: 6px;
}
.m-tag {
border: 0.5px solid rgba(232, 224, 208, 0.3);
color: var(--ink-text);
border: 0.5px solid rgba(255, 252, 247, 0.3);
color: var(--on-ink);
padding: 3px 8px;
border-radius: 999px;
font-family: var(--font-sans);

View 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>

View 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>

View file

@ -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 { dispatchKindLabel } from '../../lib/format';
interface Props {
dispatches: DispatchWithAuthor[];
editing: DispatchWithAuthor | null;
editingPoll: PulseRow | null;
fenjaUsers: UserPublic[];
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> = {
draft: 'Draft',
@ -57,8 +66,9 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
</div>
<div class="field">
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional — falls back to first ~200 chars of body)</label>
<input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional)</label>
<textarea id="d-excerpt" name="excerpt" class="input body-md" rows="4">{editing?.excerpt ?? ''}</textarea>
<span class="body-sm muted">Write 24 sentences. The first sentence becomes the lead paragraph on the /roadmap dispatch banner; the rest follows in muted text. Use a blank line to control the paragraph break. Falls back to the first ~200 chars of the body if empty.</span>
</div>
<div class="field">
@ -78,6 +88,65 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
</div>
)}
<!-- ── Attached poll (optional) ────────────────────────────── -->
<fieldset class="poll-fieldset">
<legend class="label-sm field-label">Attach a poll (optional)</legend>
<input type="hidden" name="poll_explicit" value="1" />
<p class="body-sm muted poll-help">
Fill in a question and at least two options to attach a poll. Leave them all blank
to {editingPoll ? 'detach the existing poll' : 'skip'}.
{editingPoll && <span class="poll-existing-flag"> · Currently attached: pulse #{editingPoll.id}, status {editingPoll.status}.</span>}
</p>
<div class="field">
<label for="d-poll-question" class="label-sm field-label">Poll question</label>
<input
type="text"
id="d-poll-question"
name="poll_question"
class="input body-md"
value={editingPoll?.question ?? ''}
placeholder={editing ? editing.title : 'A question for the council'}
/>
</div>
<div class="poll-options-grid">
{pollOptionsForForm.map((val, i) => (
<input
type="text"
name={`poll_option_${i}`}
placeholder={`Option ${String.fromCharCode(65 + i)}${i < 2 ? '' : ' (optional)'}`}
class="input body-md"
value={val}
/>
))}
</div>
<div class="form-grid">
<div class="field">
<label for="d-poll-opens" class="label-sm field-label">Poll opens at (UTC)</label>
<input
type="datetime-local"
id="d-poll-opens"
name="poll_opens_at"
class="input body-md"
value={toInputValue(editingPoll?.opens_at)}
/>
</div>
<div class="field">
<label for="d-poll-closes" class="label-sm field-label">Poll closes at (UTC)</label>
<input
type="datetime-local"
id="d-poll-closes"
name="poll_closes_at"
class="input body-md"
value={toInputValue(editingPoll?.closes_at)}
/>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
@ -144,6 +213,30 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.poll-fieldset {
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
margin: 0;
}
.poll-fieldset legend {
padding: 0 var(--space-2);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.poll-help { color: var(--on-surface-muted); margin: 0; }
.poll-existing-flag { color: var(--pigment-terracotta); }
.poll-options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.muted { color: var(--on-surface-muted); }
.status-pill {
display: inline-block;
padding: 0.15em var(--space-3);

View file

@ -9,17 +9,23 @@ interface 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 attributedSet = new Set((editing?.attributed ?? []).map(a => a.id));
// Group items by status for display
type Status = 'shipping' | 'beta' | 'exploring';
type Status = 'shipping' | 'in_beta' | 'exploring' | 'considering';
const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
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),
considering: items.filter(i => i.status === 'considering').sort((a,b) => a.display_order - b.display_order),
};
---
<div class="tab-content">
@ -39,8 +45,9 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
<div class="field">
<label for="status" class="label-sm field-label">Status</label>
<select id="status" name="status" class="select body-md" required>
<option value="considering" selected={editing?.status === 'considering'}>Considering</option>
<option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
<option value="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>
</select>
</div>
@ -59,6 +66,20 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
</div>
<div class="field">
<label for="metadata_text" class="label-sm field-label">Hover note (~60 chars)</label>
<input
type="text"
id="metadata_text"
name="metadata_text"
class="input body-md"
value={editing?.metadata_text ?? ''}
placeholder="e.g. Open question on key custody · Council input wanted"
maxlength="120"
/>
<span class="body-sm muted">A short narrative cue shown on hover in /roadmap. Optional.</span>
</div>
<fieldset class="attribution-grid">
<legend class="label-sm field-label">Attributed members (who shaped this)</legend>
{cabUsers.map(u => (
@ -78,7 +99,7 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
</section>
<!-- ── List by status ────────────────────────────────────────── -->
{(['shipping','beta','exploring'] as const).map(status => (
{(['shipping','in_beta','exploring','considering'] as const).map(status => (
<section class="section">
<h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2>
{grouped[status].length === 0 ? (
@ -176,4 +197,6 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
.muted { color: var(--on-surface-muted); }
</style>

View file

@ -16,7 +16,6 @@ const navLinks = [
const footerLinks = [
{ href: '/vision', label: 'Vision' },
{ href: '/council-manifesto', label: 'Council manifesto' },
];
const currentPath = Astro.url.pathname;
@ -112,35 +111,51 @@ const year = new Date().getFullYear();
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--space-3);
gap: 12px;
border-bottom: none;
color: var(--on-surface);
line-height: 1; /* belt + braces — no nav-row leading on the lockup */
}
.wordmark-link:hover {
border-bottom: none;
color: var(--on-surface);
}
.wordmark {
height: 22px;
height: 20px;
width: auto;
display: block;
}
.wordmark-sep {
color: var(--on-surface-muted);
font-size: 1rem;
font-family: var(--font-serif);
font-size: 18px;
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 {
font-family: var(--font-sans);
font-size: var(--text-body-md);
font-weight: 500;
color: var(--on-surface);
letter-spacing: 0;
}
/* Italic Newsreader renders ~10% visually taller than regular at the
same font-size — the cursive B has a flourish extending past the
cap line. Drop Bifrost to 16px so its cap+flourish optical height
matches Project's 18px cap, and use inline-block + tiny vertical
padding so the gradient-clip bbox doesn't chop the flourish off. */
.wordmark-project,
.wordmark-bifrost {
font-family: var(--font-serif);
font-style: italic;
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(
90deg,
var(--pigment-terracotta) 0%,
@ -165,14 +180,15 @@ const year = new Date().getFullYear();
display: inline-block;
width: 1px;
height: 18px;
background: var(--ghost-border-color);
margin: 0 var(--space-2);
background: rgba(0, 0, 0, 0.15);
margin: 0 18px;
transform: scaleX(0.5);
transform-origin: center;
}
.nav-logout-form { display: inline-flex; }
.nav-link {
position: relative;
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
@ -183,17 +199,17 @@ const year = new Date().getFullYear();
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
transition: color var(--duration-fast) var(--ease-standard);
}
.nav-link:hover {
color: var(--on-surface);
background: var(--surface-container-low);
border-bottom: none;
}
/* Active nav link: terracotta + slightly heavier weight. Colour alone
is the indicator — no badge, bullet, italic, or family swap. */
.nav-link.active {
color: var(--on-surface);
background: var(--surface-container);
color: var(--pigment-terracotta);
font-weight: 500;
}
/* ── User zone ──────────────────────────────────────────────────── */

View file

@ -634,6 +634,21 @@ export function castVote(pulseId: number, userId: number, optionIndex: number):
).run(pulseId, userId, optionIndex);
}
/** UPSERT first vote inserts, subsequent ones update option_index + voted_at.
* Use this when the UI allows members to change their pick while the pulse is
* still open. Returns true if this was a brand-new vote (so callers can
* record activity once), false if it changed an existing vote. */
export function castOrChangeVote(pulseId: number, userId: number, optionIndex: number): boolean {
const existing = getUserVote(pulseId, userId);
db.prepare(`
INSERT INTO votes (pulse_id, user_id, option_index, voted_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(pulse_id, user_id) DO UPDATE
SET option_index = excluded.option_index, voted_at = excluded.voted_at
`).run(pulseId, userId, optionIndex);
return existing === null;
}
export function getUserVote(pulseId: number, userId: number): number | null {
const r = db.prepare(
'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?'
@ -650,7 +665,7 @@ export function countPulseParticipants(pulseId: number): number {
// ── Roadmap items ────────────────────────────────────────────────
export type RoadmapStatus = 'shipping' | 'beta' | 'exploring';
export type RoadmapStatus = 'shipping' | 'in_beta' | 'exploring' | 'considering';
export interface RoadmapItem {
id: number;
@ -660,6 +675,7 @@ export interface RoadmapItem {
target: string | null;
display_order: number;
shipped_at: string | null;
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
created_at: string;
updated_at: string;
}
@ -674,11 +690,12 @@ export function createRoadmapItem(data: {
status: RoadmapStatus;
target?: string | null;
display_order?: number;
metadata_text?: string | null;
}): number {
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
VALUES (?,?,?,?,?,?)
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?)
`).run(
data.title,
data.description,
@ -686,6 +703,7 @@ export function createRoadmapItem(data: {
data.target ?? null,
data.display_order ?? 0,
shipped_at,
data.metadata_text ?? null,
);
return Number(r.lastInsertRowid);
}
@ -700,6 +718,7 @@ export function updateRoadmapItem(id: number, data: {
status: RoadmapStatus;
target: string | null;
display_order: number;
metadata_text?: string | null;
}): { shippedNow: boolean } {
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
@ -713,9 +732,9 @@ export function updateRoadmapItem(id: number, data: {
db.prepare(`
UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
shipped_at = ?, updated_at = datetime('now')
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
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 };
}
@ -1004,6 +1023,7 @@ export interface Dispatch {
published_at: string | null;
created_at: string;
updated_at: string;
pulse_id: number | null; // attached poll, if any
}
export interface DispatchWithAuthor extends Dispatch {
@ -1012,6 +1032,18 @@ export interface DispatchWithAuthor extends Dispatch {
author_role: Role;
}
export interface DispatchWithPoll extends DispatchWithAuthor {
poll: PulseWithCounts | null;
}
/** Optional poll attachment used when creating/updating a dispatch. */
export interface DispatchPollInput {
question: string;
options: string[];
opens_at: string;
closes_at: string;
}
export function createDispatch(data: {
title: string;
body: string;
@ -1019,34 +1051,99 @@ export function createDispatch(data: {
kind: DispatchKind;
author_id: number;
status: DispatchStatus;
poll?: DispatchPollInput | null;
}): number {
const published_at = data.status === 'published'
? new Date().toISOString().slice(0, 19).replace('T', ' ')
: null;
return db.transaction(() => {
let pulseId: number | null = null;
if (data.poll && data.poll.options.length >= 2) {
pulseId = createPulse({
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
status: data.status === 'published' ? 'open' : 'draft',
created_by: data.author_id,
});
}
const r = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at)
VALUES (?,?,?,?,?,?,?)
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at);
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, pulse_id)
VALUES (?,?,?,?,?,?,?,?)
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at, pulseId);
return Number(r.lastInsertRowid);
})();
}
/** Update a dispatch and, optionally, manage its attached poll. */
export function updateDispatch(id: number, data: {
title: string;
body: string;
excerpt: string | null;
kind: DispatchKind;
author_id: number;
poll?: DispatchPollInput | null; // present + has options ⇒ attach/update; explicit null ⇒ detach
pollExplicit?: boolean; // distinguishes "leave poll alone" (undefined) from "detach" (null + flag)
}): void {
db.transaction(() => {
const cur = db.prepare('SELECT pulse_id, status FROM dispatches WHERE id = ?')
.get(id) as { pulse_id: number | null; status: DispatchStatus } | undefined;
if (!cur) return;
let pulseId: number | null = cur.pulse_id;
if (data.pollExplicit) {
if (data.poll && data.poll.options.length >= 2) {
if (cur.pulse_id) {
// update the existing pulse in place
updatePulse(cur.pulse_id, {
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
});
} else {
pulseId = createPulse({
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
status: cur.status === 'published' ? 'open' : 'draft',
created_by: data.author_id,
});
}
} else {
// explicit detach
pulseId = null;
}
}
db.prepare(`
UPDATE dispatches
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now')
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?,
pulse_id = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, 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.
* 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 {
db.transaction(() => {
db.prepare(`
UPDATE dispatches
SET status = 'published',
@ -1054,11 +1151,19 @@ export function publishDispatch(id: number): void {
updated_at = datetime('now')
WHERE id = ?
`).run(id);
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
if (row?.pulse_id) publishPulse(row.pulse_id);
})();
}
/** Archive a dispatch. Leaves published_at intact for history. */
/** 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 {
db.transaction(() => {
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
if (row?.pulse_id) closePulse(row.pulse_id);
})();
}
export function deleteDispatch(id: number): void {

Binary file not shown.

119
src/lib/roadmap-layout.ts Normal file
View 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);
}

View file

@ -101,14 +101,35 @@ if (Astro.request.method === 'POST') {
const authorId = Number(data.get('author_id'));
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
// Parse optional poll attachment fields.
const pollExplicit = String(data.get('poll_explicit') ?? '') === '1';
const pollQuestion = String(data.get('poll_question') ?? '').trim();
const pollOpts = [0, 1, 2, 3]
.map(i => String(data.get(`poll_option_${i}`) ?? '').trim())
.filter(s => s.length > 0);
const pollOpens = String(data.get('poll_opens_at') ?? '');
const pollCloses = String(data.get('poll_closes_at') ?? '');
let pollInput: { question: string; options: string[]; opens_at: string; closes_at: string } | null = null;
if (pollQuestion && pollOpts.length >= 2 && pollOpens && pollCloses) {
pollInput = {
question: pollQuestion,
options: pollOpts,
opens_at: toSqlDate(pollOpens),
closes_at: toSqlDate(pollCloses),
};
}
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
formError = 'Title, body, and a valid kind are required.';
} else if (action === 'create_dispatch') {
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status });
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status, poll: pollInput });
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
} else {
const id = Number(data.get('dispatch_id'));
if (id) updateDispatch(id, { title, body, excerpt, kind, author_id: authorId || user.id });
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`);
}
} else if (action === 'publish_dispatch') {
@ -179,12 +200,13 @@ if (Astro.request.method === 'POST') {
const status = String(data.get('status') ?? '') as RoadmapStatus;
const target = String(data.get('target') ?? '').trim() || null;
const displayOrder = Number(data.get('display_order') ?? 0);
const metadataText = String(data.get('metadata_text') ?? '').trim() || null;
const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean);
if (!title || !['shipping','beta','exploring'].includes(status)) {
if (!title || !['shipping','in_beta','exploring','considering'].includes(status)) {
formError = 'Title and status are required.';
} 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);
if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
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'));
if (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);
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];
updateRoadmapItem(item.id, {
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, {
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 dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
const dispatchEditingPoll = dispatchEditing?.pulse_id ? getPulseById(dispatchEditing.pulse_id) : null;
// Per-tab data
const pulses = tab === 'pulses' ? getAllPulses() : [];
@ -335,12 +358,11 @@ actionMsg = Astro.url.searchParams.get('msg');
<h1 class="display-md page-title">Control panel.</h1>
</header>
<!-- Tabs -->
<!-- Tabs (Pulses entity merged into Dispatches — polls now attach to articles) -->
<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=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a>
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a>
<a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a>
<a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
@ -577,7 +599,7 @@ actionMsg = Astro.url.searchParams.get('msg');
)}
{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>

View file

@ -1,7 +1,10 @@
---
import AppLayout from '../../layouts/AppLayout.astro';
import Avatar from '../../components/Avatar.astro';
import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
import {
getDispatchWithPoll, getAdjacentDispatches,
getPulseById, castOrChangeVote, recordActivity, countCabMembers,
} from '../../lib/db';
import {
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
dispatchKindPigment, roleLabel,
@ -14,15 +17,38 @@ const id = parseDispatchSlug(slugParam);
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');
// Canonical-redirect when the slug changes after a rename — id is the authority
const canonical = dispatchSlug(d);
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
const totalMembers = countCabMembers();
const { prev, next } = getAdjacentDispatches(d.id);
function closeDayLabel(closesAt: string): string {
const parsed = closesAt.includes('T') ? new Date(closesAt) : new Date(closesAt.replace(' ', 'T') + 'Z');
return new Intl.DateTimeFormat('en-GB', { weekday: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed);
}
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
@ -63,6 +89,51 @@ const bodyHtml = renderMd(d.body);
<div class="body prose" set:html={bodyHtml} />
{d.poll && (
<aside class="inline-poll" aria-label="Poll attached to this dispatch">
<p class="inline-poll-question">{d.poll.question}</p>
<form method="POST" class="inline-poll-options" novalidate>
<input type="hidden" name="action" value="vote" />
<input type="hidden" name="pulse_id" value={d.poll.id} />
{d.poll.options.map((opt, i) => {
const hasVoted = d.poll!.my_vote !== null;
const chosen = d.poll!.my_vote === i;
const closed = d.poll!.status !== 'open';
const count = d.poll!.votes_by_option[i] ?? 0;
const pct = d.poll!.votes_total > 0 ? (count / d.poll!.votes_total) * 100 : 0;
const letter = String.fromCharCode(65 + i);
return (
<button
type="submit"
name="option_index"
value={i}
class:list={['inline-poll-option', { chosen, closed }]}
disabled={closed && !chosen}
aria-pressed={chosen}
>
<span class="inline-poll-letter">{letter}</span>
<span class="inline-poll-text">{opt}</span>
{hasVoted && (
<span class="inline-poll-pct">{pct.toFixed(0)}%</span>
)}
{hasVoted && (
<span class="inline-poll-bar" aria-hidden="true">
<span class="inline-poll-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
</span>
)}
</button>
);
})}
</form>
<p class="inline-poll-count">
{d.poll.votes_total} of {totalMembers} have weighed in
{d.poll.status === 'open'
? ` · closes ${closeDayLabel(d.poll.closes_at)}`
: ' · closed'}
</p>
</aside>
)}
<hr class="divider" />
<nav class="adjacent" aria-label="Adjacent dispatches">
@ -194,6 +265,95 @@ const bodyHtml = renderMd(d.body);
margin: var(--space-6) 0 0;
}
/* ── Inline poll attached to the dispatch ──────────────────────── */
.inline-poll {
margin-top: var(--space-7);
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.inline-poll-question {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.25rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.inline-poll-options {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.inline-poll-option {
position: relative;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--background);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-body-md);
color: var(--on-surface);
text-align: left;
cursor: pointer;
transition: border-color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
overflow: hidden;
}
.inline-poll-option:hover:not(.closed) { border-color: var(--outline); }
.inline-poll-option.chosen {
border-color: var(--pigment-terracotta);
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
}
.inline-poll-option.closed:not(.chosen) {
cursor: default;
color: var(--on-surface-variant);
}
.inline-poll-pct {
margin-left: auto;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
font-weight: 600;
letter-spacing: var(--tracking-wider);
color: var(--on-surface-variant);
font-variant-numeric: tabular-nums;
}
.inline-poll-option.chosen .inline-poll-pct { color: var(--pigment-terracotta); }
.inline-poll-option:disabled { opacity: 0.85; }
.inline-poll-letter {
font-weight: 600;
color: var(--on-surface-muted);
flex-shrink: 0;
}
.inline-poll-option.chosen .inline-poll-letter { color: var(--pigment-terracotta); }
.inline-poll-text { flex: 1; }
.inline-poll-bar {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: var(--surface-container);
}
.inline-poll-bar-fill {
display: block;
height: 100%;
background: var(--pigment-terracotta);
opacity: 0.55;
transition: width 600ms var(--ease-standard);
}
.inline-poll-count {
color: var(--on-surface-muted);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
margin: 0;
}
.adjacent {
display: grid;
grid-template-columns: 1fr 1fr;

View file

@ -220,7 +220,7 @@ const heroAudience = hero?.audience ?? 'Members only';
/* ── Hero ─────────────────────────────────────────────────────── */
.hero {
background: var(--ink);
color: var(--ink-text);
color: var(--on-ink);
border-radius: var(--radius-lg);
padding: 1.75rem;
display: flex;
@ -232,7 +232,7 @@ const heroAudience = hero?.audience ?? 'Members only';
display: flex;
justify-content: space-between;
align-items: center;
color: var(--ink-muted);
color: var(--on-ink-muted);
}
.hero-eyebrow {
font-family: var(--font-sans);
@ -255,7 +255,7 @@ const heroAudience = hero?.audience ?? 'Members only';
left: 100px;
top: 0; bottom: 0;
width: 0.5px;
background: rgba(232, 224, 208, 0.2);
background: var(--ink-divider);
}
.hero-date { display: flex; flex-direction: column; gap: 2px; }
@ -264,13 +264,13 @@ const heroAudience = hero?.audience ?? 'Members only';
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--ink-text);
color: var(--on-ink);
}
.hero-day {
font-family: var(--font-serif);
font-size: 2.75rem;
line-height: 1;
color: var(--ink-text);
color: var(--on-ink);
}
.hero-detail { padding-left: var(--space-5); }
@ -279,22 +279,22 @@ const heroAudience = hero?.audience ?? 'Members only';
font-weight: 400;
font-size: 1.75rem;
line-height: 1.2;
color: var(--ink-text);
color: var(--on-ink);
margin: 0 0 var(--space-3);
}
.hero-desc {
color: rgba(232, 224, 208, 0.85);
color: var(--on-ink-body);
margin: 0 0 var(--space-3);
max-width: 40rem;
}
.hero-meta {
color: var(--ink-muted);
color: var(--on-ink-muted);
font-size: var(--text-body-sm);
margin: 0;
}
.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);
display: flex;
justify-content: space-between;
@ -304,7 +304,7 @@ const heroAudience = hero?.audience ?? 'Members only';
}
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
.hero-foot-stat {
color: var(--ink-muted);
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
@ -313,7 +313,7 @@ const heroAudience = hero?.audience ?? 'Members only';
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
.hero-cta {
background: var(--ink-text);
background: var(--on-ink);
color: var(--ink);
border: none;
padding: 10px 20px;
@ -329,20 +329,20 @@ const heroAudience = hero?.audience ?? 'Members only';
.hero-cta:hover { opacity: 0.85; }
.hero-confirmed {
color: var(--ink-text);
color: var(--on-ink);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 10px 16px;
border: 0.5px solid rgba(232, 224, 208, 0.4);
border: 0.5px solid rgba(255, 252, 247, 0.4);
border-radius: 999px;
}
.hero-change {
background: transparent;
border: none;
color: var(--ink-muted);
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
@ -359,7 +359,7 @@ const heroAudience = hero?.audience ?? 'Members only';
display: flex;
}
.hero-empty-line {
color: var(--ink-text);
color: var(--on-ink);
font-family: var(--font-serif);
font-size: 1.25rem;
margin: auto;

File diff suppressed because it is too large Load diff

View file

@ -1,212 +1,111 @@
---
import { readFileSync } from 'fs';
import { join } from 'path';
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;
// Single-file roadmap — not a content collection
const raw = readFileSync(join(process.cwd(), 'content/roadmap.md'), 'utf-8');
// Strip YAML frontmatter
const body = raw.replace(/^---[\s\S]*?---\n/, '');
// Parse sections by ## headings
function parseSections(md: string) {
const sectionRe = /^## (.+)$/gm;
const sections: { title: string; items: { title: string; body: string; pilotOnly: boolean }[] }[] = [];
const matches = [...md.matchAll(sectionRe)];
for (let i = 0; i < matches.length; i++) {
const m = matches[i];
const start = m.index! + m[0].length;
const end = matches[i + 1]?.index ?? md.length;
const sectionBody = md.slice(start, end).trim();
// Each item starts with **Title** — description
const itemRe = /\*\*([^*]+)\*\*\s*—\s*([\s\S]*?)(?=\n\n\*\*|\n\n##|$)/g;
const items: { title: string; body: string; pilotOnly: boolean }[] = [];
let itemMatch: RegExpExecArray | null;
while ((itemMatch = itemRe.exec(sectionBody)) !== null) {
const rawBody = itemMatch[2].trim();
const pilotOnly = rawBody.includes('`pilot-only`');
const cleanBody = rawBody.replace(/`pilot-only`/g, '').trim();
items.push({ title: itemMatch[1], body: cleanBody, pilotOnly });
}
sections.push({ title: m[1], items });
}
return sections;
}
const sections = parseSections(body);
const horizonColors: Record<string, string> = {
'In progress': 'var(--pigment-copper)',
'Next': 'var(--pigment-ochre)',
'Later': 'var(--pigment-indigo)',
};
const items = getAllRoadmapItems()
.sort((a, b) => a.display_order - b.display_order || a.id - b.id);
---
<AppLayout title="Roadmap" user={user}>
<div class="page">
<article class="roadmap-page">
<header class="page-header">
<h1 class="display-md page-title">What we are building.</h1>
<p class="subtitle">
Three horizons. What is in progress now, what comes next,
and what is further out. This is the live picture.
<!-- Single centred header — merges the page lead with the route's
interaction hints. -->
<header class="roadmap-header">
<h1 class="roadmap-title">Roadmap</h1>
<p class="roadmap-sub">
A live picture of the work. What's in motion, what's queued,
what we're still thinking about. Tap or hover any milestone
for the full story. Drag or scroll to move.
</p>
</header>
<div class="horizons">
{sections.map((section) => (
<section class="horizon">
<div class="horizon-header">
<span
class="horizon-dot"
style={`background: ${horizonColors[section.title] ?? 'var(--on-surface-muted)'}`}
aria-hidden="true"
/>
<h2 class="headline-sm horizon-title">{section.title}</h2>
<!-- Legend lives above the route now — reads as a key the eye picks
up just before walking the path. -->
<div class="roadmap-legend" aria-label="Status legend">
<span><i style="background:#6d8c7c"></i>Shipping</span>
<span><i style="background:#b96b58"></i>In beta</span>
<span><i style="background:#b4b2a9"></i>Exploring</span>
<span><i style="background:#d4d2c8"></i>Considering</span>
</div>
<ul class="item-list">
{section.items.map((item) => (
<li class="item">
<div class="item-header">
<h3 class="item-title body-lg">{item.title}</h3>
{item.pilotOnly && (
<span class="pilot-badge label-sm" title="Available to pilot participants only">
Pilot
</span>
)}
</div>
<p class="body-md item-body">{item.body}</p>
</li>
))}
</ul>
</section>
))}
</div>
<RoadmapRoute items={items} />
</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>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
.roadmap-page {
padding: 0 36px 80px;
max-width: var(--content-max);
margin: 0 auto;
}
/* ── Header ──────────────────────────────────────────────────────── */
.page-header {
max-width: 44rem;
margin-bottom: var(--space-12);
/* ── Centred header ──────────────────────────────────────────── */
.roadmap-header {
text-align: center;
max-width: 640px;
margin: 0 auto 56px; /* generous gap to the legend */
padding-top: 96px;
}
.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 {
.roadmap-title {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: var(--tracking-snug);
margin: 0;
font-size: 48px;
line-height: 1.05;
letter-spacing: var(--tracking-tight);
color: var(--on-surface);
flex: 1;
margin: 0 0 14px;
}
.pilot-badge {
flex-shrink: 0;
padding: 0.2em var(--space-2);
background: var(--surface-container);
border-radius: var(--radius-full);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
white-space: nowrap;
}
.item-body {
margin: 0;
.roadmap-sub {
font-size: 14px;
line-height: 1.65;
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>

View file

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

View file

@ -45,8 +45,14 @@
--surface-card: #ffffff;
--surface-card-border: rgba(0, 0, 0, 0.08);
--ink: #2c3a52; /* deep indigo — membership card + event hero */
--ink-text: #e8e0d0; /* readable cream on --ink */
--ink-muted: #b8a989; /* muted label tone on --ink */
--ink-text: #e8e0d0; /* legacy warm cream — superseded by --on-ink */
--ink-muted: #b8a989; /* legacy tan — superseded by --on-ink-muted */
/* --- v4: bleached cream on indigo surfaces (replaces --ink-text) --- */
--on-ink: #fffcf7; /* primary text on --ink */
--on-ink-body: rgba(255, 252, 247, 0.85); /* body copy */
--on-ink-muted: rgba(255, 252, 247, 0.65); /* tracked labels */
--ink-divider: rgba(255, 252, 247, 0.18); /* 0.5px lines on --ink */
/* --- Semantic state mappings --- */
--color-success: var(--pigment-copper);
@ -129,6 +135,6 @@
--duration-slow: 420ms;
/* --- Layout --- */
--content-max: 83rem; /* 1328px — 15% wider than the original 72rem */
--content-max: 72rem; /* 1152px */
--reading-max: 42rem; /* 672px */
}

View 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);
});
});

View 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.');
});
});