Commit graph

137 commits

Author SHA1 Message Date
b156f5b02b fix(pulse): show pulse close date instead of weekday
The pulse close label rendered only the weekday (e.g. "Closes TUESDAY"),
which is ambiguous for pulses closing more than a week out. Show the day
and month instead (e.g. "Closes 23 JUNE") on both the /pulse page and the
dispatch detail poll footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:40:55 +02:00
4c4df45f0c feat(admin): editable member email + Danish slug folding
Lets a fenja admin edit a member's email in the People resource (the
field was read-only). Email is required, format- and uniqueness-checked,
and normalised to lowercase on save; collisions surface as a form error
via the new updateUserEmail() helper.

Also folds ø/æ/å in slugifyName so Danish names produce clean member
slugs (soren-friis, not s-ren-friis) — NFKD leaves those letters intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:44:28 +02:00
59842432bd feat(roadmap): add planned status
Adds a fifth roadmap status, `planned`, for items that are committed and
scheduled but not yet started — sitting between `in_beta` and `exploring`
in the progression. Rendered with the design system's indigo pigment
(#5a6d83) on the route, carousel, legend, and admin pill.

Migration 0008 widens the status CHECK constraint via a table rebuild
(SQLite can't alter it in place), preserving rows and attributions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:30:06 +02:00
a520e8534e fix(admin): invite magic link is absolute, not relative
The Create Invitation flow rendered "/invite?t=…" instead of
"https://host/invite?t=…" because the origin was gated on an unset
PUBLIC_ORIGIN env var.

Solution: OpContext now carries `origin` (always set by the route
handler from Astro.url.origin), and invitations.ts builds the magic
link from it. No env vars required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:29:58 +02:00
4aaf0957dd fix: nine test-note follow-ups
1. Markdown preview in the admin edit panel now re-renders from the
   textarea's current value on every toggle (dynamic-imports marked on
   the client). Previously the panel showed the server-rendered seed
   value forever, so new dispatches always previewed empty.

2. Pulse sub-form drops the opens_at field (opens on dispatch publish
   automatically) and changes closes_at to a date input — the chosen
   day is treated as end-of-day in the DB.

3. /dispatches/[slug] reading width widened 50% (720 → 1080px).

4. Roadmap display_order cascades on insert / update / delete:
   inserting at N bumps N..end up by 1, deleting N pulls N+1..end
   down by 1, moving from A to B shifts the intermediate range by 1
   in the appropriate direction. Order stays dense — no gaps, no
   collisions. All three transitions run in a transaction.

5. /roadmap always anchors at scrollLeft=0 on mount so the first
   milestone aligns with the content-column left edge. Previously
   the page jumped to the last-shipping milestone, which felt random
   once items past the viewport landed.

6. Events admin list shows the actual date (fmtDateTime) instead of
   "in 3 days" — easier to scan when planning across months.

7. duration_label is auto-computed from starts_at + ends_at on save
   (minutes < 90, hours < 4, "Half day", "Full day", "N days").
   The manual field is gone from the admin form; the column on the
   member-facing event pages keeps reading the stored value as before.

8. Pulse hero still skips office hours per the existing logic — no
   change. Confirmed via the test note's clarification.

9. Pulse "also coming up" strip relabeled to Previous + Upcoming.
   Previous = most recent past non-office-hours event. Upcoming =
   next non-office-hours event after the hero. Each card now carries
   a small terracotta eyebrow with the label.

Typecheck clean, build clean, 147/147 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:46:06 +02:00
220f8e0290 style(roadmap): align first milestone with content column left edge
The first route item now starts where the dispatch banner's left edge
sits (the page's content-max column), instead of 60px from the
viewport edge. Looks intentional now — the route and the dispatch
banner share a vertical anchor.

- computeRouteLayout now accepts optional paddingLeft / paddingRight
  that override the symmetric paddingX. Existing call sites and
  tests are unchanged.
- RoadmapRoute SSR + client recompute set paddingLeft = max(60,
  (vw - 1152) / 2), so on viewports ≤ 1152px nothing moves (degrades
  gracefully) and on wider screens the start migrates inward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:06:30 +02:00
8bbf8568f4 feat(admin): retire old admin, add resource verifier, redirect /admin
The Backstage rebuild is complete. The old single-page /admin (with
seven ?tab= sections backed by six tab partials) is gone. /admin
now redirects to the first registered resource (dispatches), and
every entity is served by the shared /admin/[resource] dynamic
route from steps 4–10.

- tests/admin-resources.test.ts: vitest-based verifier that walks
  every registered resource and asserts:
    - identity fields (key/label/plural/singular/groupKey)
    - list.queryFn, action.handler, ops.* members are functions
    - column kinds are in the registered set (text/pill/relative-date/
      number/tag-list); same for columnsByFilter overrides
    - field kinds are in the registered set (11 kinds)
    - embed.component is in the registered set (pulse-sub-form)
    - resource keys are unique, action keys are unique per resource
    - at most one filter is isDefault
    - groupKey resolves to a real group
    - review-mode resources have at least one action
    - ops.create requires a non-null form
  87 assertions, integrated into pnpm test, fails CI on any drift.
- src/pages/admin/index.astro: thin redirect to /admin/<first-key>.
- src/pages/admin/preview.astro: deleted (step-4 smoke route).
- src/components/admin/*.astro: deleted (6 old tab partials —
  ActivityTab, DispatchesTab, EventsTab, PulsesTab, RoadmapTab,
  UserEditTab — all replaced by the resource configs).

Full suite: 147 tests pass (60 prior + 87 verifier). Typecheck
clean. Build clean. Manual smoke shows every /admin/<resource>
URL resolves through the dynamic route; old /admin?tab=… references
exist only in deleted files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:52:48 +02:00
18d371b368 feat(admin): activity resource (read-only debug feed)
Last resource lands. /admin/activity tails the activity table —
votes, RSVPs, office-hour bookings, roadmap ships, pulse opens —
with last-7-days / last-30-days filters. Pure read view: no form,
no summary, no ops.

src/admin/components/ResourceListView.astro: rows fall back to <div>
when the resource has no panel pathway (form: null AND no summary).
Activity rows aren't clickable now — previously they'd dirty the URL
with a ?edit= that resolved to nothing.

The registry is complete: 7 resources across 3 groups, matching the
sidebar layout described in the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:38:11 +02:00
e9a986d484 feat(admin): council-group resources (users, invitations, join requests)
Three more resources land. /admin/users replaces the old participants
tab, /admin/invitations replaces the old invites tab, /admin/join_requests
replaces the read-only join queue.

- src/admin/resources/users.ts ("People"): single resource for all users,
  filter chips swap visible columns (council shows member_number +
  focus_tags; pilots/team show role + last_seen_at). Form fields are
  conditional — title / pull_quote / focus_tags / cab_joined_date /
  member_number render only when role === cab. No ops.create (users
  come via invites); deactivateUser is the delete handler.
- src/admin/resources/invitations.ts: form-for-create, summary-for-view.
  Create generates a token via generateInviteToken(), stores its hash,
  surfaces the magic link as a one-shot ?invite_url= block in the panel.
  Revoke is an action (sets expires_at = now); the row stays for audit.
- src/admin/resources/join-requests.ts: form: null, review-mode panel
  with the user's summary + approve_as_cab / decline actions.

Plumbing to support the above:
- src/admin/resource-types.ts: new Resource.summary callback (read-only
  field pairs for review panels); OpContext.result lets ops surface
  ActionResults (e.g. invite-link).
- src/admin/components/ResourceEditPanel.astro: review mode when an
  existing item is shown and resource.summary is defined; renders the
  ?invite_url= block above the summary with a copy-to-clipboard button.
- src/admin/components/ResourceListView.astro: "+ New" suppressed when
  ops.create is undefined.
- src/pages/admin/[resource].astro: captures ctx.result and action
  handler return values, propagates them via &invite_url=...; routes to
  the list view (not the row) when an action removes the item.
- src/lib/db.ts: adds getJoinRequestById, deleteJoinRequest,
  getInviteById.

Deviation from the original delta: no approve_as_pilot action and no
invite-link result on join-request approval. The existing
join_requests schema only stores user_id — requests come from
already-authenticated pilots asking for a CAB upgrade, not from
strangers needing an invite. The schema change for stranger sign-ups
is left for a future follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:32:26 +02:00
dd9ea68fab feat(admin): publishing-group resources (dispatches, roadmap, events)
The Backstage rebuild's first three resource configs. /admin/dispatches,
/admin/roadmap, and /admin/events now resolve through the dynamic route
with full list views, edit panels, and the publish/archive actions.

- src/admin/resources/dispatches.ts — kind/status/author/excerpt/body
  fields, embedded pulse sub-form (pulse_question + multi-text options +
  opens/closes datetimes), publish/archive actions, notifyCount on
  drafts so the sidebar lights up terracotta until they ship.
- src/admin/resources/roadmap.ts — title/description/status/target/
  display_order/metadata_text plus a multi-select-async for attributed
  members. ops.update writes via setRoadmapAttributions() after the
  basic save so the pivot table stays in sync.
- src/admin/resources/events.ts — full event fields; ops.create
  auto-generates a unique slug from the title when blank.
- src/admin/embeds/PulseSubForm.astro — reads the dispatch's current
  pulse via getPulseById(), renders question + options + opens/closes.
  Pulses follow their parent dispatch's lifecycle (draft → open on
  publish, → closed on archive); no status field of their own.
- src/admin/components/ResourceEditPanel.astro — dispatches on
  embed.component, renders PulseSubForm for 'pulse-sub-form'.
- src/admin/resource-types.ts — renamed column .valueOf to .value
  (collision with Object.prototype.valueOf was breaking TS structural
  matching); OpContext now optionally carries the raw FormData so
  resources with sub-forms can read embed fields.
- src/pages/admin/[resource].astro — passes formData into opCtx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:24:13 +02:00
3aaa21e6af feat(admin): /admin/[resource] dynamic route + POST dispatch
The production route every Backstage resource lives under. Resolves
the resource from the URL segment against the registry, gates on
user.role === 'fenja', and renders the AdminLayout shell with the
ResourceListView + (optionally) ResourceEditPanel.

POST dispatch keyed by _action:
- save: parses formdata per field.kind (multi-text/multi-select-async
  use getAll(), number coerces, others coerce to string), validates
  via validateForResource, then routes to ops.update(id) when
  ?edit=<id> is set or ops.create() when ?new=1. Redirects with
  ?msg=saved | ?msg=created. On failure, re-renders the panel with
  errors + the submitted values.
- delete: calls ops.delete(id), redirects with ?msg=deleted.
- <action.key>: looks up the action in resource.actions and runs its
  handler, redirects with ?msg=action_<key>.

404s when the resource key isn't in the registry — most keys won't
resolve until steps 8-10 land. A small .bs-flash banner above the
list surfaces the ?msg= text (or the error message after a failed
save).

Old /admin (?tab=...) continues to work alongside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:15:17 +02:00
09a10061b2 feat(admin): ResourceEditPanel + field renderers (no autosave)
Right-slide panel that renders a resource's edit form. Driven by the
URL: ?new=1 opens a fresh form, ?edit=<id> hydrates with the current
item. POSTs back to the same URL with _action (save | delete | <action
key>); the route handler in step 7 dispatches.

- FieldRenderer.astro: dispatches on field.kind, wraps each field with
  label + helper text + error state.
- fields/*.astro: one component per kind — Text, Textarea, Markdown
  (with Write/Preview toggle), Select, SelectAsync, MultiSelectAsync,
  MultiText (with add/remove), Date, Datetime, Number, Readonly.
- ResourceEditPanel.astro: header (title + close X), scrollable body,
  sticky footer (save + per-resource secondary actions + destructive
  delete when ops.delete is defined and item exists). Scrim closes on
  click, Esc, or the close link. Confirm-before-submit honours
  action.confirmText. Embedded sub-form sections render a placeholder
  until step 8 wires the pulse renderer.
- admin.css: panel chrome + scrim + slide-in keyframes, full field
  styling for every kind, mobile full-screen modal collapse.
- preview.astro: exercises every field kind so the panel can be
  eyeballed in a logged-in session. Try /admin/preview?new=1 and
  /admin/preview?edit=<id>.

Autosave deferred to Phase 2 per the approved deltas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:10:39 +02:00
cc9332e6e2 feat(admin): ResourceListView + ListCell with filter-conditional columns
The shared list-rendering component every resource will use. Reads
URL state (?filter, ?q, ?page) and derives:

- active filter (with isDefault fallback)
- active column set (columnsByFilter[filterKey] override → columns)
- filtered + searched + sorted + paginated row set

Rows are full anchor elements pointing at ?edit=<id> so the table
is fully keyboard-navigable and works without JS. The "+ New" button
is suppressed when resource.form is null (activity, join_requests).

- ResourceListView.astro: page header (eyebrow + serif h1 + optional
  description + new-item button), toolbar (search form + filter
  chips), grid table with --bs-grid-cols set from column widths,
  pagination, mobile card collapse.
- ListCell.astro: discriminated render for text / pill / relative-date
  / number / tag-list columns.
- admin.css: list-view styles plus the full pill palette (decision,
  update, note, bts, published, draft, archived, open, closed,
  pending, accepted, expired, approved, declined, shipping, in-beta,
  exploring, considering, active, departed, pilot, cab, fenja).
- preview.astro: inline sample dispatches resource so the list view
  renders against real DB rows. Step 8 moves this to its production
  config; this inline copy disappears with the preview route in
  step 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:05:15 +02:00
dd7215d828 feat(admin): AdminLayout shell + empty resource registry
Two-pane Backstage chrome: sticky topbar with the wordmark + " /
Backstage" lockup and a "Back to the portal" link, plus a left
sidebar that walks the resource registry and renders grouped
links with active-state and count badges.

- src/admin/components/AdminLayout.astro — the shell. Pre-resolves
  list-counts and notify-counts per resource so the sidebar can
  render badges without async work in markup. Renders an empty
  state until resources land.
- src/admin/resources/index.ts — empty registry stub. Three groups
  declared (publishing, council, system); resources populated in
  steps 8–10.
- src/admin/admin.css — Backstage tokens (--admin-sidebar-bg,
  --admin-active-accent, etc.) and the shell styles (bs-topbar,
  bs-sidebar, bs-resource, bs-count). Mobile collapses the sidebar
  above the main pane.
- src/pages/admin/preview.astro — temporary smoke-test route at
  /admin/preview. Deleted in step 11 when the new admin replaces
  the old one.

Old /admin (?tab=…) is untouched and continues to work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:00:57 +02:00
ea056fff7b feat(admin): add Resource type definitions + form validator
Establishes the load-bearing type surface for the Backstage rebuild:

- src/admin/resource-types.ts — discriminated unions for Field, Column,
  Filter, Action, plus the top-level Resource and ResourceGroup. Strict
  per the maintainability bar: a config object missing a required key
  fails TypeScript.
- src/admin/validate.ts — validateForResource() derives validation
  from the field definitions (required, maxLength, multi-text min/max,
  number bounds, date parse, visibleWhen-aware).
- tests/admin-validate.test.ts — 8 cases locking the validator API:
  required, maxLength, visibleWhen skip & reveal, multi-text bounds,
  number bounds, all-valid, form-null short-circuit.

No consumers yet. Next commit pulls these into admin.css and the
shared layout components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:56:42 +02:00
103bfa2f0c refactor(admin): extract inline styles to src/admin/admin.css
Move the ~300-line <style> block from src/pages/admin/index.astro
into a dedicated stylesheet, imported from the page frontmatter.
No rule changes — verbatim extraction so the existing admin UI
continues to render identically.

This is the first commit of the Backstage rebuild: it establishes
the shared admin stylesheet that the resource-pattern components
will consume in subsequent steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:53:10 +02:00
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