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.
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 keepContainerWidth.XL(or aGrid-XL.N-Col) as amax-width. - A component only ever composed inside another band's column keeps the raw
token. The
embed-*blocks andrte-tableland in the article's rich-text well andheader-controlsinside the listing header; bound to the measure, they took the gutter a second time. 100%, not100vw— a classic scrollbar sits inside100vwand would overflow by its width.- Below
mdtoo: 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
mdcarries an.mdsub-key (gap.level1.s/.md, asnavigation-mega-menu-mobiledoes). - A component that already has flat
*Desktoptwins may add a*Tablettwin beside them (list-card-grid'scolumnsTablet) rather than restructure every override of the existing leaf. WithTokenOverridessets every rung where a leaf has three.- Every rail that goes two-up at
mdupdates its imagesizesstring in the same change, with a unit assertion (packages/content-app/src/lib/images.test.tsis 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 atxl.
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.