style(roadmap): align first milestone with content column left edge

The first route item now starts where the dispatch banner's left edge
sits (the page's content-max column), instead of 60px from the
viewport edge. Looks intentional now — the route and the dispatch
banner share a vertical anchor.

- computeRouteLayout now accepts optional paddingLeft / paddingRight
  that override the symmetric paddingX. Existing call sites and
  tests are unchanged.
- RoadmapRoute SSR + client recompute set paddingLeft = max(60,
  (vw - 1152) / 2), so on viewports ≤ 1152px nothing moves (degrades
  gracefully) and on wider screens the start migrates inward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 17:06:30 +02:00
parent 8bbf8568f4
commit 220f8e0290
2 changed files with 27 additions and 9 deletions

View file

@ -9,7 +9,17 @@ interface Props {
const { items, viewportWidth = 1100 } = Astro.props;
const layout = computeRouteLayout({ itemCount: items.length, viewportWidth });
// 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> = {
@ -158,6 +168,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
// 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');
@ -180,14 +191,17 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const targetUsableWidth = vw * 0.80;
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
const trackWidth = usableWidth + PADDING_X * 2;
// 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
? PADDING_X + usableWidth / 2
: PADDING_X + (i / (itemCount - 1)) * usableWidth,
? paddingLeft + usableWidth / 2
: paddingLeft + (i / (itemCount - 1)) * usableWidth,
);
}

View file

@ -19,7 +19,9 @@ export interface LayoutOpts {
minSpacingX?: number; // default 320
trackHeight?: number; // default 460
amplitude?: number; // default 120
paddingX?: number; // default 60
paddingX?: number; // default 60 — symmetric leading + trailing padding
paddingLeft?: number; // overrides paddingX on the leading edge only
paddingRight?: number; // overrides paddingX on the trailing edge only
}
export interface LayoutResult {
@ -35,7 +37,9 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
const minSpacing = opts.minSpacingX ?? 320;
const trackHeight = opts.trackHeight ?? 420;
const amplitude = opts.amplitude ?? 120;
const padding = opts.paddingX ?? 60;
const paddingDef = opts.paddingX ?? 60;
const paddingL = opts.paddingLeft ?? paddingDef;
const paddingR = opts.paddingRight ?? paddingDef;
const midY = trackHeight / 2;
const itemCount = Math.max(0, opts.itemCount);
@ -57,12 +61,12 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
const targetUsableWidth = opts.viewportWidth * 0.80;
const dataDrivenWidth = (itemCount - 1) * minSpacing;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
const trackWidth = usableWidth + padding * 2;
const trackWidth = paddingL + usableWidth + paddingR;
const itemX: number[] = Array.from({ length: itemCount }, (_, i) =>
itemCount === 1
? padding + usableWidth / 2
: padding + (i / (itemCount - 1)) * usableWidth,
? paddingL + usableWidth / 2
: paddingL + (i / (itemCount - 1)) * usableWidth,
);
// First item on the centreline; subsequent items alternate up/down with