feat(pulse): nav restructure, white surfaces, membership card, dispatches

Nav (AppLayout.astro): brand stays left; everything else is a single
right-aligned flex group — Pulse · Roadmap · Members · Events · [Admin
if fenja] · 0.5px vertical divider · name · Sign out. The .nav-user
wrapper is gone; the name and logout button now belong to the same flex
flow as the link list.

/pulse:
- ActivityTicker render removed. The component file gets a one-line
  deprecation comment; the activity table and write hooks stay in place
  for later use.
- 'X others online now' chip strip removed — including all its supporting
  helpers and styles in the page.
- CouncilMark replaced with <MembershipCard> in the right column of the
  preview row. The roadmap preview is now a white --surface-card with
  0.5px border; pulse-card switches to the same white surface and
  --radius-lg. The .chosen pulse option uses --pigment-terracotta border
  and a 6% terracotta tint via color-mix.
- <DispatchesSection limit={4} /> and <RecentlyFromTheCouncil /> stacked
  below the preview row, in the position the online-now strip vacated.
- Vote-count denominator pulls from countCabMembers() and renders via
  voteCountSentence(votes, total) — a new helper covering 0/1/5+ cases.
- Event row: dark dinner card now uses --ink/--ink-text; light card uses
  --surface-card with 0.5px border.

Tests: 3 new cases for voteCountSentence (0/1/5). 36/36 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-11 16:02:46 +02:00
parent 3e9fadcf79
commit 243a456880
5 changed files with 83 additions and 221 deletions

View file

@ -1,4 +1,7 @@
--- ---
// Deprecated for Phase 2: no longer rendered on /pulse. The activity table
// and its write hooks remain — kept around in case we resurface this later
// or build an admin debug view that streams from it.
import type { TickerItem } from '../lib/format'; import type { TickerItem } from '../lib/format';
interface Props { interface Props {

View file

@ -32,7 +32,7 @@ const year = new Date().getFullYear();
<img src="/logo.svg" alt="Fenja AI" class="wordmark" /> <img src="/logo.svg" alt="Fenja AI" class="wordmark" />
</a> </a>
<nav class="nav-links" aria-label="Main navigation"> <nav class="nav-right" aria-label="Main navigation">
{navLinks.map(({ href, label }) => ( {navLinks.map(({ href, label }) => (
<a <a
href={href} href={href}
@ -49,14 +49,12 @@ const year = new Date().getFullYear();
Admin Admin
</a> </a>
)} )}
</nav> <span class="nav-divider" aria-hidden="true"></span>
<div class="nav-user">
<a href="/account" class="nav-user-name body-sm">{user.name}</a> <a href="/account" class="nav-user-name body-sm">{user.name}</a>
<form method="POST" action="/api/logout"> <form method="POST" action="/api/logout" class="nav-logout-form">
<button type="submit" class="logout-btn label-sm">Sign out</button> <button type="submit" class="logout-btn label-sm">Sign out</button>
</form> </form>
</div> </nav>
</div> </div>
</header> </header>
@ -126,12 +124,22 @@ const year = new Date().getFullYear();
} }
/* ── Nav links ──────────────────────────────────────────────────── */ /* ── Nav links ──────────────────────────────────────────────────── */
.nav-links { .nav-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-1);
flex: 1; margin-left: auto;
} }
.nav-divider {
display: inline-block;
width: 1px;
height: 18px;
background: var(--ghost-border-color);
margin: 0 var(--space-2);
transform: scaleX(0.5);
transform-origin: center;
}
.nav-logout-form { display: inline-flex; }
.nav-link { .nav-link {
font-family: var(--font-sans); font-family: var(--font-sans);
@ -158,13 +166,6 @@ const year = new Date().getFullYear();
} }
/* ── User zone ──────────────────────────────────────────────────── */ /* ── User zone ──────────────────────────────────────────────────── */
.nav-user {
display: flex;
align-items: center;
gap: var(--space-4);
flex-shrink: 0;
}
.nav-user-name { .nav-user-name {
color: var(--on-surface-variant); color: var(--on-surface-variant);
text-decoration: none; text-decoration: none;

Binary file not shown.

View file

@ -1,14 +1,14 @@
--- ---
import AppLayout from '../layouts/AppLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
import ActivityTicker from '../components/ActivityTicker.astro'; import MembershipCard from '../components/MembershipCard.astro';
import CouncilMark from '../components/CouncilMark.astro'; import DispatchesSection from '../components/DispatchesSection.astro';
import RecentlyFromTheCouncil from '../components/RecentlyFromTheCouncil.astro';
import { import {
getOpenPulse, getPulseWithCounts, castVote, recordActivity, getOpenPulse, getPulseWithCounts, castVote, recordActivity,
getRecentActivity, getPulseById, getEventById, getRoadmapItem, getPulseById, getAllRoadmapItems, getUpcomingEvents,
getAllRoadmapItems, getUpcomingEvents, getAllUsersPublic, countCabMembers, getUserVote,
getLitQuarters, countShippedAttributions, getUserVote,
} from '../lib/db'; } from '../lib/db';
import { tickerItem, pulseDateLabel, timeOfDay, tenureSince, redactName } from '../lib/format'; import { pulseDateLabel, timeOfDay, tenureSince, voteCountSentence } from '../lib/format';
const user = Astro.locals.user; const user = Astro.locals.user;
@ -42,33 +42,9 @@ const tenureAnchor = user.role === 'cab' && user.cab_joined_date
: user.created_at; : user.created_at;
const tenure = tenureSince(tenureAnchor); const tenure = tenureSince(tenureAnchor);
// ── Activity ticker ────────────────────────────────────────────────
const activity = getRecentActivity({ limit: 12, sinceDays: 7 });
const tickerItems = activity.map(row => {
let label: string | null = null;
switch (row.subject_type) {
case 'pulse': {
const p = getPulseById(row.subject_id);
label = p?.question ?? null;
break;
}
case 'event': {
const e = getEventById(row.subject_id);
label = e?.title ?? null;
break;
}
case 'roadmap': {
const r = getRoadmapItem(row.subject_id);
label = r?.title ?? null;
break;
}
}
return tickerItem(row, label);
});
// ── This week's Pulse ────────────────────────────────────────────── // ── This week's Pulse ──────────────────────────────────────────────
const openPulseRaw = getOpenPulse(); const openPulseRaw = getOpenPulse();
const totalMembers = getAllUsersPublic().filter(u => u.role === 'cab').length; const totalMembers = countCabMembers();
const openPulse = openPulseRaw ? getPulseWithCounts(openPulseRaw.id, user.id) : null; const openPulse = openPulseRaw ? getPulseWithCounts(openPulseRaw.id, user.id) : null;
// Time-left label: "32 seconds" / "3 hours" / "2 days" — soft countdown // Time-left label: "32 seconds" / "3 hours" / "2 days" — soft countdown
@ -113,31 +89,6 @@ function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; t
} }
} }
// ── Council mark + stat ────────────────────────────────────────────
const litCount = getLitQuarters(user.id).size;
const shippedCount = countShippedAttributions(user.id);
// ── Members in the room ────────────────────────────────────────────
const allMembers = getAllUsersPublic();
// SQL stores 'YYYY-MM-DD HH:MM:SS' UTC; new Date() would parse as local — coerce to UTC ISO first.
function sqlToUtcDate(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');
}
const onlineOthers = allMembers.filter(u =>
u.id !== user.id
&& u.last_seen_at
&& (Date.now() - sqlToUtcDate(u.last_seen_at).getTime()) < 5 * 60_000
);
const visibleChips = onlineOthers.slice(0, 4);
const overflowCount = Math.max(0, onlineOthers.length - visibleChips.length);
function initials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return (parts[0][0] ?? '').toUpperCase();
return ((parts[0][0] ?? '') + (parts[parts.length - 1][0] ?? '')).toUpperCase();
}
// ── Events row ───────────────────────────────────────────────────── // ── Events row ─────────────────────────────────────────────────────
const upcoming = getUpcomingEvents(20); const upcoming = getUpcomingEvents(20);
const nextExclusive = upcoming.find(e => e.kind === 'dinner' || e.kind === 'summit') ?? null; const nextExclusive = upcoming.find(e => e.kind === 'dinner' || e.kind === 'summit') ?? null;
@ -163,13 +114,6 @@ function formatEventDate(iso: string): string {
</p> </p>
</section> </section>
<!-- ── Ticker ───────────────────────────────────────────────── -->
{tickerItems.length > 0 && (
<section class="cascade ticker-wrap">
<ActivityTicker items={tickerItems} />
</section>
)}
<!-- ── This week's Pulse ────────────────────────────────────── --> <!-- ── This week's Pulse ────────────────────────────────────── -->
<section class="cascade pulse-card"> <section class="cascade pulse-card">
{openPulse ? ( {openPulse ? (
@ -212,7 +156,7 @@ function formatEventDate(iso: string): string {
</form> </form>
<p class="pulse-count body-sm"> <p class="pulse-count body-sm">
<strong>{openPulse.votes_total}</strong> of {totalMembers} council member{totalMembers === 1 ? '' : 's'} weighed in. Closes {closeDayLabel(openPulse.closes_at)}. {voteCountSentence(openPulse.votes_total, totalMembers)} Closes {closeDayLabel(openPulse.closes_at)}.
</p> </p>
</> </>
) : ( ) : (
@ -249,41 +193,20 @@ function formatEventDate(iso: string): string {
<a href="/roadmap" class="see-all label-sm">See the full roadmap →</a> <a href="/roadmap" class="see-all label-sm">See the full roadmap →</a>
</div> </div>
<aside class="mark-card"> <aside class="membership-slot">
<p class="label-sm section-eyebrow">Your council mark</p> <MembershipCard member={user} />
<div class="mark-svg-wrap">
<CouncilMark member={user} size="md" />
</div>
<p class="mark-stat label-sm">
{litCount} of 12 quarters with a contribution that shipped
{shippedCount > 0 && <span class="mark-stat-sub"> ({shippedCount} item{shippedCount === 1 ? '' : 's'})</span>}
</p>
</aside> </aside>
</section> </section>
<!-- ── Members in the room ──────────────────────────────────── --> <!-- ── Dispatches ───────────────────────────────────────────── -->
{visibleChips.length > 0 && ( <section class="cascade">
<section class="cascade room-row"> <DispatchesSection limit={4} />
<div class="room-label"> </section>
<span class="online-dot" aria-hidden="true"></span>
<span class="label-sm">{onlineOthers.length} other{onlineOthers.length === 1 ? '' : 's'} online now</span> <!-- ── Recently from the council ────────────────────────────── -->
</div> <section class="cascade">
<div class="chip-row"> <RecentlyFromTheCouncil />
{visibleChips.map(m => (
<a href={m.slug ? `/members/${m.slug}` : '#'} class="member-chip">
<span class="chip-initials" aria-hidden="true">{initials(m.name)}</span>
<span class="chip-text">
<span class="chip-name">{redactName(m.name)}</span>
<span class="chip-org label-sm">{m.organisation}</span>
</span>
</a>
))}
{overflowCount > 0 && (
<span class="overflow-chip label-sm">+{overflowCount} others</span>
)}
</div>
</section> </section>
)}
<!-- ── Event row ────────────────────────────────────────────── --> <!-- ── Event row ────────────────────────────────────────────── -->
{(nextExclusive || nextOfficeHours) && ( {(nextExclusive || nextOfficeHours) && (
@ -373,8 +296,9 @@ function formatEventDate(iso: string): string {
/* ── Pulse card ───────────────────────────────────────────────── */ /* ── Pulse card ───────────────────────────────────────────────── */
.pulse-card { .pulse-card {
background: var(--surface-container-lowest); background: var(--surface-card);
border-radius: var(--radius-md); border: 0.5px solid var(--surface-card-border);
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;
@ -461,7 +385,7 @@ function formatEventDate(iso: string): string {
} }
.pulse-option.chosen { .pulse-option.chosen {
border-color: var(--pigment-terracotta); border-color: var(--pigment-terracotta);
background: var(--surface-container-low); background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
} }
.pulse-option.locked:not(.chosen) { .pulse-option.locked:not(.chosen) {
cursor: default; cursor: default;
@ -511,21 +435,33 @@ function formatEventDate(iso: string): string {
margin: 0; margin: 0;
} }
/* ── Roadmap preview + Council mark ──────────────────────────── */ /* ── Roadmap preview + Membership card ──────────────────────── */
.preview-row { .preview-row {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 2fr 1fr;
gap: var(--space-8); gap: var(--space-6);
align-items: stretch;
} }
.section-eyebrow { .section-eyebrow {
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-muted); color: var(--on-surface-variant);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
.roadmap-preview { display: flex; flex-direction: column; gap: var(--space-4); } .roadmap-preview {
display: flex;
flex-direction: column;
gap: var(--space-3);
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
}
.membership-slot { display: flex; }
.membership-slot > * { flex: 1; }
.roadmap-list { .roadmap-list {
list-style: none; list-style: none;
@ -575,104 +511,6 @@ function formatEventDate(iso: string): string {
} }
.see-all:hover { color: var(--on-surface); border-bottom: none; } .see-all:hover { color: var(--on-surface); border-bottom: none; }
.mark-card {
background: var(--surface-container-low);
border-radius: var(--radius-md);
padding: var(--space-6);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
}
.mark-svg-wrap {
display: flex;
align-items: center;
justify-content: center;
}
.mark-stat {
color: var(--on-surface-variant);
letter-spacing: var(--tracking-wide);
text-align: center;
margin: 0;
max-width: 18rem;
line-height: var(--leading-normal);
text-transform: none;
}
.mark-stat-sub { color: var(--on-surface-muted); }
/* ── Members in the room ─────────────────────────────────────── */
.room-row { display: flex; flex-direction: column; gap: var(--space-4); }
.room-label { display: flex; align-items: center; gap: var(--space-3); }
.online-dot {
width: 8px;
height: 8px;
background: var(--pigment-copper);
border-radius: 50%;
animation: breathe 2.4s ease-in-out infinite;
}
.room-label .label-sm {
color: var(--on-surface-variant);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.member-chip {
display: inline-flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4) var(--space-2) var(--space-2);
background: var(--surface-container-lowest);
border-radius: var(--radius-full);
color: var(--on-surface);
text-decoration: none;
border-bottom: none;
transition: transform 300ms var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.member-chip:hover {
transform: translateY(-2px);
background: var(--surface-container-low);
border-bottom: none;
}
.chip-initials {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--surface-container);
color: var(--secondary);
font-family: var(--font-serif);
font-style: italic;
font-size: 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.chip-text { display: inline-flex; flex-direction: column; line-height: 1.2; }
.chip-name { font-size: var(--text-body-sm); }
.chip-org {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.overflow-chip {
align-self: center;
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: var(--space-2) var(--space-3);
}
/* ── Events row ──────────────────────────────────────────────── */ /* ── Events row ──────────────────────────────────────────────── */
.event-row { .event-row {
display: grid; display: grid;
@ -691,17 +529,18 @@ function formatEventDate(iso: string): string {
.event-card:hover { transform: translateY(-2px); } .event-card:hover { transform: translateY(-2px); }
.event-card--dark { .event-card--dark {
background: var(--pigment-indigo); background: var(--ink);
color: var(--on-primary); color: var(--ink-text);
} }
.event-card--dark .event-title, .event-card--dark .event-title,
.event-card--dark .event-desc, .event-card--dark .event-desc,
.event-card--dark .event-scarcity { .event-card--dark .event-scarcity {
color: var(--on-primary); color: var(--ink-text);
} }
.event-card--light { .event-card--light {
background: var(--surface-container-low); background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
} }
.event-eyebrow { .event-eyebrow {
@ -709,7 +548,7 @@ function formatEventDate(iso: string): string {
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-muted); color: var(--on-surface-muted);
} }
.event-eyebrow--light { color: rgba(255, 252, 247, 0.7); } .event-eyebrow--light { color: var(--ink-muted); }
.event-title { .event-title {
font-family: var(--font-serif); font-family: var(--font-serif);

19
tests/vote-count.test.ts Normal file
View file

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { voteCountSentence } from '../src/lib/format.js';
describe('voteCountSentence — denominator pluralisation', () => {
it('reads correctly for a council of 0 (defensive — should never ship)', () => {
expect(voteCountSentence(0, 0)).toBe('0 of 0 council members have weighed in.');
});
it('uses singular "member has" when the council has exactly 1 member', () => {
expect(voteCountSentence(0, 1)).toBe('0 of 1 council member has weighed in.');
expect(voteCountSentence(1, 1)).toBe('1 of 1 council member has weighed in.');
});
it('uses plural "members have" for a council of 5', () => {
expect(voteCountSentence(0, 5)).toBe('0 of 5 council members have weighed in.');
expect(voteCountSentence(2, 5)).toBe('2 of 5 council members have weighed in.');
expect(voteCountSentence(5, 5)).toBe('5 of 5 council members have weighed in.');
});
});