layout: rulings for the leaf-only rule

Seven arguments that keep coming back, each settled once. A sides pad a container and pay for it in negative margins; B sides hand the padding to the leaves. Inspector: amber owns padding, red is a hack.

text wrapping inline elements

Chips, code and links flow inside a paragraph. The paragraph is a leaf; the chips inside it are inline flow, not children to arrange. Pad the paragraph, not the card around it: pad the card and every full-bleed band after it must escape with a negative margin. Drag the width: the paragraph is the only padded node, and inline leaves wrap with the text.

card width200 – 520

A: the card pads

ship only after just lint passes, then open a pr — review is required before merge.

the chips are inline leaves; only the paragraph should carry the inset.

the note bar escapes the padding to run full bleed.

B: the paragraph is the leaf

ship only after just lint passes, then open a pr — review is required before merge.

the chips are inline leaves; only the paragraph should carry the inset.

paragraph insets, note band runs edge to edge by construction.

ruling

inline flow is arrangement inside one leaf. pad the block (the paragraph, the note), never the text and never the container around both. a full-bleed band next to prose means two sibling leaves, not an escaped child.

B markup + rules
<div class="lay-shell is-wide">
<p class="lay-prose">ship after <code class="lay-chip">just lint</code></p>
<div class="lay-notebar">full-bleed sibling leaf</div>
</div>
/* globals.css — the shell pads nothing; two sibling leaves */
.lay-prose { @apply px-4 pt-4 pb-3 leading-relaxed; }
.lay-notebar { @apply bg-amber-500/10 px-4 py-2 leading-relaxed; }

icon + label + badge rows

A list of rows, each an icon, a label and a count. Pad the list and every hover fill, active edge and focus ring stops at the inset or climbs out with negative margins. Make whatever is clickable the leaf: the row button pads itself, so every state travels with the padding.

A: the list pads

  • deploys12
  • reviews3
  • incidents1

each row climbs out with negative margins; the active fill and hover stop at the inset.

B: the row button is the leaf

buttons pad themselves and own a focus ring that stays inside the list.

ruling

the row is the leaf, not the list. whatever is clickable pads itself: when rows are buttons, the focus ring travels with the padding instead of being clipped or escaped.

B markup + rules
<ul class="lay-rowlist">
<li><button type="button" class="lay-rowbtn">
<span class="lay-ico"></span><span class="lay-rowlabel">deploys</span>
</button></li>
</ul>
/* globals.css — the list arranges only; the button is the leaf */
.lay-rowlist { @apply flex flex-col overflow-hidden rounded-xl bg-zinc-900; }
.lay-rowbtn { @apply flex flex-1 cursor-pointer items-center gap-3 px-4 py-2 …; }

grid gutters

A data grid with a header band. Gutters are arrangement, so they belong to the grid: expressed as gap, never as margins on cells or escapes on a header. When the gutter line itself should be visible, paint the container and let a 1px gap show it through. Resize both from the corner.

A: the grid pads

namestatusowner
buildokana
testokbinh
scanwarnchi

the header band escapes with negative margins; row separators are borders that stop at the padding.

B: gutters are gap

namestatusownerbuildokanatestokbinhscanwarnchi

one grid, one gap, gutters for free: no borders, no escapes.

ruling

gutters are the grid's arrangement: express them as gap. when the gutter itself is visible, paint the container and let gap-px show it through. cells never carry margins, headers never escape.

B markup + rules
<div class="lay-cells">
<span class="is-head">name</span><span>build</span><span>…</span>
</div>
/* globals.css — paint the container, gap-px reveals the gutters,
every cell is a leaf that pads itself */
.lay-cells { @apply grid w-full grid-cols-3 gap-px overflow-hidden rounded-xl bg-zinc-800; }
.lay-cells span { @apply bg-zinc-900 px-3 py-2; }

focus rings that clip

Focus the inputs. A ring draws outside the input's border box, so a flush input clips it and the common fix pushes the input past its frame: the layout now moves for a focus state. The right fix gives the indicator its own room without touching layout: draw it inside, or offset the outline, or give the frame a gap.

A: the layout moves for the ring

negative margins buy the ring its pixels; the frame and the input are now coupled.

B: the indicator insets or offsets

ring-inset draws inside the input; a negative outline offset draws outside without touching layout.

ruling

focus indicators are leaves too: they live on the control and get their own room: inset rings, outline offsets, gapped frames. no layout may move to make room for a focus state.

B markup + rules
<input class="lay-input is-inset-ring" value="ring inside">
<input class="lay-input is-offset-ring" value="outline outside">
/* globals.css — both keep the layout intact while focused */
.lay-input.is-inset-ring { @apply … focus:ring-4 focus:ring-inset focus:ring-indigo-500; }
.lay-input.is-offset-ring { @apply … focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500; }

scroll gutter and snap

Sticky header, six sections, jump buttons. Scroll behavior (snap points, scroll padding, scrollbar gutter) belongs to the scroller, the one node that actually scrolls. A pads the scroller so the sticky header must hack through the padding; B keeps the scroller unpadded, sticks the header at top-0 by construction, snaps to section tops with scroll-margin clearance and reserves the scrollbar gutter.

A: the scroller pads

as builds
as1: queue
as2: build
as3: test
as4: package
as5: sign
as6: release

the sticky header escapes the padding to reach the top edge.

B: the scroller is the leaf

bs builds
bs1: queue
bs2: build
bs3: test
bs4: package
bs5: sign
bs6: release

snap points and a stable gutter live on the scroller; targets clear the sticky header with scroll-margin. the header sticks at top-0 by construction.

ruling

a scroll container is a leaf: it owns scrollbar gutter, scroll padding and snap. children stick to its edge and never compensate for its padding.

B markup + rules
<div class="lay-scroll is-snap">
<div class="lay-scroll-head">bs builds</div>
<div class="lay-scroll-sec">bs1: queue</div>
</div>
/* globals.css — scroll behavior sits on the unpadded scroller;
snap targets clear the sticky header via the global [id] scroll-margin */
.lay-scroll { @apply flex h-64 flex-col overflow-y-auto rounded-xl bg-zinc-900; }
.lay-scroll.is-snap { @apply snap-y snap-mandatory [scrollbar-gutter:stable]; }

collapsible panels

Toggle both. A height animation must measure the box, and a padded box makes the measurement lie: the height has to over-measure to swallow the padding, and children escape it. Animate arrangement instead (grid rows from 0fr to 1fr measure the content) and let the leaves keep their padding at every frame.

A: the box pads and collapses

height must over-measure to swallow the padding, and the tip escapes it.

B: rows collapse, leaves pad

build #127 rolls out to 10% of traffic.

tip runs full bleed

grid-rows 0fr to 1fr measures content; padding rides on the leaves and never enters the math.

ruling

the collapsing box is a fill: it animates its arrangement (rows), never an inset. leaves keep their padding at every frame, so open and closed states share one set of numbers.

B markup + rules
<div class="lay-fold is-open">
<div class="lay-fold-inner">
<p class="lay-fold-text">build #127…</p>
<div class="lay-tip is-end">tip runs full bleed</div>
</div>
</div>
/* globals.css — the fill animates rows; the leaves never change */
.lay-fold { @apply grid transition-all duration-300; }
.lay-fold.is-open { @apply grid-rows-[1fr]; }
.lay-fold.is-closed { @apply grid-rows-[0fr]; }
.lay-fold-inner { @apply flex min-h-0 flex-col overflow-hidden; }

a lone leaf alone in a fill

One button on a band. A single child does not turn the band into a leaf-inset: the fill stays a container, and the leaf still chooses its own inset. When the design wants edge-to-edge, the leaf opts in with a zero inset variant: the owner never forces one and the button never escapes one.

A: the band pads, the button escapes

the button climbs out of padding the band should never have had.

B1: the leaf insets

band is a fill, button is a leaf with its own margin of comfort.

B2: the leaf chooses full bleed

full bleed is the leaf picking a zero inset, not the owner forcing one.

ruling

a lone leaf still insets itself; the fill stays a container even with one child. when the design wants edge-to-edge, the leaf opts in with a zero inset variant, not a negative margin.

B markup + rules
<div class="lay-bandbtn">
<button type="button" class="lay-btn is-primary">deploy now</button>
</div>
<!-- or the leaf opts into full bleed -->
<button type="button" class="lay-btn is-primary is-bleed">deploy now</button>
/* globals.css — the band pads nothing; the inset lives on the leaf */
.lay-bandbtn { @apply flex rounded-xl bg-zinc-900; }
.lay-bandbtn .lay-btn { @apply m-3 flex-1; }
.lay-btn.is-bleed { @apply m-0 rounded-xl py-3; }

search pages

go to any page