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:
parent
867661ee3d
commit
9800d0a448
3 changed files with 489 additions and 103 deletions
|
|
@ -143,14 +143,17 @@ const nowIso = (offsetSeconds = 0) => {
|
|||
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||
};
|
||||
|
||||
// ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
|
||||
// ── Poll attached to a dispatch (the decision) — open, 2/4 voted ──
|
||||
// Polls are no longer standalone; they attach to a dispatch via pulse_id.
|
||||
// We create the pulse first, capture its id, and stamp it on the dispatch
|
||||
// when we INSERT it further down.
|
||||
const pulseOptions = [
|
||||
'Locking down on-prem deployment first',
|
||||
'Pushing the traceability layer to GA',
|
||||
'Going wide on document ingestion',
|
||||
'Building the agentic query loop',
|
||||
];
|
||||
const pulseId = db.prepare(`
|
||||
const decisionPulseId = db.prepare(`
|
||||
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
|
|
@ -160,11 +163,11 @@ const pulseId = db.prepare(`
|
|||
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
|
||||
).lastInsertRowid;
|
||||
|
||||
// 2 votes from cabs[0] and cabs[1]
|
||||
// 2 votes — Lars and Anna
|
||||
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
||||
.run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600));
|
||||
.run(decisionPulseId, cabs[0].id, 1, nowIso(-2 * 3600));
|
||||
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
||||
.run(pulseId, cabs[1].id, 1, nowIso(-30 * 60));
|
||||
.run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60));
|
||||
|
||||
// ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
|
||||
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 insertDispatch = db.prepare(`
|
||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,'published',?,?,?)
|
||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id)
|
||||
VALUES (?,?,?,?,?,'published',?,?,?,?)
|
||||
`);
|
||||
for (let i = 0; i < dispatchSeed.length; i += 1) {
|
||||
const d = dispatchSeed[i];
|
||||
const when = nowIso(-d.ageDays * 24 * 60 * 60);
|
||||
const authorId = fenjas[i % fenjas.length].id;
|
||||
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
|
||||
// Attach the decision-pulse to the decision dispatch — this is the demo
|
||||
// case for polls-as-articles. Other dispatches stay poll-free.
|
||||
const attachedPulse = d.kind === 'decision' ? decisionPulseId : null;
|
||||
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when, attachedPulse);
|
||||
}
|
||||
|
||||
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
|
||||
|
|
@ -329,12 +335,12 @@ const insertActivity = db.prepare(`
|
|||
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
`);
|
||||
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
|
||||
insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
|
||||
insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60));
|
||||
insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600));
|
||||
insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600));
|
||||
insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60));
|
||||
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
|
||||
|
||||
console.log(' pulse #' + pulseId + ' open, 2 of 4 voted');
|
||||
console.log(' pulse #' + decisionPulseId + ' 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)');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
---
|
||||
import AppLayout from '../../layouts/AppLayout.astro';
|
||||
import Avatar from '../../components/Avatar.astro';
|
||||
import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
|
||||
import {
|
||||
getDispatchWithPoll, getAdjacentDispatches,
|
||||
getPulseById, getUserVote, castVote, recordActivity, countCabMembers,
|
||||
} from '../../lib/db';
|
||||
import {
|
||||
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
|
||||
dispatchKindPigment, roleLabel,
|
||||
|
|
@ -14,15 +17,39 @@ const id = parseDispatchSlug(slugParam);
|
|||
|
||||
if (!id) return Astro.redirect('/dispatches');
|
||||
|
||||
const d = getDispatchById(id);
|
||||
// Vote POST — handled before main render so we can refresh state
|
||||
if (Astro.request.method === 'POST') {
|
||||
const data = await Astro.request.formData();
|
||||
const action = String(data.get('action') ?? '');
|
||||
if (action === 'vote') {
|
||||
const pulseId = Number(data.get('pulse_id'));
|
||||
const optionIndex = Number(data.get('option_index'));
|
||||
const target = getPulseById(pulseId);
|
||||
if (target && target.status === 'open' && Number.isInteger(optionIndex)
|
||||
&& optionIndex >= 0 && optionIndex < target.options.length
|
||||
&& 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');
|
||||
|
||||
// Canonical-redirect when the slug changes after a rename — id is the authority
|
||||
const canonical = dispatchSlug(d);
|
||||
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
|
||||
|
||||
const totalMembers = countCabMembers();
|
||||
const { prev, next } = getAdjacentDispatches(d.id);
|
||||
|
||||
function closeDayLabel(closesAt: string): string {
|
||||
const parsed = closesAt.includes('T') ? new Date(closesAt) : new Date(closesAt.replace(' ', 'T') + 'Z');
|
||||
return new Intl.DateTimeFormat('en-GB', { weekday: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed);
|
||||
}
|
||||
|
||||
function parseUtc(s: string): Date {
|
||||
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
||||
return new Date(s.replace(' ', 'T') + 'Z');
|
||||
|
|
@ -63,6 +90,47 @@ const bodyHtml = renderMd(d.body);
|
|||
|
||||
<div class="body prose" set:html={bodyHtml} />
|
||||
|
||||
{d.poll && (
|
||||
<aside class="inline-poll" aria-label="Poll attached to this dispatch">
|
||||
<p class="inline-poll-question">{d.poll.question}</p>
|
||||
<form method="POST" class="inline-poll-options" novalidate>
|
||||
<input type="hidden" name="action" value="vote" />
|
||||
<input type="hidden" name="pulse_id" value={d.poll.id} />
|
||||
{d.poll.options.map((opt, i) => {
|
||||
const 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" />
|
||||
|
||||
<nav class="adjacent" aria-label="Adjacent dispatches">
|
||||
|
|
@ -194,6 +262,85 @@ const bodyHtml = renderMd(d.body);
|
|||
margin: var(--space-6) 0 0;
|
||||
}
|
||||
|
||||
/* ── Inline poll attached to the dispatch ──────────────────────── */
|
||||
.inline-poll {
|
||||
margin-top: var(--space-7);
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.inline-poll-question {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.inline-poll-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.inline-poll-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--background);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body-md);
|
||||
color: var(--on-surface);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--duration-fast) var(--ease-standard),
|
||||
background var(--duration-fast) var(--ease-standard);
|
||||
overflow: hidden;
|
||||
}
|
||||
.inline-poll-option:hover:not(.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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
|
|||
|
|
@ -5,20 +5,22 @@ import AvatarPile from '../components/AvatarPile.astro';
|
|||
import {
|
||||
getUpcomingEvents, getEventBySlug, getEventAttendees,
|
||||
getUserRsvp, setEventRsvp, recordActivity,
|
||||
getAllRoadmapItems, getLatestPublishedDispatches, getAllCabMembers,
|
||||
getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
|
||||
getAllCabMembers, getPulseById, getUserVote, castVote,
|
||||
} from '../lib/db';
|
||||
import {
|
||||
pulseDateLabel, timeOfDay, tenureSince, relativeTime,
|
||||
timeOfDay, tenureSince, relativeTime,
|
||||
eventKindLabel,
|
||||
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
|
||||
} from '../lib/format';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
|
||||
// ── POST: RSVP from the hero card ──────────────────────────────────
|
||||
// ── POST: RSVP + Vote ──────────────────────────────────────────────
|
||||
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';
|
||||
|
|
@ -31,12 +33,26 @@ if (Astro.request.method === 'POST') {
|
|||
}
|
||||
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 ───────────────────────────────────────────────────────
|
||||
const firstName = user.name.split(' ')[0];
|
||||
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
|
||||
? user.cab_joined_date
|
||||
|
|
@ -64,11 +80,20 @@ 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) : '';
|
||||
// ── Latest from Fenja (featured + 2 minimalist below) ──────────────
|
||||
const recentDispatches = getLatestPublishedDispatches(3);
|
||||
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()
|
||||
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1))
|
||||
.slice(0, 3);
|
||||
|
|
@ -89,7 +114,7 @@ function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; t
|
|||
}
|
||||
}
|
||||
|
||||
// ── Council members ─────────────────────────────────────────────────
|
||||
// ── Council ─────────────────────────────────────────────────────────
|
||||
const members = getAllCabMembers();
|
||||
---
|
||||
<AppLayout title="Pulse" user={user}>
|
||||
|
|
@ -103,11 +128,11 @@ const members = getAllCabMembers();
|
|||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── Events (--ink card with hero + bundled coming-up + see all) -->
|
||||
<!-- ── Events (--ink card) ──────────────────────────────────── -->
|
||||
{hero ? (
|
||||
<section class="cascade events-card" aria-label="Events">
|
||||
|
||||
<!-- Hero -->
|
||||
<!-- Hero — more air, vertically aligned columns -->
|
||||
<div class="hero-body">
|
||||
<div class="hero-date">
|
||||
<span class="hero-weekday">{weekday(hero.starts_at)}</span>
|
||||
|
|
@ -148,7 +173,7 @@ const members = getAllCabMembers();
|
|||
</form>
|
||||
</footer>
|
||||
|
||||
<!-- Bundled coming-up sub-cards (no RSVP buttons) -->
|
||||
<!-- Bundled coming-up sub-cards (no RSVP) -->
|
||||
{comingUp.length > 0 && (
|
||||
<ul class="coming-up-grid">
|
||||
{comingUp.map(ev => (
|
||||
|
|
@ -166,34 +191,97 @@ const members = getAllCabMembers();
|
|||
</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 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>
|
||||
<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>
|
||||
<!-- ── Latest from Fenja: 2-box article + poll, plus 2 below ── -->
|
||||
{featured && (
|
||||
<section class="cascade fenja-section" aria-label="Latest from Fenja">
|
||||
<div class:list={['fenja-row', { 'fenja-row--with-poll': !!featured.poll }]}>
|
||||
|
||||
<h2 class="latest-title">{latestDispatch.title}</h2>
|
||||
<p class="latest-body">{latestPreview}</p>
|
||||
<article class="fenja-article-box">
|
||||
<header class="fenja-byline">
|
||||
<Avatar id={featured.author_id} name={featured.author_name} size={28} />
|
||||
<span class="fenja-byline-name">{featured.author_name}</span>
|
||||
{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>
|
||||
</header>
|
||||
|
||||
<a href={`/dispatches/${dispatchSlug(latestDispatch)}`} class="section-link">Read the full dispatch →</a>
|
||||
</article>
|
||||
<h2 class="fenja-title">{featured.title}</h2>
|
||||
<p class="fenja-body">{featuredPreview}</p>
|
||||
|
||||
<a href={`/dispatches/${dispatchSlug(featured)}`} class="section-link fenja-read">Read the full dispatch</a>
|
||||
</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 ───────────────────────────── -->
|
||||
|
|
@ -214,26 +302,26 @@ const members = getAllCabMembers();
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href="/roadmap" class="section-link">See the full roadmap →</a>
|
||||
<a href="/roadmap" class="section-link">See the full roadmap</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- ── Council members — larger cards with company ──────────── -->
|
||||
<!-- ── Council members — single background, no per-member boxes - -->
|
||||
{members.length > 0 && (
|
||||
<section class="cascade council-section" aria-label="The council">
|
||||
<ul class="council-grid">
|
||||
{members.map(m => (
|
||||
<li class="council-card">
|
||||
<li class="council-cell">
|
||||
<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 class="council-cell-text">
|
||||
<span class="council-cell-name">{m.name}</span>
|
||||
{m.title && <span class="council-cell-title">{m.title}</span>}
|
||||
<span class="council-cell-org">{m.organisation}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
@ -242,12 +330,12 @@ const members = getAllCabMembers();
|
|||
|
||||
<style>
|
||||
.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);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
/* ── Cascade entry (first paint only) ─────────────────────────── */
|
||||
|
|
@ -283,20 +371,20 @@ const members = getAllCabMembers();
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Events card (--ink) ──────────────────────────────────────── */
|
||||
/* ── Events card (--ink) — more air, prominent hero ───────────── */
|
||||
.events-card {
|
||||
background: var(--ink);
|
||||
color: var(--ink-text);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-7) var(--space-8);
|
||||
padding: var(--space-10) var(--space-10);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
gap: var(--space-7);
|
||||
}
|
||||
.events-card--empty {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
min-height: 160px;
|
||||
min-height: 200px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.events-empty-line {
|
||||
|
|
@ -308,22 +396,28 @@ const members = getAllCabMembers();
|
|||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* Hero (lighter, fewer italics) */
|
||||
.hero-body {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: var(--space-7);
|
||||
grid-template-columns: 150px 1fr;
|
||||
gap: var(--space-8);
|
||||
align-items: center; /* vertically align date + detail */
|
||||
padding: var(--space-3) 0;
|
||||
position: relative;
|
||||
}
|
||||
.hero-body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 110px;
|
||||
left: 150px;
|
||||
top: 0; bottom: 0;
|
||||
width: 0.5px;
|
||||
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 {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
|
|
@ -334,23 +428,25 @@ const members = getAllCabMembers();
|
|||
.hero-day {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 2.75rem;
|
||||
font-size: 3.5rem;
|
||||
line-height: 1;
|
||||
color: var(--ink-text);
|
||||
margin: 2px 0;
|
||||
}
|
||||
.hero-detail { padding-left: var(--space-6); }
|
||||
.hero-detail { padding-left: var(--space-7); }
|
||||
.hero-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
color: var(--ink-text);
|
||||
margin: 0 0 var(--space-3);
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
.hero-desc {
|
||||
color: rgba(232, 224, 208, 0.92);
|
||||
margin: 0 0 var(--space-3);
|
||||
margin: 0 0 var(--space-4);
|
||||
max-width: 50rem;
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
.hero-meta {
|
||||
color: rgba(232, 224, 208, 0.7);
|
||||
|
|
@ -361,7 +457,7 @@ const members = getAllCabMembers();
|
|||
/* Hero foot */
|
||||
.hero-foot {
|
||||
border-top: 0.5px solid rgba(232, 224, 208, 0.18);
|
||||
padding-top: var(--space-4);
|
||||
padding-top: var(--space-5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
|
@ -415,7 +511,7 @@ const members = getAllCabMembers();
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Bundled coming-up sub-cards (no RSVP buttons) */
|
||||
/* Bundled coming-up sub-cards (no RSVP) */
|
||||
.coming-up-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
|
@ -428,7 +524,7 @@ const members = getAllCabMembers();
|
|||
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);
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
|
|
@ -462,31 +558,51 @@ const members = getAllCabMembers();
|
|||
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-article {
|
||||
/* ── Latest from Fenja: two boxes + 2 minimalist rows below ──── */
|
||||
.fenja-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
max-width: 56rem;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
.fenja-byline-name { font-weight: 600; color: var(--on-surface); }
|
||||
.fenja-byline-title { color: var(--on-surface-variant); }
|
||||
.fenja-byline-time {
|
||||
color: var(--on-surface-muted);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin-left: auto;
|
||||
}
|
||||
.latest-kind-pill {
|
||||
.fenja-kind-pill {
|
||||
background: color-mix(in oklab, var(--pill) 14%, transparent);
|
||||
color: var(--pill);
|
||||
padding: 2px 9px;
|
||||
|
|
@ -496,27 +612,141 @@ const members = getAllCabMembers();
|
|||
letter-spacing: var(--tracking-wide);
|
||||
font-weight: 600;
|
||||
}
|
||||
.latest-title {
|
||||
.fenja-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.625rem;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.25;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
letter-spacing: var(--tracking-snug);
|
||||
}
|
||||
.latest-body {
|
||||
.fenja-body {
|
||||
color: var(--on-surface);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin: 0;
|
||||
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-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.roadmap-grid {
|
||||
list-style: none;
|
||||
|
|
@ -524,17 +754,17 @@ const members = getAllCabMembers();
|
|||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.roadmap-card {
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-height: 130px;
|
||||
min-height: 140px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
|
|
@ -565,48 +795,49 @@ const members = getAllCabMembers();
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Council cards — larger, with company ─────────────────────── */
|
||||
/* ── Council — one outer surface, no per-member boxes ─────────── */
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.council-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: var(--space-6) var(--space-8);
|
||||
}
|
||||
.council-card {
|
||||
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);
|
||||
.council-cell {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.council-card-text {
|
||||
.council-cell-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.council-card-name {
|
||||
.council-cell-name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.2;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.council-card-title {
|
||||
.council-cell-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.council-card-org {
|
||||
.council-cell-org {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
|
|
@ -616,8 +847,10 @@ const members = getAllCabMembers();
|
|||
/* ── Responsive ───────────────────────────────────────────────── */
|
||||
@media (max-width: 880px) {
|
||||
.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-detail { padding-left: 0; }
|
||||
.fenja-row--with-poll { grid-template-columns: 1fr; }
|
||||
.events-card { padding: var(--space-7) var(--space-6); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue