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(); Fenja AI - @@ -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 -
-
- {visibleChips.map(m => ( - - - - {redactName(m.name)} - {m.organisation} - - - ))} - {overflowCount > 0 && ( - +{overflowCount} others - )} -
-
- )} + +
+ +
+ + +
+ +
{(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.'); + }); +});