1. Markdown preview in the admin edit panel now re-renders from the textarea's current value on every toggle (dynamic-imports marked on the client). Previously the panel showed the server-rendered seed value forever, so new dispatches always previewed empty. 2. Pulse sub-form drops the opens_at field (opens on dispatch publish automatically) and changes closes_at to a date input — the chosen day is treated as end-of-day in the DB. 3. /dispatches/[slug] reading width widened 50% (720 → 1080px). 4. Roadmap display_order cascades on insert / update / delete: inserting at N bumps N..end up by 1, deleting N pulls N+1..end down by 1, moving from A to B shifts the intermediate range by 1 in the appropriate direction. Order stays dense — no gaps, no collisions. All three transitions run in a transaction. 5. /roadmap always anchors at scrollLeft=0 on mount so the first milestone aligns with the content-column left edge. Previously the page jumped to the last-shipping milestone, which felt random once items past the viewport landed. 6. Events admin list shows the actual date (fmtDateTime) instead of "in 3 days" — easier to scan when planning across months. 7. duration_label is auto-computed from starts_at + ends_at on save (minutes < 90, hours < 4, "Half day", "Full day", "N days"). The manual field is gone from the admin form; the column on the member-facing event pages keeps reading the stored value as before. 8. Pulse hero still skips office hours per the existing logic — no change. Confirmed via the test note's clarification. 9. Pulse "also coming up" strip relabeled to Previous + Upcoming. Previous = most recent past non-office-hours event. Upcoming = next non-office-hours event after the hero. Each card now carries a small terracotta eyebrow with the label. Typecheck clean, build clean, 147/147 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
690 lines
24 KiB
Text
690 lines
24 KiB
Text
---
|
|
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
|
|
import { computeRouteLayout, travelledStopFor } from '../lib/roadmap-layout';
|
|
|
|
interface Props {
|
|
items: RoadmapItemWithAttribution[];
|
|
viewportWidth?: number; // SSR fallback for the layout math
|
|
}
|
|
|
|
const { items, viewportWidth = 1100 } = Astro.props;
|
|
|
|
// Align the first milestone with the left edge of the page's content column
|
|
// (matches the LatestDispatchBanner below). --content-max is 72rem = 1152px.
|
|
const CONTENT_MAX = 1152;
|
|
const DEFAULT_PADDING = 60;
|
|
const paddingLeft = Math.max(DEFAULT_PADDING, (viewportWidth - CONTENT_MAX) / 2);
|
|
|
|
const layout = computeRouteLayout({
|
|
itemCount: items.length,
|
|
viewportWidth,
|
|
paddingLeft,
|
|
});
|
|
const travelledStop = travelledStopFor(items.map(i => i.status));
|
|
|
|
const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
|
shipping: 'SHIPPING',
|
|
in_beta: 'IN BETA',
|
|
exploring: 'EXPLORING',
|
|
considering: 'CONSIDERING',
|
|
};
|
|
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
|
|
shipping: '#6d8c7c',
|
|
in_beta: '#b96b58',
|
|
exploring: '#b4b2a9',
|
|
considering: '#b4b2a9',
|
|
};
|
|
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
|
|
shipping: '#6d8c7c',
|
|
in_beta: '#b96b58',
|
|
exploring: '#b4b2a9',
|
|
considering: '#d4d2c8',
|
|
};
|
|
|
|
// "You are here" — the most recent shipping item. -1 if nothing has shipped yet.
|
|
let lastShippingIndex = -1;
|
|
items.forEach((it, i) => { if (it.status === 'shipping') lastShippingIndex = i; });
|
|
|
|
function trailingLine(item: RoadmapItemWithAttribution): string | null {
|
|
if (item.metadata_text && item.metadata_text.trim().length > 0) return item.metadata_text;
|
|
if (item.attributed.length > 0) {
|
|
const names = item.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)}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Stringified x position of the 'you are here' milestone for the
|
|
// initial-scroll logic in the nav script. -1 → 0 (no scroll offset).
|
|
const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex] : 0;
|
|
---
|
|
<section class="route" aria-label="Roadmap route" data-initial-x={initialShippingX}>
|
|
|
|
<!-- The route — desktop horizontal. .rr-fullbleed escapes the parent
|
|
.page max-width so the route can span the actual viewport while
|
|
the header above and legend below stay centred in the content
|
|
column. -->
|
|
<div class="rr-wrap rr-fullbleed rr-desktop" data-item-count={items.length}>
|
|
<div class="rr-scroll" id="rr-scroll">
|
|
<div class="rr-scroll-inner">
|
|
<div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
|
|
<svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="420" aria-hidden="true">
|
|
<defs>
|
|
<linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/>
|
|
<stop offset={String(travelledStop)} stop-color="#2a2520" stop-opacity="0.55"/>
|
|
<stop offset={String(Math.min(1, travelledStop + 0.06))} stop-color="#2a2520" stop-opacity="0.15"/>
|
|
<stop offset="1" stop-color="#2a2520" stop-opacity="0.15"/>
|
|
</linearGradient>
|
|
</defs>
|
|
{layout.pathD && (
|
|
<path id="rr-path-d" d={layout.pathD} fill="none" stroke="url(#rr-path-gradient)" stroke-width="1.25" stroke-linecap="round"/>
|
|
)}
|
|
</svg>
|
|
|
|
{items.map((item, i) => (
|
|
<div
|
|
class="rr-milestone"
|
|
data-y={layout.itemY[i]}
|
|
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
|
|
>
|
|
<div
|
|
class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]}
|
|
style={`background:${STATUS_DOT_COLOR[item.status]};`}
|
|
aria-hidden="true"
|
|
></div>
|
|
|
|
<div class:list={['rr-attach', `rr-attach-${layout.cardSide[i]}`]}>
|
|
<div class="rr-connector" aria-hidden="true"></div>
|
|
<a class="rr-card" tabindex="0" href={`#item-${item.id}`} id={`item-${item.id}`}>
|
|
<p class="rr-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
|
|
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
|
|
</p>
|
|
<p class="rr-card-title">{item.title}</p>
|
|
<div class="rr-more">
|
|
{item.description && <p class="rr-desc">{item.description}</p>}
|
|
{trailingLine(item) && <p class="rr-trail">{trailingLine(item)}</p>}
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rr-fade-left" id="rr-fade-l" aria-hidden="true"></div>
|
|
<div class="rr-fade-right" id="rr-fade-r" aria-hidden="true"></div>
|
|
|
|
<!-- Single forward-only advance affordance anchored to the right
|
|
viewport edge. There's no left arrow on purpose — the path
|
|
reads left-to-right and the user's instinct after looking at
|
|
a milestone is 'what's next?', not 'what came before?'. -->
|
|
<button
|
|
type="button"
|
|
class="rr-advance"
|
|
id="rr-advance"
|
|
aria-label="Further along the route"
|
|
>
|
|
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
|
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Legend lives in /roadmap.astro now so it returns to centred
|
|
content-column width below the full-bleed route. -->
|
|
|
|
<!-- Mobile vertical timeline -->
|
|
<ol class="rr-mobile" aria-label="Roadmap timeline">
|
|
{items.map((item, i) => (
|
|
<li class="rrm-row">
|
|
<div class="rrm-track-col" aria-hidden="true">
|
|
<span class="rrm-dot" style={`background:${STATUS_DOT_COLOR[item.status]};`}></span>
|
|
{i < items.length - 1 && <span class="rrm-line"></span>}
|
|
</div>
|
|
<div class="rrm-body">
|
|
<p class="rrm-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
|
|
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
|
|
</p>
|
|
<p class="rrm-title">{item.title}</p>
|
|
{item.description && <p class="rrm-desc">{item.description}</p>}
|
|
{trailingLine(item) && <p class="rrm-trail">{trailingLine(item)}</p>}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</section>
|
|
|
|
<script>
|
|
// Vanilla route runtime. Two concerns:
|
|
// 1. Nav: arrow buttons, edge fades, initial-scroll into shipping
|
|
// 2. Viewport-aware layout — SSR uses a 1100px fallback for the math;
|
|
// on the client we know the real viewport, so we recompute itemX
|
|
// positions + SVG path d + track width on mount and on (debounced)
|
|
// resize. itemY values come from data-y on each milestone (path
|
|
// amplitude doesn't change with viewport, only the horizontal spread).
|
|
const MIN_SPACING = 320;
|
|
const PADDING_X = 60;
|
|
const CONTENT_MAX = 1152; // matches --content-max (72rem)
|
|
|
|
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
|
|
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
|
|
const wrap = section.querySelector<HTMLElement>('.rr-wrap');
|
|
const track = section.querySelector<HTMLElement>('#rr-track');
|
|
const svg = section.querySelector<SVGSVGElement>('#rr-path-svg');
|
|
const pathD = section.querySelector<SVGPathElement>('#rr-path-d');
|
|
const milestones = Array.from(section.querySelectorAll<HTMLElement>('.rr-milestone'));
|
|
const fadeL = section.querySelector<HTMLElement>('#rr-fade-l');
|
|
const fadeR = section.querySelector<HTMLElement>('#rr-fade-r');
|
|
const advance = section.querySelector<HTMLButtonElement>('#rr-advance');
|
|
if (!scroll || !track || !svg) return;
|
|
|
|
const itemCount = milestones.length;
|
|
const itemY: number[] = milestones.map(m => Number(m.dataset.y ?? 0));
|
|
|
|
/** Recompute trackWidth + itemX[] + pathD using the live viewport. */
|
|
function recompute() {
|
|
const vw = window.innerWidth;
|
|
const targetUsableWidth = vw * 0.80;
|
|
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
|
|
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
|
|
// Match the SSR offset — first item aligns with the content-column
|
|
// left edge so the route lines up with the dispatch banner below.
|
|
const paddingLeft = Math.max(PADDING_X, (vw - CONTENT_MAX) / 2);
|
|
const trackWidth = paddingLeft + usableWidth + PADDING_X;
|
|
|
|
const itemX: number[] = [];
|
|
for (let i = 0; i < itemCount; i += 1) {
|
|
itemX.push(
|
|
itemCount === 1
|
|
? paddingLeft + usableWidth / 2
|
|
: paddingLeft + (i / (itemCount - 1)) * usableWidth,
|
|
);
|
|
}
|
|
|
|
// Bezier path: control points at the segment midpoint x with control
|
|
// y values matching the prior and next milestone (keeps the tangent
|
|
// flat at each dot — the "river" feel from the layout helper).
|
|
let d = '';
|
|
if (itemCount > 0) {
|
|
d = `M ${itemX[0]} ${itemY[0]}`;
|
|
for (let i = 1; i < itemCount; i += 1) {
|
|
const cx = (itemX[i - 1] + itemX[i]) / 2;
|
|
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
|
|
}
|
|
}
|
|
|
|
// Apply.
|
|
track!.style.width = `${trackWidth}px`;
|
|
svg!.setAttribute('width', String(trackWidth));
|
|
if (pathD && d) pathD.setAttribute('d', d);
|
|
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
|
|
}
|
|
|
|
/* Edge state — fades + advance disable. */
|
|
function updateNav() {
|
|
const max = scroll!.scrollWidth - scroll!.clientWidth;
|
|
const atStart = scroll!.scrollLeft <= 2;
|
|
const atEnd = scroll!.scrollLeft >= max - 2;
|
|
if (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
|
|
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
|
|
if (advance) advance.classList.toggle('rr-at-end', atEnd);
|
|
}
|
|
|
|
/* ── Unified scroll handling: wheel, drag, animated glide. ──
|
|
No CSS scroll-snap and no scroll-behavior: smooth — both fight
|
|
the JS-driven smooth motion. Drag has momentum; wheel translates
|
|
vertical to horizontal; arrow click runs a cubic-ease animation. */
|
|
|
|
let isDragging = false;
|
|
let dragStartX = 0;
|
|
let dragStartScrollLeft = 0;
|
|
let dragTotalMovement = 0;
|
|
let lastMoveX = 0;
|
|
let lastMoveTime = 0;
|
|
let velocity = 0; // px/ms, signed (positive = pointer moving right)
|
|
let momentumRAF: number | null = null;
|
|
let animateRAF: number | null = null;
|
|
|
|
function cancelAnims() {
|
|
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
|
|
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
|
|
}
|
|
|
|
function animateScrollTo(target: number, durationMs: number) {
|
|
cancelAnims();
|
|
const start = scroll!.scrollLeft;
|
|
const delta = target - start;
|
|
const startTime = performance.now();
|
|
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
|
|
const step = () => {
|
|
const t = Math.min(1, (performance.now() - startTime) / durationMs);
|
|
scroll!.scrollLeft = start + delta * easeOut(t);
|
|
updateNav();
|
|
if (t < 1) animateRAF = requestAnimationFrame(step);
|
|
else animateRAF = null;
|
|
};
|
|
animateRAF = requestAnimationFrame(step);
|
|
}
|
|
|
|
// Wheel — vertical wheel becomes horizontal scroll on this element.
|
|
// Trackpads sending horizontal deltaX go through unchanged (1:1, no scaling).
|
|
scroll.addEventListener('wheel', (e) => {
|
|
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
|
if (dx === 0) return;
|
|
e.preventDefault();
|
|
cancelAnims();
|
|
scroll!.scrollLeft += dx;
|
|
updateNav();
|
|
}, { passive: false });
|
|
|
|
// Drag — pointer events; momentum on release.
|
|
scroll.addEventListener('pointerdown', (e) => {
|
|
if (e.button !== undefined && e.button !== 0) return;
|
|
// Don't start a drag when the click target is the advance button.
|
|
if (advance && advance.contains(e.target as Node)) return;
|
|
|
|
isDragging = true;
|
|
dragStartX = e.pageX;
|
|
dragStartScrollLeft = scroll!.scrollLeft;
|
|
dragTotalMovement = 0;
|
|
lastMoveX = e.pageX;
|
|
lastMoveTime = performance.now();
|
|
velocity = 0;
|
|
|
|
cancelAnims();
|
|
try { scroll!.setPointerCapture(e.pointerId); } catch { /* not all envs */ }
|
|
scroll!.classList.add('rr-dragging');
|
|
});
|
|
|
|
scroll.addEventListener('pointermove', (e) => {
|
|
if (!isDragging) return;
|
|
const dx = e.pageX - dragStartX;
|
|
scroll!.scrollLeft = dragStartScrollLeft - dx;
|
|
dragTotalMovement = Math.max(dragTotalMovement, Math.abs(dx));
|
|
|
|
const now = performance.now();
|
|
const dt = now - lastMoveTime;
|
|
if (dt > 0) velocity = (e.pageX - lastMoveX) / dt;
|
|
lastMoveX = e.pageX;
|
|
lastMoveTime = now;
|
|
|
|
updateNav();
|
|
});
|
|
|
|
function endDrag() {
|
|
if (!isDragging) return;
|
|
isDragging = false;
|
|
scroll!.classList.remove('rr-dragging');
|
|
|
|
// Click vs drag: anything under 5px total movement is a click —
|
|
// skip momentum and let the underlying card's <a> handle the click.
|
|
if (dragTotalMovement < 5) return;
|
|
|
|
// Otherwise it's a real drag — suppress the synthetic click that
|
|
// follows so a drag-then-release-over-a-card doesn't navigate.
|
|
const suppressClick = (ev: Event) => {
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
scroll!.removeEventListener('click', suppressClick, true);
|
|
};
|
|
scroll!.addEventListener('click', suppressClick, true);
|
|
|
|
// Momentum: signed velocity, decay 0.93 per frame, stop under 0.4 px/frame.
|
|
// Direction inverted because dragging right moves scrollLeft left.
|
|
let v = -velocity * 16;
|
|
const step = () => {
|
|
if (Math.abs(v) < 0.4) { momentumRAF = null; return; }
|
|
scroll!.scrollLeft += v;
|
|
v *= 0.93;
|
|
updateNav();
|
|
momentumRAF = requestAnimationFrame(step);
|
|
};
|
|
momentumRAF = requestAnimationFrame(step);
|
|
}
|
|
|
|
scroll.addEventListener('pointerup', endDrag);
|
|
scroll.addEventListener('pointercancel', endDrag);
|
|
|
|
// Advance arrow — animated glide of 60% viewport width.
|
|
advance?.addEventListener('click', () => {
|
|
const target = Math.min(
|
|
scroll!.scrollLeft + scroll!.clientWidth * 0.6,
|
|
scroll!.scrollWidth - scroll!.clientWidth,
|
|
);
|
|
animateScrollTo(target, 480);
|
|
});
|
|
|
|
scroll.addEventListener('scroll', updateNav, { passive: true });
|
|
|
|
// Debounced resize → recompute layout + refresh nav state. 120ms is
|
|
// long enough to coalesce drag-resize events without feeling laggy.
|
|
let resizeTimer: number | undefined;
|
|
window.addEventListener('resize', () => {
|
|
if (resizeTimer !== undefined) window.clearTimeout(resizeTimer);
|
|
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
|
|
});
|
|
|
|
// Initial mount: recompute with the real viewport, then anchor at the
|
|
// start so the first milestone aligns with the content-column left edge.
|
|
// (The "you are here" highlight on the most-recent shipping milestone is
|
|
// still visible — but it's no longer the scroll anchor.)
|
|
recompute();
|
|
scroll.scrollLeft = 0;
|
|
setTimeout(updateNav, 50);
|
|
updateNav();
|
|
|
|
// Three-pulse hint on the advance arrow ~100ms after layout settles
|
|
// so the user notices the affordance once and then it sits quietly.
|
|
setTimeout(() => advance?.classList.add('rr-hint'), 100);
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
/* ── Desktop route ──────────────────────────────────────────────── */
|
|
.rr-wrap { position: relative; }
|
|
|
|
/* Escape the parent .page max-width so the route can use the actual
|
|
viewport width. The headline, dispatch banner, section header, and
|
|
legend all stay centred at content width — only the route widens. */
|
|
.rr-fullbleed {
|
|
width: 100vw;
|
|
margin-left: calc(50% - 50vw);
|
|
margin-right: calc(50% - 50vw);
|
|
}
|
|
.rr-scroll {
|
|
/* overflow-x: auto + overflow-y: visible lets hovered cards expand
|
|
above/below the track without being clipped. .rr-scroll-inner is
|
|
the spec-recommended belt-and-braces wrapper in case a browser
|
|
misbehaves on the combination.
|
|
NO scroll-snap-type and NO scroll-behavior: smooth — both fight
|
|
the JS drag-momentum + animated-glide implementation below. The
|
|
path is meant to glide continuously, not click into fixed
|
|
positions. */
|
|
overflow-x: auto;
|
|
overflow-y: visible;
|
|
scrollbar-width: none;
|
|
padding: 60px 80px 80px;
|
|
|
|
/* Drag affordance: cursor + suppress native horizontal swipe so
|
|
horizontal drag triggers our handler while vertical drag still
|
|
scrolls the page. user-select stops drag from selecting text. */
|
|
cursor: grab;
|
|
touch-action: pan-y;
|
|
user-select: none;
|
|
}
|
|
.rr-scroll::-webkit-scrollbar { display: none; }
|
|
.rr-scroll.rr-dragging { cursor: grabbing; }
|
|
/* Pointer-events off the cards mid-drag — prevents accidental hover
|
|
reveal while the track is being dragged past. */
|
|
.rr-scroll.rr-dragging .rr-card { pointer-events: none; }
|
|
.rr-scroll-inner { /* structural — keeps the track on its own layer */ }
|
|
.rr-track { position: relative; }
|
|
.rr-path { position: absolute; top: 0; left: 0; pointer-events: none; }
|
|
|
|
.rr-milestone {
|
|
position: absolute;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
|
|
.rr-dot {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 50%;
|
|
box-shadow: 0 0 0 5px var(--background); /* halo cuts the path under the dot */
|
|
transition: transform .25s ease, box-shadow .25s ease;
|
|
}
|
|
.rr-dot.rr-current {
|
|
transform: scale(1.3);
|
|
box-shadow:
|
|
0 0 0 5px var(--background), /* cream halo */
|
|
0 0 0 6px rgba(185, 107, 88, 0.45); /* terracotta ring outside */
|
|
}
|
|
|
|
/* Hover-on-card animates the sibling dot too. :has() is fine on every
|
|
evergreen browser we target; older Firefox just doesn't grow the dot. */
|
|
.rr-milestone:has(.rr-card:hover) .rr-dot,
|
|
.rr-milestone:has(.rr-card:focus-visible) .rr-dot {
|
|
transform: scale(1.15);
|
|
}
|
|
.rr-milestone:has(.rr-card:hover) .rr-dot.rr-current,
|
|
.rr-milestone:has(.rr-card:focus-visible) .rr-dot.rr-current {
|
|
transform: scale(1.4);
|
|
}
|
|
|
|
.rr-attach {
|
|
position: absolute;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
.rr-attach-below { top: 7px; } /* hangs down from the dot */
|
|
.rr-attach-above { bottom: 7px; flex-direction: column-reverse; }
|
|
|
|
.rr-connector {
|
|
width: 1px;
|
|
height: 30px;
|
|
background: rgba(0, 0, 0, 0.18);
|
|
}
|
|
|
|
.rr-card {
|
|
display: block;
|
|
width: 220px;
|
|
padding: 12px 14px;
|
|
border-radius: 10px;
|
|
background: transparent;
|
|
color: inherit;
|
|
text-decoration: none;
|
|
border-bottom: none;
|
|
transition:
|
|
transform .35s cubic-bezier(.2,.7,.3,1),
|
|
box-shadow .35s ease,
|
|
background .25s ease;
|
|
cursor: pointer;
|
|
}
|
|
.rr-card:hover,
|
|
.rr-card:focus-visible {
|
|
background: var(--surface-card);
|
|
box-shadow:
|
|
0 12px 32px -16px rgba(42, 37, 32, 0.25),
|
|
0 0 0 0.5px var(--surface-card-border);
|
|
transform: translateY(-2px);
|
|
z-index: 10;
|
|
border-bottom: none;
|
|
outline: none;
|
|
}
|
|
|
|
.rr-eyebrow {
|
|
font-family: var(--font-sans);
|
|
font-size: 9px;
|
|
letter-spacing: 1.4px;
|
|
text-transform: uppercase;
|
|
margin: 0 0 6px;
|
|
font-weight: 600;
|
|
}
|
|
.rr-card-title {
|
|
font-family: var(--font-serif);
|
|
font-size: 16px;
|
|
line-height: 1.2;
|
|
color: var(--on-surface);
|
|
margin: 0;
|
|
}
|
|
.rr-more {
|
|
max-height: 0;
|
|
opacity: 0;
|
|
overflow: hidden;
|
|
transition:
|
|
max-height .35s ease,
|
|
opacity .25s ease,
|
|
margin-top .35s ease;
|
|
margin-top: 0;
|
|
}
|
|
.rr-card:hover .rr-more,
|
|
.rr-card:focus-visible .rr-more {
|
|
max-height: 280px;
|
|
opacity: 1;
|
|
margin-top: 10px;
|
|
}
|
|
.rr-desc {
|
|
font-family: var(--font-sans);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
color: var(--on-surface-variant);
|
|
margin: 0 0 10px;
|
|
}
|
|
.rr-trail {
|
|
font-family: var(--font-sans);
|
|
font-size: 9px;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
color: var(--on-surface-muted);
|
|
margin: 0;
|
|
}
|
|
|
|
/* ── Advance arrow ─────────────────────────────────────────────── */
|
|
.rr-advance {
|
|
position: absolute;
|
|
right: 32px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 50%;
|
|
border: 1px solid var(--pigment-terracotta);
|
|
background: var(--background);
|
|
color: var(--pigment-terracotta);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
z-index: 5;
|
|
transition: background .2s ease,
|
|
color .2s ease,
|
|
opacity .25s ease,
|
|
transform .25s ease;
|
|
}
|
|
.rr-advance:hover,
|
|
.rr-advance:focus-visible {
|
|
background: var(--pigment-terracotta);
|
|
color: var(--background);
|
|
outline: none;
|
|
transform: translateY(-50%) scale(1.06);
|
|
}
|
|
.rr-advance[disabled],
|
|
.rr-advance.rr-at-end {
|
|
opacity: 0.25;
|
|
pointer-events: none;
|
|
}
|
|
/* Three-pulse hint on first load — fires once, then stops. */
|
|
@keyframes rr-advance-pulse {
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(185, 107, 88, 0); }
|
|
50% { box-shadow: 0 0 0 8px rgba(185, 107, 88, 0.15); }
|
|
}
|
|
.rr-advance.rr-hint {
|
|
animation: rr-advance-pulse 1.4s ease-in-out 3;
|
|
}
|
|
|
|
/* Edge fades cover only the track itself — the top/bottom padding
|
|
zones (60/80) on .rr-scroll exist so hover cards can overflow there
|
|
without clipping, so the fades shouldn't paint over them. */
|
|
.rr-fade-left, .rr-fade-right {
|
|
position: absolute;
|
|
top: 60px;
|
|
bottom: 80px;
|
|
pointer-events: none;
|
|
transition: opacity .25s ease;
|
|
}
|
|
.rr-fade-left {
|
|
left: 0; width: 60px;
|
|
background: linear-gradient(to left, transparent, var(--background));
|
|
opacity: 0;
|
|
}
|
|
.rr-fade-right {
|
|
right: 0; width: 90px;
|
|
background: linear-gradient(to right, transparent, var(--background));
|
|
}
|
|
|
|
/* ── Mobile vertical timeline ──────────────────────────────────── */
|
|
.rr-mobile { display: none; }
|
|
|
|
@media (max-width: 767px) {
|
|
.rr-desktop { display: none; }
|
|
.rr-mobile {
|
|
display: block;
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.rrm-row {
|
|
display: grid;
|
|
grid-template-columns: 32px 1fr;
|
|
gap: 16px;
|
|
align-items: start;
|
|
}
|
|
.rrm-track-col {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0;
|
|
min-height: 100%;
|
|
padding-top: 6px;
|
|
}
|
|
.rrm-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
.rrm-line {
|
|
width: 1px;
|
|
flex: 1;
|
|
min-height: 28px;
|
|
background: rgba(0, 0, 0, 0.18);
|
|
margin-top: 4px;
|
|
}
|
|
.rrm-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
padding-bottom: 28px;
|
|
}
|
|
.rrm-eyebrow {
|
|
font-family: var(--font-sans);
|
|
font-size: 9px;
|
|
letter-spacing: 1.4px;
|
|
text-transform: uppercase;
|
|
margin: 0;
|
|
font-weight: 600;
|
|
}
|
|
.rrm-title {
|
|
font-family: var(--font-serif);
|
|
font-size: 18px;
|
|
line-height: 1.2;
|
|
color: var(--on-surface);
|
|
margin: 0;
|
|
}
|
|
.rrm-desc {
|
|
font-family: var(--font-sans);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
color: var(--on-surface-variant);
|
|
margin: 0;
|
|
}
|
|
.rrm-trail {
|
|
font-family: var(--font-sans);
|
|
font-size: 9px;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
color: var(--on-surface-muted);
|
|
margin: 0;
|
|
}
|
|
}
|
|
</style>
|