Responsive rungs

Lay out the tablet and laptop bands the design does not draw: the page measure and its gutter, tablet steps per family, and the gates.

2026-11-07

The page measure

Ghost and the ELB forks author most components at two breakpoints — Breakpoint=Small (360) and Breakpoint=X-Large (1440) — and the house convention is mobile-first, with mediaUp("lg") carrying the X-Large frame from 1024. That leaves two bands no canvas describes:

Band Viewport What used to ship
Tablet 768 – 1023 the phone layout: a 328px column centred in 768, one card a row
Laptop 1024 – 1439 the 1440 layout with no page margins, flush to the viewport edge

No gate failed for either — nothing overflowed, nothing was clipped. The binding ruling (1 Oct 2026) is .claude/rules/frontend/intermediate-rungs.md; this guide is the working summary. The original measurements are in .claude/plans/tablet-small-desktop-breakpoints.md (news and video) and .claude/plans/basketball-tablet-small-desktop.md.

The breakpoints

mediaUp() (@v4/ghost-core/theme, theme/helpers/media.ts) takes md 768, lg 1024, xl 1440 and xxl 1920; the 360 base has no query. The values mirror the generated token contract and media.test.ts keeps them in step.

The grid is the design for the undrawn bands

The token export defines a grid per band, and every rule below derives from it (Euro League values from packages/tokens-euro-league; Ghost's in brackets where they differ):

Band Columns × width Gutter Container Margin (bp − container) / 2
S 360 4 × 76 8 328 (Ghost 296) 16 (Ghost 32)
M 768 6 × 100 16 680 (Ghost 672) 44 (Ghost 48)
L 1024 12 × 60 16 896 (Ghost 888) 64 (Ghost 68)
XL 1440 12 × 96 16 1328 (Ghost 1264) 56 (Ghost 88)

Bind the measure on ONE outermost container

A band's content column binds g.semantic.size.pageMeasure:

min(calc(100% − 2 × current gutter), ContainerWidth.XL)

The per-band gutters (semantic.size.pageGutter.{s,md,lg,xl}) are formulas over the breakpoint and container vars on the global surface (packages/ghost-core/src/theme/global-surface.ts), so a theme that re-points a container moves its gutter with it. The one moving part — which band applies — is PAGE_GUTTER_CURRENT_VAR (--ph__page-gutter-current), written per rung by packages/ghost-core/src/theme/page-measure.css.ts. It is deliberately not a theme leaf: when it was, Storybook's scoped Euro League theme reset it to 0px on every component root. At each authored viewport the measure resolves to exactly the authored container; between them it fills the viewport less the band's gutter. In the app the gutter is declared on :root with the club's global layer, because a formula leaf resolves where it is declared.

semantic.size.pageMeasureFromS is the same measure with the Small container as a floor — what a band's base-rung well binds, so it is never narrower than the token inside a narrow containing block.

Rules:

  • One element per band — the outermost container. 100% is read where the var is applied, so binding it again on a descendant takes the gutter twice. Inner wells keep ContainerWidth.XL (or a Grid-XL.N-Col) as a max-width.
  • A component only ever composed inside another band's column keeps the raw token. The embed-* blocks and rte-table land in the article's rich-text well and header-controls inside the listing header; bound to the measure, they took the gutter a second time.
  • 100%, not 100vw — a classic scrollbar sits inside 100vw and would overflow by its width.
  • Below md too: a band's wells bind the measure from the base rung rather than the fixed Small container, so between 361 and 767 the column fills the viewport less the gutter instead of sitting at 328 in the middle.
  • Keep the Token Studio name in the comment (// S.Size.ContainerWidth.XL …) — the coverage tool credits the binding by name.

Basketball does this once for every page through the shared _internal/basketball-page frame (page-frame.styles.ts).

Tablet steps and the gates

A tablet step is declared per family

Not every lg-only component needs an md rule — an atom that hugs its content is fine at any width. The ones that lay out columns or rails do, on the M grid (a 680 container in Euro League, 672 in Ghost):

Family At md
Card rails and grids two per row; carousels show two cards per page
Page headers copy across the M container, stacked as at Small — no side-by-side until lg — unless the set authors Medium (header-block-image)
Text and reading wells the measure: a reading column, not a phone column
Promo / text-with-image promo-block-supporting-image and text-image-block author Medium — build those; the rest stay stacked, and a stacked image well fills the measure
Footer two link columns per row
Chrome authored Medium already

Mechanics, so the old breakpoint-twin bug does not reopen:

  • A new leaf that steps at md carries an .md sub-key (gap.level1.s / .md, as navigation-mega-menu-mobile does).
  • A component that already has flat *Desktop twins may add a *Tablet twin beside them (list-card-grid's columnsTablet) rather than restructure every override of the existing leaf.
  • WithTokenOverrides sets every rung where a leaf has three.
  • Every rail that goes two-up at md updates its image sizes string in the same change, with a unit assertion (packages/content-app/src/lib/images.test.ts is the pattern) — otherwise the browser fetches a full-width picture for a half-width slot.

Carousels page by a fraction of the column

A card binds a fixed S.Layout.Grid-* width at the two authored rungs, which tiles the column at 360 and 1440 and nowhere between. From md the slide takes (100cqi − (n − 1) × gap) / n — cqi against the rail's column, because the slide row is max-content — with n = 2 on the M grid, 3 on the L grid and the X-Large frame's count from xl. At every authored viewport this resolves to the token's own width. The pattern is _internal/video-block's pagedSlide with PH_VIDEO_BLOCK_RAIL_PAGES (packages/ghost-video); the content package's _internal/carousel-block follows it. Below md the Small card width is a floor: the authored peek in the 328 column, two-up as soon as two fit. Rails also bleed to the screen edges (viewport-bleed.ts) while the first slide still lines up with the column above.

Typography has two ramps

The token ramps are authored at Small and X-Large only. Display headings (Heading-S and larger) take the X-Large ramp from xl and the Small ramp below it, so a 48px heading never lands in the 896 L container. Everything else steps at lg.

Two declared exceptions

  • The site header hands off at 1440, by design. The ELB site header authors Small to XX-Large and draws the compact (burger) header at Large. A compact header over a measured, guttered column reads as a deliberate tablet layout, so it stays.
  • The video hero's row arrives at lg, not at its X-Large frame: stacked to 1439 it put a ~500-tall picture over the panel on every laptop. On the L grid the media stretches to the panel's row; the authored 16:9 returns at xl.

Keeping the rungs measured

Gate What it does
apps/e2e/tests/shared/page-measure.e2e.ts derives the expected measure from the viewport, checks the gutter either side, and fails on the double-gutter signature (measure − 2 × gutter inside)
*-bleed.e2e.ts (ghost-core, ghost-content, ghost-video) carousel rails stay in the column and page sideways without scrolling the page
Playwright tablet (834) and laptop (1180) run ONLY those viewport-derived specs (INTERMEDIATE_RUNG_SPECS in apps/e2e/playwright.config.ts); a spec that encodes 1280 values must not run there
node scripts/rung-census.mjs [--list] how many components step at each rung, per package, naming the lg-only ones
scripts/page-audit.mjs, scripts/clip-sweep.mjs sweep a running app at the rung edges and the widths real devices report (1280, 1180, 834) — see Testing

Assert every md rule at 768 and 834 with page.setViewportSize; no project renders 768, and a rule that only works at one of them is the bug.