Compare commits

..

10 commits

Author SHA1 Message Date
66c3f6492f style: drop eyebrows + body italics across editorial pages
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>
2026-05-12 09:52:29 +02:00
637055a73e feat(pulse): events box lighter + bundled coming-up, unboxed Fenja, horizontal roadmap, bigger council cards
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>
2026-05-12 09:47:44 +02:00
ca3686de29 feat(layout): wider canvas + 'Project Bifrost' wordmark + section-link utility
- 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>
2026-05-12 09:44:59 +02:00
6b30593abb chore(seed): fake email domains + extend wipe chain
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.dkmette@virk1.dk
- lars@rigspolitiet.dklars@virk2.dk
- jonathan@fenja.aijonathan@studio.test (separate fake domain for the
  team account, kept distinct from the council virkN namespace)
- anna@kommune.dkanna@virk3.dk
- soren@energinet.dksoren@virk4.dk
- henriette@dnv.dkhenriette@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>
2026-05-11 17:12:47 +02:00
3240e0f052 feat(pulse): simplify home — events on top, merged roadmap+fenja, members strip
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>
2026-05-11 17:12:38 +02:00
1518bfa3d3 docs: capture deferred home-page features in todo.md
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>
2026-05-11 17:04:40 +02:00
ed2c272d3a chore: Studio hours rename + Phase 2 demo seed
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>
2026-05-11 16:16:24 +02:00
fd3f433933 feat(admin): Dispatches tab + user-edit form + extended event form
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>
2026-05-11 16:10:20 +02:00
1bf1993040 feat(page): /dispatches index + /dispatches/[slug] detail
/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>
2026-05-11 16:07:13 +02:00
b0e6d7e18b feat(page): /events + /events/past + AvatarPile component
/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>
2026-05-11 16:05:47 +02:00
20 changed files with 2446 additions and 539 deletions

View file

@ -12,7 +12,39 @@
"Bash(node scripts/seed.js)", "Bash(node scripts/seed.js)",
"Bash(pnpm typecheck *)", "Bash(pnpm typecheck *)",
"Bash(pnpm build *)", "Bash(pnpm build *)",
"Bash(identify /home/jonathan/Documents/DEV/Project-Bifrost/public/innofounder-logo.png)" "Bash(identify /home/jonathan/Documents/DEV/Project-Bifrost/public/innofounder-logo.png)",
"Bash(pnpm dev *)",
"Bash(awk '{print $1}')",
"Bash(pnpm db:migrate *)",
"Bash(sqlite3 bifrost.db \".schema users\")",
"Bash(sqlite3 bifrost.db \".schema attendance\")",
"Bash(sqlite3 bifrost.db \"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;\")",
"Bash(sqlite3 bifrost.db \"SELECT id, name, slug FROM users;\")",
"Bash(node *)",
"Bash(pnpm db:seed:roadmap)",
"Bash(pnpm test *)",
"Bash(curl -sI http://localhost:4321/pulse -b \"bifrost_session=test\")",
"Bash(curl -sI http://localhost:4321/admin)",
"Bash(pnpm db:seed:demo *)",
"Bash(curl -s -c /tmp/jar.txt -b /tmp/jar.txt -d \"email=lars@rigspolitiet.dk&password=cab123\" http://localhost:4321/login -i)",
"Bash(curl -sI -b /tmp/jar.txt http://localhost:4321/)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/pulse)",
"Bash(curl -s -c /tmp/jonjar.txt -b /tmp/jonjar.txt -d \"email=jonathan@fenja.ai&password=fenja123\" http://localhost:4321/login -i)",
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=pulses\")",
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=roadmap\")",
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=events\")",
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=activity\")",
"Bash(awk NR>=107 && NR<=117 *)",
"Bash(awk 'NR==111' src/lib/format.ts)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@rigspolitiet.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
"Bash(curl -sI -b /tmp/jar.txt http://localhost:4321/pulse)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/members)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/events)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches/1-we-are-deprioritising-public-cloud-parity-for-q3)",
"Bash(pnpm db:seed)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)"
] ]
} }
} }

View file

@ -1,13 +1,16 @@
#!/usr/bin/env node #!/usr/bin/env node
// Demo seed for first-load credibility: one open pulse, one shipped roadmap // Phase 2 demo seed — produces the visual state described in SPEC §Phase 2:
// item attributed to the cab user, one dinner + one office hours event, and // 4 cab members with title/pull_quote/focus_tags/member_number, 1 active
// a handful of hand-crafted activity rows so the ticker has something to // pulse (2 of 4 voted), 4 roadmap items (1 shipping / 1 beta / 2 exploring),
// scroll on a fresh demo. // 3 contributions with reactions, 4 dispatches at staggered ages, 1 hero
// event + 1 studio hours + 1 working session + 2 past events.
// //
// Idempotent: skips if a pulse already exists. Run AFTER scripts/seed.js // Destructive in scope: wipes the data tables it owns then re-inserts.
// and scripts/seed-roadmap.js (or via `pnpm db:setup`). // Users (created by seed.js) are kept; new ones are added with INSERT OR
// IGNORE. Idempotent: re-running produces the same demo state.
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@ -16,15 +19,24 @@ const dbPath = process.env.BIFROST_DB_PATH ?? join(__dirname, '..', 'bifrost.db'
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
const existing = db.prepare('SELECT COUNT(*) AS n FROM pulses').get().n; // ── Wipe the tables this seed owns ─────────────────────────────────
if (existing > 0) { db.exec(`
console.log(` demo data already present (${existing} pulse(s)) — skipping.`); DELETE FROM activity;
db.close(); DELETE FROM attendance;
process.exit(0); DELETE FROM reactions;
} DELETE FROM replies;
DELETE FROM contributions;
DELETE FROM roadmap_attributions;
DELETE FROM roadmap_items;
DELETE FROM votes;
DELETE FROM pulses;
DELETE FROM dispatches;
DELETE FROM events;
`);
const users = db.prepare("SELECT id, name, role FROM users WHERE active = 1").all(); // ── Locate canonical users from seed.js ────────────────────────────
const byRole = (r) => users.find(u => u.role === r); const allUsers = db.prepare("SELECT id, name, role FROM users WHERE active = 1").all();
const byRole = (r) => allUsers.find(u => u.role === r);
const mette = byRole('pilot'); const mette = byRole('pilot');
const lars = byRole('cab'); const lars = byRole('cab');
const jon = byRole('fenja'); const jon = byRole('fenja');
@ -34,105 +46,297 @@ if (!mette || !lars || !jon) {
process.exit(1); process.exit(1);
} }
// Backdate Lars's cab membership to give realistic tenure on /pulse // ── Add 3 additional CAB members, then populate metadata on all 4 ──
db.prepare(`UPDATE users SET cab_joined_date = date('now', '-2 years', '-4 months') WHERE id = ?`).run(lars.id); const ROUNDS = 10;
// Mark all three as recently seen so the "online now" chip strip has content const hash = bcrypt.hashSync('cab123', ROUNDS);
// (current viewer is excluded from "others online" — see /pulse)
db.prepare(`UPDATE users SET last_seen_at = datetime('now', '-2 minutes') WHERE id IN (?, ?, ?)`)
.run(lars.id, mette.id, jon.id);
function kebab(s) {
return s.toLowerCase()
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const newCabs = [
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
];
const insertUser = db.prepare(`
INSERT OR IGNORE INTO users (email, password_hash, name, organisation, role, slug, cab_joined_date)
VALUES (?, ?, ?, ?, 'cab', ?, NULL)
`);
for (const c of newCabs) {
insertUser.run(c.email, hash, c.name, c.org, kebab(c.name));
}
// Allocate member_numbers in member-since order, tiebreak id asc.
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
const setCabMeta = db.prepare(`
UPDATE users
SET cab_joined_date = date('now', ?),
title = ?,
pull_quote = ?,
focus_tags = ?,
last_seen_at = datetime('now', '-2 minutes')
WHERE id = ?
`);
const cabMeta = {
'lars@virk2.dk': {
title: 'Senior Adviser, Operational Risk',
pull_quote: 'A model is only as auditable as the chain of evidence behind it. That chain is the work.',
focus_tags: ['Risk', 'Audit trail', 'GDPR'],
},
'anna@virk3.dk': {
title: 'Director of Digital Services',
pull_quote: 'Municipalities can\'t outsource sovereignty. We need tools that assume that.',
focus_tags: ['Public sector', 'Sovereignty'],
},
'soren@virk4.dk': {
title: 'Head of Data Engineering',
pull_quote: 'Make it boring to deploy and surprising to query.',
focus_tags: ['Infrastructure', 'Telemetry'],
},
'henriette@virk5.dk': {
title: 'Lead Counsel, Compliance',
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
focus_tags: ['Legal', 'Policy', 'EU AI Act'],
},
};
for (const u of cabRows) {
const m = cabMeta[u.email] ?? null;
if (!m) continue;
const weeks = tenureWeeks[u.email] ?? 0;
setCabMeta.run(`-${weeks} weeks`, m.title, m.pull_quote, JSON.stringify(m.focus_tags), u.id);
}
// Allocate member_numbers using the SQL backfill from migration 0004,
// but only for cab rows that still lack one (idempotent).
db.exec(`
WITH ranked AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY COALESCE(cab_joined_date, created_at) ASC, id ASC) AS rn
FROM users WHERE role = 'cab'
)
UPDATE users
SET member_number = (SELECT rn FROM ranked WHERE ranked.id = users.id)
WHERE role = 'cab' AND member_number IS NULL;
`);
// Mark Mette + Jonathan recently seen too (for the avatar pile on /events)
db.prepare("UPDATE users SET last_seen_at = datetime('now', '-2 minutes') WHERE id IN (?, ?)").run(mette.id, jon.id);
// Re-fetch cab rows ordered by member_number
const cabs = db.prepare("SELECT id, name, email, member_number FROM users WHERE role = 'cab' AND active = 1 ORDER BY member_number ASC").all();
console.log(` cab members: ${cabs.map(c => `${c.name} #${c.member_number}`).join(', ')}`);
// ── Helper: SQL datetime string at offset seconds from now ─────────
const nowIso = (offsetSeconds = 0) => { const nowIso = (offsetSeconds = 0) => {
const d = new Date(Date.now() + offsetSeconds * 1000); const d = new Date(Date.now() + offsetSeconds * 1000);
return d.toISOString().replace('T', ' ').slice(0, 19); return d.toISOString().replace('T', ' ').slice(0, 19);
}; };
// ── Pulse: open now, closes in 5 days ──────────────────────────────── // ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
const opensAt = nowIso(-3600); // opened an hour ago const pulseOptions = [
const closesAt = nowIso(5 * 24 * 3600); // closes in 5 days
const options = [
'Locking down on-prem deployment first', 'Locking down on-prem deployment first',
'Pushing the traceability layer to GA', 'Pushing the traceability layer to GA',
'Going wide on document ingestion', 'Going wide on document ingestion',
'Building the agentic query loop', 'Building the agentic query loop',
]; ];
const pulseId = db.prepare(` const pulseId = db.prepare(`
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by) INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
VALUES (?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?)
`).run( `).run(
'Which milestone should we anchor Q3 around?', 'Which milestone should we anchor Q3 around?',
'Council input on this directly shapes what the team works on in JulySeptember. Read the roadmap before voting.', 'Council input on this directly shapes what the team works on in JulySeptember. Read the roadmap before voting.',
JSON.stringify(options), JSON.stringify(pulseOptions),
opensAt, nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
closesAt,
'open',
jon.id,
).lastInsertRowid; ).lastInsertRowid;
// Lars votes for the traceability option // 2 votes from cabs[0] and cabs[1]
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(pulseId, lars.id, 1, nowIso(-2 * 3600)); .run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600));
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(pulseId, cabs[1].id, 1, nowIso(-30 * 60));
// ── Roadmap: mark "Traceability layer" as shipping, attribute to Lars ── // ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
const traceability = db.prepare("SELECT id FROM roadmap_items WHERE title LIKE 'Traceability%'").get(); const roadmap = [
if (traceability) { { title: 'Traceability layer', description: 'Every response cites its sources with structured provenance.', status: 'shipping', target: 'Live now', display_order: 10, shipped_at: nowIso(-2 * 24 * 3600), attributed: [cabs[0].id] },
db.prepare(`UPDATE roadmap_items SET status = 'shipping', shipped_at = datetime('now', '-2 days'), target = 'Live now' WHERE id = ?`).run(traceability.id); { title: 'Document ingestion pipeline', description: 'Upload PDF, Word, plain text. Chunked, indexed, retrievable.', status: 'beta', target: null, display_order: 10, shipped_at: null, attributed: [cabs[1].id, cabs[2].id] },
db.prepare('INSERT OR IGNORE INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)').run(traceability.id, lars.id); { title: 'Contextual memory', description: 'The system learns the regulatory and organisational context over time.', status: 'exploring', target: 'Q3 2026', display_order: 10, shipped_at: null, attributed: [cabs[3].id] },
{ title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, shipped_at: null, attributed: [] },
];
const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
VALUES (?,?,?,?,?,?)
`);
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
for (const r of roadmap) {
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at).lastInsertRowid);
for (const uid of r.attributed) insertAttr.run(id, uid);
} }
// ── Events ──────────────────────────────────────────────────────────── // ── Contributions: 3 entries; most recent gets 3 reactions ─────────
const dinnerStart = nowIso(38 * 24 * 3600); // ~5.5 weeks out const contribs = [
db.prepare(` { user_id: cabs[2].id, type: 'idea',
INSERT INTO events (slug, title, kind, description, location, starts_at, capacity, created_by) body_md: 'What if we let council members write **inline annotations** on the roadmap items they shaped? A trail of "here\'s what I pushed for."',
VALUES (?,?,?,?,?,?,?,?) when: nowIso(-2 * 60 * 60), reactors: [cabs[0].id, cabs[1].id, jon.id] },
`).run( { user_id: cabs[1].id, type: 'question',
'kickoff-dinner-2026-06', body_md: 'How will the traceability layer handle documents with conflicting metadata across versions?',
'Council kickoff dinner', when: nowIso(-2 * 24 * 60 * 60) },
'dinner', { user_id: cabs[0].id, type: 'inspiration',
body_md: 'A piece on **institutional memory** in regulated industries — it lines up almost exactly with what we\'re trying to build.',
when: nowIso(-9 * 24 * 60 * 60) },
];
const insertContrib = db.prepare(`
INSERT INTO contributions (user_id, type, body_md, created_at) VALUES (?,?,?,?)
`);
const insertReact = db.prepare('INSERT INTO reactions (user_id, contribution_id) VALUES (?,?)');
for (const c of contribs) {
const id = Number(insertContrib.run(c.user_id, c.type, c.body_md, c.when).lastInsertRowid);
for (const uid of c.reactors ?? []) insertReact.run(uid, id);
}
// ── Dispatches: 4 published at staggered ages ──────────────────────
const dispatchSeed = [
{ kind: 'decision', ageDays: 2,
title: 'We are deprioritising public-cloud parity for Q3',
excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.',
body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction.
So for Q3 the platform supports two deployment targets only on-prem inside the customer's own VPC, and our Hetzner sovereign cloud in Helsinki. Everything else is parked. The agentic query work moves up a quarter to fill the gap.
We'll revisit public cloud in Q4 once the on-prem story is boring.`,
},
{ kind: 'behind_the_scenes', ageDays: 5,
title: 'A morning at the council kickoff',
excerpt: 'Four members in the room, two on video. The whiteboard ended up with three lists: must-have, would-be-nice, do-not-build.',
body: `Four council members in the room, two on video. The kickoff meeting was meant to be 90 minutes. It went four hours.
The whiteboard ended up with three lists: must-have, would-be-nice, and the most interesting one do-not-build.
Henriette pushed back hard on the "agent that emails on your behalf" pattern. "I don't want a system speaking on my legal team's behalf. Ever." That note alone reshaped a whole feature.
Photos to come, with permission.`,
},
{ kind: 'update', ageDays: 9,
title: 'Document ingestion is now feature-complete in beta',
excerpt: 'PDF, Word, plain text. Chunking, metadata, deduplication, basic OCR. Three pilots have run it against their corpora.',
body: `Document ingestion is feature-complete in beta. PDF, Word, plain text. Chunking, metadata extraction, deduplication, and basic OCR for scanned PDFs.
Three pilots have now run it against their internal corpora biggest was 47,000 documents, smallest was 380. Both worked. The 47k run took 8 hours and surfaced some neat edge cases (mostly around tables that span pages).
Next week we open it to the full pilot group. We'll need notes.`,
},
{ kind: 'note', ageDays: 12,
title: 'Welcome to the council',
excerpt: 'A short note to mark the start. This page will fill up with decisions, half-built ideas, and things we have changed our minds about.',
body: `This page will fill up with decisions, half-built ideas, and things we have changed our minds about.
It is not a blog. It is the studio talking to the room short, dated, signed.`,
},
];
const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
const insertDispatch = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
VALUES (?,?,?,?,?,'published',?,?,?)
`);
for (let i = 0; i < dispatchSeed.length; i += 1) {
const d = dispatchSeed[i];
const when = nowIso(-d.ageDays * 24 * 60 * 60);
const authorId = fenjas[i % fenjas.length].id;
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
}
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
const insertEvent = db.prepare(`
INSERT INTO events (slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, audience, duration_label, action_label, notes_url, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`);
insertEvent.run(
'kickoff-dinner-2026-06', 'Council kickoff dinner', 'dinner',
'A private dinner at the studio. Conversation about what we ship next, no slides.', 'A private dinner at the studio. Conversation about what we ship next, no slides.',
'Studio, Refshalevej · Copenhagen', 'Studio, Refshalevej · Copenhagen',
dinnerStart, nowIso(38 * 24 * 3600), null, 12, null,
12, 'Members only', null, null, null,
jon.id, jon.id,
); );
const dinnerId = db.prepare("SELECT id FROM events WHERE slug = 'kickoff-dinner-2026-06'").get().id; const dinnerSlug = 'kickoff-dinner-2026-06';
const officeHoursStart = nowIso(14 * 24 * 3600); // 2 weeks out insertEvent.run(
db.prepare(` 'studio-hours-2026-05', 'Studio hours with Jonathan', 'office_hours',
INSERT INTO events (slug, title, kind, description, location, starts_at, created_by) '30-minute slots. Open agenda. Drop in when you\'ve got something to talk through.',
VALUES (?,?,?,?,?,?,?)
`).run(
'office-hours-2026-05',
'Office hours with the founder',
'office_hours',
'30-minute one-on-one slots. Open agenda. Book one or just drop by.',
'Virtual (link sent after RSVP)', 'Virtual (link sent after RSVP)',
officeHoursStart, nowIso(14 * 24 * 3600), null, null, null,
'Council members', '30 minutes', null, null,
jon.id, jon.id,
); );
const officeHoursId = db.prepare("SELECT id FROM events WHERE slug = 'office-hours-2026-05'").get().id;
// ── Activity rows ───────────────────────────────────────────────────── insertEvent.run(
// Mix of real (Lars's vote, Jonathan's publish, Jonathan's ship) and 'working-session-2026-06', 'Working session: traceability UX', 'working_session',
// hand-crafted demo rows so the ticker has six items to scroll. 'Three-person session to walk through the new traceability UI before it lands in beta.',
'Studio, Refshalevej · Copenhagen',
nowIso(21 * 24 * 3600), null, 6, null,
'Council + Fenja team', '90 minutes', 'RSVP →', null,
jon.id,
);
insertEvent.run(
'apr-roundtable-2026', 'April roundtable', 'summit',
'A half-day session anchored around the EU AI Act compliance roadmap.',
'Studio, Refshalevej · Copenhagen',
nowIso(-21 * 24 * 3600), null, 10, null,
'Members only', null, null, 'https://example.invalid/notes/april',
jon.id,
);
const aprilSlug = 'apr-roundtable-2026';
insertEvent.run(
'march-launch-dinner', 'Launch dinner', 'dinner',
'The first dinner. Where the council was formally introduced.',
'Aamanns Etablissement · Copenhagen',
nowIso(-52 * 24 * 3600), null, 10, null,
'Members only', null, null, null,
jon.id,
);
const marchSlug = 'march-launch-dinner';
// Past-event RSVPs (drives the "attended_count" on past cards)
const insertRsvp = db.prepare(`
INSERT INTO attendance (user_id, meeting_slug, kind, status, updated_at)
VALUES (?, ?, 'event', 'yes', datetime('now'))
`);
for (const c of cabs) insertRsvp.run(c.id, aprilSlug);
for (const c of cabs.slice(0, 3)) insertRsvp.run(c.id, marchSlug);
// Hero dinner: 1 confirmed so far (Lars) — keeps the avatar pile small at first
insertRsvp.run(cabs[0].id, dinnerSlug);
// ── Activity rows for the (now hidden but still-written) feed ──────
const insertActivity = db.prepare(` const insertActivity = db.prepare(`
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at) INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
VALUES (?,?,?,?,?) VALUES (?,?,?,?,?)
`); `);
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600)); insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
insertActivity.run(lars.id, 'voted', 'pulse', pulseId, nowIso(-2 * 3600)); insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
if (traceability) { insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60));
insertActivity.run(jon.id, 'roadmap_shipped', 'roadmap', traceability.id, nowIso(-2 * 24 * 3600)); insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
}
insertActivity.run(lars.id, 'rsvped', 'event', dinnerId, nowIso(-8 * 3600));
insertActivity.run(mette.id,'rsvped', 'event', officeHoursId, nowIso(-30 * 60));
insertActivity.run(jon.id, 'booked_office_hours', 'event', officeHoursId, nowIso(-1 * 24 * 3600));
console.log(' demo data seeded:');
console.log(` pulse #${pulseId} (open, closes in 5 days)`);
if (traceability) console.log(` roadmap #${traceability.id} → shipping, attributed to ${lars.name}`);
console.log(` events: kickoff-dinner-2026-06, office-hours-2026-05`);
console.log(` activity: 6 rows`);
console.log(' pulse #' + pulseId + ' open, 2 of 4 voted');
console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
console.log(' events: dinner + studio hours + working session, 2 past');
db.close(); db.close();

View file

@ -17,14 +17,22 @@ try {
process.exit(1); process.exit(1);
} }
// Wipe existing seed data (idempotent) // Wipe existing seed data (idempotent). Order matters: every table that
// FK-references users (without ON DELETE CASCADE) must be cleared first.
db.exec(` db.exec(`
DELETE FROM activity;
DELETE FROM votes;
DELETE FROM dispatches;
DELETE FROM events;
DELETE FROM pulses;
DELETE FROM roadmap_attributions;
DELETE FROM reactions; DELETE FROM reactions;
DELETE FROM replies; DELETE FROM replies;
DELETE FROM contributions; DELETE FROM contributions;
DELETE FROM attendance; DELETE FROM attendance;
DELETE FROM sessions; DELETE FROM join_requests;
DELETE FROM invites; DELETE FROM invites;
DELETE FROM sessions;
DELETE FROM users; DELETE FROM users;
`); `);
@ -32,21 +40,21 @@ const ROUNDS = 10;
const users = [ const users = [
{ {
email: 'mette@ssi.dk', email: 'mette@virk1.dk',
password: 'pilot123', password: 'pilot123',
name: 'Mette Hansen', name: 'Mette Hansen',
organisation: 'Statens Serum Institut', organisation: 'Virksomhed 1',
role: 'pilot', role: 'pilot',
}, },
{ {
email: 'lars@rigspolitiet.dk', email: 'lars@virk2.dk',
password: 'cab123', password: 'cab123',
name: 'Lars Thomsen', name: 'Lars Thomsen',
organisation: 'Rigspolitiet', organisation: 'Virksomhed 2',
role: 'cab', role: 'cab',
}, },
{ {
email: 'jonathan@fenja.ai', email: 'jonathan@studio.test',
password: 'fenja123', password: 'fenja123',
name: 'Jonathan', name: 'Jonathan',
organisation: 'Fenja AI', organisation: 'Fenja AI',
@ -55,14 +63,15 @@ const users = [
]; ];
const insertUser = db.prepare(` const insertUser = db.prepare(`
INSERT INTO users (email, password_hash, name, organisation, role, bio) INSERT INTO users (email, password_hash, name, organisation, role, bio, slug)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
const userIds = {}; const userIds = {};
for (const u of users) { for (const u of users) {
const hash = bcrypt.hashSync(u.password, ROUNDS); const hash = bcrypt.hashSync(u.password, ROUNDS);
const result = insertUser.run(u.email, hash, u.name, u.organisation, u.role, ''); const slug = u.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
const result = insertUser.run(u.email, hash, u.name, u.organisation, u.role, '', slug);
userIds[u.role] = Number(result.lastInsertRowid); userIds[u.role] = Number(result.lastInsertRowid);
console.log(` created user: ${u.name} (${u.role}) — password: ${u.password}`); console.log(` created user: ${u.name} (${u.role}) — password: ${u.password}`);
} }
@ -135,7 +144,7 @@ insertAttendance.run(userIds['cab'], '2026-04-25-cab-q2-session', 'no');
console.log('\n Seed complete.'); console.log('\n Seed complete.');
console.log('\n Test credentials:'); console.log('\n Test credentials:');
console.log(' mette@ssi.dk / pilot123 (pilot)'); console.log(' mette@virk1.dk / pilot123 (pilot)');
console.log(' lars@rigspolitiet.dk / cab123 (cab)'); console.log(' lars@virk2.dk / cab123 (cab)');
console.log(' jonathan@fenja.ai / fenja123 (fenja)'); console.log(' jonathan@studio.test / fenja123 (fenja)');
db.close(); db.close();

View file

@ -0,0 +1,56 @@
---
import Avatar from './Avatar.astro';
import type { UserPublic } from '../lib/db';
interface Props {
users: UserPublic[];
max?: number;
size?: number;
/** Border color between overlapping avatars — defaults to --surface for cream surfaces. */
borderColor?: string;
}
const { users, max = 5, size = 22, borderColor = 'var(--surface)' } = Astro.props;
const shown = users.slice(0, max);
const overflow = Math.max(0, users.length - shown.length);
---
<div class="pile" style={`--pile-size: ${size}px; --pile-border: ${borderColor};`}>
{shown.map((u, i) => (
<span class="pile-slot" style={`z-index: ${shown.length - i}`}>
<Avatar id={u.id} name={u.name} size={size} />
</span>
))}
{overflow > 0 && (
<span class="pile-slot pile-overflow label-sm" aria-label={`${overflow} more`}>
+{overflow}
</span>
)}
</div>
<style>
.pile {
display: inline-flex;
align-items: center;
}
.pile-slot {
display: inline-flex;
border-radius: 50%;
box-shadow: 0 0 0 1.5px var(--pile-border);
}
.pile-slot:not(:first-child) {
margin-left: calc(var(--pile-size) * -0.32);
}
.pile-overflow {
width: var(--pile-size);
height: var(--pile-size);
background: var(--ink-muted);
color: var(--ink);
align-items: center;
justify-content: center;
font-family: var(--font-sans);
letter-spacing: var(--tracking-wide);
font-weight: 600;
font-size: calc(var(--pile-size) * 0.36);
}
</style>

View file

@ -0,0 +1,175 @@
---
import type { DispatchWithAuthor, UserPublic } from '../../lib/db';
import { fmtDateTime } from '../../lib/markdown';
import { dispatchKindLabel } from '../../lib/format';
interface Props {
dispatches: DispatchWithAuthor[];
editing: DispatchWithAuthor | null;
fenjaUsers: UserPublic[];
currentUserId: number;
}
const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
const STATUS_LABEL: Record<string, string> = {
draft: 'Draft',
published: 'Published',
archived: 'Archived',
};
const formAction = editing ? 'update_dispatch' : 'create_dispatch';
const defaultAuthorId = editing?.author_id ?? currentUserId;
---
<div class="tab-content">
<section class="section">
<h2 class="label-sm section-heading">{editing ? 'Edit dispatch' : 'New dispatch'}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value={formAction} />
{editing && <input type="hidden" name="dispatch_id" value={editing.id} />}
<div class="field">
<label for="d-title" class="label-sm field-label">Title</label>
<input type="text" id="d-title" name="title" class="input body-md" required value={editing?.title ?? ''} />
</div>
<div class="form-grid">
<div class="field">
<label for="d-kind" class="label-sm field-label">Kind</label>
<select id="d-kind" name="kind" class="select body-md" required>
{(['decision','update','behind_the_scenes','note'] as const).map(k => (
<option value={k} selected={editing?.kind === k}>{dispatchKindLabel(k)}</option>
))}
</select>
</div>
<div class="field">
<label for="d-author" class="label-sm field-label">Author (Fenja team)</label>
<select id="d-author" name="author_id" class="select body-md" required>
{fenjaUsers.map(u => (
<option value={u.id} selected={u.id === defaultAuthorId}>
{u.name}{u.title ? ` — ${u.title}` : ''}
</option>
))}
</select>
</div>
</div>
<div class="field">
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional — falls back to first ~200 chars of body)</label>
<input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
</div>
<div class="field">
<label for="d-body" class="label-sm field-label">Body (markdown)</label>
<textarea id="d-body" name="body" class="input body-md mono" rows="12" required>{editing?.body ?? ''}</textarea>
</div>
{!editing && (
<div class="form-grid">
<div class="field">
<label for="d-status" class="label-sm field-label">Status on save</label>
<select id="d-status" name="status" class="select body-md">
<option value="draft" selected>Draft (hidden from members)</option>
<option value="published">Published (stamps published_at)</option>
</select>
</div>
</div>
)}
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
</div>
</form>
</section>
<section class="section">
<h2 class="label-sm section-heading">All dispatches</h2>
{dispatches.length === 0 ? (
<p class="body-sm empty-msg">No dispatches yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Title</th>
<th class="label-sm">Kind</th>
<th class="label-sm">Author</th>
<th class="label-sm">Status</th>
<th class="label-sm">Published</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{dispatches.map(d => (
<tr>
<td class="body-sm">{d.title}</td>
<td class="body-sm muted">{dispatchKindLabel(d.kind)}</td>
<td class="body-sm">{d.author_name}</td>
<td class="body-sm"><span class:list={['status-pill', `status-${d.status}`]}>{STATUS_LABEL[d.status]}</span></td>
<td class="body-sm muted">{d.published_at ? fmtDateTime(d.published_at) : '—'}</td>
<td class="action-cell">
<a href={`/admin?tab=dispatches&edit=${d.id}`} class="action-link label-sm">Edit</a>
{d.status === 'draft' && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="publish_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="action-link label-sm">Publish</button>
</form>
)}
{d.status === 'published' && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="archive_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="action-link label-sm">Archive</button>
</form>
)}
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="delete_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this dispatch?')">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
<style>
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.status-pill {
display: inline-block;
padding: 0.15em var(--space-3);
border-radius: var(--radius-full);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.status-draft { background: var(--surface-container); color: var(--on-surface-muted); }
.status-published { background: rgba(109, 140, 124, 0.18); color: var(--pigment-copper); font-weight: 600; }
.status-archived { background: var(--surface-container-low); color: var(--on-surface-muted); font-style: italic; }
.action-cell { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style>

View file

@ -13,9 +13,10 @@ const { events, editing, viewing, viewingRsvps } = Astro.props;
const KIND_LABEL = { const KIND_LABEL = {
dinner: 'Dinner', dinner: 'Dinner',
office_hours: 'Office hours', office_hours: 'Studio hours',
summit: 'Summit', summit: 'Summit',
virtual: 'Virtual', virtual: 'Virtual',
working_session: 'Working session',
} as const; } as const;
function toInputValue(sql: string | null | undefined): string { function toInputValue(sql: string | null | undefined): string {
@ -59,7 +60,8 @@ const formAction = editing ? 'update_event' : 'create_event';
<label for="kind" class="label-sm field-label">Kind</label> <label for="kind" class="label-sm field-label">Kind</label>
<select id="kind" name="kind" class="select body-md" required> <select id="kind" name="kind" class="select body-md" required>
<option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option> <option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option>
<option value="office_hours" selected={editing?.kind === 'office_hours'}>Office hours</option> <option value="office_hours" selected={editing?.kind === 'office_hours'}>Studio hours</option>
<option value="working_session" selected={editing?.kind === 'working_session'}>Working session</option>
<option value="summit" selected={editing?.kind === 'summit'}>Summit</option> <option value="summit" selected={editing?.kind === 'summit'}>Summit</option>
<option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option> <option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option>
</select> </select>
@ -84,6 +86,22 @@ const formAction = editing ? 'update_event' : 'create_event';
<label for="photo_url" class="label-sm field-label">Photo URL (optional, for past events)</label> <label for="photo_url" class="label-sm field-label">Photo URL (optional, for past events)</label>
<input type="text" id="photo_url" name="photo_url" class="input body-md" value={editing?.photo_url ?? ''} /> <input type="text" id="photo_url" name="photo_url" class="input body-md" value={editing?.photo_url ?? ''} />
</div> </div>
<div class="field">
<label for="audience" class="label-sm field-label">Audience (e.g. "Members only")</label>
<input type="text" id="audience" name="audience" class="input body-md" value={editing?.audience ?? ''} />
</div>
<div class="field">
<label for="duration_label" class="label-sm field-label">Duration label</label>
<input type="text" id="duration_label" name="duration_label" class="input body-md" value={editing?.duration_label ?? ''} placeholder="e.g. 30 minutes, 7pm onwards" />
</div>
<div class="field">
<label for="action_label" class="label-sm field-label">Action label (optional)</label>
<input type="text" id="action_label" name="action_label" class="input body-md" value={editing?.action_label ?? ''} placeholder="Override the default for this event kind" />
</div>
<div class="field">
<label for="notes_url" class="label-sm field-label">Notes URL (optional)</label>
<input type="url" id="notes_url" name="notes_url" class="input body-md" value={editing?.notes_url ?? ''} placeholder="https://…" />
</div>
</div> </div>
<div class="field"> <div class="field">

View file

@ -0,0 +1,90 @@
---
import type { UserPublic } from '../../lib/db';
import { readFocusTags } from '../../lib/format';
interface Props {
member: UserPublic;
}
const { member } = Astro.props;
const tagsStr = readFocusTags(member.focus_tags).join(', ');
---
<div class="tab-content">
<section class="section">
<a href="/admin?tab=participants" class="action-link label-sm">← Back to participants</a>
<h2 class="label-sm section-heading">Edit member — {member.name}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value="update_user_admin" />
<input type="hidden" name="user_id" value={member.id} />
<div class="form-grid">
<div class="field">
<label class="label-sm field-label">Name</label>
<input type="text" class="input body-md" value={member.name} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Email</label>
<input type="text" class="input body-md" value={member.email} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Organisation</label>
<input type="text" class="input body-md" value={member.organisation} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Member number {member.role === 'cab' ? '(allocated)' : '(only set for cab role)'}</label>
<input type="text" class="input body-md" value={member.member_number ?? '—'} disabled />
</div>
<div class="field">
<label for="title" class="label-sm field-label">Job title</label>
<input type="text" id="title" name="title" class="input body-md" value={member.title ?? ''} placeholder="e.g. Senior Adviser" />
</div>
<div class="field">
<label for="focus_tags" class="label-sm field-label">Focus tags (comma-separated, max 3 × 24 chars)</label>
<input type="text" id="focus_tags" name="focus_tags" class="input body-md" value={tagsStr} placeholder="GDPR, Telemetry, Policy" />
</div>
</div>
<div class="field">
<label for="pull_quote" class="label-sm field-label">Pull quote (one sentence in their voice — max 200 chars)</label>
<textarea id="pull_quote" name="pull_quote" class="input body-md" rows="3" maxlength="200" data-counter>{member.pull_quote ?? ''}</textarea>
<span class="char-counter label-sm" data-counter-for="pull_quote">{(member.pull_quote ?? '').length} / 200</span>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">Save changes</button>
<a href="/admin?tab=participants" class="action-link label-sm">Cancel</a>
</div>
</form>
<p class="body-sm note">
Role transitions and deactivation live in the participants table.
A member-number is allocated the first time a user becomes CAB and is never reused.
</p>
</section>
</div>
<script>
// Tiny live counter for the 200-char pull-quote field — no framework.
document.querySelectorAll<HTMLTextAreaElement>('[data-counter]').forEach((el) => {
const counter = document.querySelector<HTMLElement>(`[data-counter-for="${el.id}"]`);
if (!counter) return;
const update = () => { counter.textContent = `${el.value.length} / 200`; };
el.addEventListener('input', update);
});
</script>
<style>
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.char-counter { color: var(--on-surface-muted); margin-top: var(--space-1); display: inline-block; }
.note {
color: var(--on-surface-muted);
margin-top: var(--space-4);
max-width: var(--reading-max);
}
.input:disabled {
color: var(--on-surface-muted);
background: var(--surface-container-low);
cursor: not-allowed;
}
</style>

View file

@ -12,8 +12,6 @@ const { title, user } = Astro.props;
const navLinks = [ const navLinks = [
{ href: '/pulse', label: 'Pulse' }, { href: '/pulse', label: 'Pulse' },
{ href: '/roadmap', label: 'Roadmap' }, { href: '/roadmap', label: 'Roadmap' },
{ href: '/members', label: 'Members' },
{ href: '/events', label: 'Events' },
]; ];
const footerLinks = [ const footerLinks = [
@ -30,6 +28,8 @@ const year = new Date().getFullYear();
<div class="nav-inner"> <div class="nav-inner">
<a href="/" class="wordmark-link" aria-label="Project Bifrost — home"> <a href="/" class="wordmark-link" aria-label="Project Bifrost — home">
<img src="/logo.svg" alt="Fenja AI" class="wordmark" /> <img src="/logo.svg" alt="Fenja AI" class="wordmark" />
<span class="wordmark-sep" aria-hidden="true">·</span>
<span class="wordmark-project">Project <em class="wordmark-bifrost">Bifrost</em></span>
</a> </a>
<nav class="nav-right" aria-label="Main navigation"> <nav class="nav-right" aria-label="Main navigation">
@ -112,16 +112,47 @@ const year = new Date().getFullYear();
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3);
border-bottom: none; border-bottom: none;
color: var(--on-surface);
} }
.wordmark-link:hover { .wordmark-link:hover {
border-bottom: none; border-bottom: none;
color: var(--on-surface);
} }
.wordmark { .wordmark {
height: 22px; height: 22px;
width: auto; width: auto;
display: block; display: block;
} }
.wordmark-sep {
color: var(--on-surface-muted);
font-size: 1rem;
line-height: 1;
}
.wordmark-project {
font-family: var(--font-sans);
font-size: var(--text-body-md);
font-weight: 500;
color: var(--on-surface);
letter-spacing: 0;
}
.wordmark-bifrost {
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
background-image: linear-gradient(
90deg,
var(--pigment-terracotta) 0%,
var(--pigment-ochre) 28%,
var(--pigment-copper) 54%,
var(--pigment-indigo) 78%,
var(--pigment-heather) 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* ── Nav links ──────────────────────────────────────────────────── */ /* ── Nav links ──────────────────────────────────────────────────── */
.nav-right { .nav-right {

Binary file not shown.

View file

@ -2,24 +2,29 @@
import AppLayout from '../../layouts/AppLayout.astro'; import AppLayout from '../../layouts/AppLayout.astro';
import { import {
getAllInvites, getAllUsersPublic, revokeInvite, getAllInvites, getAllUsersPublic, revokeInvite,
createInvite, updateUserRole, deactivateUser, createInvite, updateUserRole, deactivateUser, updateUserAdminFields,
getAllJoinRequests, getUserPublicById, getAllJoinRequests,
createPulse, updatePulse, publishPulse, closePulse, deletePulse, createPulse, updatePulse, publishPulse, closePulse, deletePulse,
getAllPulses, getPulseById, getPulseWithCounts, getAllPulses, getPulseById, getPulseWithCounts,
createRoadmapItem, updateRoadmapItem, deleteRoadmapItem, createRoadmapItem, updateRoadmapItem, deleteRoadmapItem,
setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem, setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem,
createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug, createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug,
getEventRsvpCount, getEventById, getEventRsvpCount, getEventById,
createDispatch, updateDispatch, publishDispatch, archiveDispatch,
deleteDispatch, getAllDispatchesForAdmin, getDispatchById,
recordActivity, getAllActivityForAdmin, recordActivity, getAllActivityForAdmin,
} from '../../lib/db'; } from '../../lib/db';
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth'; import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
import { fmtDate } from '../../lib/markdown'; import { fmtDate } from '../../lib/markdown';
import { parseFocusTags } from '../../lib/format';
import { notifyPulseOpened } from '../../lib/notify'; import { notifyPulseOpened } from '../../lib/notify';
import PulsesTab from '../../components/admin/PulsesTab.astro'; import PulsesTab from '../../components/admin/PulsesTab.astro';
import RoadmapTab from '../../components/admin/RoadmapTab.astro'; import RoadmapTab from '../../components/admin/RoadmapTab.astro';
import EventsTab from '../../components/admin/EventsTab.astro'; import EventsTab from '../../components/admin/EventsTab.astro';
import ActivityTab from '../../components/admin/ActivityTab.astro'; import ActivityTab from '../../components/admin/ActivityTab.astro';
import type { Role, RoadmapStatus, EventKind } from '../../lib/db'; import DispatchesTab from '../../components/admin/DispatchesTab.astro';
import UserEditTab from '../../components/admin/UserEditTab.astro';
import type { Role, RoadmapStatus, EventKind, DispatchKind, DispatchStatus } from '../../lib/db';
const user = Astro.locals.user; const user = Astro.locals.user;
@ -75,6 +80,50 @@ if (Astro.request.method === 'POST') {
if (userId && userId !== user.id) deactivateUser(userId); if (userId && userId !== user.id) deactivateUser(userId);
return Astro.redirect('/admin?tab=participants&msg=deactivated'); return Astro.redirect('/admin?tab=participants&msg=deactivated');
// ── User profile edit (title / pull_quote / focus_tags) ─────
} else if (action === 'update_user_admin') {
const userId = Number(data.get('user_id'));
if (userId) {
const title = String(data.get('title') ?? '').trim() || null;
const pullQuote = String(data.get('pull_quote') ?? '').trim() || null;
const tagsInput = String(data.get('focus_tags') ?? '');
const focusTags = parseFocusTags(tagsInput);
updateUserAdminFields(userId, { title, pull_quote: pullQuote, focus_tags: focusTags });
}
return Astro.redirect(`/admin?tab=participants&edit=${userId}&msg=user_updated`);
// ── Dispatches ───────────────────────────────────────────────
} else if (action === 'create_dispatch' || action === 'update_dispatch') {
const title = String(data.get('title') ?? '').trim();
const body = String(data.get('body') ?? '');
const excerpt = String(data.get('excerpt') ?? '').trim() || null;
const kind = String(data.get('kind') ?? '') as DispatchKind;
const authorId = Number(data.get('author_id'));
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
formError = 'Title, body, and a valid kind are required.';
} else if (action === 'create_dispatch') {
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status });
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
} else {
const id = Number(data.get('dispatch_id'));
if (id) updateDispatch(id, { title, body, excerpt, kind, author_id: authorId || user.id });
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
}
} else if (action === 'publish_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) publishDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_published');
} else if (action === 'archive_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) archiveDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_archived');
} else if (action === 'delete_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) deleteDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_deleted');
// ── Pulses ─────────────────────────────────────────────────── // ── Pulses ───────────────────────────────────────────────────
} else if (action === 'create_pulse' || action === 'update_pulse') { } else if (action === 'create_pulse' || action === 'update_pulse') {
const question = String(data.get('question') ?? '').trim(); const question = String(data.get('question') ?? '').trim();
@ -229,6 +278,12 @@ const joinRequests = getAllJoinRequests();
const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null; const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null;
const viewId = Number(Astro.url.searchParams.get('view') ?? 0) || null; const viewId = Number(Astro.url.searchParams.get('view') ?? 0) || null;
const fenjaUsers = users.filter(u => u.role === 'fenja');
const editingUser = tab === 'participants' && editId ? getUserPublicById(editId) : null;
const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : [];
const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
// Per-tab data // Per-tab data
const pulses = tab === 'pulses' ? getAllPulses() : []; const pulses = tab === 'pulses' ? getAllPulses() : [];
const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null; const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null;
@ -251,6 +306,7 @@ const MSGS: Record<string, string> = {
revoked: 'Invite revoked.', revoked: 'Invite revoked.',
updated: 'Role updated.', updated: 'Role updated.',
deactivated: 'User deactivated.', deactivated: 'User deactivated.',
user_updated: 'Member profile updated.',
pulse_created: 'Pulse saved.', pulse_created: 'Pulse saved.',
pulse_updated: 'Pulse updated.', pulse_updated: 'Pulse updated.',
pulse_published: 'Pulse published — members notified.', pulse_published: 'Pulse published — members notified.',
@ -263,6 +319,11 @@ const MSGS: Record<string, string> = {
event_created: 'Event saved.', event_created: 'Event saved.',
event_updated: 'Event updated.', event_updated: 'Event updated.',
event_deleted: 'Event deleted.', event_deleted: 'Event deleted.',
dispatch_created: 'Dispatch saved.',
dispatch_updated: 'Dispatch updated.',
dispatch_published: 'Dispatch published.',
dispatch_archived: 'Dispatch archived.',
dispatch_deleted: 'Dispatch deleted.',
}; };
actionMsg = Astro.url.searchParams.get('msg'); actionMsg = Astro.url.searchParams.get('msg');
--- ---
@ -279,6 +340,7 @@ actionMsg = Astro.url.searchParams.get('msg');
<a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</a> <a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</a>
<a href="/admin?tab=roadmap" class:list={['tab label-sm', { active: tab === 'roadmap' }]}>Roadmap</a> <a href="/admin?tab=roadmap" class:list={['tab label-sm', { active: tab === 'roadmap' }]}>Roadmap</a>
<a href="/admin?tab=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a> <a href="/admin?tab=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a>
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a>
<a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a> <a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a>
<a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a> <a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}> <a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
@ -397,7 +459,11 @@ actionMsg = Astro.url.searchParams.get('msg');
)} )}
<!-- Participants tab --> <!-- Participants tab -->
{tab === 'participants' && ( {tab === 'participants' && editingUser && (
<UserEditTab member={editingUser} />
)}
{tab === 'participants' && !editingUser && (
<div class="tab-content"> <div class="tab-content">
<section class="section"> <section class="section">
<h2 class="label-sm section-heading">All participants</h2> <h2 class="label-sm section-heading">All participants</h2>
@ -436,7 +502,8 @@ actionMsg = Astro.url.searchParams.get('msg');
<td class="body-sm muted"> <td class="body-sm muted">
{u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'} {u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'}
</td> </td>
<td> <td class="action-cell">
<a href={`/admin?tab=participants&edit=${u.id}`} class="action-link label-sm">Edit</a>
{u.id !== user.id && ( {u.id !== user.id && (
<form method="POST" class="inline-form"> <form method="POST" class="inline-form">
<input type="hidden" name="action" value="deactivate_user" /> <input type="hidden" name="action" value="deactivate_user" />
@ -509,6 +576,10 @@ actionMsg = Astro.url.searchParams.get('msg');
<ActivityTab rows={activityRows} /> <ActivityTab rows={activityRows} />
)} )}
{tab === 'dispatches' && (
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} fenjaUsers={fenjaUsers} currentUserId={user.id} />
)}
</div> </div>
</AppLayout> </AppLayout>
@ -791,4 +862,21 @@ actionMsg = Astro.url.searchParams.get('msg');
.danger-btn:hover { .danger-btn:hover {
background: rgba(185, 107, 88, 0.08); background: rgba(185, 107, 88, 0.08);
} }
.action-cell { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
font-size: var(--text-label-md);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style> </style>

View file

@ -0,0 +1,246 @@
---
import AppLayout from '../../layouts/AppLayout.astro';
import Avatar from '../../components/Avatar.astro';
import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
import {
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
dispatchKindPigment, roleLabel,
} from '../../lib/format';
import { renderMd } from '../../lib/markdown';
const user = Astro.locals.user;
const slugParam = Astro.params.slug ?? '';
const id = parseDispatchSlug(slugParam);
if (!id) return Astro.redirect('/dispatches');
const d = getDispatchById(id);
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
// Canonical-redirect when the slug changes after a rename — id is the authority
const canonical = dispatchSlug(d);
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
const { prev, next } = getAdjacentDispatches(d.id);
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(iso: string): string {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Copenhagen',
}).format(parseUtc(iso));
}
const bodyHtml = renderMd(d.body);
---
<AppLayout title={d.title} user={user}>
<article class="page">
<a href="/dispatches" class="section-link back-link">← All dispatches</a>
<header class="head">
<div class="head-meta">
<span class="kind-pill" style={`--pill: ${dispatchKindPigment(d.kind)}`}>
{dispatchKindLabel(d.kind)}
</span>
<time class="head-date label-sm" datetime={d.published_at ?? d.created_at}>
{fmt(d.published_at ?? d.created_at)}
</time>
</div>
<h1 class="title">{d.title}</h1>
<div class="byline">
<Avatar id={d.author_id} name={d.author_name} size={32} />
<div class="byline-text">
<span class="byline-name">{d.author_name}</span>
<span class="byline-role label-sm">{d.author_title ?? roleLabel(d.author_role)}</span>
</div>
</div>
</header>
<div class="body prose" set:html={bodyHtml} />
<hr class="divider" />
<nav class="adjacent" aria-label="Adjacent dispatches">
{prev ? (
<a class="adj-card adj-prev" href={`/dispatches/${dispatchSlug(prev)}`}>
<span class="adj-direction label-sm">← Previous</span>
<span class="adj-kind-pill" style={`--pill: ${dispatchKindPigment(prev.kind)}`}>
{dispatchKindLabel(prev.kind)}
</span>
<span class="adj-title">{prev.title}</span>
</a>
) : (
<span class="adj-empty"></span>
)}
{next ? (
<a class="adj-card adj-next" href={`/dispatches/${dispatchSlug(next)}`}>
<span class="adj-direction label-sm">Next →</span>
<span class="adj-kind-pill" style={`--pill: ${dispatchKindPigment(next.kind)}`}>
{dispatchKindLabel(next.kind)}
</span>
<span class="adj-title">{next.title}</span>
</a>
) : (
<span class="adj-empty"></span>
)}
</nav>
</article>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.back-link { align-self: flex-start; }
.head { display: flex; flex-direction: column; gap: var(--space-4); }
.head-meta {
display: flex;
align-items: center;
gap: var(--space-3);
}
.kind-pill {
background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill);
padding: 3px 12px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.head-date {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 2rem;
line-height: 1.2;
letter-spacing: var(--tracking-tight);
color: var(--on-surface);
margin: 0;
}
.byline {
display: flex;
align-items: center;
gap: var(--space-3);
}
.byline-text { display: flex; flex-direction: column; gap: 2px; }
.byline-name { font-weight: 600; color: var(--on-surface); }
.byline-role {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.body {
font-size: var(--text-body-lg);
line-height: 1.7;
color: var(--on-surface);
}
.body :global(p) { margin: 0 0 var(--space-4); }
.body :global(h2) {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.5rem;
margin: var(--space-6) 0 var(--space-3);
}
.body :global(h3) {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.25rem;
margin: var(--space-5) 0 var(--space-2);
}
.body :global(blockquote) {
border-left: 2px solid color-mix(in oklab, var(--pigment-terracotta) 40%, transparent);
padding-left: var(--space-4);
color: var(--on-surface-variant);
margin: var(--space-5) 0;
}
.body :global(code) {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--surface-container);
padding: 0.15em 0.4em;
border-radius: var(--radius-sm);
}
.body :global(ul), .body :global(ol) {
padding-left: var(--space-5);
margin: 0 0 var(--space-4);
}
.divider {
border: none;
height: 0.5px;
background: var(--surface-card-border);
margin: var(--space-6) 0 0;
}
.adjacent {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5);
}
.adj-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-5);
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
text-decoration: none;
border-bottom: 0.5px solid var(--surface-card-border);
color: inherit;
transition: transform 300ms var(--ease-standard);
}
.adj-card:hover { transform: translateY(-2px); border-bottom-color: var(--surface-card-border); }
.adj-next { text-align: right; align-items: flex-end; }
.adj-direction {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.adj-kind-pill {
align-self: flex-start;
background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill);
padding: 2px 9px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.adj-next .adj-kind-pill { align-self: flex-end; }
.adj-title {
font-family: var(--font-serif);
color: var(--on-surface);
}
.adj-empty {} /* placeholder for missing prev/next slot */
@media (max-width: 640px) {
.adjacent { grid-template-columns: 1fr; }
.adj-next { text-align: left; align-items: flex-start; }
.adj-next .adj-kind-pill { align-self: flex-start; }
}
</style>

View file

@ -0,0 +1,179 @@
---
import AppLayout from '../../layouts/AppLayout.astro';
import Avatar from '../../components/Avatar.astro';
import { getLatestPublishedDispatches } from '../../lib/db';
import {
dispatchSlug, dispatchKindLabel, dispatchKindPigment,
dispatchExcerptParas, roleLabel,
} from '../../lib/format';
const user = Astro.locals.user;
const dispatches = getLatestPublishedDispatches(200);
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(iso: string): string {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Copenhagen',
}).format(parseUtc(iso));
}
---
<AppLayout title="Dispatches" user={user}>
<div class="page">
<header class="head">
<h1 class="head-title">Notes from the studio.</h1>
<p class="head-sub">Decisions, half-built ideas, and things we've changed our mind about.</p>
</header>
{dispatches.length === 0 ? (
<p class="body-md empty">Nothing posted yet.</p>
) : (
<ul class="d-list">
{dispatches.map(d => (
<li class="d-row">
<a href={`/dispatches/${dispatchSlug(d)}`} class="d-link">
<div class="d-byline">
<Avatar id={d.author_id} name={d.author_name} size={28} />
<span class="d-author-text">
<span class="d-author-name">{d.author_name}</span>
<span class="d-author-role label-sm">{d.author_title ?? roleLabel(d.author_role)}</span>
</span>
</div>
<div class="d-body">
<header class="d-title-row">
<h2 class="d-title">{d.title}</h2>
<span class="d-kind-pill" style={`--pill: ${dispatchKindPigment(d.kind)}`}>
{dispatchKindLabel(d.kind)}
</span>
</header>
<p class="d-excerpt">{dispatchExcerptParas(d).lead}</p>
</div>
<time class="d-date label-sm" datetime={d.published_at ?? d.created_at}>
{fmt(d.published_at ?? d.created_at)}
</time>
</a>
</li>
))}
</ul>
)}
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.head { max-width: 46rem; }
.head-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-3);
}
.head-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: var(--text-display-md);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--on-surface);
margin: 0;
}
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
.empty { color: var(--on-surface-muted); }
.d-list { list-style: none; padding: 0; margin: 0; }
.d-row { border-bottom: 0.5px solid var(--surface-card-border); }
.d-row:last-child { border-bottom: none; }
.d-link {
display: grid;
grid-template-columns: 180px 1fr 130px;
gap: var(--space-5);
padding: var(--space-5) var(--space-3);
align-items: start;
text-decoration: none;
border-bottom: none;
color: inherit;
transition: background var(--duration-fast) var(--ease-standard);
}
.d-link:hover {
background: color-mix(in oklab, var(--surface-card) 60%, transparent);
border-bottom: none;
}
.d-byline { display: flex; align-items: center; gap: var(--space-3); min-width: 0; }
.d-author-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.d-author-name {
font-weight: 600;
color: var(--on-surface);
font-size: var(--text-body-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.d-author-role {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.d-body { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
.d-title-row {
display: flex;
align-items: baseline;
gap: var(--space-3);
flex-wrap: wrap;
}
.d-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.25rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.d-kind-pill {
background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill);
padding: 2px 9px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.d-excerpt {
color: var(--on-surface-variant);
margin: 0;
line-height: var(--leading-relaxed);
}
.d-date {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
justify-self: end;
white-space: nowrap;
}
@media (max-width: 720px) {
.d-link {
grid-template-columns: 1fr;
gap: var(--space-3);
}
.d-date { justify-self: start; }
}
</style>

513
src/pages/events.astro Normal file
View file

@ -0,0 +1,513 @@
---
import AppLayout from '../layouts/AppLayout.astro';
import AvatarPile from '../components/AvatarPile.astro';
import {
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
getEventRsvpCount, getUserRsvp, setEventRsvp, recordActivity,
} from '../lib/db';
import { eventKindLabel, defaultActionLabel, pigmentForId } from '../lib/format';
const user = Astro.locals.user;
// ── POST: RSVP ──────────────────────────────────────────────────────
if (Astro.request.method === 'POST') {
const data = await Astro.request.formData();
const action = String(data.get('action') ?? '');
if (action === 'rsvp') {
const slug = String(data.get('event_slug') ?? '');
const status = String(data.get('status') ?? '') as 'yes' | 'no' | 'interested';
if (slug && ['yes', 'no', 'interested'].includes(status)) {
const ev = getEventBySlug(slug);
if (ev) {
setEventRsvp(user.id, slug, status);
recordActivity(user.id, 'rsvped', 'event', ev.id);
}
}
return Astro.redirect('/events');
}
}
// ── Data ───────────────────────────────────────────────────────────
const upcoming = getUpcomingEvents(20);
const hero = upcoming.find(e => e.kind !== 'office_hours') ?? null;
const alsoUpcoming = upcoming.filter(e => e.id !== hero?.id);
const past = getPastEvents(8);
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
}
function dayNum(iso: string) { return fmt({ day: 'numeric' }, iso); }
function weekday(iso: string) { return fmt({ weekday: 'short' }, iso).toUpperCase(); }
function monthShort(iso: string) { return fmt({ month: 'short' }, iso).toUpperCase(); }
function timeStr(iso: string) { return fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso); }
const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
const heroConfirmedCount = heroAttendees.length;
const heroMyRsvp = hero ? getUserRsvp(user.id, hero.slug) : null;
const heroAudience = hero?.audience ?? 'Members only';
---
<AppLayout title="Events" user={user}>
<div class="page">
<header class="head">
<h1 class="head-title">Where the council gathers.</h1>
<p class="head-sub">Dinners, working sessions, the occasional summit. Always small, always off the record.</p>
</header>
<!-- ── Hero invitation ─────────────────────────────────────── -->
{hero ? (
<article class="hero" aria-label={`Next up: ${hero.title}`}>
<div class="hero-body">
<div class="hero-date">
<span class="hero-weekday">{weekday(hero.starts_at)}</span>
<span class="hero-day">{dayNum(hero.starts_at)}</span>
<span class="hero-month">{monthShort(hero.starts_at)}</span>
</div>
<div class="hero-detail">
<h2 class="hero-title">{hero.title}</h2>
<p class="hero-desc">{hero.description}</p>
<p class="hero-meta">{hero.location}{hero.location && ' · '}{timeStr(hero.starts_at)}{hero.duration_label ? ` · ${hero.duration_label}` : ''}</p>
</div>
</div>
<footer class="hero-foot">
<div class="hero-foot-left">
<span class="hero-foot-stat">
{hero.capacity ? `${hero.capacity} seats · ` : ''}{heroConfirmedCount} confirmed
</span>
{heroAttendees.length > 0 && (
<AvatarPile users={heroAttendees} max={5} size={22} borderColor="var(--ink)" />
)}
</div>
<form method="POST" class="hero-foot-right">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={hero.slug} />
{heroMyRsvp === 'yes' ? (
<>
<span class="hero-confirmed">You're confirmed ✓</span>
<button type="submit" name="status" value="no" class="hero-change">Change</button>
</>
) : (
<button type="submit" name="status" value="yes" class="hero-cta">Save your seat →</button>
)}
</form>
</footer>
</article>
) : (
<article class="hero hero--empty">
<p class="hero-empty-line">
Nothing scheduled yet — when we have something, you'll be the first to know.
</p>
</article>
)}
<!-- ── Also coming up ──────────────────────────────────────── -->
{alsoUpcoming.length > 0 && (
<section class="also">
<ul class="also-list">
{alsoUpcoming.map(ev => (
<li class="also-row">
<div class="also-date">
<span class="also-day">{dayNum(ev.starts_at)}</span>
<span class="also-month">{monthShort(ev.starts_at)}</span>
</div>
<div class="also-body">
<h3 class="also-title">{ev.title}</h3>
<p class="also-meta">
{[ev.duration_label, ev.audience, ev.location].filter(Boolean).join(' · ')
|| eventKindLabel(ev.kind)}
</p>
</div>
<form method="POST" class="also-action-form">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={ev.slug} />
<button type="submit" name="status" value="yes" class="also-action">
{ev.action_label ?? defaultActionLabel(ev.kind)}
</button>
</form>
</li>
))}
</ul>
</section>
)}
<!-- ── Past gatherings ─────────────────────────────────────── -->
{past.length > 0 && (
<section class="past">
<ul class="past-grid">
{past.map(ev => {
const monthCode = monthShort(ev.starts_at);
const attended = getEventRsvpCount(ev.slug).going;
const hasNotes = !!ev.notes_url;
const pigA = pigmentForId(ev.id);
const pigB = pigmentForId(ev.id + 1);
return (
<li class="past-card">
{ev.photo_url ? (
<img class="past-thumb" src={ev.photo_url} alt="" loading="lazy" />
) : hasNotes ? (
<a href={ev.notes_url!} class="past-thumb past-thumb--notes" aria-label="Read the notes">
<span class="past-thumb-month">{monthCode}</span>
</a>
) : (
<div
class="past-thumb past-thumb--gradient"
style={`background: linear-gradient(135deg, ${pigA.hex}, ${pigB.hex});`}
aria-hidden="true"
>
<span class="past-thumb-month">{monthCode}</span>
</div>
)}
<div class="past-text">
<h3 class="past-title">{ev.title}</h3>
<p class="past-meta">{fmt({ day: 'numeric', month: 'long', year: 'numeric' }, ev.starts_at)}{ev.location && ` · ${ev.location}`}</p>
<p class="past-foot label-sm">
{attended} attended · {hasNotes ? 'Notes shared' : 'No notes'}
</p>
</div>
</li>
);
})}
</ul>
<a href="/events/past" class="section-link">View all past gatherings →</a>
</section>
)}
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-10);
}
/* ── Head ─────────────────────────────────────────────────────── */
.head { max-width: 46rem; }
.head-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-3);
}
.head-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: var(--text-display-md);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--on-surface);
margin: 0;
}
.head-sub {
color: var(--on-surface-variant);
margin-top: var(--space-3);
max-width: 32rem;
}
/* ── Hero ─────────────────────────────────────────────────────── */
.hero {
background: var(--ink);
color: var(--ink-text);
border-radius: var(--radius-lg);
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.hero-top {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--ink-muted);
}
.hero-eyebrow {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.hero-body {
display: grid;
grid-template-columns: 100px 1fr;
gap: var(--space-6);
padding: var(--space-4) 0;
border-left: 0 solid transparent;
position: relative;
}
.hero-body::after {
content: '';
position: absolute;
left: 100px;
top: 0; bottom: 0;
width: 0.5px;
background: rgba(232, 224, 208, 0.2);
}
.hero-date { display: flex; flex-direction: column; gap: 2px; }
.hero-weekday, .hero-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--ink-text);
}
.hero-day {
font-family: var(--font-serif);
font-size: 2.75rem;
line-height: 1;
color: var(--ink-text);
}
.hero-detail { padding-left: var(--space-5); }
.hero-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.75rem;
line-height: 1.2;
color: var(--ink-text);
margin: 0 0 var(--space-3);
}
.hero-desc {
color: rgba(232, 224, 208, 0.85);
margin: 0 0 var(--space-3);
max-width: 40rem;
}
.hero-meta {
color: var(--ink-muted);
font-size: var(--text-body-sm);
margin: 0;
}
.hero-foot {
border-top: 0.5px solid rgba(232, 224, 208, 0.2);
padding-top: var(--space-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
.hero-foot-stat {
color: var(--ink-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
.hero-cta {
background: var(--ink-text);
color: var(--ink);
border: none;
padding: 10px 20px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.hero-cta:hover { opacity: 0.85; }
.hero-confirmed {
color: var(--ink-text);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 10px 16px;
border: 0.5px solid rgba(232, 224, 208, 0.4);
border-radius: 999px;
}
.hero-change {
background: transparent;
border: none;
color: var(--ink-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
text-decoration: underline;
}
.hero--empty {
align-items: stretch;
justify-content: center;
text-align: center;
min-height: 200px;
display: flex;
}
.hero-empty-line {
color: var(--ink-text);
font-family: var(--font-serif);
font-size: 1.25rem;
margin: auto;
max-width: 32rem;
}
/* ── Section eyebrow shared ──────────────────────────────────── */
.section-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-4);
}
/* ── Also coming up ──────────────────────────────────────────── */
.also-list { list-style: none; padding: 0; margin: 0; }
.also-row {
display: grid;
grid-template-columns: 70px 1fr 110px;
gap: var(--space-4);
align-items: center;
padding: var(--space-4) 0;
border-bottom: 0.5px solid var(--surface-card-border);
}
.also-row:last-child { border-bottom: none; }
.also-date {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.also-day {
font-family: var(--font-serif);
font-size: 1.5rem;
line-height: 1;
color: var(--on-surface);
}
.also-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin-top: 4px;
}
.also-body { display: flex; flex-direction: column; gap: 4px; }
.also-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.0625rem;
color: var(--on-surface);
margin: 0;
}
.also-meta { font-size: 0.75rem; color: var(--on-surface-variant); margin: 0; }
.also-action-form { justify-self: end; }
.also-action {
background: none;
border: none;
color: var(--pigment-terracotta);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
padding: 0;
}
.also-action:hover { opacity: 0.85; }
/* ── Past gatherings ─────────────────────────────────────────── */
.past-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-3);
}
.past-all {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
}
.past-all:hover { opacity: 0.85; border-bottom: none; }
.past-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5);
}
.past-card {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-4);
align-items: start;
}
.past-thumb {
width: 56px;
height: 56px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
text-decoration: none;
border: none;
}
.past-thumb--notes {
background: color-mix(in oklab, var(--pigment-copper) 18%, transparent);
color: var(--pigment-copper);
}
.past-thumb-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
font-weight: 600;
color: rgba(250, 246, 238, 0.6);
}
.past-thumb--notes .past-thumb-month { color: var(--pigment-copper); }
.past-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.past-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.0625rem;
color: var(--on-surface);
margin: 0;
}
.past-meta { font-size: 0.75rem; color: var(--on-surface-variant); margin: 0; }
.past-foot {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
margin: 0;
}
@media (max-width: 720px) {
.hero-body { grid-template-columns: 1fr; }
.hero-body::after { display: none; }
.past-grid { grid-template-columns: 1fr; }
.also-row { grid-template-columns: 60px 1fr; }
.also-action-form { grid-column: 1 / -1; justify-self: start; padding-top: var(--space-2); }
}
</style>

159
src/pages/events/past.astro Normal file
View file

@ -0,0 +1,159 @@
---
import AppLayout from '../../layouts/AppLayout.astro';
import { getPastEvents, getEventRsvpCount } from '../../lib/db';
import { pigmentForId } from '../../lib/format';
const user = Astro.locals.user;
const past = getPastEvents(500);
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
}
---
<AppLayout title="Past gatherings" user={user}>
<div class="page">
<header class="head">
<h1 class="head-title">The archive.</h1>
<p class="head-sub">Everything the council has gathered around so far.</p>
<a href="/events" class="section-link back-link">← Back to upcoming</a>
</header>
{past.length === 0 ? (
<p class="body-md empty">No past events yet.</p>
) : (
<ul class="past-list">
{past.map(ev => {
const monthCode = fmt({ month: 'short' }, ev.starts_at).toUpperCase();
const attended = getEventRsvpCount(ev.slug).going;
const hasNotes = !!ev.notes_url;
const pigA = pigmentForId(ev.id);
const pigB = pigmentForId(ev.id + 1);
return (
<li class="past-card">
{ev.photo_url ? (
<img class="past-thumb" src={ev.photo_url} alt="" loading="lazy" />
) : hasNotes ? (
<a href={ev.notes_url!} class="past-thumb past-thumb--notes" aria-label="Read the notes">
<span class="past-thumb-month">{monthCode}</span>
</a>
) : (
<div
class="past-thumb past-thumb--gradient"
style={`background: linear-gradient(135deg, ${pigA.hex}, ${pigB.hex});`}
aria-hidden="true"
>
<span class="past-thumb-month">{monthCode}</span>
</div>
)}
<div class="past-text">
<h3 class="past-title">{ev.title}</h3>
<p class="past-meta">
{fmt({ day: 'numeric', month: 'long', year: 'numeric' }, ev.starts_at)}
{ev.location && ` · ${ev.location}`}
</p>
<p class="past-foot label-sm">
{attended} attended · {hasNotes ? 'Notes shared' : 'No notes'}
</p>
</div>
</li>
);
})}
</ul>
)}
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.head { max-width: 46rem; }
.head-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-3);
}
.head-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: var(--text-display-md);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--on-surface);
margin: 0;
}
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
.back-link { margin-top: var(--space-4); }
.empty { color: var(--on-surface-muted); }
.past-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5);
}
.past-card {
display: grid;
grid-template-columns: 56px 1fr;
gap: var(--space-4);
align-items: start;
}
.past-thumb {
width: 56px;
height: 56px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
text-decoration: none;
border: none;
}
.past-thumb--notes {
background: color-mix(in oklab, var(--pigment-copper) 18%, transparent);
color: var(--pigment-copper);
}
.past-thumb-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
font-weight: 600;
color: rgba(250, 246, 238, 0.6);
}
.past-thumb--notes .past-thumb-month { color: var(--pigment-copper); }
.past-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.past-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.0625rem;
color: var(--on-surface);
margin: 0;
}
.past-meta { font-size: 0.75rem; color: var(--on-surface-variant); margin: 0; }
.past-foot {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
margin: 0;
}
@media (max-width: 720px) {
.past-list { grid-template-columns: 1fr; }
}
</style>

View file

@ -19,8 +19,7 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
<div class="page"> <div class="page">
<header class="members-head"> <header class="members-head">
<p class="label-sm members-eyebrow">Members</p> <h1 class="members-title">The council.</h1>
<h1 class="members-title"><em>The council.</em></h1>
<p class="members-sub"> <p class="members-sub">
An invited circle of operators shaping what Project Bifrost becomes. An invited circle of operators shaping what Project Bifrost becomes.
</p> </p>
@ -82,13 +81,6 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
.members-head { margin-bottom: var(--space-6); max-width: 46rem; } .members-head { margin-bottom: var(--space-6); max-width: 46rem; }
.members-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin-bottom: var(--space-3);
}
.members-title { .members-title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 400; font-weight: 400;
@ -98,7 +90,6 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
color: var(--on-surface); color: var(--on-surface);
margin: 0; margin: 0;
} }
.members-title em { font-style: italic; }
.members-sub { .members-sub {
color: var(--on-surface-variant); color: var(--on-surface-variant);
@ -140,7 +131,6 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
.m-name { .m-name {
font-family: var(--font-serif); font-family: var(--font-serif);
font-style: italic;
font-weight: 400; font-weight: 400;
font-size: 1.375rem; font-size: 1.375rem;
line-height: 1.2; line-height: 1.2;
@ -174,8 +164,7 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
padding-left: 12px; padding-left: 12px;
border-left: 2px solid color-mix(in oklab, var(--row-pigment) 40%, transparent); border-left: 2px solid color-mix(in oklab, var(--row-pigment) 40%, transparent);
font-family: var(--font-serif); font-family: var(--font-serif);
font-style: italic; font-size: 0.9375rem;
font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
color: var(--on-surface); color: var(--on-surface);
max-width: 38rem; max-width: 38rem;

View file

@ -1,31 +1,32 @@
--- ---
import AppLayout from '../layouts/AppLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
import MembershipCard from '../components/MembershipCard.astro'; import Avatar from '../components/Avatar.astro';
import DispatchesSection from '../components/DispatchesSection.astro'; import AvatarPile from '../components/AvatarPile.astro';
import RecentlyFromTheCouncil from '../components/RecentlyFromTheCouncil.astro';
import { import {
getOpenPulse, getPulseWithCounts, castVote, recordActivity, getUpcomingEvents, getEventBySlug, getEventAttendees,
getPulseById, getAllRoadmapItems, getUpcomingEvents, getUserRsvp, setEventRsvp, recordActivity,
countCabMembers, getUserVote, getAllRoadmapItems, getLatestPublishedDispatches, getAllCabMembers,
} from '../lib/db'; } from '../lib/db';
import { pulseDateLabel, timeOfDay, tenureSince, voteCountSentence } from '../lib/format'; import {
pulseDateLabel, timeOfDay, tenureSince, relativeTime,
eventKindLabel,
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
} from '../lib/format';
const user = Astro.locals.user; const user = Astro.locals.user;
// ── POST: cast vote ──────────────────────────────────────────────── // ── POST: RSVP from the hero card ──────────────────────────────────
if (Astro.request.method === 'POST') { if (Astro.request.method === 'POST') {
const data = await Astro.request.formData(); const data = await Astro.request.formData();
const action = String(data.get('action') ?? ''); const action = String(data.get('action') ?? '');
if (action === 'vote') { if (action === 'rsvp') {
const pulseId = Number(data.get('pulse_id')); const slug = String(data.get('event_slug') ?? '');
const optionIndex = Number(data.get('option_index')); const status = String(data.get('status') ?? '') as 'yes' | 'no' | 'interested';
const target = getPulseById(pulseId); if (slug && ['yes', 'no', 'interested'].includes(status)) {
if (target && target.status === 'open' && Number.isInteger(optionIndex) const ev = getEventBySlug(slug);
&& optionIndex >= 0 && optionIndex < target.options.length) { if (ev) {
const existing = getUserVote(pulseId, user.id); setEventRsvp(user.id, slug, status);
if (existing === null) { recordActivity(user.id, 'rsvped', 'event', ev.id);
castVote(pulseId, user.id, optionIndex);
recordActivity(user.id, 'voted', 'pulse', pulseId);
} }
} }
return Astro.redirect('/pulse'); return Astro.redirect('/pulse');
@ -35,40 +36,39 @@ if (Astro.request.method === 'POST') {
// ── Greeting ─────────────────────────────────────────────────────── // ── Greeting ───────────────────────────────────────────────────────
const firstName = user.name.split(' ')[0]; const firstName = user.name.split(' ')[0];
const greeting = `Good ${timeOfDay()}, ${firstName}.`; const greeting = `Good ${timeOfDay()}, ${firstName}.`;
const dateLabel = pulseDateLabel(); // (date label dropped per the v3 eyebrow-removal pass; tenure line stays inline)
const tenureAnchor = user.role === 'cab' && user.cab_joined_date const tenureAnchor = user.role === 'cab' && user.cab_joined_date
? user.cab_joined_date ? user.cab_joined_date
: user.created_at; : user.created_at;
const tenure = tenureSince(tenureAnchor); const tenure = tenureSince(tenureAnchor);
// ── This week's Pulse ────────────────────────────────────────────── // ── Events ─────────────────────────────────────────────────────────
const openPulseRaw = getOpenPulse(); const upcoming = getUpcomingEvents(20);
const totalMembers = countCabMembers(); const hero = upcoming.find(e => e.kind !== 'office_hours') ?? upcoming[0] ?? null;
const openPulse = openPulseRaw ? getPulseWithCounts(openPulseRaw.id, user.id) : null; const comingUp = upcoming.filter(e => e.id !== hero?.id).slice(0, 4);
// Time-left label: "32 seconds" / "3 hours" / "2 days" — soft countdown function parseUtc(s: string): Date {
function timeLeftLabel(closesAt: string): string { if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
const ms = new Date(closesAt).getTime() - Date.now(); return new Date(s.replace(' ', 'T') + 'Z');
if (ms <= 0) return 'closing now';
const d = Math.floor(ms / 86400000);
if (d >= 1) return `${d} day${d === 1 ? '' : 's'}`;
const h = Math.floor(ms / 3600000);
if (h >= 1) return `${h} hour${h === 1 ? '' : 's'}`;
const m = Math.floor(ms / 60000);
if (m >= 1) return `${m} minute${m === 1 ? '' : 's'}`;
const s = Math.floor(ms / 1000);
return `${s} seconds`;
} }
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
function closeDayLabel(closesAt: string): string { return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
const d = new Date(closesAt);
return new Intl.DateTimeFormat('en-GB', {
weekday: 'long', timeZone: 'Europe/Copenhagen',
}).format(d);
} }
const dayNum = (iso: string) => fmt({ day: 'numeric' }, iso);
const weekday = (iso: string) => fmt({ weekday: 'short' }, iso).toUpperCase();
const monthShort = (iso: string) => fmt({ month: 'short' }, iso).toUpperCase();
const timeStr = (iso: string) => fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso);
// ── Roadmap preview (3 most-recently-updated items) ──────────────── const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
const heroConfirmedCount = heroAttendees.length;
const heroMyRsvp = hero ? getUserRsvp(user.id, hero.slug) : null;
// ── Latest from Fenja ──────────────────────────────────────────────
const [latestDispatch] = getLatestPublishedDispatches(1);
const latestPreview = latestDispatch ? dispatchLongPreview(latestDispatch, 520) : '';
// ── Roadmap preview (3 most-recently-updated items, horizontal) ────
const roadmapPreview = getAllRoadmapItems() const roadmapPreview = getAllRoadmapItems()
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1)) .sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1))
.slice(0, 3); .slice(0, 3);
@ -80,7 +80,7 @@ function roadmapStatusDot(status: 'shipping' | 'beta' | 'exploring'): string {
exploring: 'var(--on-surface-muted)', exploring: 'var(--on-surface-muted)',
})[status]; })[status];
} }
function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; target: string | null; attributed: unknown[] }): string { function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; target: string | null }): string {
const target = item.target ? ` · ${item.target}` : ''; const target = item.target ? ` · ${item.target}` : '';
switch (item.status) { switch (item.status) {
case 'shipping': return `Shipping${target}`; case 'shipping': return `Shipping${target}`;
@ -89,149 +89,151 @@ function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; t
} }
} }
// ── Events row ───────────────────────────────────────────────────── // ── Council members ─────────────────────────────────────────────────
const upcoming = getUpcomingEvents(20); const members = getAllCabMembers();
const nextExclusive = upcoming.find(e => e.kind === 'dinner' || e.kind === 'summit') ?? null;
const nextOfficeHours = upcoming.find(e => e.kind === 'office_hours') ?? null;
function formatEventDate(iso: string): string {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen',
}).format(new Date(iso)).toUpperCase();
}
--- ---
<AppLayout title="Pulse" user={user}> <AppLayout title="Pulse" user={user}>
<div class="page"> <div class="page">
<!-- ── Greeting ─────────────────────────────────────────────── --> <!-- ── Greeting ─────────────────────────────────────────────── -->
<section class="cascade greeting"> <section class="cascade greeting">
<p class="label-sm date-label">{dateLabel}</p> <h1 class="greeting-line">{greeting}</h1>
<h1 class="greeting-line">
<span class="greeting-italic">{greeting}</span>
</h1>
<p class="greeting-sub body-md"> <p class="greeting-sub body-md">
You've been a member for <em>{tenure}</em>. The team is reading every note you leave. You've been a member for {tenure}. The team is reading every note you leave.
</p> </p>
</section> </section>
<!-- ── This week's Pulse ────────────────────────────────────── --> <!-- ── Events (--ink card with hero + bundled coming-up + see all) -->
<section class="cascade pulse-card"> {hero ? (
{openPulse ? ( <section class="cascade events-card" aria-label="Events">
<>
<div class="pulse-meta"> <!-- Hero -->
<span class="live-dot" aria-hidden="true"></span> <div class="hero-body">
<span class="label-sm pulse-label">This week's pulse · closes in {timeLeftLabel(openPulse.closes_at)}</span> <div class="hero-date">
<span class="hero-weekday">{weekday(hero.starts_at)}</span>
<span class="hero-day">{dayNum(hero.starts_at)}</span>
<span class="hero-month">{monthShort(hero.starts_at)}</span>
</div> </div>
<p class="pulse-question">{openPulse.question}</p>
{openPulse.context && <p class="pulse-context body-md">{openPulse.context}</p>}
<form method="POST" class="pulse-options" novalidate> <div class="hero-detail">
<input type="hidden" name="action" value="vote" /> <h2 class="hero-title">{hero.title}</h2>
<input type="hidden" name="pulse_id" value={openPulse.id} /> <p class="hero-desc">{hero.description}</p>
{openPulse.options.map((opt, i) => { <p class="hero-meta">
const chosen = openPulse.my_vote === i; {hero.location}{hero.location && ' · '}{timeStr(hero.starts_at)}{hero.duration_label ? ` · ${hero.duration_label}` : ''}
const count = openPulse.votes_by_option[i] ?? 0;
const pct = openPulse.votes_total > 0 ? (count / openPulse.votes_total) * 100 : 0;
const locked = openPulse.my_vote !== null;
const letter = String.fromCharCode(65 + i); // A/B/C/D
return (
<button
type="submit"
name="option_index"
value={i}
class:list={['pulse-option', { chosen, locked }]}
disabled={locked && !chosen}
aria-pressed={chosen}
>
<span class="pulse-option-letter label-sm">{letter}</span>
<span class="pulse-option-text">{opt}</span>
{locked && (
<span class="pulse-option-bar" aria-hidden="true">
<span class="pulse-option-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
</span>
)}
</button>
);
})}
</form>
<p class="pulse-count body-sm">
{voteCountSentence(openPulse.votes_total, totalMembers)} Closes {closeDayLabel(openPulse.closes_at)}.
</p> </p>
</div>
</div>
<footer class="hero-foot">
<div class="hero-foot-left">
<span class="hero-foot-stat">
{hero.capacity ? `${hero.capacity} seats · ` : ''}{heroConfirmedCount} confirmed
</span>
{heroAttendees.length > 0 && (
<AvatarPile users={heroAttendees} max={5} size={22} borderColor="var(--ink)" />
)}
</div>
<form method="POST" class="hero-foot-right">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={hero.slug} />
{heroMyRsvp === 'yes' ? (
<>
<span class="hero-confirmed">You're confirmed ✓</span>
<button type="submit" name="status" value="no" class="hero-change">Change</button>
</> </>
) : ( ) : (
<div class="pulse-empty"> <button type="submit" name="status" value="yes" class="hero-cta">Save your seat →</button>
<span class="label-sm pulse-label-muted">This week's pulse</span>
<p class="pulse-empty-line">No pulse is open right now. The next one drops soon.</p>
</div>
)} )}
</section> </form>
</footer>
<!-- ── Roadmap preview + Council mark ───────────────────────── --> <!-- Bundled coming-up sub-cards (no RSVP buttons) -->
<section class="cascade preview-row"> {comingUp.length > 0 && (
<div class="roadmap-preview"> <ul class="coming-up-grid">
<p class="label-sm section-eyebrow">From the roadmap</p> {comingUp.map(ev => (
{roadmapPreview.length === 0 ? ( <li class="coming-up-card">
<p class="body-sm muted">No roadmap items yet.</p> <div class="cu-date">
) : ( <span class="cu-day">{dayNum(ev.starts_at)}</span>
<ul class="roadmap-list"> <span class="cu-month">{monthShort(ev.starts_at)}</span>
{roadmapPreview.map(item => ( </div>
<li class="roadmap-row"> <div class="cu-body">
<span <h3 class="cu-title">{ev.title}</h3>
class:list={['status-dot', { breathing: item.status === 'shipping' }]} <p class="cu-meta">{[ev.duration_label, ev.audience, eventKindLabel(ev.kind)].filter(Boolean).join(' · ')}</p>
style={`background:${roadmapStatusDot(item.status)}`}
aria-hidden="true"
></span>
<div class="roadmap-row-text">
<p class="roadmap-row-title">{item.title}</p>
<p class="roadmap-row-blurb label-sm">{roadmapStatusBlurb(item)}</p>
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
)} )}
<a href="/roadmap" class="see-all label-sm">See the full roadmap →</a>
<a href="/events" class="section-link section-link--ink hero-see-all">See all events →</a>
</section>
) : (
<section class="cascade events-card events-card--empty">
<p class="events-empty-line">Nothing scheduled yet — when we have something, you'll be the first to know.</p>
<a href="/events" class="section-link section-link--ink">See all events →</a>
</section>
)}
<!-- ── Latest from Fenja (unboxed, longer excerpt) ──────────── -->
{latestDispatch && (
<article class="cascade latest-article">
<header class="latest-byline">
<Avatar id={latestDispatch.author_id} name={latestDispatch.author_name} size={28} />
<span class="latest-byline-name">{latestDispatch.author_name}</span>
{latestDispatch.author_title && <span class="latest-byline-title">· {latestDispatch.author_title}</span>}
<span class="latest-byline-time">{relativeTime(latestDispatch.published_at ?? latestDispatch.created_at)}</span>
<span class="latest-kind-pill" style={`--pill: ${dispatchKindPigment(latestDispatch.kind)}`}>
{dispatchKindLabel(latestDispatch.kind)}
</span>
</header>
<h2 class="latest-title">{latestDispatch.title}</h2>
<p class="latest-body">{latestPreview}</p>
<a href={`/dispatches/${dispatchSlug(latestDispatch)}`} class="section-link">Read the full dispatch →</a>
</article>
)}
<!-- ── Roadmap — horizontal cards ───────────────────────────── -->
{roadmapPreview.length > 0 && (
<section class="cascade roadmap-section" aria-label="From the roadmap">
<ul class="roadmap-grid">
{roadmapPreview.map(item => (
<li class="roadmap-card">
<span
class:list={['status-dot', { breathing: item.status === 'shipping' }]}
style={`background:${roadmapStatusDot(item.status)}`}
aria-hidden="true"
></span>
<div class="roadmap-card-text">
<h3 class="roadmap-card-title">{item.title}</h3>
<p class="roadmap-card-blurb">{roadmapStatusBlurb(item)}</p>
</div> </div>
</li>
<aside class="membership-slot"> ))}
<MembershipCard member={user} /> </ul>
</aside> <a href="/roadmap" class="section-link">See the full roadmap →</a>
</section> </section>
<!-- ── Dispatches ───────────────────────────────────────────── -->
<section class="cascade">
<DispatchesSection limit={4} />
</section>
<!-- ── Recently from the council ────────────────────────────── -->
<section class="cascade">
<RecentlyFromTheCouncil />
</section>
<!-- ── Event row ────────────────────────────────────────────── -->
{(nextExclusive || nextOfficeHours) && (
<section class="cascade event-row">
{nextExclusive && (
<article class="event-card event-card--dark">
<p class="label-sm event-eyebrow event-eyebrow--light">
Members only · {formatEventDate(nextExclusive.starts_at)}
</p>
<h3 class="event-title">{nextExclusive.title}</h3>
<p class="event-desc">{nextExclusive.description}</p>
{nextExclusive.capacity && (
<p class="event-scarcity label-sm">{nextExclusive.capacity} seats · invitation by hand</p>
)}
</article>
)}
{nextOfficeHours && (
<article class="event-card event-card--light">
<p class="label-sm event-eyebrow">
Office hours · {formatEventDate(nextOfficeHours.starts_at)}
</p>
<h3 class="event-title">{nextOfficeHours.title}</h3>
<p class="event-desc">{nextOfficeHours.description}</p>
</article>
)} )}
<!-- ── Council members — larger cards with company ──────────── -->
{members.length > 0 && (
<section class="cascade council-section" aria-label="The council">
<ul class="council-grid">
{members.map(m => (
<li class="council-card">
<Avatar id={m.id} name={m.name} size={56} />
<div class="council-card-text">
<span class="council-card-name">{m.name}</span>
{m.title && <span class="council-card-title">{m.title}</span>}
<span class="council-card-org">{m.organisation}</span>
</div>
</li>
))}
</ul>
<a href="/members" class="section-link">See who our council is made up of →</a>
</section> </section>
)} )}
@ -259,23 +261,13 @@ function formatEventDate(iso: string): string {
.cascade:nth-child(3) { animation-delay: 200ms; } .cascade:nth-child(3) { animation-delay: 200ms; }
.cascade:nth-child(4) { animation-delay: 300ms; } .cascade:nth-child(4) { animation-delay: 300ms; }
.cascade:nth-child(5) { animation-delay: 400ms; } .cascade:nth-child(5) { animation-delay: 400ms; }
.cascade:nth-child(6) { animation-delay: 500ms; } @keyframes cascade-in { to { opacity: 1; transform: translateY(0); } }
@keyframes cascade-in {
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.cascade { opacity: 1; transform: none; animation: none; } .cascade { opacity: 1; transform: none; animation: none; }
} }
/* ── Greeting ─────────────────────────────────────────────────── */ /* ── Greeting ─────────────────────────────────────────────────── */
.greeting { display: flex; flex-direction: column; gap: var(--space-3); } .greeting { display: flex; flex-direction: column; gap: var(--space-3); }
.date-label {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.greeting-line { .greeting-line {
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 400; font-weight: 400;
@ -285,288 +277,347 @@ function formatEventDate(iso: string): string {
color: var(--on-surface); color: var(--on-surface);
margin: 0; margin: 0;
} }
.greeting-italic { font-style: italic; }
.greeting-sub { .greeting-sub {
color: var(--on-surface-variant); color: var(--on-surface-variant);
max-width: 48rem; max-width: 48rem;
margin: 0; margin: 0;
} }
.greeting-sub em { font-style: italic; color: var(--on-surface); }
/* ── Pulse card ───────────────────────────────────────────────── */ /* ── Events card (--ink) ──────────────────────────────────────── */
.pulse-card { .events-card {
background: var(--surface-card); background: var(--ink);
border: 0.5px solid var(--surface-card-border); color: var(--ink-text);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: var(--space-7) var(--space-8); padding: var(--space-7) var(--space-8);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-4);
}
.pulse-meta {
display: flex;
align-items: center;
gap: var(--space-3);
}
.live-dot {
width: 8px;
height: 8px;
background: var(--pigment-terracotta);
border-radius: 50%;
animation: breathe 2.4s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.5; }
}
.pulse-label {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
font-weight: 500;
}
.pulse-label-muted {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.pulse-question {
font-family: var(--font-serif);
font-style: italic;
font-size: 1.375rem;
line-height: var(--leading-snug);
color: var(--on-surface);
margin: 0;
max-width: 50rem;
}
.pulse-context {
color: var(--on-surface-variant);
margin: 0;
max-width: 50rem;
}
.pulse-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
margin-top: var(--space-2);
}
.pulse-option {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: var(--background);
border: var(--ghost-border);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-body-md);
color: var(--on-surface);
text-align: left;
cursor: pointer;
transition: transform 300ms var(--ease-standard),
border-color 300ms var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
overflow: hidden;
}
.pulse-option:hover:not(.locked) {
transform: translateY(-2px);
border-color: var(--outline);
}
.pulse-option.chosen {
border-color: var(--pigment-terracotta);
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
}
.pulse-option.locked:not(.chosen) {
cursor: default;
color: var(--on-surface-variant);
}
.pulse-option:disabled { opacity: 0.8; }
.pulse-option-letter {
font-weight: 600;
color: var(--on-surface-muted);
flex-shrink: 0;
}
.pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); }
.pulse-option-text { flex: 1; }
.pulse-option-bar {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: var(--surface-container);
}
.pulse-option-bar-fill {
display: block;
height: 100%;
background: var(--pigment-terracotta);
opacity: 0.6;
transition: width 600ms var(--ease-standard);
}
.pulse-count {
color: var(--on-surface-variant);
margin: 0;
}
.pulse-count strong { color: var(--on-surface); font-weight: 600; }
.pulse-empty {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.pulse-empty-line {
font-family: var(--font-serif);
font-style: italic;
font-size: 1.25rem;
color: var(--on-surface-variant);
margin: 0;
}
/* ── Roadmap preview + Membership card ──────────────────────── */
.preview-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-6); gap: var(--space-6);
align-items: stretch; }
.events-card--empty {
align-items: flex-start;
text-align: left;
min-height: 160px;
justify-content: space-between;
}
.events-empty-line {
color: var(--ink-text);
font-family: var(--font-serif);
font-size: 1.25rem;
margin: 0;
max-width: 32rem;
opacity: 0.92;
} }
.section-eyebrow { /* Hero (lighter, fewer italics) */
.hero-body {
display: grid;
grid-template-columns: 110px 1fr;
gap: var(--space-7);
position: relative;
}
.hero-body::after {
content: '';
position: absolute;
left: 110px;
top: 0; bottom: 0;
width: 0.5px;
background: rgba(232, 224, 208, 0.18);
}
.hero-date { display: flex; flex-direction: column; gap: 4px; }
.hero-weekday, .hero-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-variant); color: rgba(232, 224, 208, 0.75);
margin-bottom: var(--space-4); }
.hero-day {
font-family: var(--font-serif);
font-weight: 400;
font-size: 2.75rem;
line-height: 1;
color: var(--ink-text);
}
.hero-detail { padding-left: var(--space-6); }
.hero-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.75rem;
line-height: 1.2;
color: var(--ink-text);
margin: 0 0 var(--space-3);
}
.hero-desc {
color: rgba(232, 224, 208, 0.92);
margin: 0 0 var(--space-3);
max-width: 50rem;
}
.hero-meta {
color: rgba(232, 224, 208, 0.7);
font-size: var(--text-body-sm);
margin: 0;
} }
.roadmap-preview { /* Hero foot */
.hero-foot {
border-top: 0.5px solid rgba(232, 224, 208, 0.18);
padding-top: var(--space-4);
display: flex; display: flex;
flex-direction: column; justify-content: space-between;
gap: var(--space-3); align-items: center;
background: var(--surface-card); gap: var(--space-4);
border: 0.5px solid var(--surface-card-border); flex-wrap: wrap;
border-radius: var(--radius-lg); }
padding: var(--space-6); .hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
.hero-foot-stat {
color: rgba(232, 224, 208, 0.7);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
.hero-cta {
background: var(--ink-text);
color: var(--ink);
border: none;
padding: 10px 20px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.hero-cta:hover { opacity: 0.85; }
.hero-confirmed {
color: var(--ink-text);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 10px 16px;
border: 0.5px solid rgba(232, 224, 208, 0.4);
border-radius: 999px;
}
.hero-change {
background: transparent;
border: none;
color: rgba(232, 224, 208, 0.75);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
text-decoration: underline;
} }
.membership-slot { display: flex; } /* Bundled coming-up sub-cards (no RSVP buttons) */
.membership-slot > * { flex: 1; } .coming-up-grid {
.roadmap-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-3);
}
.coming-up-card {
background: rgba(232, 224, 208, 0.06);
border: 0.5px solid rgba(232, 224, 208, 0.14);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5);
display: flex;
gap: var(--space-4);
align-items: flex-start;
}
.cu-date { display: flex; flex-direction: column; gap: 2px; min-width: 36px; }
.cu-day {
font-family: var(--font-serif);
font-size: 1.5rem;
line-height: 1;
color: var(--ink-text);
}
.cu-month {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: rgba(232, 224, 208, 0.7);
}
.cu-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.cu-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1rem;
line-height: 1.25;
color: var(--ink-text);
margin: 0;
}
.cu-meta {
font-size: 0.75rem;
color: rgba(232, 224, 208, 0.65);
margin: 0;
}
.hero-see-all { align-self: flex-start; }
/* ── Latest from Fenja (unboxed) ──────────────────────────────── */
.latest-article {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: var(--space-3);
max-width: 56rem;
} }
.latest-byline {
.roadmap-row {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: var(--space-4); gap: var(--space-2);
padding: var(--space-4) 0; flex-wrap: wrap;
border-top: var(--ghost-border); font-size: var(--text-body-sm);
}
.latest-byline-name { font-weight: 600; color: var(--on-surface); }
.latest-byline-title { color: var(--on-surface-variant); }
.latest-byline-time {
color: var(--on-surface-muted);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
margin-left: auto;
}
.latest-kind-pill {
background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill);
padding: 2px 9px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.latest-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.625rem;
line-height: 1.25;
color: var(--on-surface);
margin: 0;
letter-spacing: var(--tracking-snug);
}
.latest-body {
color: var(--on-surface);
line-height: var(--leading-relaxed);
margin: 0;
font-size: var(--text-body-lg);
} }
.roadmap-row:last-child { border-bottom: var(--ghost-border); }
/* ── Roadmap horizontal cards ─────────────────────────────────── */
.roadmap-section {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.roadmap-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
.roadmap-card {
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
min-height: 130px;
}
.status-dot { .status-dot {
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
margin-top: 0.4em; }
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.5; }
} }
.status-dot.breathing { animation: breathe 2.4s ease-in-out infinite; } .status-dot.breathing { animation: breathe 2.4s ease-in-out infinite; }
.roadmap-card-text { display: flex; flex-direction: column; gap: var(--space-1); }
.roadmap-row-text { flex: 1; display: flex; flex-direction: column; gap: var(--space-1); } .roadmap-card-title {
.roadmap-row-title { margin: 0; font-weight: 500; color: var(--on-surface); } font-family: var(--font-serif);
.roadmap-row-blurb { font-weight: 400;
font-size: 1.0625rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.roadmap-card-blurb {
color: var(--on-surface-muted); color: var(--on-surface-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase; text-transform: uppercase;
margin: 0; margin: 0;
} }
.see-all { /* ── Council cards — larger, with company ─────────────────────── */
color: var(--on-surface-variant); .council-section {
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
margin-top: var(--space-3);
align-self: flex-start;
transition: color var(--duration-fast) var(--ease-standard);
}
.see-all:hover { color: var(--on-surface); border-bottom: none; }
/* ── Events row ──────────────────────────────────────────────── */
.event-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
}
.event-card {
padding: var(--space-8);
border-radius: var(--radius-md);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-5);
transition: transform 300ms var(--ease-standard);
} }
.event-card:hover { transform: translateY(-2px); } .council-grid {
list-style: none;
.event-card--dark { padding: 0;
background: var(--ink); margin: 0;
color: var(--ink-text); display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-4);
} }
.event-card--dark .event-title, .council-card {
.event-card--dark .event-desc,
.event-card--dark .event-scarcity {
color: var(--ink-text);
}
.event-card--light {
background: var(--surface-card); background: var(--surface-card);
border: 0.5px solid var(--surface-card-border); border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
padding: var(--space-6) var(--space-6) var(--space-7);
display: flex;
align-items: center;
gap: var(--space-5);
} }
.council-card-text {
.event-eyebrow { display: flex;
letter-spacing: var(--tracking-wider); flex-direction: column;
text-transform: uppercase; gap: 4px;
color: var(--on-surface-muted); min-width: 0;
} }
.event-eyebrow--light { color: var(--ink-muted); } .council-card-name {
.event-title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 1.5rem; font-weight: 400;
line-height: var(--leading-snug); font-size: 1.125rem;
margin: 0; line-height: 1.2;
color: var(--on-surface);
} }
.council-card-title {
.event-desc { margin: 0; } font-family: var(--font-sans);
font-size: var(--text-body-sm);
.event-scarcity { color: var(--on-surface-variant);
color: var(--on-surface-muted); }
.council-card-org {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
margin: 0; color: var(--on-surface-muted);
} }
/* ── Responsive: collapse 2-col rows on narrow widths ────────── */ /* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) { @media (max-width: 880px) {
.preview-row, .event-row, .pulse-options { grid-template-columns: 1fr; } .roadmap-grid { grid-template-columns: 1fr; }
.hero-body { grid-template-columns: 1fr; }
.hero-body::after { display: none; }
.hero-detail { padding-left: 0; }
} }
</style> </style>

View file

@ -52,9 +52,8 @@ const horizonColors: Record<string, string> = {
<div class="page"> <div class="page">
<header class="page-header"> <header class="page-header">
<p class="label-sm eyebrow">Roadmap</p>
<h1 class="display-md page-title">What we are building.</h1> <h1 class="display-md page-title">What we are building.</h1>
<p class="lead subtitle"> <p class="subtitle">
Three horizons. What is in progress now, what comes next, Three horizons. What is in progress now, what comes next,
and what is further out. This is the live picture. and what is further out. This is the live picture.
</p> </p>

View file

@ -232,6 +232,31 @@ a:hover {
.ghost-border { border: var(--ghost-border); } .ghost-border { border: var(--ghost-border); }
.ghost-border-bottom { border-bottom: var(--ghost-border); } .ghost-border-bottom { border-bottom: var(--ghost-border); }
/* --- Section link prominent italic serif, placed at the bottom of
its respective box or article. See points 8 + 10 in the v3 spec:
italics are reserved for links + the Bifrost wordmark. --- */
.section-link {
display: inline-block;
font-family: var(--font-serif);
font-style: italic;
font-size: var(--text-body-md);
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.section-link:hover {
border-bottom: none;
opacity: 0.78;
color: var(--pigment-terracotta);
}
.section-link--ink {
color: var(--ink-text);
}
.section-link--ink:hover {
color: var(--ink-text);
}
/* --- Focus ring --- */ /* --- Focus ring --- */
:focus-visible { :focus-visible {
outline: 2px solid var(--secondary); outline: 2px solid var(--secondary);

View file

@ -129,6 +129,6 @@
--duration-slow: 420ms; --duration-slow: 420ms;
/* --- Layout --- */ /* --- Layout --- */
--content-max: 72rem; /* 1152px */ --content-max: 83rem; /* 1328px — 15% wider than the original 72rem */
--reading-max: 42rem; /* 672px */ --reading-max: 42rem; /* 672px */
} }

43
todo.md Normal file
View file

@ -0,0 +1,43 @@
# Todo
Deferred from the Phase 2 home-page simplification (commits after `ed2c272`).
## Recently from the council (the feed)
Removed from `/pulse` in this pass. Currently no surface for member-authored
contributions on the home page.
The underlying `contributions` table, the `RecentlyFromTheCouncil` component,
and the existing `/contribute` page all remain in place — only the home-page
embed is gone.
When we revisit this, options to consider:
- Inline a compact "What members are saying" row inside the
Roadmap + Latest from Fenja card — single quote, smallest possible
treatment, doesn't repeat dispatch real-estate.
- A dedicated Voices / Council notes section much further down the page
with a stronger editorial frame.
- Surface contributions only on the dedicated /contribute page (already
exists) and rely on dispatches as the primary "what's moving" signal.
## This week's pulse voting block
Removed from `/pulse` in this pass. The `pulses`, `votes`, vote-uniqueness
constraint, admin Pulses tab, and the activity `voted` kind all remain — only
the on-page voting card is gone.
**Idea worth trying:** rather than rebuild as its own block, fold polling
into the "Latest from Fenja" stream. A dispatch can be tagged as a poll
(one of the existing kinds, or a new `poll` kind) and rendered inline with
the 24 vote options + locked state + result bars. Single editorial channel
instead of two competing "what should I read first" surfaces on the home page.
If we go that way:
- Schema: extend `dispatches` with a nullable `pulse_id` foreign key (or
embed options on the dispatch directly).
- Render: when a dispatch is poll-shaped, its excerpt block becomes the
Pulse question + options; the "Read the full dispatch" affordance can
stay for context/comments.
- Admin: a checkbox on the dispatch form ("Attach an active poll") +
inline options inputs. No need for the standalone Pulses tab once this
lands — fold it in.