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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## Unreleased

### Bug Fixes
- **layout:** `.sf-bento` and `.sf-grid-cols-2/3/4/6` now actually respond to their `@container` breakpoints — both used to declare a `container` on themselves and then query that same container to resize themselves, which is a no-op per spec (a container can't be the subject of its own `@container` query). Fixed by relying on an ancestor container (`.sf-container` / `.sf-cq` / `.sf-fluid-cq`) instead, matching how `.sf-alternate` and `.sf-grid-cols-1-2` etc. already do it. `.sf-bento-wide` / `.sf-bento-featured` also no longer force a phantom 2nd column once the grid collapses to 1 column at the mobile breakpoint.

## [0.7.16] - 2026-07-13

### Features
Expand Down
29 changes: 24 additions & 5 deletions core/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,19 @@
SL-005: every @container query in this file hardcodes its breakpoint
instead of referencing a --sf-* token — var() is not allowed inside an
@container condition per the CSS spec. This applies framework-wide to
every @container site below; not re-explained at each one. */
every @container site below; not re-explained at each one.
SL-034: a query container cannot be the subject of its own @container
query — conditions only ever match descendants of the nearest ancestor
container, never the element that establishes it (self-querying is a
silent no-op, not an error). Primitives that need to resize THEMSELVES
(.sf-grid-cols-2/3/4/6, .sf-bento) must therefore rely on an ancestor
container (.sf-container / .sf-cq / .sf-fluid-cq) rather than declaring
`container` on themselves. .sf-grid-cols-1-2 etc. already follow this —
they've never declared their own `container` — so they were unaffected.
.sf-alternate is a different, unrelated-but-similar-looking pattern: it
DOES declare `container` on itself, but only to restyle its CHILDREN
(`.sf-alternate > *`), which is not self-querying and is fine. Not
re-explained at each site below. */

@layer slashed.layout {

Expand Down Expand Up @@ -509,9 +521,8 @@


.sf-grid-cols-2, .sf-grid-cols-3, .sf-grid-cols-4, .sf-grid-cols-6 {
display: grid;
gap: var(--sf-grid-gap);
container: sf-grid / inline-size;
display: grid;
gap: var(--sf-grid-gap);
}

@container (min-width: 30em) {
Expand Down Expand Up @@ -579,7 +590,6 @@
grid-auto-rows: minmax(var(--sf-bento-row, var(--sf-bento-row-default)), auto);
grid-auto-flow: dense;
gap: var(--sf-bento-gap);
container: sf-bento / inline-size;
}

@container (max-width: 29.99em) {
Expand All @@ -605,6 +615,15 @@
.sf-bento-tall { grid-row: span 2; }
.sf-bento-featured { grid-column: span 2; grid-row: span 2; }

/* A spanning child can't request more columns than the 1-column mobile
grid has, or CSS Grid adds an implicit column to satisfy the span,
breaking the single-column layout the query above exists to guarantee.
Placed after the modifiers above (equal specificity) so it wins on
source order regardless of query-match state. */
@container (max-width: 29.99em) {
.sf-bento-wide, .sf-bento-featured { grid-column: span 1; }
}


.sf-subgrid { display: grid; grid-template-columns: subgrid; }
.sf-subgrid-rows { display: grid; grid-template-rows: subgrid; }
Expand Down
134 changes: 134 additions & 0 deletions tests/layout.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,140 @@ test.describe('layout: .sf-bento', () => {
);
expect(cols).toBe(2);
});

// .sf-bento's own @container breakpoints must resolve against an ANCESTOR
// container, not itself — a size container cannot be the subject of its
// own @container query (SL-034); .sf-bento used to declare `container`
// on itself, which made these breakpoints a permanent silent no-op at
// every viewport, mobile included.
test('collapses to 1 column below the mobile breakpoint (ancestor container)', async ({ page }) => {
await setup(page, `
<div style="container-type:inline-size; width:300px">
<div id="g" class="sf-bento"><div>A</div><div>B</div></div>
</div>
`);
const cols = await page.locator('#g').evaluate(el =>
getComputedStyle(el).gridTemplateColumns.split(' ').length
);
expect(cols).toBe(1);
});

test('a spanning child does not force a phantom 2nd column at the mobile breakpoint', async ({ page }) => {
await setup(page, `
<div style="container-type:inline-size; width:300px">
<div class="sf-bento">
<div id="w" class="sf-bento-wide">wide</div>
<div>B</div>
</div>
</div>
`);
const [gridWidth, itemWidth] = await Promise.all([
page.locator('.sf-bento').evaluate(el => el.getBoundingClientRect().width),
page.locator('#w').evaluate(el => el.getBoundingClientRect().width),
]);
expect(itemWidth).toBeLessThanOrEqual(gridWidth + 1);
});
Comment on lines +665 to +679

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 .sf-bento-featured mobile overflow not exercised

The CSS fix resets both .sf-bento-wide and .sf-bento-featured to span 1 at the mobile breakpoint, but only .sf-bento-wide is covered by this test. A featured child carries grid-column: span 2; grid-row: span 2;, so an item-width assertion analogous to the sf-bento-wide case would confirm its span 2 is also being overridden correctly and not creating a phantom column.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


test('--featured (span 2/2) also does not force a phantom 2nd column at the mobile breakpoint', async ({ page }) => {
await setup(page, `
<div style="container-type:inline-size; width:300px">
<div class="sf-bento">
<div id="f" class="sf-bento-featured">featured</div>
<div>B</div>
</div>
</div>
`);
const [gridWidth, itemWidth] = await Promise.all([
page.locator('.sf-bento').evaluate(el => el.getBoundingClientRect().width),
page.locator('#f').evaluate(el => el.getBoundingClientRect().width),
]);
expect(itemWidth).toBeLessThanOrEqual(gridWidth + 1);
});

test('uses the wider column count once past the mobile breakpoint', async ({ page }) => {
await setup(page, `
<div style="container-type:inline-size; width:1600px">
<div id="g" class="sf-bento"><div>A</div><div>B</div></div>
</div>
`);
const cols = await page.locator('#g').evaluate(el =>
getComputedStyle(el).gridTemplateColumns.split(' ').length
);
expect(cols).toBeGreaterThan(1);
});

// `em` inside an @container condition resolves against the container's
// (fluid, viewport-driven) inherited font-size here — not a fixed 16px —
// so the effective pixel breakpoint isn't identical across engines. 750px
// sits comfortably between every observed 30em/48em interpretation; 900px
// was measured to land right on that boundary and flipped between 2 and 4
// columns depending on the browser (see the grid-cols-4 test below).
test('uses 2 columns in the mid-range between 30em and 48em', async ({ page }) => {
await setup(page, `
<div style="container-type:inline-size; width:750px">
<div id="g" class="sf-bento"><div>A</div><div>B</div></div>
</div>
`);
const cols = await page.locator('#g').evaluate(el =>
getComputedStyle(el).gridTemplateColumns.split(' ').length
);
expect(cols).toBe(2);
});
});

// ── .sf-grid-cols-2/3/4/6 ──────────────────────────────────────────
test.describe('layout: .sf-grid-cols-2/3/4/6', () => {
// Same SL-034 constraint as .sf-bento above: these used to establish and
// then query their own container, so the breakpoint below was dead code
// at every viewport — only an ancestor container makes it fire.
// A width of 900px (56.25em at a fixed 16px basis) originally lived here
// and was flaky in CI: `em` inside an @container condition resolves
// against the container's inherited font-size, which here is the fluid
// `--sf-text-m` (~19px at this viewport, not 16px), and engines don't
// agree on the resulting effective breakpoint down to the pixel — 900px
// sat close enough to the 48em boundary that Chromium read it as past the
// breakpoint while Firefox/WebKit read it as short of it. 1600px clears
// every observed interpretation.
test('resolves its column count against an ancestor container, not itself', async ({ page }) => {
await setup(page, `
<div style="container-type:inline-size; width:1600px">
<div id="g" class="sf-grid-cols-4"><div>1</div><div>2</div><div>3</div><div>4</div></div>
</div>
`);
const cols = await page.locator('#g').evaluate(el =>
getComputedStyle(el).gridTemplateColumns.split(' ').length
);
expect(cols).toBe(4);
});

test('stays 1 column below the breakpoint', async ({ page }) => {
await setup(page, `
<div style="container-type:inline-size; width:300px">
<div id="g" class="sf-grid-cols-4"><div>1</div><div>2</div><div>3</div><div>4</div></div>
</div>
`);
const cols = await page.locator('#g').evaluate(el =>
getComputedStyle(el).gridTemplateColumns.split(' ').length
);
expect(cols).toBe(1);
});
Comment on lines +753 to +763

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 "Stays 1 column" assertion passes for an unrelated reason

At 300px, no grid-template-columns rule is set for .sf-grid-cols-4 (neither @container breakpoint fires), so getComputedStyle(el).gridTemplateColumns returns "none", and "none".split(' ').length === 1 is trivially true — the test would pass even without an ancestor container at all. A complementary test at 30em–47.99em (e.g. 600px) would verify the intermediate 2-column state and give the "stays below 30em" assertion real meaning by contrast.


// Below 30em NEITHER @container rule matches, so gridTemplateColumns falls
// back to the unset "none" (still 1 "column" by construction) — the case
// above alone can't distinguish a working ancestor container from a
// completely absent one. This mid-range case only passes if the 30em
// breakpoint's ancestor-container query actually fired.
test('uses the 2-column mid breakpoint between 30em and 48em', async ({ page }) => {
await setup(page, `
<div style="container-type:inline-size; width:750px">
<div id="g" class="sf-grid-cols-4"><div>1</div><div>2</div><div>3</div><div>4</div></div>
</div>
`);
const cols = await page.locator('#g').evaluate(el =>
getComputedStyle(el).gridTemplateColumns.split(' ').length
);
expect(cols).toBe(2);
});
});

// ── .sf-alternate ───────────────────────────────────────────────
Expand Down