Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions core/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@

.sf-section--guttered { padding-inline: var(--sf-gutter); }
.sf-section--guttered .sf-container { padding-inline: 0; }
/* .sf-content-grid carries the gutter in its own edge tracks
(minmax(gutter,1fr)), not in padding — so unlike .sf-container it can't be
de-duplicated by zeroing its padding (it has none). Left alone, the
section's padding boxes the grid in: content ends up double-guttered and
.sf-full-bleed stops at the padding edge instead of the section edge.
Drop the section's own padding so the grid spans edge-to-edge and its
tracks provide the single gutter; full-bleed then reaches the true edge.
Only when the content grid is the SOLE direct child: dropping the section
padding is right for the grid but would strip the gutter from any non-grid
sibling (a heading, a plain block) that legitimately relies on it — and
those two wants are physically irreconcilable (the gutter *is* the section
padding that full-bleed must escape). So we restrict to the unambiguous
case; a mixed section keeps its padding and behaves exactly as before this
rule existed. Direct-child scope also stops a nested grid deeper in the
section from stripping the gutter. */
.sf-section--guttered:has(> .sf-content-grid):not(:has(> :not(.sf-content-grid))) {
padding-inline: 0;
}

.sf-section-group > .sf-section + .sf-section { padding-block-start: 0; }

Expand Down Expand Up @@ -579,6 +597,21 @@

.sf-content-grid {
display: grid;
/* Establish the same NAMED inline-size query container as .sf-container
(`sf-layout`), not just an anonymous one. .sf-content-grid is a
section-level wrapper that REPLACES .sf-container when you need
breakout/full-bleed — but .sf-container carries the CQ scope that
.sf-grid-cols-* / .sf-bento rely on, and dropping it silently killed
those primitives inside a content grid. Matching the name too means a
user's `@container sf-layout (…)` rule keeps matching across the swap, so
the two wrappers are genuinely interchangeable (anonymous
`@container (…)` queries already matched either — the framework's own
primitives use those — but a name-targeted query would not have).
Safe re SL-034: .sf-content-grid's own tracks are fixed (they never
@container-query themselves), so becoming a query container can't create
a self-query. Safe re full-bleed: inline-size containment doesn't change
track resolution — the `full` track still reaches the wrapper's edges. */
container: sf-layout / inline-size;
grid-template-columns:
[full-start]
minmax(var(--sf-gutter), 1fr)
Expand Down
22 changes: 21 additions & 1 deletion docs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ All primitives are exercised live in the [demo](/demo/).
| `.sf-bento` | dense free-form grid; container modifiers `--2/--3/--6`, `--row-compact/--row-tall`; child span classes `.sf-bento-wide/-full/-tall/-featured` | `--sf-bento-*` |
| `.sf-alternate` | zigzag two-column layout, reverses every other row; CQ-responsive | `--sf-content-gap`, `--sf-gap` |
| `.sf-pancake` | sticky-footer grid: header / main(1fr) / footer | — |
| `.sf-content-grid` | breakout layout; children `.sf-breakout`, `.sf-full-bleed` | `--sf-content-width`, `--sf-breakout-width` |
| `.sf-content-grid` | breakout layout; children `.sf-breakout`, `.sf-full-bleed`; establishes an inline-size CQ scope (like `.sf-container`) so `.sf-grid-cols-*`/`.sf-bento` still respond when it replaces a container | `--sf-content-width`, `--sf-breakout-width` |
| `.sf-grid-flex` | flex-based grid alternative for uneven item counts; last-row leftovers stretch to fill (default) or stay fixed and centered (`--center`); `--xs … --2xl` | `--sf-grid-min`, `--sf-grid-gap` |
| `.sf-cover` | full-height region with a centered `.sf-cover__center`; `--min/--max/--padding-*` | `--sf-cover-*` |
| `.sf-frame` | aspect-ratio media box | `--sf-frame-ratio` |
Expand Down Expand Up @@ -144,3 +144,23 @@ requirement as `.sf-grid-cols-*`. The gap steps at the breakpoint rather than
interpolating across it; for a gap that single step is imperceptible in normal use.
The same pattern works for any scoped gap token (`--sf-gap`, `--sf-content-gap`,
`--sf-gutter`, `--sf-cluster-gap`, …).

## Container-query scope

The container-responsive primitives (`.sf-grid-cols-*`, `.sf-bento`, and any
`@container`-scoped token override like the one above) resolve against the
nearest ancestor that establishes an inline-size query container. Two wrappers
establish one for you: `.sf-container` and `.sf-content-grid` — so either can
host those primitives directly.

`.sf-center` deliberately does **not**. It's a minimal centring primitive with
no side effects (it doesn't become a containing block for `position: fixed`
descendants or a new stacking context), so it stays composable. When you need a
container-responsive child inside a centred wrapper, add the query scope
explicitly by composing `.sf-cq`:

```html
<div class="sf-center sf-cq">
<div class="sf-grid-cols-3">…</div>
</div>
```
121 changes: 121 additions & 0 deletions tests/layout.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,127 @@ test.describe('layout: .sf-content-grid', () => {
const col = await page.locator('#f').evaluate(el => getComputedStyle(el).gridColumn);
expect(col).toMatch(/full/);
});

// .sf-content-grid establishes its own inline-size query container, at parity
// with .sf-container. Before this, switching a section wrapper from
// .sf-container to .sf-content-grid (to get breakout/full-bleed) silently
// removed the CQ scope that .sf-grid-cols-* / .sf-bento depend on, leaving
// them stuck at their 1-column fallback at every width.
test('establishes the named sf-layout inline-size query container', async ({ page }) => {
await setup(page, `<div id="t" class="sf-content-grid">x</div>`);
const cs = await page.locator('#t').evaluate(el => ({
type: getComputedStyle(el).containerType,
name: getComputedStyle(el).containerName,
}));
expect(cs.type).toContain('inline-size');
// Same name as .sf-container so a user's `@container sf-layout (…)` rule
// keeps matching when the wrapper is swapped for breakout/full-bleed.
expect(cs.name).toContain('sf-layout');
});

test('a name-targeted @container sf-layout rule matches under the content grid', async ({ page }) => {
await setup(page, `
<div class="sf-content-grid" style="width:1200px">
<div id="probe">x</div>
</div>
`);
await page.addStyleTag({ content: `
#probe { --matched: 0; }
@container sf-layout (min-width: 40rem) { #probe { --matched: 1; } }
` });
const matched = await page.locator('#probe').evaluate(el =>
getComputedStyle(el).getPropertyValue('--matched').trim()
);
expect(matched).toBe('1');
});

test('a .sf-grid-cols-* child responds to the content grid as its CQ ancestor', async ({ page }) => {
await setup(page, `
<div class="sf-content-grid" style="width:1200px">
<div id="g" class="sf-grid-cols-3"><div>1</div><div>2</div><div>3</div></div>
</div>
`);
// The 48rem breakpoint resolves against .sf-content-grid's full 1200px
// (the CQ ancestor), not #g's narrower content-column width — same as it
// would against .sf-container. That's why 3 columns fire here.
const cols = await page.locator('#g').evaluate(el =>
getComputedStyle(el).gridTemplateColumns.split(' ').length
);
Comment thread
jackgranatowski marked this conversation as resolved.
expect(cols).toBe(3);
Comment thread
jackgranatowski marked this conversation as resolved.
});

test('becoming a CQ container does not shrink full-bleed reach', async ({ page }) => {
await setup(page, `
<div id="cg" class="sf-content-grid" style="width:1200px">
<div id="f" class="sf-full-bleed">full</div>
<div id="c">content</div>
</div>
`);
const res = await page.evaluate(() => {
const cg = document.getElementById('cg').getBoundingClientRect();
const f = document.getElementById('f').getBoundingClientRect();
const c = document.getElementById('c').getBoundingClientRect();
return { cgW: cg.width, fW: f.width, cW: c.width };
});
// full-bleed still spans the whole grid; the content track stays narrower.
expect(Math.abs(res.fW - res.cgW)).toBeLessThan(1);
expect(res.cW).toBeLessThan(res.fW);
});

// Inside a .sf-section--guttered, the section owns the inline gutter. A
// content grid carries its gutter in its edge tracks, so without a reset the
// section's padding boxes it in — double-guttering content and stopping
// full-bleed one gutter short of the section edge. The reset drops the
// section padding so full-bleed reaches the true edge — but only when the
// grid is the section's sole direct child (see the sibling test below). The
// gutter probe therefore lives OUTSIDE the section so it doesn't disqualify
// the sole-child guard.
test('reaches the section edge inside a guttered section (no double gutter)', async ({ page }) => {
await setup(page, `
<div id="probe" style="width:var(--sf-gutter)"></div>
<section id="s" class="sf-section sf-section--guttered" style="width:1000px">
<div class="sf-content-grid">
<div id="f" class="sf-full-bleed">full</div>
<div id="c">content</div>
</div>
</section>
`);
const res = await page.evaluate(() => {
const s = document.getElementById('s').getBoundingClientRect();
const f = document.getElementById('f').getBoundingClientRect();
const c = document.getElementById('c').getBoundingClientRect();
// The section's own padding is now 0 (reset), so read one gutter off a probe.
const gutter = document.getElementById('probe').getBoundingClientRect().width;
return { fullInset: f.left - s.left, contentInset: c.left - s.left, gutter, sW: s.width, fW: f.width };
});
// full-bleed reaches the section edge; content sits at exactly one gutter.
expect(res.fullInset).toBeLessThan(1);
expect(Math.abs(res.fW - res.sW)).toBeLessThan(1);
expect(res.contentInset).toBeGreaterThan(res.gutter - 1);
expect(res.contentInset).toBeLessThan(res.gutter + 2);
});

// The reset is scoped to a sole-child content grid. A guttered section that
// also holds a non-grid sibling keeps its padding, so that sibling keeps its
// gutter (stripping it to satisfy the grid's full-bleed would be wrong — the
// two wants are irreconcilable, so the mixed case is left untouched).
test('a non-grid sibling keeps its gutter when the grid is not the sole child', async ({ page }) => {
await setup(page, `
<section id="s" class="sf-section sf-section--guttered" style="width:1000px">
<h2 id="sib">Heading</h2>
<div class="sf-content-grid"><div>content</div></div>
</section>
`);
const res = await page.evaluate(() => {
const s = document.getElementById('s').getBoundingClientRect();
const sib = document.getElementById('sib').getBoundingClientRect();
const pad = parseFloat(getComputedStyle(document.getElementById('s')).paddingLeft);
return { sibInset: sib.left - s.left, pad };
});
// Section keeps a positive gutter and the sibling is inset by it.
expect(res.pad).toBeGreaterThan(1);
expect(res.sibInset).toBeGreaterThan(res.pad - 1);
});
});

// ── .sf-subgrid ─────────────────────────────────────────────────
Expand Down