feat(pulse): council marquee auto-scrolls all 7 members across the page

Council section gets a proper carousel — a horizontal marquee that
moves continuously across the page, listing every cab member in turn
rather than a fixed-size grid.

Implementation:
- Members rendered twice in a single flex track; CSS keyframe translates
  from translateX(0) to translateX(-50%) over 40s+ (duration scales with
  member count via the --marquee-duration inline custom prop, capped at
  6 sec per member or 28 sec minimum). At -50% the first copy is fully
  offscreen and the second copy occupies the visible window seamlessly;
  the loop resets without a visible jump.
- aria-hidden on the duplicated copies so screen readers don't double-
  announce.
- mask-image fades both edges so members slide in and out softly rather
  than clipping at the container edge.
- Paused on hover so a reader can stop and parse a tile.
- prefers-reduced-motion: animation off and the strip becomes a quietly
  scrollable horizontal list — keyboard / trackpad users can pan
  manually instead of relying on the animation.

Seed adds 3 more cab members for a total of 7 (Mads Lindberg, Camilla
Storm, Frederik Lund) with backdated cab_joined_date so member_numbers
allocate 5/6/7. Each gets title + pull_quote + focus_tags consistent
with the existing four. Tenure spread is now 3 → 24 weeks across the
seven members so /members renders meaningfully varied 'member since'
dates.

The previous 4-tile grid + 5th-tile-as-link case is gone; the marquee
loops the full set so no truncation is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 11:21:53 +02:00
parent 5ddaad3da3
commit 39996ab93e
2 changed files with 80 additions and 46 deletions

View file

@ -61,6 +61,9 @@ const newCabs = [
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
{ name: 'Mads Lindberg', email: 'mads@virk6.dk', org: 'Virksomhed 6' },
{ name: 'Camilla Storm', email: 'camilla@virk7.dk', org: 'Virksomhed 7' },
{ name: 'Frederik Lund', email: 'frederik@virk8.dk', org: 'Virksomhed 8' },
];
const insertUser = db.prepare(`
@ -75,7 +78,15 @@ for (const c of newCabs) {
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
const tenureWeeks = {
'lars@virk2.dk': 24,
'anna@virk3.dk': 14,
'soren@virk4.dk': 12,
'henriette@virk5.dk': 10,
'mads@virk6.dk': 8,
'camilla@virk7.dk': 6,
'frederik@virk8.dk': 3,
};
const setCabMeta = db.prepare(`
UPDATE users
@ -108,6 +119,21 @@ const cabMeta = {
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
focus_tags: ['Legal', 'Policy', 'EU AI Act'],
},
'mads@virk6.dk': {
title: 'Chief Strategy Officer',
pull_quote: 'Healthcare runs on consent — and consent runs on trust.',
focus_tags: ['Healthcare', 'Consent', 'Governance'],
},
'camilla@virk7.dk': {
title: 'Head of Cyber Resilience',
pull_quote: 'Cyber resilience is not a feature — it is the substrate.',
focus_tags: ['Defence', 'Resilience'],
},
'frederik@virk8.dk': {
title: 'Director of Public Innovation',
pull_quote: 'Public innovation succeeds when it is measurably better, not just newer.',
focus_tags: ['Public sector', 'Measurement'],
},
};
for (const u of cabRows) {
@ -343,7 +369,7 @@ insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, no
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 #' + decisionPulseId + ' open, 2 of 4 voted');
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`);
console.log(' roadmap: 7 items (2 shipping / 1 in_beta / 2 exploring / 2 considering)');
console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)');

View file

@ -229,7 +229,7 @@ const members = getAllCabMembers();
</div>
)}
<!-- ── Council — matches roadmap section framing ─────────────── -->
<!-- ── Council — auto-scrolling marquee, all members ───────── -->
{members.length > 0 && (
<section class="cascade council-section" aria-label="The council">
<header class="council-header">
@ -237,9 +237,10 @@ const members = getAllCabMembers();
<a href="/members" class="council-all">See who our council is made up of →</a>
</header>
<ul class="council-grid">
{members.slice(0, 4).map(m => (
<li class="council-tile">
<div class="council-marquee" aria-roledescription="carousel">
<ul class="council-marquee-track" style={`--marquee-duration: ${Math.max(28, members.length * 6)}s;`}>
{[...members, ...members].map((m, i) => (
<li class="council-tile" aria-hidden={i >= members.length ? 'true' : undefined}>
<Avatar id={m.id} name={m.name} size={38} />
<div class="council-tile-text">
<span class="council-tile-name">{m.name}</span>
@ -248,14 +249,8 @@ const members = getAllCabMembers();
</div>
</li>
))}
{members.length > 4 && (
<li class="council-tile council-tile--more">
<a href="/members" class="council-tile-link">
See all {members.length} council members →
</a>
</li>
)}
</ul>
</div>
</section>
)}
@ -648,19 +643,48 @@ const members = getAllCabMembers();
}
.council-all:hover { opacity: 0.8; border-bottom: none; }
.council-grid {
/* Marquee — list is rendered twice so translateX(-50%) wraps seamlessly.
The duration is set via --marquee-duration on the inline style so the
loop speed scales with member count. Paused on hover and for users
with reduced-motion preferences. */
.council-marquee {
position: relative;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 6%, #000 94%, transparent 100%);
mask-image: linear-gradient(90deg, transparent 0, #000 6%, #000 94%, transparent 100%);
}
.council-marquee-track {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
display: flex;
gap: 48px;
width: max-content;
animation: council-scroll var(--marquee-duration, 40s) linear infinite;
will-change: transform;
}
.council-marquee:hover .council-marquee-track {
animation-play-state: paused;
}
@keyframes council-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.council-marquee-track { animation: none; }
.council-marquee {
overflow-x: auto;
scrollbar-width: none;
}
.council-marquee::-webkit-scrollbar { display: none; }
}
.council-tile {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 0 0 auto;
min-width: 220px;
}
.council-tile-text {
display: flex;
@ -674,12 +698,14 @@ const members = getAllCabMembers();
font-size: 15px;
line-height: 1.15;
color: var(--on-surface);
white-space: nowrap;
}
.council-tile-title {
font-family: var(--font-sans);
font-size: 11px;
color: var(--on-surface-variant);
line-height: 1.35;
white-space: nowrap;
}
.council-tile-org {
font-family: var(--font-sans);
@ -687,30 +713,12 @@ const members = getAllCabMembers();
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
white-space: nowrap;
}
.council-tile--more {
align-items: center;
justify-content: flex-end;
}
.council-tile-link {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
text-align: right;
}
.council-tile-link:hover { opacity: 0.8; border-bottom: none; }
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) {
.editorial-row { grid-template-columns: 1fr; gap: var(--space-8); }
.also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
.council-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 520px) {
.council-grid { grid-template-columns: 1fr; }
}
</style>