feat(pulse): two-box Fenja+poll, prominent hero, single-bg council, more air

Layout (per the v4 follow-up spec):

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 10:19:00 +02:00
parent 867661ee3d
commit 9800d0a448
3 changed files with 489 additions and 103 deletions

View file

@ -143,14 +143,17 @@ const nowIso = (offsetSeconds = 0) => {
return d.toISOString().replace('T', ' ').slice(0, 19); return d.toISOString().replace('T', ' ').slice(0, 19);
}; };
// ── Pulse: open now, closes in 5 days, 2 of 4 voted ──────────────── // ── Poll attached to a dispatch (the decision) — open, 2/4 voted ──
// Polls are no longer standalone; they attach to a dispatch via pulse_id.
// We create the pulse first, capture its id, and stamp it on the dispatch
// when we INSERT it further down.
const pulseOptions = [ const pulseOptions = [
'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 decisionPulseId = 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(
@ -160,11 +163,11 @@ const pulseId = db.prepare(`
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id, nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
).lastInsertRowid; ).lastInsertRowid;
// 2 votes from cabs[0] and cabs[1] // 2 votes — Lars and Anna
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600)); .run(decisionPulseId, cabs[0].id, 1, nowIso(-2 * 3600));
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(pulseId, cabs[1].id, 1, nowIso(-30 * 60)); .run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60));
// ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ─────── // ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
const roadmap = [ const roadmap = [
@ -249,14 +252,17 @@ It is not a blog. It is the studio talking to the room — short, dated, signed.
const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all(); const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
const insertDispatch = db.prepare(` const insertDispatch = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at) INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id)
VALUES (?,?,?,?,?,'published',?,?,?) VALUES (?,?,?,?,?,'published',?,?,?,?)
`); `);
for (let i = 0; i < dispatchSeed.length; i += 1) { for (let i = 0; i < dispatchSeed.length; i += 1) {
const d = dispatchSeed[i]; const d = dispatchSeed[i];
const when = nowIso(-d.ageDays * 24 * 60 * 60); const when = nowIso(-d.ageDays * 24 * 60 * 60);
const authorId = fenjas[i % fenjas.length].id; const authorId = fenjas[i % fenjas.length].id;
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when); // Attach the decision-pulse to the decision dispatch — this is the demo
// case for polls-as-articles. Other dispatches stay poll-free.
const attachedPulse = d.kind === 'decision' ? decisionPulseId : null;
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when, attachedPulse);
} }
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past // ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
@ -329,12 +335,12 @@ 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', decisionPulseId, nowIso(-3600));
insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600)); insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600));
insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60)); insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60));
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600)); insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
console.log(' pulse #' + pulseId + ' open, 2 of 4 voted'); console.log(' pulse #' + decisionPulseId + ' open, 2 of 4 voted');
console.log(' roadmap: 1 shipping / 1 beta / 2 exploring'); console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
console.log(' contributions: 3 (most recent has 3 reactions)'); console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)'); console.log(' dispatches: 4 published (2/5/9/12 days ago)');

View file

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

View file

@ -5,20 +5,22 @@ import AvatarPile from '../components/AvatarPile.astro';
import { import {
getUpcomingEvents, getEventBySlug, getEventAttendees, getUpcomingEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity, getUserRsvp, setEventRsvp, recordActivity,
getAllRoadmapItems, getLatestPublishedDispatches, getAllCabMembers, getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
getAllCabMembers, getPulseById, getUserVote, castVote,
} from '../lib/db'; } from '../lib/db';
import { import {
pulseDateLabel, timeOfDay, tenureSince, relativeTime, timeOfDay, tenureSince, relativeTime,
eventKindLabel, eventKindLabel,
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview, dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
} from '../lib/format'; } from '../lib/format';
const user = Astro.locals.user; const user = Astro.locals.user;
// ── POST: RSVP from the hero card ────────────────────────────────── // ── POST: RSVP + Vote ──────────────────────────────────────────────
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 === 'rsvp') { if (action === 'rsvp') {
const slug = String(data.get('event_slug') ?? ''); const slug = String(data.get('event_slug') ?? '');
const status = String(data.get('status') ?? '') as 'yes' | 'no' | 'interested'; const status = String(data.get('status') ?? '') as 'yes' | 'no' | 'interested';
@ -31,12 +33,26 @@ if (Astro.request.method === 'POST') {
} }
return Astro.redirect('/pulse'); return Astro.redirect('/pulse');
} }
if (action === 'vote') {
const pulseId = Number(data.get('pulse_id'));
const optionIndex = Number(data.get('option_index'));
const target = getPulseById(pulseId);
if (target && target.status === 'open' && Number.isInteger(optionIndex)
&& optionIndex >= 0 && optionIndex < target.options.length) {
const existing = getUserVote(pulseId, user.id);
if (existing === null) {
castVote(pulseId, user.id, optionIndex);
recordActivity(user.id, 'voted', 'pulse', pulseId);
}
}
return Astro.redirect('/pulse');
}
} }
// ── Greeting ─────────────────────────────────────────────────────── // ── Greeting ───────────────────────────────────────────────────────
const firstName = user.name.split(' ')[0]; const firstName = user.name.split(' ')[0];
const greeting = `Good ${timeOfDay()}, ${firstName}.`; const greeting = `Good ${timeOfDay()}, ${firstName}.`;
// (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
@ -64,11 +80,20 @@ const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
const heroConfirmedCount = heroAttendees.length; const heroConfirmedCount = heroAttendees.length;
const heroMyRsvp = hero ? getUserRsvp(user.id, hero.slug) : null; const heroMyRsvp = hero ? getUserRsvp(user.id, hero.slug) : null;
// ── Latest from Fenja ────────────────────────────────────────────── // ── Latest from Fenja (featured + 2 minimalist below) ──────────────
const [latestDispatch] = getLatestPublishedDispatches(1); const recentDispatches = getLatestPublishedDispatches(3);
const latestPreview = latestDispatch ? dispatchLongPreview(latestDispatch, 520) : ''; const featuredSummary = recentDispatches[0] ?? null;
const featured = featuredSummary ? getDispatchWithPoll(featuredSummary.id, user.id) : null;
const moreDispatches = recentDispatches.slice(1, 3);
const featuredPreview = featured ? dispatchLongPreview(featured, 720) : '';
// ── Roadmap preview (3 most-recently-updated items, horizontal) ──── function closeDayLabel(closesAt: string): string {
return new Intl.DateTimeFormat('en-GB', {
weekday: 'long', timeZone: 'Europe/Copenhagen',
}).format(parseUtc(closesAt));
}
// ── Roadmap preview (3 most-recently-updated, 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);
@ -89,7 +114,7 @@ function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; t
} }
} }
// ── Council members ───────────────────────────────────────────────── // ── Council ─────────────────────────────────────────────────────────
const members = getAllCabMembers(); const members = getAllCabMembers();
--- ---
<AppLayout title="Pulse" user={user}> <AppLayout title="Pulse" user={user}>
@ -103,11 +128,11 @@ const members = getAllCabMembers();
</p> </p>
</section> </section>
<!-- ── Events (--ink card with hero + bundled coming-up + see all) --> <!-- ── Events (--ink card) ──────────────────────────────────── -->
{hero ? ( {hero ? (
<section class="cascade events-card" aria-label="Events"> <section class="cascade events-card" aria-label="Events">
<!-- Hero --> <!-- Hero — more air, vertically aligned columns -->
<div class="hero-body"> <div class="hero-body">
<div class="hero-date"> <div class="hero-date">
<span class="hero-weekday">{weekday(hero.starts_at)}</span> <span class="hero-weekday">{weekday(hero.starts_at)}</span>
@ -148,7 +173,7 @@ const members = getAllCabMembers();
</form> </form>
</footer> </footer>
<!-- Bundled coming-up sub-cards (no RSVP buttons) --> <!-- Bundled coming-up sub-cards (no RSVP) -->
{comingUp.length > 0 && ( {comingUp.length > 0 && (
<ul class="coming-up-grid"> <ul class="coming-up-grid">
{comingUp.map(ev => ( {comingUp.map(ev => (
@ -166,34 +191,97 @@ const members = getAllCabMembers();
</ul> </ul>
)} )}
<a href="/events" class="section-link section-link--ink hero-see-all">See all events</a> <a href="/events" class="section-link section-link--ink hero-see-all">See all events</a>
</section> </section>
) : ( ) : (
<section class="cascade events-card events-card--empty"> <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> <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> <a href="/events" class="section-link section-link--ink">See all events</a>
</section> </section>
)} )}
<!-- ── Latest from Fenja (unboxed, longer excerpt) ──────────── --> <!-- ── Latest from Fenja: 2-box article + poll, plus 2 below ── -->
{latestDispatch && ( {featured && (
<article class="cascade latest-article"> <section class="cascade fenja-section" aria-label="Latest from Fenja">
<header class="latest-byline"> <div class:list={['fenja-row', { 'fenja-row--with-poll': !!featured.poll }]}>
<Avatar id={latestDispatch.author_id} name={latestDispatch.author_name} size={28} />
<span class="latest-byline-name">{latestDispatch.author_name}</span> <article class="fenja-article-box">
{latestDispatch.author_title && <span class="latest-byline-title">· {latestDispatch.author_title}</span>} <header class="fenja-byline">
<span class="latest-byline-time">{relativeTime(latestDispatch.published_at ?? latestDispatch.created_at)}</span> <Avatar id={featured.author_id} name={featured.author_name} size={28} />
<span class="latest-kind-pill" style={`--pill: ${dispatchKindPigment(latestDispatch.kind)}`}> <span class="fenja-byline-name">{featured.author_name}</span>
{dispatchKindLabel(latestDispatch.kind)} {featured.author_title && <span class="fenja-byline-title">· {featured.author_title}</span>}
<span class="fenja-byline-time">{relativeTime(featured.published_at ?? featured.created_at)}</span>
<span class="fenja-kind-pill" style={`--pill: ${dispatchKindPigment(featured.kind)}`}>
{dispatchKindLabel(featured.kind)}
</span> </span>
</header> </header>
<h2 class="latest-title">{latestDispatch.title}</h2> <h2 class="fenja-title">{featured.title}</h2>
<p class="latest-body">{latestPreview}</p> <p class="fenja-body">{featuredPreview}</p>
<a href={`/dispatches/${dispatchSlug(latestDispatch)}`} class="section-link">Read the full dispatch →</a> <a href={`/dispatches/${dispatchSlug(featured)}`} class="section-link fenja-read">Read the full dispatch</a>
</article> </article>
{featured.poll && (
<aside class="fenja-poll-box" aria-label="Attached poll">
<p class="fenja-poll-question">{featured.poll.question}</p>
<form method="POST" class="fenja-poll-options" novalidate>
<input type="hidden" name="action" value="vote" />
<input type="hidden" name="pulse_id" value={featured.poll.id} />
{featured.poll.options.map((opt, i) => {
const chosen = featured.poll!.my_vote === i;
const count = featured.poll!.votes_by_option[i] ?? 0;
const pct = featured.poll!.votes_total > 0 ? (count / featured.poll!.votes_total) * 100 : 0;
const locked = featured.poll!.my_vote !== null || featured.poll!.status !== 'open';
const letter = String.fromCharCode(65 + i);
return (
<button
type="submit"
name="option_index"
value={i}
class:list={['fenja-poll-option', { chosen, locked }]}
disabled={locked && !chosen}
aria-pressed={chosen}
>
<span class="fenja-poll-letter">{letter}</span>
<span class="fenja-poll-text">{opt}</span>
{locked && (
<span class="fenja-poll-bar" aria-hidden="true">
<span class="fenja-poll-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
</span>
)}
</button>
);
})}
</form>
<p class="fenja-poll-count">
{featured.poll.votes_total} of {members.length} have weighed in
{featured.poll.status === 'open'
? ` · closes ${closeDayLabel(featured.poll.closes_at)}`
: ' · closed'}
</p>
</aside>
)}
</div>
{moreDispatches.length > 0 && (
<ul class="fenja-more">
{moreDispatches.map(d => (
<li class="fenja-more-row">
<a href={`/dispatches/${dispatchSlug(d)}`} class="fenja-more-link">
<span class="fenja-more-title">{d.title}</span>
<span class="fenja-more-meta">{dispatchKindLabel(d.kind)} · {relativeTime(d.published_at ?? d.created_at)}</span>
</a>
</li>
))}
</ul>
)}
<a href="/dispatches" class="section-link fenja-all">All updates from Fenja</a>
</section>
)} )}
<!-- ── Roadmap — horizontal cards ───────────────────────────── --> <!-- ── Roadmap — horizontal cards ───────────────────────────── -->
@ -214,26 +302,26 @@ const members = getAllCabMembers();
</li> </li>
))} ))}
</ul> </ul>
<a href="/roadmap" class="section-link">See the full roadmap</a> <a href="/roadmap" class="section-link">See the full roadmap</a>
</section> </section>
)} )}
<!-- ── Council members — larger cards with company ──────────── --> <!-- ── Council members — single background, no per-member boxes - -->
{members.length > 0 && ( {members.length > 0 && (
<section class="cascade council-section" aria-label="The council"> <section class="cascade council-section" aria-label="The council">
<ul class="council-grid"> <ul class="council-grid">
{members.map(m => ( {members.map(m => (
<li class="council-card"> <li class="council-cell">
<Avatar id={m.id} name={m.name} size={56} /> <Avatar id={m.id} name={m.name} size={56} />
<div class="council-card-text"> <div class="council-cell-text">
<span class="council-card-name">{m.name}</span> <span class="council-cell-name">{m.name}</span>
{m.title && <span class="council-card-title">{m.title}</span>} {m.title && <span class="council-cell-title">{m.title}</span>}
<span class="council-card-org">{m.organisation}</span> <span class="council-cell-org">{m.organisation}</span>
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
<a href="/members" class="section-link">See who our council is made up of</a> <a href="/members" class="section-link">See who our council is made up of</a>
</section> </section>
)} )}
@ -242,12 +330,12 @@ const members = getAllCabMembers();
<style> <style>
.page { .page {
padding: var(--space-12) var(--space-20) var(--space-16); padding: var(--space-12) var(--space-16) var(--space-16);
max-width: var(--content-max); max-width: var(--content-max);
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-10); gap: var(--space-12);
} }
/* ── Cascade entry (first paint only) ─────────────────────────── */ /* ── Cascade entry (first paint only) ─────────────────────────── */
@ -283,20 +371,20 @@ const members = getAllCabMembers();
margin: 0; margin: 0;
} }
/* ── Events card (--ink) ──────────────────────────────────────── */ /* ── Events card (--ink) — more air, prominent hero ───────────── */
.events-card { .events-card {
background: var(--ink); background: var(--ink);
color: var(--ink-text); color: var(--ink-text);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: var(--space-7) var(--space-8); padding: var(--space-10) var(--space-10);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-6); gap: var(--space-7);
} }
.events-card--empty { .events-card--empty {
align-items: flex-start; align-items: flex-start;
text-align: left; text-align: left;
min-height: 160px; min-height: 200px;
justify-content: space-between; justify-content: space-between;
} }
.events-empty-line { .events-empty-line {
@ -308,22 +396,28 @@ const members = getAllCabMembers();
opacity: 0.92; opacity: 0.92;
} }
/* Hero (lighter, fewer italics) */
.hero-body { .hero-body {
display: grid; display: grid;
grid-template-columns: 110px 1fr; grid-template-columns: 150px 1fr;
gap: var(--space-7); gap: var(--space-8);
align-items: center; /* vertically align date + detail */
padding: var(--space-3) 0;
position: relative; position: relative;
} }
.hero-body::after { .hero-body::after {
content: ''; content: '';
position: absolute; position: absolute;
left: 110px; left: 150px;
top: 0; bottom: 0; top: 0; bottom: 0;
width: 0.5px; width: 0.5px;
background: rgba(232, 224, 208, 0.18); background: rgba(232, 224, 208, 0.18);
} }
.hero-date { display: flex; flex-direction: column; gap: 4px; } .hero-date {
display: flex;
flex-direction: column;
gap: 6px;
padding-right: var(--space-4);
}
.hero-weekday, .hero-month { .hero-weekday, .hero-month {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
@ -334,23 +428,25 @@ const members = getAllCabMembers();
.hero-day { .hero-day {
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 400; font-weight: 400;
font-size: 2.75rem; font-size: 3.5rem;
line-height: 1; line-height: 1;
color: var(--ink-text); color: var(--ink-text);
margin: 2px 0;
} }
.hero-detail { padding-left: var(--space-6); } .hero-detail { padding-left: var(--space-7); }
.hero-title { .hero-title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 400; font-weight: 400;
font-size: 1.75rem; font-size: 2rem;
line-height: 1.2; line-height: 1.2;
color: var(--ink-text); color: var(--ink-text);
margin: 0 0 var(--space-3); margin: 0 0 var(--space-4);
} }
.hero-desc { .hero-desc {
color: rgba(232, 224, 208, 0.92); color: rgba(232, 224, 208, 0.92);
margin: 0 0 var(--space-3); margin: 0 0 var(--space-4);
max-width: 50rem; max-width: 50rem;
line-height: var(--leading-relaxed);
} }
.hero-meta { .hero-meta {
color: rgba(232, 224, 208, 0.7); color: rgba(232, 224, 208, 0.7);
@ -361,7 +457,7 @@ const members = getAllCabMembers();
/* Hero foot */ /* Hero foot */
.hero-foot { .hero-foot {
border-top: 0.5px solid rgba(232, 224, 208, 0.18); border-top: 0.5px solid rgba(232, 224, 208, 0.18);
padding-top: var(--space-4); padding-top: var(--space-5);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -415,7 +511,7 @@ const members = getAllCabMembers();
text-decoration: underline; text-decoration: underline;
} }
/* Bundled coming-up sub-cards (no RSVP buttons) */ /* Bundled coming-up sub-cards (no RSVP) */
.coming-up-grid { .coming-up-grid {
list-style: none; list-style: none;
padding: 0; padding: 0;
@ -428,7 +524,7 @@ const members = getAllCabMembers();
background: rgba(232, 224, 208, 0.06); background: rgba(232, 224, 208, 0.06);
border: 0.5px solid rgba(232, 224, 208, 0.14); border: 0.5px solid rgba(232, 224, 208, 0.14);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5); padding: var(--space-5);
display: flex; display: flex;
gap: var(--space-4); gap: var(--space-4);
align-items: flex-start; align-items: flex-start;
@ -462,31 +558,51 @@ const members = getAllCabMembers();
margin: 0; margin: 0;
} }
.hero-see-all { align-self: flex-start; } .hero-see-all { align-self: flex-start; color: var(--ink-text); }
.section-link--ink { text-decoration-color: rgba(232, 224, 208, 0.5); }
/* ── Latest from Fenja (unboxed) ──────────────────────────────── */ /* ── Latest from Fenja: two boxes + 2 minimalist rows below ──── */
.latest-article { .fenja-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-6);
max-width: 56rem;
} }
.latest-byline { .fenja-row {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-5);
}
.fenja-row--with-poll {
grid-template-columns: 1.6fr 1fr;
}
.fenja-article-box,
.fenja-poll-box {
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
padding: var(--space-7) var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.fenja-byline {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
flex-wrap: wrap; flex-wrap: wrap;
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
} }
.latest-byline-name { font-weight: 600; color: var(--on-surface); } .fenja-byline-name { font-weight: 600; color: var(--on-surface); }
.latest-byline-title { color: var(--on-surface-variant); } .fenja-byline-title { color: var(--on-surface-variant); }
.latest-byline-time { .fenja-byline-time {
color: var(--on-surface-muted); color: var(--on-surface-muted);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
margin-left: auto; margin-left: auto;
} }
.latest-kind-pill { .fenja-kind-pill {
background: color-mix(in oklab, var(--pill) 14%, transparent); background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill); color: var(--pill);
padding: 2px 9px; padding: 2px 9px;
@ -496,27 +612,141 @@ const members = getAllCabMembers();
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
font-weight: 600; font-weight: 600;
} }
.latest-title { .fenja-title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 400; font-weight: 400;
font-size: 1.625rem; font-size: 1.75rem;
line-height: 1.25; line-height: 1.25;
color: var(--on-surface); color: var(--on-surface);
margin: 0; margin: 0;
letter-spacing: var(--tracking-snug); letter-spacing: var(--tracking-snug);
} }
.latest-body { .fenja-body {
color: var(--on-surface); color: var(--on-surface);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
margin: 0; margin: 0;
font-size: var(--text-body-lg); font-size: var(--text-body-lg);
} }
.fenja-read { margin-top: var(--space-2); align-self: flex-start; }
/* Poll box */
.fenja-poll-question {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.125rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.fenja-poll-options {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.fenja-poll-option {
position: relative;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--background);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-body-sm);
color: var(--on-surface);
text-align: left;
cursor: pointer;
transition: border-color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
overflow: hidden;
}
.fenja-poll-option:hover:not(.locked) {
border-color: var(--outline);
}
.fenja-poll-option.chosen {
border-color: var(--pigment-terracotta);
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
}
.fenja-poll-option.locked:not(.chosen) {
cursor: default;
color: var(--on-surface-variant);
}
.fenja-poll-option:disabled { opacity: 0.85; }
.fenja-poll-letter {
font-weight: 600;
color: var(--on-surface-muted);
font-size: var(--text-label-sm);
flex-shrink: 0;
}
.fenja-poll-option.chosen .fenja-poll-letter { color: var(--pigment-terracotta); }
.fenja-poll-text { flex: 1; }
.fenja-poll-bar {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: var(--surface-container);
}
.fenja-poll-bar-fill {
display: block;
height: 100%;
background: var(--pigment-terracotta);
opacity: 0.55;
transition: width 600ms var(--ease-standard);
}
.fenja-poll-count {
color: var(--on-surface-muted);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
margin: 0;
}
/* 2 minimalist rows below the article */
.fenja-more {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
}
.fenja-more-row {
border-bottom: 0.5px solid var(--surface-card-border);
}
.fenja-more-row:last-child { border-bottom: none; }
.fenja-more-link {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-4);
padding: var(--space-3) 0;
color: var(--on-surface);
text-decoration: none;
border-bottom: none;
}
.fenja-more-link:hover {
border-bottom: none;
opacity: 0.75;
}
.fenja-more-title {
font-family: var(--font-serif);
font-size: var(--text-body-md);
color: var(--on-surface);
}
.fenja-more-meta {
color: var(--on-surface-muted);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
white-space: nowrap;
}
.fenja-all { align-self: flex-start; }
/* ── Roadmap horizontal cards ─────────────────────────────────── */ /* ── Roadmap horizontal cards ─────────────────────────────────── */
.roadmap-section { .roadmap-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-4); gap: var(--space-5);
} }
.roadmap-grid { .roadmap-grid {
list-style: none; list-style: none;
@ -524,17 +754,17 @@ const members = getAllCabMembers();
margin: 0; margin: 0;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: var(--space-4); gap: var(--space-5);
} }
.roadmap-card { .roadmap-card {
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-md); border-radius: var(--radius-md);
padding: var(--space-5); padding: var(--space-6);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-3);
min-height: 130px; min-height: 140px;
} }
.status-dot { .status-dot {
width: 10px; width: 10px;
@ -565,48 +795,49 @@ const members = getAllCabMembers();
margin: 0; margin: 0;
} }
/* ── Council cards — larger, with company ─────────────────────── */ /* ── Council — one outer surface, no per-member boxes ─────────── */
.council-section { .council-section {
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
padding: var(--space-8) var(--space-8) var(--space-7);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-5); gap: var(--space-6);
} }
.council-grid { .council-grid {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--space-4); gap: var(--space-6) var(--space-8);
} }
.council-card { .council-cell {
background: var(--surface-card);
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; display: flex;
gap: var(--space-4);
align-items: center; align-items: center;
gap: var(--space-5); min-width: 0;
} }
.council-card-text { .council-cell-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
min-width: 0; min-width: 0;
} }
.council-card-name { .council-cell-name {
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 400; font-weight: 400;
font-size: 1.125rem; font-size: 1.125rem;
line-height: 1.2; line-height: 1.2;
color: var(--on-surface); color: var(--on-surface);
} }
.council-card-title { .council-cell-title {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--on-surface-variant); color: var(--on-surface-variant);
} }
.council-card-org { .council-cell-org {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
@ -616,8 +847,10 @@ const members = getAllCabMembers();
/* ── Responsive ───────────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) { @media (max-width: 880px) {
.roadmap-grid { grid-template-columns: 1fr; } .roadmap-grid { grid-template-columns: 1fr; }
.hero-body { grid-template-columns: 1fr; } .hero-body { grid-template-columns: 1fr; gap: var(--space-5); }
.hero-body::after { display: none; } .hero-body::after { display: none; }
.hero-detail { padding-left: 0; } .hero-detail { padding-left: 0; }
.fenja-row--with-poll { grid-template-columns: 1fr; }
.events-card { padding: var(--space-7) var(--space-6); }
} }
</style> </style>