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>
- 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>
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>
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>
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>
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>
- --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>
Applies the v3 rules globally: italics reserved for the Bifrost wordmark
and section-links; uppercase tracked eyebrows removed from page heads
and content sections.
Pages updated:
- /members: drops the 'MEMBERS' eyebrow + italic on h1 + italic on member
name + italic on pull-quote.
- /events: drops the head eyebrow + 'Next up · Members only' /
'Invitation by hand' hero eyebrows + 'Also coming up' / 'Past
gatherings' sub-section eyebrows + italics on hero day, hero title,
also-row titles, past-card titles. The past-gatherings header-row
'View all →' link migrates to the bottom of the section as a
section-link.
- /events/past: drops the eyebrow + italic h1; back-link uses
.section-link.
- /dispatches/: drops the 'DISPATCHES' eyebrow + italic h1 + dispatch-row
title italic + date column italic.
- /dispatches/[slug]: drops italic on the article title + h2/h3 inside
rendered markdown + blockquote italic + adjacent prev/next title
italic. Back-link migrates to .section-link.
- /roadmap: drops the 'ROADMAP' eyebrow and the .lead class on the
subtitle.
Orphaned eyebrow class rules left in place; harmless and the next
visual pass can sweep them with the rest of the unused CSS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Events card (--ink):
- 'Next up · Members only' and 'Invitation by hand' eyebrows removed.
- All ink-card text uses cream tones (rgba(232,224,208,...) at 92/75/70/65%)
instead of the warm tan --ink-muted; the previous low-contrast labels
read 'dark' on the indigo and now read uniformly light.
- Italic font removed everywhere on the card (hero day number, hero title,
coming-up titles, etc.) — italic is reserved for the Bifrost wordmark
and section-links only.
- Past gatherings dropped from /pulse entirely; the listing lives on
/events and /events/past.
- 'Also coming up' is now a grid of small bundled sub-cards inside the
blue surface (auto-fit minmax 220px). Each card shows date + title +
meta only — no RSVP action, no per-row submit form.
- 'See all events →' section-link replaces the old past-gatherings
'View all →' as the sole bottom-of-block link to /events.
Latest from Fenja (unboxed):
- Card surface dropped. Article sits on the cream page background.
- Excerpt extended via new dispatchLongPreview(d, 520) helper —
sentence-boundary cut at ~520 chars (was ~200). Title in serif regular,
not italic.
- 'Read the full dispatch →' section-link at the bottom.
Roadmap (horizontal):
- Three roadmap items become a 3-column grid of small white cards instead
of a vertical list. Each card has status dot + title + status blurb
with consistent min-height.
- 'See the full roadmap →' section-link at the bottom.
Council members (larger cards):
- Was a flowing pill row, now an auto-fit grid (minmax 260px) of larger
white cards. Each card has a 56px avatar + name + title + company,
with generous padding for whitespace. Company name is the new field.
- 'See who our council is made up of →' section-link at the bottom.
General (eyebrows + italics): all uppercase tracked eyebrow labels gone
from /pulse — date label, 'Latest from Fenja', 'From the roadmap', 'The
council', etc. Italic body text removed throughout — greeting, titles,
member names, dispatch title, roadmap titles. The Bifrost wordmark in the
header and the .section-link utility class are the only remaining italics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Width: --content-max 72rem → 83rem (15% wider, per the v3 follow-up
spec). Every page that uses var(--content-max) gets the new bound.
- Wordmark in the top-left nav: Fenja logo · "Project Bifrost". 'Bifrost'
is serif italic with a horizontal pigment-rainbow gradient
(terracotta → ochre → copper → indigo → heather), background-clip:text.
The bullet separator uses --on-surface-muted at 1rem.
- Global .section-link utility class: serif italic, terracotta, no
underline, no all-caps. Modifier --ink for use on the dark events card.
This becomes the only italic body text on the site (along with the
Bifrost wordmark); everywhere else loses italics in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swaps real-looking organisation domains in seed fixtures for the
virkN.dk placeholder pattern, so demos and screenshots can't be misread
as implying real-world relationships with the originals.
- mette@ssi.dk → mette@virk1.dk
- lars@rigspolitiet.dk → lars@virk2.dk
- jonathan@fenja.ai → jonathan@studio.test (separate fake domain for the
team account, kept distinct from the council virkN namespace)
- anna@kommune.dk → anna@virk3.dk
- soren@energinet.dk → soren@virk4.dk
- henriette@dnv.dk → henriette@virk5.dk
Organisation strings get the same treatment ('Virksomhed 1' …).
Also fixes two latent bugs surfaced while re-seeding:
- seed.js's INSERT didn't populate the slug column added in migration
0003. After a re-seed the three base users had NULL slugs. Add a
kebab-from-name fallback in the INSERT so slugs round-trip.
- seed.js's DELETE chain pre-dated the Phase 1/2 schema additions and
failed FK constraints (pulses/dispatches/events/votes/activity/
join_requests/roadmap_attributions). Extend the wipe order so all
user-referencing tables clear before users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures /pulse around three blocks instead of seven, per the
follow-up simplification spec.
Nav: Events and Members drop out of the top bar. Becomes
Pulse · Roadmap · [Admin]. Members and Events remain reachable via the
two new on-page links ('See who our council is made up of →' and
'View all →' under past gatherings).
/pulse render order:
1. Greeting (unchanged)
2. Events card (--ink). One blue card now holds all three sub-sections:
- Hero NEXT UP / INVITATION BY HAND treatment for the soonest event,
full date+title+desc+capacity+RSVP CTA. AvatarPile of confirmed.
- 0.5px ink-muted divider, then ALSO COMING UP — compact list of other
upcoming events with their action-label fallback. Less visual weight,
same dark surface.
- Divider, then PAST GATHERINGS — compact list with notes / no-notes
indicator, plus a 'View all →' link to /events/past.
- Empty state retains the visual weight of the card if nothing is up.
3. Combined Roadmap + Latest from Fenja (--surface-card). One white card,
two stacked sub-sections separated by a 1px divider. Top is the single
most recent published dispatch (was 'Latest from the studio', now
labeled 'LATEST FROM FENJA'; 'All updates →' link to /dispatches). Bottom
is the three most-recently-updated roadmap items + 'See the full roadmap →'.
4. Members strip (--surface-card). Every cab user as a pill (avatar + name
+ title) flowing horizontally. Header has the 'See who our council is
made up of →' link to /members.
Removed from /pulse:
- This-week's-pulse voting block (deferred → todo.md, idea is to fold
poll-shaped dispatches into the Latest from Fenja stream)
- MembershipCard (the COUNCIL · NNN identity card)
- RecentlyFromTheCouncil (deferred → todo.md)
- Bottom event-row with the two small dinner + studio hours cards (events
moved to the top hero card, so these were duplicates)
POST handler is now RSVP-only — vote handling went with the pulse block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two things lifted off /pulse in the follow-up pass need somewhere to live
so they aren't forgotten:
- The 'Recently from the council' feed (RecentlyFromTheCouncil reading
from the contributions table). Component and underlying table stay in
place; only the /pulse embed is gone. Records the options for re-
introducing it: inline in the Latest-from-Fenja card, a dedicated
Voices section further down, or stop surfacing on the home page.
- 'This week's pulse' voting card. pulses/votes schema + admin Pulses tab
stay. Records the merge-into-dispatches idea — fold poll-shaped
dispatches into the Latest from Fenja stream rather than rebuilding a
parallel block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Studio hours rename pass (one commit, grep-driven):
- /pulse right-card eyebrow: 'Office hours' → 'Studio hours'
- scripts/seed-demo.js: event title + description match the spec
('Studio hours with Jonathan' · '30-minute slots. Open agenda. Drop in
when you've got something to talk through.')
- No code-level enum changes — the kind value office_hours is preserved
for back-compat; display labels switch wherever surfaced (admin form
select, /pulse eyebrow, /events list and meta)
scripts/seed-demo.js — Phase 2 demo state. Destructive in scope (wipes the
data tables it owns then re-inserts), idempotent on re-run:
- 4 cab members: Lars (existing) + Anna Kjær / Søren Vedel / Henriette
Rask. cab_joined_date staggered 24/6/4/2 weeks ago so tenures vary.
title, pull_quote, focus_tags populated per spec. member_number
backfilled via the same SQL pattern as migration 0004 (deterministic).
- 1 active pulse with 2 of 4 council members voted. Vote count on /pulse
now reads '2 of 4 council members have weighed in.' — the line voted
test was designed to lock down.
- 4 roadmap items: Traceability layer (shipping, attributed to Lars),
Document ingestion (beta, attributed to Anna + Søren), Contextual
memory (exploring, attributed to Henriette), Agentic query mode
(exploring, unattributed).
- 3 contributions, most recent ('inline annotations' idea by Søren) has
3 reactions — populates the RecentlyFromTheCouncil card.
- 4 published dispatches at 2/5/9/12 days ago covering all four kinds
(decision / behind_the_scenes / update / note). Real-ish prose so the
excerpt cutter has actual sentence boundaries to find.
- Events: hero dinner 5w out, Studio hours 2w out, a working_session 3w
out (exercising the new kind), April roundtable 3w ago with a
notes_url, March launch dinner 7.5w ago without notes (exercises both
past-card thumb modes). Hero dinner has 1 confirmed RSVP (Lars) to
drive the avatar pile at small scale.
- Activity rows for the (now-hidden but still-written) feed so admin's
Activity tab has something to display.
Smoke (curl as Lars): /pulse renders 'Good afternoon, Lars.' · COUNCIL · 001
· '2 of 4' · Latest from the studio · Recently from the council · Studio
hours. /members shows all four members with pull quotes + focus pills.
/events shows the dinner hero, 'Save your seat →', Studio hours +
working session in 'also coming up', April and March in 'past gatherings'.
/dispatches lists all four; /dispatches/{slug} renders body + adjacent
prev/next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New Dispatches tab — full CRUD matching the existing pattern (?tab=
querystring, plain HTML POST, hidden action field, redirect-with-?msg=).
Author select is restricted to Fenja-role users (defaults to the current
admin). Create form has a status toggle (draft / publish on save).
publish_dispatch stamps published_at via the existing helper; archive
preserves it. Body is a monospace textarea so admins can see markdown
without proportional kerning confusion.
Participants tab gains a per-row Edit link. When ?tab=participants&edit=ID
is set, the table is replaced by <UserEditTab>: title input, comma-
separated focus_tags input (parsed server-side via parseFocusTags), a
pull_quote textarea with a 200-char live counter, and a read-only
member_number display (set on role transition to cab). The inline role
dropdown + deactivate stay on the table.
EventsTab — adds audience, duration_label, action_label, notes_url
inputs. Kind <select> now labels office_hours as 'Studio hours' and
exposes working_session as the new fifth option.
Admin action-link / action-cell styles were missing on admin/index.astro
(they were defined only inside per-tab components); added to the page
stylesheet so the new Participants Edit link inherits the same look.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/dispatches: editorial header (DISPATCHES · 'Notes from the studio.' ·
'Decisions, half-built ideas, and things we've changed our mind about.')
+ a vertical list of every published dispatch. Each row is a single link
target with a three-column grid: 180px byline (avatar + author name + title
or role label) / 1fr body (serif italic title + kind pill, then a single-
paragraph excerpt) / 130px date column. 0.5px bottom borders, hover tint.
/dispatches/[slug]: 720px single-column read view. Header is kind pill +
publish date, serif italic title at 2rem, author byline with 32px avatar.
Body uses the existing renderMd() (marked) with serif italic h2s, copper
blockquotes, mono code blocks. Footer is a 0.5px divider then two adj-card
links (prev / next in published order) on opposite ends — the missing side
renders an empty grid slot so layout is preserved.
Canonical-slug redirect: if /dispatches/12-old-title is hit but the title
has since changed, the page issues a 302 to /dispatches/12-new-title. id
is the authority, kebab title is for readability.
format.ts: adds roleLabel (pilot/cab/fenja → 'Pilot' / 'Council' /
'Fenja team') for the byline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/events:
- Header: EVENTS · 'Where the council gathers.' · one-line subtitle
- Hero invitation card on --ink for the soonest non-office_hours event:
NEXT UP · MEMBERS ONLY / INVITATION BY HAND eyebrow strip, two-column
date/detail body separated by a 0.5px vertical line, foot strip with
'{capacity} seats · {confirmed} confirmed' + AvatarPile of confirmed
attendees and the RSVP CTA. The RSVP button toggles between cream-on-ink
'Save your seat →' and outlined 'You're confirmed ✓ Change'. Empty-state
card retains the visual weight when no upcoming non-office_hours event.
- ALSO COMING UP — every other upcoming event including office_hours.
Three-column rows; the right column uses event.action_label or falls back
to defaultActionLabel(kind). Studio hours surfaces with 'Book a slot →'.
- PAST GATHERINGS — two-column grid. Each card has a 56px thumb: photo_url
if set, else a copper-tinted notes square when notes_url is present, else
a deterministic two-pigment gradient block. View all → links to /events/past.
/events/past — same card component, full list of starts_at < now() events.
No boolean past flag column; filter is purely date-based.
AvatarPile (src/components/AvatarPile.astro) — reusable. Overlapping circle
slots with a 1.5px border in a caller-provided colour (defaults to surface,
the hero card overrides to --ink so circles read on dark). Stacks z-index
so leftmost is on top; +N overflow chip at the end.
format.ts: adds eventKindLabel (office_hours → 'Studio hours') and
defaultActionLabel per kind.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Header: MEMBERS eyebrow, serif italic 'The council.', subtitle pitched at
'an invited circle of operators' — no number, no seat count anywhere.
One row per cab user, ordered by member_number asc. Three-column grid
(60px / 1fr / 130px, 18px gap) with 0.5px bottom borders. Avatar uses the
deterministic id-pigment palette; the same id always yields the same
colour across the portal.
Body column: serif italic name; if the row belongs to the signed-in user,
a small uppercase tracked 'You' tag in terracotta. One metadata line —
title · organisation · Member since {month year} — title is dropped if
null. The pull-quote block is only rendered when pull_quote is non-null;
its 2px left border is the user's pigment at 40% opacity (50% on the
signed-in user's own row).
Right column: up to 3 focus-tag pills, vertical right-aligned, tinted in
the same pigment family (15% bg / 100% colour). Empty focus_tags renders
nothing — no placeholder pills.
Signed-in user's row gets a 4% pigment tint and rounded corners; no border
between rows in that case.
No filters, no search, no sort, no header count, no empty-seat row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nav (AppLayout.astro): brand stays left; everything else is a single
right-aligned flex group — Pulse · Roadmap · Members · Events · [Admin
if fenja] · 0.5px vertical divider · name · Sign out. The .nav-user
wrapper is gone; the name and logout button now belong to the same flex
flow as the link list.
/pulse:
- ActivityTicker render removed. The component file gets a one-line
deprecation comment; the activity table and write hooks stay in place
for later use.
- 'X others online now' chip strip removed — including all its supporting
helpers and styles in the page.
- CouncilMark replaced with <MembershipCard> in the right column of the
preview row. The roadmap preview is now a white --surface-card with
0.5px border; pulse-card switches to the same white surface and
--radius-lg. The .chosen pulse option uses --pigment-terracotta border
and a 6% terracotta tint via color-mix.
- <DispatchesSection limit={4} /> and <RecentlyFromTheCouncil /> stacked
below the preview row, in the position the online-now strip vacated.
- Vote-count denominator pulls from countCabMembers() and renders via
voteCountSentence(votes, total) — a new helper covering 0/1/5+ cases.
- Event row: dark dinner card now uses --ink/--ink-text; light card uses
--surface-card with 0.5px border.
Tests: 3 new cases for voteCountSentence (0/1/5). 36/36 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
White-card surface. Header row with secondary 'Recently from the council'
eyebrow and a terracotta 'All contributions →' link. Body is one card:
22px id-pigment avatar, full name, relative time, contribution-type pill
(idea→copper, inspiration→ochre, question→indigo), then the body rendered
as a serif italic pull-quote with a 40%-opacity terracotta left border,
clamped to 3 lines via -webkit-line-clamp with markdown stripped before
trimming. Footer shows the reaction count.
Hidden entirely when the contributions table has no visible rows — no
empty-state placeholder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DispatchesSection (white-card, used on /pulse):
- Header row: LATEST FROM THE STUDIO + All dispatches → link
- Featured: 26px avatar byline (name · title · relative time + kind pill),
serif italic title, two-paragraph excerpt (lead in body tone, trail in
--secondary with ellipsis) cut on the nearest sentence boundary, then
the terracotta uppercase 'Read the full dispatch →' link
- Earlier: 1px divider + EARLIER label + up to 3 rows with 22px avatar,
serif italic title (single-line ellipsis), relative time
- Hidden entirely when zero published dispatches; divider + earlier list
omitted when exactly one published dispatch exists
Avatar component (src/components/Avatar.astro) — pure presentational,
takes id + name + size, paints a deterministic-pigment circle with serif
italic initials. Reused by DispatchesSection now and by /members, /events,
and RecentlyFromTheCouncil in the next commits.
format.ts: adds dispatchKindPigment (decision→terracotta, update→indigo,
behind_the_scenes→ochre, note→heather) for pill backgrounds.
Tests: 9 cases covering create-as-draft vs published, publishDispatch
idempotency (re-publish preserves published_at), archive preserves
published_at, the published feed excludes drafts/archived, adjacent
prev/next, and slug round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the CouncilMark slot on /pulse. Dark --ink surface, --ink-text
foreground, --ink-muted for labels. Top row: 22px cream circle with the
N monogram, COUNCIL · NNN (zero-padded to 3) on the right; defensive
fallback COUNCIL · MEMBER when member_number is null. First and last name
on separate lines in serif italic, split on the last space so compound
surnames hang together. MEMBER SINCE / month-year block sits above the
focus-tag pill row. Pills render only when focus_tags is non-empty —
no placeholder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
users: adds pull_quote, member_number (unique partial index, NULLs allowed),
focus_tags. title already exists from 0003 — not re-added. Backfills
member_number for existing cab users in COALESCE(cab_joined_date,
created_at) asc, tiebreak id asc. Lars gets #1.
events: rebuild required to widen the kind CHECK constraint (SQLite can't
ALTER it in place). Adds working_session as a new kind. Same rebuild adds
four new columns: audience, duration_label, action_label, notes_url. Data
preserved.
dispatches: new entity, status enum draft/published/archived, kind enum
decision/update/behind_the_scenes/note. published_at nullable until
publishPulse-equivalent stamps it. Indexes on (status, published_at) and
author_id.
Tokens (src/styles/tokens.css): adds --surface-card, --surface-card-border,
--ink, --ink-text, --ink-muted. Spec called these out as the only
additions; existing --radius-lg covers the spec's --border-radius-lg
reference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Confirmed before writing code:
- latest migration: 0003_council_portal.sql
- /pulse render order: greeting · ActivityTicker · pulse card · preview
row (roadmap + CouncilMark) · room-row (online chip strip) · event row
- CouncilMark exists and works (one lit dot for Lars in seed); not broken
- ActivityTicker exists at src/components/ActivityTicker.astro and renders
at pulse.astro:169
- 'online now' strip lives at pulse.astro:266-290
- vote-count denominator is already count(role='cab'); '1 of 1' on the
smoke run was seed shape, not a bug — Phase 2 seed populates 4 CAB users
- events.kind enum: ('dinner','office_hours','summit','virtual'). Adding
'working_session' needs a CHECK rebuild
- 'Office hours with the founder' → exactly one match in
scripts/seed-demo.js:106
Divergences resolved with the user before step 2:
1. Migration 0004 must skip users.title (already added in 0003) and only
add pull_quote, member_number, focus_tags.
2. Token name: spec writes --border-radius-lg; use existing --radius-lg.
3. --secondary stays warm-walnut accent. Muted body text uses
--on-surface-variant.
4. Member-number backfill: order by cab_joined_date asc (NULLS via
COALESCE to created_at), tiebreak user.id asc. Deterministic across
re-runs and machines.
5. focus_tags parser: trim, dedupe, strip control chars, collapse internal
whitespace to single space, cap 3 entries × 24 chars.
6. /events/past filters on starts_at < now() — no boolean past flag column.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scripts/seed-demo.js — one open Pulse with realistic context, marks
"Traceability layer" as shipping with shipped_at -2 days and attributes
it to the cab user, two events (dinner in 5 weeks, office hours in 2
weeks), six hand-crafted activity rows mixing all 5 activity kinds so
the ticker has something to scroll on first load. Idempotent: skips if
any pulses exist. Backdates Lars's cab_joined_date so the greeting
renders "2 years, 4 months". Wired to db:setup and db:seed:demo.
Also fixes a parse bug on /pulse: SQL stores last_seen_at as
'YYYY-MM-DD HH:MM:SS' UTC, but new Date(string) parses that as local
time — on a non-UTC server the freshness check was wrong by the server's
offset. Coerce to UTC ISO before parsing. Manual smoke as Lars now shows
two member chips in "online now"; admin tabs all render.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four new tabs added to /admin, matching the existing pattern exactly: ?tab=
querystring, plain HTML POST with hidden action field, redirect-with-?msg=
success path, existing .tabs / .section / .data-table / .form-grid / .input
/ .select / .btn-primary / .danger-btn classes — no new form library, no JS
beyond `confirm()`.
Pulses tab — create / edit / publish / close / delete + a results view
(?view=ID) with per-option vote counts and bar charts. Publish writes the
'pulse_opened' activity row and calls notifyPulseOpened() so members can
be notified the same way once the integration lands.
Roadmap tab — full CRUD + multi-select attribution (checkbox grid of
council + pilot users) + up/down arrow reorder within each status column
(JS-free, swaps display_order with neighbour). Status transition to
'shipping' stamps shipped_at exactly once and writes 'roadmap_shipped'
activity.
Events tab — full CRUD + an RSVP summary view (?view=ID) showing going /
interested / declined counts. Slug is required on create and readonly on
edit (it's the URL handle).
Activity tab — read-only debug table of the last 200 activity rows. Per
your call: shipping with the rest, not optional. Saves hours of "why
isn't the ticker showing X" later.
Tab views extracted to src/components/admin/{Pulses,Roadmap,Events,Activity}Tab.astro
to keep admin/index.astro navigable; the POST handlers and data loading
stay in index.astro as the single dispatch point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Today this just logs to console. Wired up in step 10's admin pulse-publish
handler so the integration point exists from day one — only the body
changes when we pick a transactional email or Slack webhook later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sections (top to bottom, all wrapped in a cascade-entry animation):
- greeting block: MONDAY · 11 MAY label, serif-italic "Good morning, X."
greeting (server time, Europe/Copenhagen), tenure line that uses
cab_joined_date for cab members and falls back to created_at
- ActivityTicker fed by getRecentActivity (hidden when empty)
- this-week Pulse card: live breathing dot + time-left countdown, italic
serif question, 2×2 grid of option buttons that submit a hidden-action
vote form. Once voted, options lock and show a distribution bar fill;
the user's choice keeps a terracotta border. Empty state when no pulse
is open.
- 2-col roadmap preview + council mark: three most-recently-updated items
with status dot (copper/ochre/muted), and the user's CouncilMark md with
the "{n} of 12 quarters" stat
- members-in-the-room: 4 chips for other CAB members seen within 5 min,
+N overflow chip; pulsing copper dot for the "online now" indicator
- two-column event row: dark indigo "members-only" card (next dinner or
summit) + light "office hours" card (next office_hours event). Hidden
per-card when no matching upcoming event exists.
Vote POST uses the existing admin form pattern (hidden action field,
redirect on success). Activity row for 'voted' is written inline here;
step 11 covers the other activity sources.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ActivityTicker — pure presentational. Renders a horizontal strip of items
that scrolls right→left over 38s, with CSS mask fades on both edges and
pause-on-hover. Items are duplicated so translateX(-50%) wraps seamlessly.
Hidden entirely when empty (no "no activity" placeholder).
format.ts — small set of formatters used by the ticker and the upcoming
/pulse greeting:
- relativeTime (2m / 3h / 5d / just now)
- redactName ("Maya Rasmussen" → "Maya R.")
- tenureSince (e.g. "2 years, 4 months")
- pulseDateLabel ("MONDAY · 11 MAY", Europe/Copenhagen)
- timeOfDay (morning/afternoon/evening, Europe/Copenhagen)
- tickerItem (ActivityRow + subject label → display struct, with role-dot
colours mapped to existing pigments: copper for pilot, terracotta for
cab, indigo for fenja)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SVG component, sizes sm (48) / md (100) / lg (200) on a 120 viewBox.
12 quarter positions on a circle, i=0 = current quarter at 12 o'clock,
going clockwise into the past. A position is lit iff the member has a
roadmap_attribution to an item with shipped_at in that quarter. Pulse
votes do NOT light a quarter — the sigil represents consequential
contribution, not participation.
Visual layers:
- faint dotted outer ring rotating 90s linear
- small grey dots on empty quarters
- terracotta-filled polygon (15% fill) connecting lit dots, draws in
over 1.6s on mount via stroke-dasharray
- terracotta dots on lit quarters with staggered 2.4s breathing
- serif italic initials in the centre, walnut secondary
Uses --pigment-terracotta instead of the spec's coral — no new tokens.
Honours prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cab and fenja are sent to /pulse on landing; pilot continues to see the
existing editorial home content in place. Uses the same helper the test
suite already covers, so behaviour is locked in.
Adds a minimal /pulse stub so the redirect target resolves; step 7 replaces
it with the full member view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three test files covering the Phase 1 invariants:
- derivePulseStatus: draft/closed are sticky; open auto-closes by date.
- vote UNIQUE(pulse_id, user_id): castVote (OR IGNORE) keeps the first vote
silently, a raw second INSERT raises a constraint error, and uniqueness is
per-pulse (same user on a different pulse is fine).
- homeRouteForRole: cab/fenja → /pulse, pilot → null (render existing home).
tests/setup.ts opens BIFROST_DB_PATH=':memory:' and applies all migrations
before tests run, so the in-memory DB has the live schema. Each vitest fork
gets its own globalThis → its own fresh in-memory DB.
The homeRouteForRole helper extraction makes the / role-redirect testable
without booting Astro. Step 6 will use it from /index.astro.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Parses the H2 sections (In progress / Next / Later) into roadmap_items rows.
Maps In-progress → beta (actively built, tested with pilots) and Next/Later
→ exploring with a target hint. Idempotent: skips entirely if the table is
already populated, so admin edits are never overwritten.
content/roadmap.md stays in the repo as the seed source. Once admin starts
editing via /admin, the DB is the source of truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds typed query functions for the council-portal entities. Pulse status is
stored AND derived: draft and closed are sticky, open auto-decays to closed
once now ≥ closes_at. Draft → open is an explicit admin Publish action, not
date-driven, so admins can stage a pulse without surprise auto-publishing.
Roadmap updateRoadmapItem stamps shipped_at the first time status transitions
to 'shipping' and never resets it; returns { shippedNow } so callers can fire
the roadmap_shipped activity row exactly once.
Event RSVPs reuse the existing attendance table with kind='event'; no
parallel Rsvp table. setEventRsvp upserts on UNIQUE(user_id, meeting_slug).
getLitQuarters drives the CouncilMark dot pattern from
roadmap_attributions × shipped_at — admin-curated, not derived from votes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- BIFROST_DB_PATH env var overrides the default bifrost.db path; lets
vitest open ':memory:' per suite without touching prod data.
- Extend User/UserPublic with title, cab_joined_date, slug.
- Update SELECT lists for getUserPublicById and getAllUsersPublic.
- Add getUserBySlug for /members/:slug routes.
- Add slugifyName + generateUniqueSlug; createUser now auto-slugs from name
and stamps cab_joined_date for cab-role users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds title/cab_joined_date/slug to users, extends attendance with kind enum
and 'interested' status, and creates pulses, votes, roadmap_items,
roadmap_attributions, events, and activity tables.
Slug backfill covers the three seed users; new-user slug generation will
live in db.ts. roadmap_items has shipped_at to drive the council mark
(simpler than an audit table). roadmap_attributions is admin-curated only.
Also logs the pre-existing /api/contributions/[id]/edit 302-only bug in
KNOWN_ISSUES.md so it isn't lost; out of scope for this work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>