From 243a456880136cbd33f7a54ab53d6ecabad11e6b Mon Sep 17 00:00:00 2001
From: Jonathan Hvid
Date: Mon, 11 May 2026 16:02:46 +0200
Subject: [PATCH] feat(pulse): nav restructure, white surfaces, membership
card, dispatches
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 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.
- and 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)
---
src/components/ActivityTicker.astro | 3 +
src/layouts/AppLayout.astro | 31 ++--
src/lib/format.ts | Bin 8719 -> 9323 bytes
src/pages/pulse.astro | 251 +++++-----------------------
tests/vote-count.test.ts | 19 +++
5 files changed, 83 insertions(+), 221 deletions(-)
create mode 100644 tests/vote-count.test.ts
diff --git a/src/components/ActivityTicker.astro b/src/components/ActivityTicker.astro
index 1e6edfd..20ad4ed 100644
--- a/src/components/ActivityTicker.astro
+++ b/src/components/ActivityTicker.astro
@@ -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';
interface Props {
diff --git a/src/layouts/AppLayout.astro b/src/layouts/AppLayout.astro
index 1b3104f..968220f 100644
--- a/src/layouts/AppLayout.astro
+++ b/src/layouts/AppLayout.astro
@@ -32,7 +32,7 @@ const year = new Date().getFullYear();
-
@@ -126,12 +124,22 @@ const year = new Date().getFullYear();
}
/* ── Nav links ──────────────────────────────────────────────────── */
- .nav-links {
+ .nav-right {
display: flex;
align-items: center;
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 {
font-family: var(--font-sans);
@@ -158,13 +166,6 @@ const year = new Date().getFullYear();
}
/* ── User zone ──────────────────────────────────────────────────── */
- .nav-user {
- display: flex;
- align-items: center;
- gap: var(--space-4);
- flex-shrink: 0;
- }
-
.nav-user-name {
color: var(--on-surface-variant);
text-decoration: none;
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 5795cf85ee1111ab8e6c83a3e46faa88bca2cf3c..07d758325a27f35bc43ec808fe3941ca9054df31 100644
GIT binary patch
delta 450
zcmZvY&q~8U5Qk5qjTe!E9)FY~yIM=)v67P>dKWx5rqedC-IV {
- 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 ──────────────────────────────────────────────
const openPulseRaw = getOpenPulse();
-const totalMembers = getAllUsersPublic().filter(u => u.role === 'cab').length;
+const totalMembers = countCabMembers();
const openPulse = openPulseRaw ? getPulseWithCounts(openPulseRaw.id, user.id) : null;
// 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 ─────────────────────────────────────────────────────
const upcoming = getUpcomingEvents(20);
const nextExclusive = upcoming.find(e => e.kind === 'dinner' || e.kind === 'summit') ?? null;
@@ -163,13 +114,6 @@ function formatEventDate(iso: string): string {
-
- {tickerItems.length > 0 && (
-
- )}
-
{openPulse ? (
@@ -212,7 +156,7 @@ function formatEventDate(iso: string): string {
- {openPulse.votes_total} of {totalMembers} council member{totalMembers === 1 ? '' : 's'} weighed in. Closes {closeDayLabel(openPulse.closes_at)}.
+ {voteCountSentence(openPulse.votes_total, totalMembers)} Closes {closeDayLabel(openPulse.closes_at)}.
>
) : (
@@ -249,41 +193,20 @@ function formatEventDate(iso: string): string {
See the full roadmap →
-
-
- {visibleChips.length > 0 && (
-
-
-
- {onlineOthers.length} other{onlineOthers.length === 1 ? '' : 's'} online now
-
-
-
- )}
+
+
+
+
+
{(nextExclusive || nextOfficeHours) && (
@@ -373,8 +296,9 @@ function formatEventDate(iso: string): string {
/* ── Pulse card ───────────────────────────────────────────────── */
.pulse-card {
- background: var(--surface-container-lowest);
- border-radius: var(--radius-md);
+ 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;
@@ -461,7 +385,7 @@ function formatEventDate(iso: string): string {
}
.pulse-option.chosen {
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) {
cursor: default;
@@ -511,21 +435,33 @@ function formatEventDate(iso: string): string {
margin: 0;
}
- /* ── Roadmap preview + Council mark ──────────────────────────── */
+ /* ── Roadmap preview + Membership card ──────────────────────── */
.preview-row {
display: grid;
grid-template-columns: 2fr 1fr;
- gap: var(--space-8);
+ gap: var(--space-6);
+ align-items: stretch;
}
.section-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
- color: var(--on-surface-muted);
+ color: var(--on-surface-variant);
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 {
list-style: none;
@@ -575,104 +511,6 @@ function formatEventDate(iso: string): string {
}
.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 ──────────────────────────────────────────────── */
.event-row {
display: grid;
@@ -691,17 +529,18 @@ function formatEventDate(iso: string): string {
.event-card:hover { transform: translateY(-2px); }
.event-card--dark {
- background: var(--pigment-indigo);
- color: var(--on-primary);
+ background: var(--ink);
+ color: var(--ink-text);
}
.event-card--dark .event-title,
.event-card--dark .event-desc,
.event-card--dark .event-scarcity {
- color: var(--on-primary);
+ color: var(--ink-text);
}
.event-card--light {
- background: var(--surface-container-low);
+ background: var(--surface-card);
+ border: 0.5px solid var(--surface-card-border);
}
.event-eyebrow {
@@ -709,7 +548,7 @@ function formatEventDate(iso: string): string {
text-transform: uppercase;
color: var(--on-surface-muted);
}
- .event-eyebrow--light { color: rgba(255, 252, 247, 0.7); }
+ .event-eyebrow--light { color: var(--ink-muted); }
.event-title {
font-family: var(--font-serif);
diff --git a/tests/vote-count.test.ts b/tests/vote-count.test.ts
new file mode 100644
index 0000000..3e01a4c
--- /dev/null
+++ b/tests/vote-count.test.ts
@@ -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.');
+ });
+});