diff --git a/core/layout.css b/core/layout.css index 4e1df918..ebedefb9 100644 --- a/core/layout.css +++ b/core/layout.css @@ -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; } @@ -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) diff --git a/docs/layout.md b/docs/layout.md index ab332a87..3170df67 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -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` | @@ -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 +
+
+
+``` diff --git a/tests/layout.spec.js b/tests/layout.spec.js index 6a6460d2..b867095a 100644 --- a/tests/layout.spec.js +++ b/tests/layout.spec.js @@ -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, `
x
`); + 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, ` +
+
x
+
+ `); + 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, ` +
+
1
2
3
+
+ `); + // 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 + ); + expect(cols).toBe(3); + }); + + test('becoming a CQ container does not shrink full-bleed reach', async ({ page }) => { + await setup(page, ` +
+
full
+
content
+
+ `); + 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, ` +
+
+
+
full
+
content
+
+
+ `); + 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, ` +
+

Heading

+
content
+
+ `); + 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 ─────────────────────────────────────────────────