From 7bd3997564815aa66631b995d931bd1d4591b678 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 10:55:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(component):=20RoadmapCarousel=20=E2=80=94?= =?UTF-8?q?=20snap-scrolling=20horizontal=20strip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/RoadmapCarousel.astro | 260 +++++++++++++++++++++++++++ src/pages/pulse.astro | 105 +---------- 2 files changed, 269 insertions(+), 96 deletions(-) create mode 100644 src/components/RoadmapCarousel.astro diff --git a/src/components/RoadmapCarousel.astro b/src/components/RoadmapCarousel.astro new file mode 100644 index 0000000..bfeccb6 --- /dev/null +++ b/src/components/RoadmapCarousel.astro @@ -0,0 +1,260 @@ +--- +import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db'; + +interface Props { + items: RoadmapItemWithAttribution[]; +} + +const { items } = Astro.props; + +const STATUS_LABEL: Record = { + shipping: 'SHIPPING', + in_beta: 'IN BETA', + exploring: 'EXPLORING', + considering: 'CONSIDERING', +}; + +const STATUS_LABEL_COLOR: Record = { + shipping: 'var(--pigment-copper)', + in_beta: 'var(--pigment-terracotta)', + exploring: '#b4b2a9', + considering: '#b4b2a9', +}; + +const STATUS_DOT_COLOR: Record = { + 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; +--- +
+
+

On the roadmap

+
+ See the full roadmap → + {hasArrows && ( +
+ + +
+ )} +
+
+ + +
+ + + + diff --git a/src/pages/pulse.astro b/src/pages/pulse.astro index 87af797..efbdc62 100644 --- a/src/pages/pulse.astro +++ b/src/pages/pulse.astro @@ -2,6 +2,7 @@ import AppLayout from '../layouts/AppLayout.astro'; import Avatar from '../components/Avatar.astro'; import EventHeroCard from '../components/EventHeroCard.astro'; +import RoadmapCarousel from '../components/RoadmapCarousel.astro'; import { getUpcomingEvents, getEventBySlug, getEventAttendees, getUserRsvp, setEventRsvp, recordActivity, @@ -98,28 +99,9 @@ function closeDayLabel(closesAt: string): string { }).format(parseUtc(closesAt)); } -// ── Roadmap preview (3 most-recently-updated, horizontal) ────────── -const roadmapPreview = getAllRoadmapItems() - .sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1)) - .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}`; - } -} +// ── Roadmap (all items, admin orders chronologically near→far) ───── +const roadmapItems = getAllRoadmapItems() + .sort((a, b) => a.display_order - b.display_order || a.id - b.id); // ── Council ───────────────────────────────────────────────────────── const members = getAllCabMembers(); @@ -243,26 +225,11 @@ const members = getAllCabMembers(); )} - - {roadmapPreview.length > 0 && ( -
-
    - {roadmapPreview.map(item => ( -
  • - -
    -

    {item.title}

    -

    {roadmapStatusBlurb(item)}

    -
    -
  • - ))} -
- See the full roadmap -
+ + {roadmapItems.length > 0 && ( +
+ +
)} @@ -636,59 +603,6 @@ const members = getAllCabMembers(); .pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); } .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-section { background: var(--surface-card); @@ -740,7 +654,6 @@ const members = getAllCabMembers(); /* ── Responsive ───────────────────────────────────────────────── */ @media (max-width: 880px) { - .roadmap-grid { grid-template-columns: 1fr; } .editorial-row { grid-template-columns: 1fr; gap: var(--space-8); } .also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); } }