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