feat(component): RoadmapCarousel — snap-scrolling horizontal strip

Replaces the 3-column grid on /pulse with a CSS scroll-snap carousel.
3 cards per view on desktop (flex 0 0 calc((100% - 2px) / 3)), one full
card with a slice of the next at 88% on mobile.

Header row: serif 22px 'On the roadmap' title on the left; right side has
the 'See the full roadmap →' section link plus two 30px round arrow
buttons. Arrows disable (opacity 0.25) when at start/end of scroll. Hidden
on mobile — touch swipe is the affordance.

Carousel-scroll has 0.5px top + bottom borders in rgba(0,0,0,0.08).
Scrollbar hidden cross-browser. Each card has a 0.5px right border (last
card excluded), 24/26 padding, background --background so it sits on the
cream rather than introducing a white surface.

Card contents:
  - 6px status dot in the status colour + tracked 10px label
    '{STATUS_LABEL} · {TARGET}' in the same family colour. The
    considering tier uses the lighter #d4d2c8 dot with the #b4b2a9 label
    to distinguish it from exploring.
  - 19px serif title, line-height 1.2.
  - 12px description, line-height 1.55, --on-surface-variant. If the
    item has attributions, an attributionLine() trailer appends 'Shaped
    by Lars.' / 'Shaped by Lars and Anna.' / etc. in --on-surface-muted.

Right-edge fade gradient (80px) fades to opacity 0 at scrollEnd via a
small vanilla script. The script also handles arrow disabled state and
scrollBy ±cardWidth on click. No library.

Items are loaded with the full set ordered by display_order ASC (then
id ASC tiebreak) — admin orders chronologically nearest-to-furthest and
the carousel just consumes that.

If items.length < 4 the arrows + fade are hidden; the cards still flex
naturally and don't actually need to scroll.

/pulse: dropped the old .roadmap-section/.roadmap-grid/.roadmap-card +
status-dot/breathe styles. Carousel does its own.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 10:55:44 +02:00
parent 4219cda7b6
commit 7bd3997564
2 changed files with 269 additions and 96 deletions

View file

@ -0,0 +1,260 @@
---
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
interface Props {
items: RoadmapItemWithAttribution[];
}
const { items } = Astro.props;
const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING',
in_beta: 'IN BETA',
exploring: 'EXPLORING',
considering: 'CONSIDERING',
};
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
shipping: 'var(--pigment-copper)',
in_beta: 'var(--pigment-terracotta)',
exploring: '#b4b2a9',
considering: '#b4b2a9',
};
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
shipping: 'var(--pigment-copper)',
in_beta: 'var(--pigment-terracotta)',
exploring: '#b4b2a9',
considering: '#d4d2c8',
};
/** First-names-only attribution string. Empty when no attribution exists. */
function attributionLine(attributed: { name: string }[]): string {
if (!attributed.length) return '';
const names = attributed.map(a => a.name.split(' ')[0]);
if (names.length === 1) return `Shaped by ${names[0]}.`;
if (names.length === 2) return `Shaped by ${names[0]} and ${names[1]}.`;
return `Shaped by ${names.slice(0, -1).join(', ')} and ${names.at(-1)}.`;
}
const hasArrows = items.length > 3;
---
<section class="roadmap-section" aria-label="On the roadmap">
<header class="roadmap-header">
<h2 class="roadmap-title">On the roadmap</h2>
<div class="roadmap-actions">
<a href="/roadmap" class="roadmap-all">See the full roadmap →</a>
{hasArrows && (
<div class="roadmap-arrows" role="group" aria-label="Scroll controls">
<button type="button" class="roadmap-arrow" data-dir="prev" aria-label="Previous" disabled>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M9 2 L4 7 L9 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button type="button" class="roadmap-arrow" data-dir="next" aria-label="Next">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M5 2 L10 7 L5 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
)}
</div>
</header>
<div class="carousel-wrap">
<div class="carousel-scroll" data-carousel-scroll>
<div class="carousel-strip">
{items.map(item => (
<article class="carousel-card">
<header class="card-status">
<span class="card-dot" style={`background:${STATUS_DOT_COLOR[item.status]}`} aria-hidden="true"></span>
<span class="card-status-label" style={`color:${STATUS_LABEL_COLOR[item.status]}`}>
{STATUS_LABEL[item.status]}{item.target ? ` · ${item.target.toUpperCase()}` : ''}
</span>
</header>
<h3 class="card-title">{item.title}</h3>
<p class="card-desc">
{item.description}
{item.attributed.length > 0 && (
<span class="card-attribution"> {attributionLine(item.attributed)}</span>
)}
</p>
</article>
))}
</div>
</div>
{hasArrows && <div class="carousel-fade-right" data-carousel-fade></div>}
</div>
</section>
<script>
// Vanilla carousel — scroll by card width, update arrow disabled state,
// fade the right gradient when scrolled to the end.
document.querySelectorAll<HTMLElement>('.roadmap-section').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('[data-carousel-scroll]');
const fade = section.querySelector<HTMLElement>('[data-carousel-fade]');
if (!scroll) return;
const prev = section.querySelector<HTMLButtonElement>('.roadmap-arrow[data-dir="prev"]');
const next = section.querySelector<HTMLButtonElement>('.roadmap-arrow[data-dir="next"]');
function cardWidth() {
const card = scroll!.querySelector<HTMLElement>('.carousel-card');
return card ? card.getBoundingClientRect().width : scroll!.clientWidth;
}
function update() {
const max = scroll!.scrollWidth - scroll!.clientWidth;
const atStart = scroll!.scrollLeft <= 1;
const atEnd = scroll!.scrollLeft >= max - 1;
if (prev) prev.disabled = atStart;
if (next) next.disabled = atEnd;
if (fade) fade.style.opacity = atEnd ? '0' : '1';
}
prev?.addEventListener('click', () => scroll.scrollBy({ left: -cardWidth(), behavior: 'smooth' }));
next?.addEventListener('click', () => scroll.scrollBy({ left: cardWidth(), behavior: 'smooth' }));
scroll.addEventListener('scroll', update, { passive: true });
update();
});
</script>
<style>
.roadmap-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.roadmap-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.roadmap-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 22px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.roadmap-actions {
display: flex;
align-items: baseline;
gap: 18px;
}
.roadmap-all {
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;
}
.roadmap-all:hover { opacity: 0.8; border-bottom: none; }
.roadmap-arrows {
display: flex;
gap: 8px;
align-self: center;
}
.roadmap-arrow {
width: 30px;
height: 30px;
border-radius: 50%;
border: 0.5px solid rgba(0, 0, 0, 0.15);
background: var(--background);
color: var(--on-surface);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: opacity var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.roadmap-arrow:hover:not(:disabled) { background: var(--surface-container-low); }
.roadmap-arrow:disabled { opacity: 0.25; cursor: default; }
.carousel-wrap { position: relative; }
.carousel-scroll {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
border-top: 0.5px solid rgba(0, 0, 0, 0.08);
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
}
.carousel-scroll::-webkit-scrollbar { display: none; }
.carousel-strip {
display: flex;
}
.carousel-card {
flex: 0 0 calc((100% - 2px) / 3);
scroll-snap-align: start;
background: var(--background);
padding: 24px 26px;
box-sizing: border-box;
border-right: 0.5px solid rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
gap: 12px;
min-height: 168px;
}
.carousel-card:last-child { border-right: none; }
.carousel-fade-right {
position: absolute;
right: 0; top: 0; bottom: 0;
width: 80px;
background: linear-gradient(to right, transparent, var(--background));
pointer-events: none;
transition: opacity 0.2s ease;
}
/* Card contents */
.card-status {
display: flex;
align-items: center;
gap: 8px;
}
.card-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.card-status-label {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
font-weight: 600;
}
.card-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 19px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.card-desc {
font-size: 12px;
line-height: 1.55;
color: var(--on-surface-variant);
margin: 0;
}
.card-attribution {
color: var(--on-surface-muted);
}
/* Mobile: one card per view */
@media (max-width: 768px) {
.carousel-card { flex: 0 0 88%; }
.roadmap-arrows { display: none; }
.carousel-fade-right { display: none; }
}
</style>

View file

@ -2,6 +2,7 @@
import AppLayout from '../layouts/AppLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
import Avatar from '../components/Avatar.astro'; import Avatar from '../components/Avatar.astro';
import EventHeroCard from '../components/EventHeroCard.astro'; import EventHeroCard from '../components/EventHeroCard.astro';
import RoadmapCarousel from '../components/RoadmapCarousel.astro';
import { import {
getUpcomingEvents, getEventBySlug, getEventAttendees, getUpcomingEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity, getUserRsvp, setEventRsvp, recordActivity,
@ -98,28 +99,9 @@ function closeDayLabel(closesAt: string): string {
}).format(parseUtc(closesAt)); }).format(parseUtc(closesAt));
} }
// ── Roadmap preview (3 most-recently-updated, horizontal) ────────── // ── Roadmap (all items, admin orders chronologically near→far) ─────
const roadmapPreview = getAllRoadmapItems() const roadmapItems = getAllRoadmapItems()
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1)) .sort((a, b) => a.display_order - b.display_order || a.id - b.id);
.slice(0, 3);
function roadmapStatusDot(status: 'shipping' | 'in_beta' | 'exploring' | 'considering'): string {
return ({
shipping: 'var(--pigment-copper)',
in_beta: 'var(--pigment-terracotta)',
exploring: '#b4b2a9',
considering: '#d4d2c8',
})[status];
}
function roadmapStatusBlurb(item: { status: 'shipping' | 'in_beta' | 'exploring' | 'considering'; target: string | null }): string {
const target = item.target ? ` · ${item.target}` : '';
switch (item.status) {
case 'shipping': return `Shipping${target}`;
case 'in_beta': return `In beta${target}`;
case 'exploring': return `Exploring${target}`;
case 'considering': return `Considering${target}`;
}
}
// ── Council ───────────────────────────────────────────────────────── // ── Council ─────────────────────────────────────────────────────────
const members = getAllCabMembers(); const members = getAllCabMembers();
@ -243,26 +225,11 @@ const members = getAllCabMembers();
</section> </section>
)} )}
<!-- ── Roadmap — horizontal cards ───────────────────────────── --> <!-- ── Roadmap — horizontal snap-scrolling carousel ─────────── -->
{roadmapPreview.length > 0 && ( {roadmapItems.length > 0 && (
<section class="cascade roadmap-section" aria-label="From the roadmap"> <div class="cascade">
<ul class="roadmap-grid"> <RoadmapCarousel items={roadmapItems} />
{roadmapPreview.map(item => ( </div>
<li class="roadmap-card">
<span
class:list={['status-dot', { breathing: item.status === 'shipping' }]}
style={`background:${roadmapStatusDot(item.status)}`}
aria-hidden="true"
></span>
<div class="roadmap-card-text">
<h3 class="roadmap-card-title">{item.title}</h3>
<p class="roadmap-card-blurb">{roadmapStatusBlurb(item)}</p>
</div>
</li>
))}
</ul>
<a href="/roadmap" class="section-link">See the full roadmap</a>
</section>
)} )}
<!-- ── Council members — single background, no per-member boxes - --> <!-- ── Council members — single background, no per-member boxes - -->
@ -636,59 +603,6 @@ const members = getAllCabMembers();
.pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); } .pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); }
.pulse-option-text { flex: 1; } .pulse-option-text { flex: 1; }
/* ── Roadmap horizontal cards ─────────────────────────────────── */
.roadmap-section {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.roadmap-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-5);
}
.roadmap-card {
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-3);
min-height: 140px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.5; }
}
.status-dot.breathing { animation: breathe 2.4s ease-in-out infinite; }
.roadmap-card-text { display: flex; flex-direction: column; gap: var(--space-1); }
.roadmap-card-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.0625rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.roadmap-card-blurb {
color: var(--on-surface-muted);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
margin: 0;
}
/* ── Council — one outer surface, no per-member boxes ─────────── */ /* ── Council — one outer surface, no per-member boxes ─────────── */
.council-section { .council-section {
background: var(--surface-card); background: var(--surface-card);
@ -740,7 +654,6 @@ const members = getAllCabMembers();
/* ── Responsive ───────────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) { @media (max-width: 880px) {
.roadmap-grid { grid-template-columns: 1fr; }
.editorial-row { grid-template-columns: 1fr; gap: var(--space-8); } .editorial-row { grid-template-columns: 1fr; gap: var(--space-8); }
.also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); } .also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
} }