feat(pulse): full member-portal landing at /pulse
Sections (top to bottom, all wrapped in a cascade-entry animation):
- greeting block: MONDAY · 11 MAY label, serif-italic "Good morning, X."
greeting (server time, Europe/Copenhagen), tenure line that uses
cab_joined_date for cab members and falls back to created_at
- ActivityTicker fed by getRecentActivity (hidden when empty)
- this-week Pulse card: live breathing dot + time-left countdown, italic
serif question, 2×2 grid of option buttons that submit a hidden-action
vote form. Once voted, options lock and show a distribution bar fill;
the user's choice keeps a terracotta border. Empty state when no pulse
is open.
- 2-col roadmap preview + council mark: three most-recently-updated items
with status dot (copper/ochre/muted), and the user's CouncilMark md with
the "{n} of 12 quarters" stat
- members-in-the-room: 4 chips for other CAB members seen within 5 min,
+N overflow chip; pulsing copper dot for the "online now" indicator
- two-column event row: dark indigo "members-only" card (next dinner or
summit) + light "office hours" card (next office_hours event). Hidden
per-card when no matching upcoming event exists.
Vote POST uses the existing admin form pattern (hidden action field,
redirect on success). Activity row for 'voted' is written inline here;
step 11 covers the other activity sources.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39c9c805cd
commit
351d90a3e8
1 changed files with 706 additions and 6 deletions
|
|
@ -1,14 +1,312 @@
|
|||
---
|
||||
import AppLayout from '../layouts/AppLayout.astro';
|
||||
import ActivityTicker from '../components/ActivityTicker.astro';
|
||||
import CouncilMark from '../components/CouncilMark.astro';
|
||||
import {
|
||||
getOpenPulse, getPulseWithCounts, castVote, recordActivity,
|
||||
getRecentActivity, getPulseById, getEventById, getRoadmapItem,
|
||||
getAllRoadmapItems, getUpcomingEvents, getAllUsersPublic,
|
||||
getLitQuarters, countShippedAttributions, getUserVote,
|
||||
} from '../lib/db';
|
||||
import { tickerItem, pulseDateLabel, timeOfDay, tenureSince, redactName } from '../lib/format';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
|
||||
// ── POST: cast vote ────────────────────────────────────────────────
|
||||
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) {
|
||||
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}.`;
|
||||
const dateLabel = pulseDateLabel();
|
||||
|
||||
const tenureAnchor = user.role === 'cab' && user.cab_joined_date
|
||||
? user.cab_joined_date
|
||||
: user.created_at;
|
||||
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 ──────────────────────────────────────────────
|
||||
const openPulseRaw = getOpenPulse();
|
||||
const totalMembers = getAllUsersPublic().filter(u => u.role === 'cab').length;
|
||||
const openPulse = openPulseRaw ? getPulseWithCounts(openPulseRaw.id, user.id) : null;
|
||||
|
||||
// Time-left label: "32 seconds" / "3 hours" / "2 days" — soft countdown
|
||||
function timeLeftLabel(closesAt: string): string {
|
||||
const ms = new Date(closesAt).getTime() - Date.now();
|
||||
if (ms <= 0) return 'closing now';
|
||||
const d = Math.floor(ms / 86400000);
|
||||
if (d >= 1) return `${d} day${d === 1 ? '' : 's'}`;
|
||||
const h = Math.floor(ms / 3600000);
|
||||
if (h >= 1) return `${h} hour${h === 1 ? '' : 's'}`;
|
||||
const m = Math.floor(ms / 60000);
|
||||
if (m >= 1) return `${m} minute${m === 1 ? '' : 's'}`;
|
||||
const s = Math.floor(ms / 1000);
|
||||
return `${s} seconds`;
|
||||
}
|
||||
|
||||
function closeDayLabel(closesAt: string): string {
|
||||
const d = new Date(closesAt);
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'long', timeZone: 'Europe/Copenhagen',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// ── Roadmap preview (3 most-recently-updated items) ────────────────
|
||||
const roadmapPreview = getAllRoadmapItems()
|
||||
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1))
|
||||
.slice(0, 3);
|
||||
|
||||
function roadmapStatusDot(status: 'shipping' | 'beta' | 'exploring'): string {
|
||||
return ({
|
||||
shipping: 'var(--pigment-copper)',
|
||||
beta: 'var(--pigment-ochre)',
|
||||
exploring: 'var(--on-surface-muted)',
|
||||
})[status];
|
||||
}
|
||||
function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; target: string | null; attributed: unknown[] }): string {
|
||||
const target = item.target ? ` · ${item.target}` : '';
|
||||
switch (item.status) {
|
||||
case 'shipping': return `Shipping${target}`;
|
||||
case 'beta': return `In beta${target}`;
|
||||
case 'exploring': return `Exploring${target}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Council mark + stat ────────────────────────────────────────────
|
||||
const litCount = getLitQuarters(user.id).size;
|
||||
const shippedCount = countShippedAttributions(user.id);
|
||||
|
||||
// ── Members in the room ────────────────────────────────────────────
|
||||
const allMembers = getAllUsersPublic();
|
||||
const onlineOthers = allMembers.filter(u =>
|
||||
u.id !== user.id
|
||||
&& u.last_seen_at
|
||||
&& (Date.now() - new Date(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;
|
||||
const nextOfficeHours = upcoming.find(e => e.kind === 'office_hours') ?? null;
|
||||
|
||||
function formatEventDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen',
|
||||
}).format(new Date(iso)).toUpperCase();
|
||||
}
|
||||
---
|
||||
<AppLayout title="Pulse" user={user}>
|
||||
<div class="page">
|
||||
<p class="label-sm eyebrow">Pulse</p>
|
||||
<h1 class="display-md">Welcome back, <em>{firstName}</em>.</h1>
|
||||
<p class="lead">The full member view lands in the next commit.</p>
|
||||
|
||||
<!-- ── Greeting ─────────────────────────────────────────────── -->
|
||||
<section class="cascade greeting">
|
||||
<p class="label-sm date-label">{dateLabel}</p>
|
||||
<h1 class="greeting-line">
|
||||
<span class="greeting-italic">{greeting}</span>
|
||||
</h1>
|
||||
<p class="greeting-sub body-md">
|
||||
You've been a council member for <em>{tenure}</em>. The team is reading every note you leave.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── Ticker ───────────────────────────────────────────────── -->
|
||||
{tickerItems.length > 0 && (
|
||||
<section class="cascade ticker-wrap">
|
||||
<ActivityTicker items={tickerItems} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- ── This week's Pulse ────────────────────────────────────── -->
|
||||
<section class="cascade pulse-card">
|
||||
{openPulse ? (
|
||||
<>
|
||||
<div class="pulse-meta">
|
||||
<span class="live-dot" aria-hidden="true"></span>
|
||||
<span class="label-sm pulse-label">This week's pulse · closes in {timeLeftLabel(openPulse.closes_at)}</span>
|
||||
</div>
|
||||
<p class="pulse-question">{openPulse.question}</p>
|
||||
{openPulse.context && <p class="pulse-context body-md">{openPulse.context}</p>}
|
||||
|
||||
<form method="POST" class="pulse-options" novalidate>
|
||||
<input type="hidden" name="action" value="vote" />
|
||||
<input type="hidden" name="pulse_id" value={openPulse.id} />
|
||||
{openPulse.options.map((opt, i) => {
|
||||
const chosen = openPulse.my_vote === i;
|
||||
const count = openPulse.votes_by_option[i] ?? 0;
|
||||
const pct = openPulse.votes_total > 0 ? (count / openPulse.votes_total) * 100 : 0;
|
||||
const locked = openPulse.my_vote !== null;
|
||||
const letter = String.fromCharCode(65 + i); // A/B/C/D
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
name="option_index"
|
||||
value={i}
|
||||
class:list={['pulse-option', { chosen, locked }]}
|
||||
disabled={locked && !chosen}
|
||||
aria-pressed={chosen}
|
||||
>
|
||||
<span class="pulse-option-letter label-sm">{letter}</span>
|
||||
<span class="pulse-option-text">{opt}</span>
|
||||
{locked && (
|
||||
<span class="pulse-option-bar" aria-hidden="true">
|
||||
<span class="pulse-option-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</form>
|
||||
|
||||
<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)}.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div class="pulse-empty">
|
||||
<span class="label-sm pulse-label-muted">This week's pulse</span>
|
||||
<p class="pulse-empty-line">No pulse is open right now. The next one drops soon.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<!-- ── Roadmap preview + Council mark ───────────────────────── -->
|
||||
<section class="cascade preview-row">
|
||||
<div class="roadmap-preview">
|
||||
<p class="label-sm section-eyebrow">From the roadmap</p>
|
||||
{roadmapPreview.length === 0 ? (
|
||||
<p class="body-sm muted">No roadmap items yet.</p>
|
||||
) : (
|
||||
<ul class="roadmap-list">
|
||||
{roadmapPreview.map(item => (
|
||||
<li class="roadmap-row">
|
||||
<span
|
||||
class:list={['status-dot', { breathing: item.status === 'shipping' }]}
|
||||
style={`background:${roadmapStatusDot(item.status)}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="roadmap-row-text">
|
||||
<p class="roadmap-row-title">{item.title}</p>
|
||||
<p class="roadmap-row-blurb label-sm">{roadmapStatusBlurb(item)}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<a href="/roadmap" class="see-all label-sm">See the full roadmap →</a>
|
||||
</div>
|
||||
|
||||
<aside class="mark-card">
|
||||
<p class="label-sm section-eyebrow">Your council mark</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- ── Members in the room ──────────────────────────────────── -->
|
||||
{visibleChips.length > 0 && (
|
||||
<section class="cascade room-row">
|
||||
<div class="room-label">
|
||||
<span class="online-dot" aria-hidden="true"></span>
|
||||
<span class="label-sm">{onlineOthers.length} other{onlineOthers.length === 1 ? '' : 's'} online now</span>
|
||||
</div>
|
||||
<div class="chip-row">
|
||||
{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>
|
||||
)}
|
||||
|
||||
<!-- ── Event row ────────────────────────────────────────────── -->
|
||||
{(nextExclusive || nextOfficeHours) && (
|
||||
<section class="cascade event-row">
|
||||
{nextExclusive && (
|
||||
<article class="event-card event-card--dark">
|
||||
<p class="label-sm event-eyebrow event-eyebrow--light">
|
||||
Members only · {formatEventDate(nextExclusive.starts_at)}
|
||||
</p>
|
||||
<h3 class="event-title">{nextExclusive.title}</h3>
|
||||
<p class="event-desc">{nextExclusive.description}</p>
|
||||
{nextExclusive.capacity && (
|
||||
<p class="event-scarcity label-sm">{nextExclusive.capacity} seats · invitation by hand</p>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
{nextOfficeHours && (
|
||||
<article class="event-card event-card--light">
|
||||
<p class="label-sm event-eyebrow">
|
||||
Office hours · {formatEventDate(nextOfficeHours.starts_at)}
|
||||
</p>
|
||||
<h3 class="event-title">{nextOfficeHours.title}</h3>
|
||||
<p class="event-desc">{nextOfficeHours.description}</p>
|
||||
</article>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
|
|
@ -17,12 +315,414 @@ const firstName = user.name.split(' ')[0];
|
|||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
.eyebrow {
|
||||
|
||||
/* ── Cascade entry (first paint only) ─────────────────────────── */
|
||||
.cascade {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: cascade-in 650ms var(--ease-standard) forwards;
|
||||
}
|
||||
.cascade:nth-child(1) { animation-delay: 0ms; }
|
||||
.cascade:nth-child(2) { animation-delay: 100ms; }
|
||||
.cascade:nth-child(3) { animation-delay: 200ms; }
|
||||
.cascade:nth-child(4) { animation-delay: 300ms; }
|
||||
.cascade:nth-child(5) { animation-delay: 400ms; }
|
||||
.cascade:nth-child(6) { animation-delay: 500ms; }
|
||||
@keyframes cascade-in {
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cascade { opacity: 1; transform: none; animation: none; }
|
||||
}
|
||||
|
||||
/* ── Greeting ─────────────────────────────────────────────────── */
|
||||
.greeting { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
|
||||
.date-label {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.lead { color: var(--on-surface-variant); max-width: var(--reading-max); }
|
||||
|
||||
.greeting-line {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: var(--text-display-md);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.greeting-italic { font-style: italic; }
|
||||
|
||||
.greeting-sub {
|
||||
color: var(--on-surface-variant);
|
||||
max-width: 48rem;
|
||||
margin: 0;
|
||||
}
|
||||
.greeting-sub em { font-style: italic; color: var(--on-surface); }
|
||||
|
||||
/* ── Pulse card ───────────────────────────────────────────────── */
|
||||
.pulse-card {
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-7) var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.pulse-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--pigment-terracotta);
|
||||
border-radius: 50%;
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.4); opacity: 0.5; }
|
||||
}
|
||||
|
||||
.pulse-label {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pulse-label-muted {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
|
||||
.pulse-question {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 1.375rem;
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
max-width: 50rem;
|
||||
}
|
||||
|
||||
.pulse-context {
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
max-width: 50rem;
|
||||
}
|
||||
|
||||
.pulse-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.pulse-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--background);
|
||||
border: var(--ghost-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: transform 300ms var(--ease-standard),
|
||||
border-color 300ms var(--ease-standard),
|
||||
background var(--duration-fast) var(--ease-standard);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pulse-option:hover:not(.locked) {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--outline);
|
||||
}
|
||||
.pulse-option.chosen {
|
||||
border-color: var(--pigment-terracotta);
|
||||
background: var(--surface-container-low);
|
||||
}
|
||||
.pulse-option.locked:not(.chosen) {
|
||||
cursor: default;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.pulse-option:disabled { opacity: 0.8; }
|
||||
|
||||
.pulse-option-letter {
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); }
|
||||
|
||||
.pulse-option-text { flex: 1; }
|
||||
|
||||
.pulse-option-bar {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
height: 2px;
|
||||
background: var(--surface-container);
|
||||
}
|
||||
.pulse-option-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--pigment-terracotta);
|
||||
opacity: 0.6;
|
||||
transition: width 600ms var(--ease-standard);
|
||||
}
|
||||
|
||||
.pulse-count {
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
}
|
||||
.pulse-count strong { color: var(--on-surface); font-weight: 600; }
|
||||
|
||||
.pulse-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.pulse-empty-line {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 1.25rem;
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Roadmap preview + Council mark ──────────────────────────── */
|
||||
.preview-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.section-eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.roadmap-preview { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
|
||||
.roadmap-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.roadmap-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) 0;
|
||||
border-top: var(--ghost-border);
|
||||
}
|
||||
.roadmap-row:last-child { border-bottom: var(--ghost-border); }
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
.status-dot.breathing { animation: breathe 2.4s ease-in-out infinite; }
|
||||
|
||||
.roadmap-row-text { flex: 1; display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.roadmap-row-title { margin: 0; font-weight: 500; color: var(--on-surface); }
|
||||
.roadmap-row-blurb {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.see-all {
|
||||
color: var(--on-surface-variant);
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
margin-top: var(--space-3);
|
||||
align-self: flex-start;
|
||||
transition: color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.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;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.event-card {
|
||||
padding: var(--space-8);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
transition: transform 300ms var(--ease-standard);
|
||||
}
|
||||
.event-card:hover { transform: translateY(-2px); }
|
||||
|
||||
.event-card--dark {
|
||||
background: var(--pigment-indigo);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
.event-card--dark .event-title,
|
||||
.event-card--dark .event-desc,
|
||||
.event-card--dark .event-scarcity {
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.event-card--light {
|
||||
background: var(--surface-container-low);
|
||||
}
|
||||
|
||||
.event-eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
.event-eyebrow--light { color: rgba(255, 252, 247, 0.7); }
|
||||
|
||||
.event-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.5rem;
|
||||
line-height: var(--leading-snug);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.event-desc { margin: 0; }
|
||||
|
||||
.event-scarcity {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive: collapse 2-col rows on narrow widths ────────── */
|
||||
@media (max-width: 880px) {
|
||||
.preview-row, .event-row, .pulse-options { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue