From eff5440a983a53a28a3fed56b3337ef6ab9d0918 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:39:18 +0000 Subject: [PATCH 1/4] feat(layout): give .sf-content-grid an inline-size CQ scope, at parity with .sf-container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .sf-content-grid is the section-level wrapper you switch to when you need breakout / full-bleed — it replaces .sf-container rather than nesting inside it. But .sf-container was the only wrapper carrying the inline-size query scope that .sf-grid-cols-* and .sf-bento depend on, so swapping it out silently stranded those primitives at their 1-column fallback at every width. Establish `container-type: inline-size` on .sf-content-grid so the two wrappers are interchangeable. Safe re SL-034 (its tracks never query themselves, so no self-query) and re full-bleed (inline-size containment doesn't change track resolution — the `full` track still reaches the edges; verified by test). Tests: add three cases to the .sf-content-grid block — establishes CQ, a .sf-grid-cols-* child revives, and full-bleed reach is unchanged. Docs: note the CQ scope on the .sf-content-grid row in layout.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LywrnXafVz2rVBNF4TuDbi --- core/layout.css | 11 +++++++++++ docs/layout.md | 2 +- tests/layout.spec.js | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/core/layout.css b/core/layout.css index 4e1df918..ada22563 100644 --- a/core/layout.css +++ b/core/layout.css @@ -579,6 +579,17 @@ .sf-content-grid { display: grid; + /* Establish an inline-size query container, at parity with .sf-container. + .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. Restoring the scope here + closes that cliff so the two wrappers are interchangeable. + 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-type: inline-size; grid-template-columns: [full-start] minmax(var(--sf-gutter), 1fr) diff --git a/docs/layout.md b/docs/layout.md index ab332a87..b5ba1da4 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` | diff --git a/tests/layout.spec.js b/tests/layout.spec.js index 6a6460d2..afa677c8 100644 --- a/tests/layout.spec.js +++ b/tests/layout.spec.js @@ -824,6 +824,47 @@ 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 an inline-size query container', async ({ page }) => { + await setup(page, `
x
`); + const ct = await page.locator('#t').evaluate(el => getComputedStyle(el).containerType); + expect(ct).toContain('inline-size'); + }); + + test('a .sf-grid-cols-* child responds to the content grid as its CQ ancestor', async ({ page }) => { + await setup(page, ` +
+
1
2
3
+
+ `); + const cols = await page.locator('#g').evaluate(el => + getComputedStyle(el).gridTemplateColumns.trim().split(/\s+/).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); + }); }); // ── .sf-subgrid ───────────────────────────────────────────────── From 850b1683aea68c6699208054724cc3273c064601 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 05:44:43 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test(layout):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20align=20split=20style,=20note=20CQ-ancestor=20width?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the file's established `.split(' ')` form in the new CQ test (matching the adjacent .sf-grid-cols-* tests) and add an inline comment explaining the breakpoint resolves against the content grid's full width, not the child's content-column width. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LywrnXafVz2rVBNF4TuDbi --- tests/layout.spec.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/layout.spec.js b/tests/layout.spec.js index afa677c8..b5966fe6 100644 --- a/tests/layout.spec.js +++ b/tests/layout.spec.js @@ -842,8 +842,11 @@ test.describe('layout: .sf-content-grid', () => {
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.trim().split(/\s+/).length + getComputedStyle(el).gridTemplateColumns.split(' ').length ); expect(cols).toBe(3); }); From 7b73a08d255541c32e5d92f7cf8e8e84c54b1445 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 12:47:11 +0000 Subject: [PATCH 3/4] fix(layout): full-bleed reaches section edge in a guttered content grid; doc center CQ scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the container-system audit: 1. .sf-section--guttered × .sf-content-grid — a guttered section owns the inline gutter, but .sf-content-grid carries its gutter in its edge tracks, not padding, so the .sf-container padding reset doesn't apply to it. Left as-is, the section's padding boxed the grid in: content was double-guttered and .sf-full-bleed stopped one gutter short of the section edge. Drop the section's padding when a content grid is its direct child (:has()) so the grid spans edge-to-edge and its own tracks provide the single gutter. Verified: full-bleed now reaches the section edge, content sits at exactly one gutter. 2. Document that .sf-center intentionally does NOT establish a CQ scope (it stays a side-effect-free centring primitive) and that container-responsive children need `.sf-center .sf-cq` composition — new "Container-query scope" section in layout.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LywrnXafVz2rVBNF4TuDbi --- core/layout.css | 10 ++++++++++ docs/layout.md | 20 ++++++++++++++++++++ tests/layout.spec.js | 30 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/core/layout.css b/core/layout.css index ada22563..bd94eb18 100644 --- a/core/layout.css +++ b/core/layout.css @@ -45,6 +45,16 @@ .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 when a content grid is its direct child so + the grid spans edge-to-edge and its tracks provide the single gutter; + full-bleed then reaches the true section edge. Scoped to a direct child so + a nested content grid deeper in the section doesn't strip the gutter. */ + .sf-section--guttered:has(> .sf-content-grid) { padding-inline: 0; } .sf-section-group > .sf-section + .sf-section { padding-block-start: 0; } diff --git a/docs/layout.md b/docs/layout.md index b5ba1da4..3170df67 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -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 b5966fe6..2039efbb 100644 --- a/tests/layout.spec.js +++ b/tests/layout.spec.js @@ -868,6 +868,36 @@ test.describe('layout: .sf-content-grid', () => { 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 :has() reset drops the + // section padding so full-bleed reaches the true edge. + 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); + }); }); // ── .sf-subgrid ───────────────────────────────────────────────── From 74034f341977ff1cadbed9ac3e3a50f4735a22b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 13:01:43 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(layout):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20name=20the=20content-grid=20container,=20guard=20the=20gutte?= =?UTF-8?q?red=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Major findings from the CodeRabbit review of the previous commit: 1. Named-container contract. .sf-container establishes the NAMED `sf-layout` query container; .sf-content-grid established an anonymous one, so a user's `@container sf-layout (…)` rule matched under the former but not the latter — contradicting the documented interchangeability. Give the grid the same name (`container: sf-layout / inline-size`). The framework's own primitives use anonymous `@container (…)` queries, which already matched either, so this is purely about honouring name-targeted user queries. Test added. 2. Guttered reset stripped sibling gutters. The `:has(> .sf-content-grid)` reset zeroed the section padding whenever a content grid was a direct child, so a non-grid sibling (heading, plain block) lost its gutter too. Restrict the reset to a SOLE-child content grid via `:not(:has(> :not(.sf-content-grid)))`; a mixed section now keeps its padding and behaves exactly as before the reset existed (the grid's full-bleed want and a sibling's gutter want are physically irreconcilable, so the ambiguous case is left untouched rather than guessed). Sibling-gutter test added; the edge-reach test's probe moved outside the section so the grid stays sole. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LywrnXafVz2rVBNF4TuDbi --- core/layout.css | 36 ++++++++++++++++++--------- tests/layout.spec.js | 59 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/core/layout.css b/core/layout.css index bd94eb18..ebedefb9 100644 --- a/core/layout.css +++ b/core/layout.css @@ -50,11 +50,19 @@ 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 when a content grid is its direct child so - the grid spans edge-to-edge and its tracks provide the single gutter; - full-bleed then reaches the true section edge. Scoped to a direct child so - a nested content grid deeper in the section doesn't strip the gutter. */ - .sf-section--guttered:has(> .sf-content-grid) { padding-inline: 0; } + 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; } @@ -589,17 +597,21 @@ .sf-content-grid { display: grid; - /* Establish an inline-size query container, at parity with .sf-container. - .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. Restoring the scope here - closes that cliff so the two wrappers are interchangeable. + /* 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-type: inline-size; + container: sf-layout / inline-size; grid-template-columns: [full-start] minmax(var(--sf-gutter), 1fr) diff --git a/tests/layout.spec.js b/tests/layout.spec.js index 2039efbb..b867095a 100644 --- a/tests/layout.spec.js +++ b/tests/layout.spec.js @@ -830,10 +830,32 @@ test.describe('layout: .sf-content-grid', () => { // .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 an inline-size query container', async ({ page }) => { + test('establishes the named sf-layout inline-size query container', async ({ page }) => { await setup(page, `
x
`); - const ct = await page.locator('#t').evaluate(el => getComputedStyle(el).containerType); - expect(ct).toContain('inline-size'); + 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 }) => { @@ -872,12 +894,15 @@ test.describe('layout: .sf-content-grid', () => { // 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 :has() reset drops the - // section padding so full-bleed reaches the true edge. + // 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
@@ -898,6 +923,28 @@ test.describe('layout: .sf-content-grid', () => { 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 ─────────────────────────────────────────────────