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:
parent
4219cda7b6
commit
7bd3997564
2 changed files with 269 additions and 96 deletions
260
src/components/RoadmapCarousel.astro
Normal file
260
src/components/RoadmapCarousel.astro
Normal 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>
|
||||||
|
|
@ -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 => (
|
|
||||||
<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>
|
</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); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue