Handbook

Layout & Composition

How a MediaOne page is built: one frame, a small set of column splits, two page shells, and the scroll behaviours that hold them together. Every number here is read from the shipping code.

01

The frame

Every page sits in the same box. Nothing on the site is wider than this, and nothing sets its own gutters.

max-w-site

80rem

The page ceiling — 1280px. From --container-site, so Tailwind resolves max-w-site natively.

px-4 sm:px-6 lg:px-8

16 / 24 / 32px

The only gutters on the site. Bands never add their own horizontal padding.

grid-cols-12

from lg

Below lg a band is one column, or two where it says so. The 12-column grid engages at 1024px.

The frame Hatched edges are the gutters — content never crosses them, ads do.
max-w-site · 80rem

main

rail

A page body is therefore always this, and a band receives no padding of its own:

<main class="flex-1 bg-bg-page">
  <div class="w-full max-w-site mx-auto bg-bg-card">
    <div class="px-4 sm:px-6 lg:px-8">
      <!-- bands -->
    </div>
    <AdSlot />              <!-- outside the gutters: ads span the frame -->
  </div>
</main>

Breakpoints, and what changes at each

Token Width What engages
sm 640px Two-up cards; list bands split in half.
md 768px The lead package pairs its stack and rail.
lg 1024px The 12-column grid engages. Rails, rules and sticky behaviour begin here.
xl 1280px Rails narrow from 4 to 3 columns, giving the main column a ninth.
2xl 1536px No layout change — the frame is already at its 80rem ceiling.
02

Column vocabulary

Five splits cover every band on the site. Reuse one before inventing a sixth — the repetition is what makes different pages feel like the same paper.

5
4
3

5 / 4 / 3

Lead package

LeadPackage

Lead card, thumbed stack, live rail. The topics strip spans the last two (7) above them.

8
4

8 / 4

Main + rail

ShelfBand, SplitBand

Widens to 9 / 3 at xl. The workhorse.

8
4

8 / 4

Main + wide rail

DeskPage

Holds 8 / 4 at xl rather than widening. The rail carries Videos, Latest and Trending, so a quarter column starved all three.

7
5

7 / 5

Feature + playlist

ProgramBand

Widens to 8 / 4 at xl.

5
3
4

5 / 3 / 4

Feature + stack + rail

FeatureBand

A feature that needs two companions rather than one column of them.

12

12

Full width

SplitBand (no rail)

Falls back to this when a band is handed no rail.

Columns are divided by a hairline, not by whitespace. A column that has a neighbour to its right carries lg:border-r border-rule lg:pr-6 — the rule sits in the gutter, and the padding keeps text off it. Rails on desk pages mirror it with lg:border-l ... lg:pl-8.

03

The band set

A front page is a stack of bands. Each is a self-contained section with its own heading, its own split and its own rules — they compose in any order.

Band Grid Below lg What it is for
LeadPackage 12 · 5 / 4 / 3 md: 2-up Opens a page. Lead card, four thumbed rows, live rail.
ListBand 2-up from sm A flat run of headlines. No 12-column phase.
ShelfBand 12 · 8 / 4 (xl 9 / 3) lg Lead, card row, rail. Takes an ad slot in the rail.
SplitBand 12 · 8 / 4 (xl 9 / 3) lg Lead + card grid; drops to 12 when given no rail.
FeatureBand 12 · 5 / 3 / 4 md: 2-up One feature, a stack, a rail.
OpinionBand 4-up from lg sm: 2-up Columnists — equal weight, no lead.
ProgramBand 12 · 7 / 5 (xl 8 / 4) lg One programme: latest episode, numbered playlist.
VideoBand Full-bleed navy sm/md 4-up leads, 3-up compact row, switchable show strip.
LeadPackage · 5 / 4 / 3 Opens a page. The lead spans two rows when a topics strip takes the first.

lead

stack

live rail

ShelfBand · 8 / 4 Lead and card row against a rail that can carry an ad. Widens to 9 / 3 at xl.

lead + cards

rail

FeatureBand · 5 / 3 / 4 One feature with a stack and a rail, rather than a single column of companions.

feature

stack

rail

ProgramBand · 7 / 5 A programme: latest episode, then the run as a numbered playlist. 8 / 4 at xl.

latest episode

playlist

VideoBand · full-bleed Leaves the frame, re-centres its own content inside it, and returns.

Every band takes the same three props for its heading — label, href, viewAllHref — and renders it through BandHeading. Rendered live in the Gallery.

04

Two page shells

A front page and a desk page are composed on opposite principles. Using the wrong one is the most expensive mistake available here.

Front page

A stack of full-width bands

Eleven desks share one page, so each band changes shape to stay distinguishable from its neighbours. There is no page-level rail — each band carries its own.

lead

Ad

shelf

video · full-bleed

feature

Ad

Scroll order. Shape changes band to band; no page-level rail.

Full-bleed bands break out of max-w-site by sitting outside the container and re-centring their own content inside it.

Desk page

One column beside a standing rail

One desk all the way down. Alternating band shapes read as churn here, so the main column holds steady and the rail does the cross-selling.

hero

featured 3-up

thumbed × 5

latest × PER_PAGE

videos

trending

Ad

One column, one rail. The rail sticks; the column pages.

Composed by src/lib/desk.ts. Page 1 shows the top block; later pages are list-only.

Desk constants

HERO

1

Opens page 1

FEATURED

3

Three-up row

THUMBED

5

Thumbed rows

TOP

9

Page 1 only

PER_PAGE

8

Latest per page

SUB_MIN / MAX

2 / 4

Sub-section block

05

Composition techniques

The handful of moves that recur. Each exists because the obvious alternative broke something.

Row-span pairing

lg:row-span-2

The lead card spans both rows of the pair beside it, so a strip can occupy the first row across columns 2+3 while the lead keeps its full height. Make the span conditional on that strip existing — without it the grid leaves an empty row under the lead.

Cursor slicing

take(n)

Bands draw from one running cursor rather than hand-picked indices, so a page consumes its material exactly once: nothing repeats, nothing is stranded, and a band never slices past the end of the array.

Flush top

flushTop

Swaps py-6 for pt-0 pb-6 on the opening band so it sits directly under the header. Do it with a prop, not by layering pt-0 over py-6 — which wins then depends on Tailwind emission order.

Two cuts, one pool

subsectionBlocks()

Sub-section blocks deliberately overlap the chronological list below them: same material, two ways in. Only the hero is held back, because repeating that one story a screen later reads as a bug rather than a route.

Rule-divided columns

border-r + pr-6

Columns are separated by a hairline in the gutter rather than by extra space. Grid items stretch by default, so the rule runs the full height of the band without being drawn.

Reserve before load

ratio + w

Every still declares its aspect ratio and width, so the box exists before the image arrives. This — not the loading attribute — is what keeps layout shift at zero.

06

Scroll composition

What the page does as it moves. All of it is transform, opacity or a reserved box — nothing here reflows mid-scroll.

Sticky header — the three states Nothing reflows: the bar is sticky and only its transform changes.

Near the top

y ≤ 120px

withdrawn

Scrolling down

delta > +4px

shown

Scrolling up

delta < −4px

The header is the floating menu

HeaderBar transform 200ms · rAF-throttled · passive listener

Trigger · scrollY > 120px

The whole header is one short sticky bar, so hiding it is a single transform — there is no cloned floating bar to keep in step. It withdraws on the way down and returns on the way up, and never hides while the reader is still near the top. A 4px delta guard absorbs trackpad jitter, scrollY is clamped at 0 for iOS overscroll, and a `focusin` listener brings it back if focus lands inside it while withdrawn.

Sticky desk rail

DeskRail top-16 clears the floating bar

Trigger · lg and up

`lg:sticky lg:top-16 lg:self-start`. The rail stays with the reader down a long list instead of scrolling away after the first screen. Below lg it is a normal block after the main column.

Anchor offset

app.css → html 6rem · 4.5rem from lg

Trigger · any in-page anchor

`scroll-padding-top` clears the sticky header so a jump target never lands underneath it — 6rem by default (the 56px bar plus its 36px desk row), tightening to 4.5rem at lg where the desks move inline.

Deferred ads

Layout + .ad-lazy opacity + max-height, 400ms

Trigger · after first paint

Slots render at their final height, then fade in — `requestIdleCallback` with a 3000ms timeout, or a 1500ms `setTimeout` where it is unsupported. Reserving the box first is what keeps ads out of CLS.

Image priority

Art / Media ratio + w are required, so the box is reserved before load

Trigger · position on the page

The opening lead loads `eager` with `fetchpriority="high"`; everything below it stays lazy. One image per page gets the fast path — giving it to two costs both.

Horizontal rails

Section bar, trending, EPG track scrollbar-none is a utility in app.css

Trigger · overflow

Rails scroll on the x axis with `overflow-x-auto scrollbar-none` rather than wrapping or hiding. The schedule track parks its live cell at the head on first fill by setting `scrollLeft`; the same line sets `scrollTop`, so one assignment serves both layouts.

Reduced motion

app.css set once, applies to *, ::before, ::after

Trigger · prefers-reduced-motion

A global override drops every animation and transition to 0.01ms and forces `scroll-behavior: auto`. Components do not need their own guard — the heartbeat, the EPG, the floating bar and the panels are all covered by it.

Rules for anything new

  • Throttle scroll work through requestAnimationFrame and register the listener passive.
  • Animate transform and opacity only — never height, top or width.
  • Give any hide/show a delta guard. Without one, trackpad jitter and iOS overscroll flicker it.
  • Anything visually hidden but still in the DOM takes inert, or it stays in the tab order.
  • Reserve the box before the content lands. A late image or ad must not move what is already read.
07

Building a new page

The order that keeps a new page inside the system rather than beside it.

  1. 1

    Pick a shell

    Front page or desk page. If the page is one subject all the way down, it is a desk page — take the desk shell even if it is not a section.

  2. 2

    Take the standard chrome

    A header variant + SectionSubNav + Footer, and VideoModal if anything on the page plays. Three mastheads share one prop shape — swap the import, do not compose a fourth.

  3. 3

    Compose from the band set

    Reach for an existing split before inventing one. A new band earns its place by being reused twice.

  4. 4

    Slice with a cursor

    Feed bands from one running cursor so the page consumes its material exactly once.

  5. 5

    Reserve every box

    ratio + w on stills, a sized AdSlot, a fixed height on anything that fills in from script.

  6. 6

    Check the seams

    One lead image eager, the rest lazy. One h1. Rails scroll rather than wrap. Hidden-but-present controls are inert.

Porting a desk

Desk pages are already standardised — a desk moves onto the shell by adding its id to PORTED_DESKS in src/lib/desk.ts. The route switches layout and its paginated pages start being generated. Nothing else needs touching, and the design it leaves stays reachable at /old-section/<id>.