ShippedSmall

Add .sidebar-end modifier so sidebar layouts can put the narrow column on the right, with contract entries

Proposed by Jonathan Miller 2 hours agoFunding opened 2 hours agoFunded 2 hours agoShipped 1 hour ago
Specification

Add .sidebar-end modifier: sidebar layout with the narrow column on the right

Motivation

.sidebar, .sidebar-sm and .sidebar-lg always make the first child the narrow column. Dashboards often need a wide main column with a narrow column on the right.

The contract currently offers no compliant way to build this:

  • Visual reordering (order, *-reverse) is banned.
  • Custom flex or grid CSS is banned (Rule 2).
  • .grid only produces equal columns.

In benchmark runs, models either fell back to an equal-width .grid or wrote non-compliant CSS.

Scope

  1. CSS: add a .sidebar-end modifier in the layout primitives stylesheet, next to the existing sidebar rules. It combines with .sidebar, .sidebar-sm and .sidebar-lg.
    • It makes the last child the narrow column, using flex-basis: var(--sidebar-width) and flex-grow: 1.
    • It makes the first child the growing main column, using flex-basis: 0, flex-grow: 999 and min-inline-size: 50%.
    • DOM order stays the visual order. No order and no *-reverse are used.
    • The proposer's reference CSS is the starting point. The agent must check that its selectors beat the existing sidebar rules on specificity, and must reset any properties the base rules set on the first and last child.
    • Edge case: with a single child, .sidebar-end must not shrink that child. The proposer's reference uses :last-child:not(:first-child) for this.
  2. Width: per-instance width uses the existing --sidebar-width token override, e.g. style="--sidebar-width: 20rem". No new tokens are added.
  3. synthcss.llm.md:
    • Add .sidebar-end to the Layout Vocabulary.
    • Add an Intent Mapping row: "Main content with a narrow side column on the right → .sidebar (or -sm/-lg) + .sidebar-end".
    • Add a Valid Example matching the proposal's markup.
  4. synthcss.ai.json: add the equivalent entries so that the existing contract sync verification passes.
  5. Tests: if the repo has CSS/contract tests, extend them to cover the new class.

Acceptance criteria

  • .sidebar-end combined with each of .sidebar, .sidebar-sm and .sidebar-lg produces this layout when there is enough room:
    • the last child at --sidebar-width;
    • the first child filling the remaining inline space.
  • When the first child cannot keep at least 50% of the container's inline size, the layout wraps to one column with the main (first) child on top.
  • Overriding --sidebar-width inline on the container changes the width of the narrow (last) column.
  • Existing .sidebar* behaviour without .sidebar-end is unchanged.
  • With one child, .sidebar-end does not narrow that child.
  • The CSS uses no order, row-reverse or column-reverse.
  • .sidebar-end appears in synthcss.llm.md (Layout Vocabulary, Intent Mapping, Valid Example) and in synthcss.ai.json.
  • The contract sync verification passes, and so does the existing test/lint suite.

Assumptions

  • No showcase update, unless the maintainer asks for one.
  • No version bump or changelog edit, unless the repo convention requires it.

Out of scope

  • New width tokens or size variants.
  • Changes to .grid or .split.
  • Container-query or breakpoint-based behaviour.
Build #7
SUCCEEDED

Model claude-opus-5-5 · ceiling $5 · started 1 hour ago · finished 1 hour ago

30agent turns
1.9Mtokens
$1.17spent at list price

Builder summary
## Add `.sidebar-end`: sidebar layouts with the narrow column on the right (nabled #6) `.sidebar-end` combines with `.sidebar`, `.sidebar-sm` or `.sidebar-lg`. With it, the **last** child is the narrow column, on the right, and the **first** child is the main area. It uses no `order` and no `*-reverse`, so the order in the HTML is still the order on screen. When the layout wraps, the main area is on top. ```html <div class="sidebar-lg sidebar-end" style="--sidebar-width: 20rem"> <main class="stack">…</main> <aside class="panel">…</aside> </div> ``` ### CSS (`src/layout.css`) - `.sidebar-end:is(.sidebar, .sidebar-sm, .sidebar-lg) > :first-child` sets `flex-basis: 0; flex-grow: 999; min-inline-size: 50%`. Its specificity is (0,3,0), which beats the base `:first-child` rule at (0,2,0). - `.sidebar-end:is(…) > :last-child:not(:first-child)` sets `flex-basis: var(--sidebar-width); flex-grow: 1; min-inline-size: auto`. Its specificity is (0,4,0), which beats the base `:last-child:not(:first-child)` rule at (0,3,0). `min-inline-size: auto` undoes the 50% minimum the base rule puts on that child. Without this reset the narrow column breaks (the browser check confirms it). - **Only one child:** that child is treated as the main area (it grows, with at least 50% width), so it keeps the full width. - **Without a sidebar class:** `.sidebar-end` has no effect. - **Width:** set per instance with the existing `--sidebar-width` token. No new tokens. ### Contract - **`synthcss.llm.md` and `synthcss.ai.json`:** `.sidebar-end` is added to the Layout Vocabulary (`layouts`), plus the requested Intent Mapping row and a fourth valid example (a dashboard). Four is the maximum the verifier allows. - **`contractVersion`:** kept at 1.1.0, because the file format did not change. - **Version:** not bumped; the release workflow does that. ### Docs - **`docs/layout.md`:** the `.sidebar` section now covers the modifier (variants, how it wraps, recommended uses, misuses). It is also in the vocabulary table, and the token table and verification notes are updated. - **Smaller wording updates:** `docs/tokens.md`, `docs/ai-contract.md` and `README.md`. ### Tests - **`scripts/check-layout.mjs`:** `sidebar-end` is now an allowed helper class and must appear in the fixture. A new static check requires both `.sidebar-end` rules with exactly the selectors and declarations above. - **`scripts/check-layout.test.mjs`:** a new test fails on: - a missing reset; - a weaker selector; - `row-reverse`; - the class missing from the fixture. - **`examples/layout.html`:** new `#sidebar-end` section with 375px and full-width frames. It includes `.sidebar`, `-sm` and `-lg` with `.sidebar-end`, an inline `--sidebar-width: 20rem` override, and a single-child case. - **`scripts/check-layout-browser.mjs`:** checks in headless Chromium at 375px and 1280px that: - in a narrow frame, the children stack with the main area first and both full width; - in a wide frame, the narrow column sits at the right edge, is `--sidebar-width` wide, and the main area fills the rest; - the 20rem override gives a 20rem column; - a single child is not narrowed. The existing `.sidebar` checks are unchanged. ### Verification - `npm test`: all checks and all 68 node tests pass. `verify-ai-contract` passes, and `synthcss.llm.md` is now about 3,030 tokens. - `npm run check:layout:browser`: passes. As a sanity check, I removed the `min-inline-size: auto` reset, and separately removed both `.sidebar-end` rules; the check failed both times. - `check:showcase:browser`: fails in this sandbox only because an external badge image (`nabled.ai`) fails its TLS check. It fails the same way on `main`, so this change didn't cause it. ### Worth a look - **Width measurement:** the width of the narrow column is measured without padding and border. Like the base `.sidebar`, `flex-basis` sizes the content box. - **Token estimate:** the showcase shows "≈ 2,830 tokens" and the README and `docs/ai-contr

Agent log
  <aside class="panel">…</aside>
</div>
```

### CSS (`src/layout.css`)
- `.sidebar-end:is(.sidebar, .sidebar-sm, .sidebar-lg) > :first-child` sets `flex-basis: 0; flex-grow: 999; min-inline-size: 50%`. Its specificity is (0,3,0), which beats the base `:first-child` rule at (0,2,0).
- `.sidebar-end:is(…) > :last-child:not(:first-child)` sets `flex-basis: var(--sidebar-width); flex-grow: 1; min-inline-size: auto`. Its specificity is (0,4,0), which beats the base `:last-child:not(:first-child)` rule at (0,3,0). `min-inline-size: auto` undoes the 50% minimum the base rule puts on that child. Without this reset the narrow column breaks (the browser check confirms it).
- **Only one child:** that child is treated as the main area (it grows, with at least 50% width), so it keeps the full width.
- **Without a sidebar class:** `.sidebar-end` has no effect.
- **Width:** set per instance with the existing `--sidebar-width` token. No new tokens.

### Contract
- **`synthcss.llm.md` and `synthcss.ai.json`:** `.sidebar-end` is added to the Layout Vocabulary (`layouts`), plus the requested Intent Mapping row and a fourth valid example (a dashboard). Four is the maximum the verifier allows.
- **`contractVersion`:** kept at 1.1.0, because the file format did not change.
- **Version:** not bumped; the release workflow does that.

### Docs
- **`docs/layout.md`:** the `.sidebar` section now covers the modifier (variants, how it wraps, recommended uses, misuses). It is also in the vocabulary table, and the token table and verification notes are updated.
- **Smaller wording updates:** `docs/tokens.md`, `docs/ai-contract.md` and `README.md`.

### Tests
- **`scripts/check-layout.mjs`:** `sidebar-end` is now an allowed helper class and must appear in the fixture. A new static check requires both `.sidebar-end` rules with exactly the selectors and declarations above.
- **`scripts/check-layout.test.mjs`:** a new test fails on:
  - a missing reset;
  - a weaker selector;
  - `row-reverse`;
  - the class missing from the fixture.
- **`examples/layout.html`:** new `#sidebar-end` section with 375px and full-width frames. It includes `.sidebar`, `-sm` and `-lg` with `.sidebar-end`, an inline `--sidebar-width: 20rem` override, and a single-child case.
- **`scripts/check-layout-browser.mjs`:** checks in headless Chromium at 375px and 1280px that:
  - in a narrow frame, the children stack with the main area first and both full width;
  - in a wide frame, the narrow column sits at the right edge, is `--sidebar-width` wide, and the main area fills the rest;
  - the 20rem override gives a 20rem column;
  - a single child is not narrowed.
  The existing `.sidebar` checks are unchanged.

### Verification
- `npm test`: all checks and all 68 node tests pass. `verify-ai-contract` passes, and `synthcss.llm.md` is now about 3,030 tokens.
- `npm run check:layout:browser`: passes. As a sanity check, I removed the `min-inline-size: auto` reset, and separately removed both `.sidebar-end` rules; the check failed both times.
- `check:showcase:browser`: fails in this sandbox only because an external badge image (`nabled.ai`) fails its TLS check. It fails the same way on `main`, so this change didn't cause it.

### Worth a look
- **Width measurement:** the width of the narrow column is measured without padding and border. Like the base `.sidebar`, `flex-basis` sizes the content box.
- **Token estimate:** the showcase shows "≈ 2,830 tokens" and the README and `docs/ai-contract.md` say "about 2,800". The real count is now about 3,030, which is within the 10% tolerance, so I left them as they are; the spec ruled out showcase changes.
- **Valid example:** I didn't have the proposer's markup, so the valid example is my own dashboard version of it.
2026-10-03T13:24:11Z  [claude] I've added the `.sidebar-end` modifier and pushed it on `nabled/feature-6-job-7`. With it, a `.sidebar`, `.sidebar-sm` or `.sidebar-lg` layout puts the narrow column on the right. `npm test` passes, including the contract check, and so does the new in-browser layout check. nabled will open the pull request.

**How it works**
- The **last** child becomes the narrow column at `--sidebar-width` and the **first** child fills the rest. It uses no `order` and no `*-reverse`, so the HTML order is the order on screen.
- When the first child can't keep half the width, the layout wraps into one column with the main content on top.
- Setting `--sidebar-width` on the container (for example `style="--sidebar-width: 20rem"`) changes the narrow column's width. No new tokens.
- The new rules are one class more specific than the existing sidebar rules. The narrow column also gets `min-inline-size: auto` to undo the existing 50% minimum. Without that reset the layout breaks.
- With a single child, that child keeps the full width. Without a sidebar class, `.sidebar-end` does nothing. Layouts that don't use `.sidebar-end` are unchanged.

**Contract and docs**
- `synthcss.llm.md` and `synthcss.ai.json` both have the new class, the Intent Mapping row and a fourth valid example (the most the checker allows).
- I didn't have the proposer's markup, so I wrote that example myself: a dashboard with the main content first and a 20rem activity panel on the right.
- I updated `docs/layout.md` and made small wording updates in `docs/tokens.md`, `docs/ai-contract.md` and the README. No version bump; the release workflow does that.

**Tests**
- The static layout check (`check-layout`) now requires both new rules with their exact selectors and values. New tests confirm it fails if the reset is missing, the selector is weaker, `row-reverse` is used, or the example page doesn't use the class.
- I added a `.sidebar-end` section to `examples/layout.html` covering the three sizes, the width override and a single child.
- The browser check (`check:layout:browser`) measures that section at 375px and 1280px. It failed when I removed the reset, and again when I removed the new rules, so it does catch these breaks.
- The showcase browser check fails here only because an external badge image from `nabled.ai` fails its security check in this sandbox. It fails the same way on `main`, so this change didn't cause it.

**One thing to know:** the contract file is now about 3,030 tokens. The showcase still says "≈ 2,830" and the README and `docs/ai-contract.md` say "about 2,800". That's within the checker's 10% tolerance, and the spec ruled out showcase changes, so I left those numbers as they are.
2026-10-03T13:24:12Z  Changed files:
modified  README.md  +1 -1
modified  docs/ai-contract.md  +1 -1
modified  docs/layout.md  +34 -6
modified  docs/tokens.md  +1 -1
modified  examples/layout.html  +21 -0
modified  scripts/check-layout-browser.mjs  +49 -0
modified  scripts/check-layout.mjs  +22 -1
modified  scripts/check-layout.test.mjs  +14 -0
modified  src/layout.css  +17 -0
modified  synthcss.ai.json  +6 -0
modified  synthcss.llm.md  +17 -0
2026-10-03T13:24:14Z  Opened pull request https://github.com/nabledhq/synthcss/pull/12
2026-10-03T13:24:14Z  Finished: success=true turns=30 tokens(in/out)=1884372/19541 list cost=$1.17

Show patch
diff --git a/README.md b/README.md
index 0887ce4..fbd492e 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ The main bundle also applies the tokens to plain markup: `--font-sans`, `--color
 
 ## Layout primitives
 
-Eight intent-named layout classes in [`src/layout.css`](src/layout.css): `.container`, `.stack`, `.cluster`, `.grid`, `.sidebar`, `.split`, `.center` and `.cover`, plus `-sm`/`-lg` gap variants for `stack`, `cluster`, `grid`, `sidebar` and `split`. They respond to the space they are given, with no media queries or breakpoint classes. Load everything with the main bundle:
+Eight intent-named layout classes in [`src/layout.css`](src/layout.css): `.container`, `.stack`, `.cluster`, `.grid`, `.sidebar`, `.split`, `.center` and `.cover`, plus `-sm`/`-lg` gap variants for `stack`, `cluster`, `grid`, `sidebar` and `split`, and a `.sidebar-end` modifier that puts the sidebar's narrow column on the right. They respond to the space they are given, with no media queries or breakpoint classes. Load everything with the main bundle:
 
 ```html
 <link rel="stylesheet" href="synthcss/src/synthcss.css">
diff --git a/docs/ai-contract.md b/docs/ai-contract.md
index 9e696cd..cca1731 100644
--- a/docs/ai-contract.md
+++ b/docs/ai-contract.md
@@ -22,7 +22,7 @@ Class names are written **without** the leading dot. Token names keep their `--`
 | `contractVersion` | string (semver) | Version of the contract format. Bump the major for a breaking change to this schema. |
 | `tokens` | object | Token name → short purpose, for every custom property on `:root` in `src/tokens.css`. |
 | `baseStyles` | object | `{ note, rules }`: what the base styles in `src/base.css` apply. `note` is a one-sentence summary that the Markdown Design Tokens section repeats; `rules` maps each selector (without `:where()`) to its declarations, exactly as in `src/base.css`. |
-| `layouts` | object | Layout class → intent: the eight primitives, their `-sm` / `-lg` gap variants and `cover-main`. |
+| `layouts` | object | Layout class → intent: the eight primitives, their `-sm` / `-lg` gap variants, `cover-main` and `sidebar-end`. |
 | `components` | object | Component base class → `{ intent, parts, variants }`. `parts` and `variants` map class → purpose (empty `{}` when there are none). |
 | `intentMap` | array | `{ intent, use }` pairs: a plain-language need and the markup to use for it. |
 | `compositionRules` | object | `{ recommended: [], avoid: [] }`: how to combine primitives and components. |
diff --git a/docs/layout.md b/docs/layout.md
index 20f5fc3..4632c70 100644
--- a/docs/layout.md
+++ b/docs/layout.md
@@ -38,7 +38,7 @@ Tokens used by the layout primitives:
 | --- | --- | --- |
 | `--container-width` | `72rem` | `.container` maximum width |
 | `--content-width` | `42rem` | `.center` maximum inline size |
-| `--sidebar-width` | `16rem` | `.sidebar` first child's preferred width |
+| `--sidebar-width` | `16rem` | `.sidebar` narrow column's preferred width (first child, or last with `.sidebar-end`) |
 | `--grid-min` | `16rem` | `.grid` minimum column width |
 | `--space-2`, `--space-4`, `--space-6` | `0.5rem`, `1rem`, `2rem` | `-sm`, default and `-lg` gaps; `.container` padding (`--space-4` to `--space-6`) |
 
@@ -60,6 +60,7 @@ Paste this table into a model's context.
 | Row of small items that wraps (tags, buttons, links) | `.cluster` |
 | Responsive cards or tiles, as many columns as fit | `.grid` |
 | Narrow side panel next to main content, stacks when narrow | `.sidebar` (sidebar is the 1st child, main the 2nd) |
+| Main content with a narrow side column on the right | `.sidebar` + `.sidebar-end` (main is the 1st child, narrow column the 2nd) |
 | Two groups pushed to opposite ends (header bar, toolbar) | `.split` |
 | Readable, horizontally centered column of text | `.center` |
 | Full-viewport-height section with vertically centered content | `.cover` (+ `.cover-main` on the centered child) |
@@ -242,6 +243,20 @@ child) that takes the rest of the space. When there is not enough room, they sta
 
 `.sidebar-sm` (`--space-2`), `.sidebar` (`--space-4`), `.sidebar-lg` (`--space-6`).
 
+Add `.sidebar-end` to any of them to put the narrow column on the right: the **last**
+child becomes the narrow column and the **first** child the main area. DOM order is
+still the visual order, so write the main area first:
+
+```html
+<div class="sidebar-lg sidebar-end" style="--sidebar-width: 20rem">
+  <main class="stack">…</main>
+  <aside class="stack">…</aside>
+</div>
+```
+
+`.sidebar-end` does nothing without `.sidebar`, `.sidebar-sm` or `.sidebar-lg`. With
+a single child, that child is the main area and keeps the full width.
+
 ### Responsive behavior
 
 The first child has a basis of `--sidebar-width`. The second child grows to fill the
@@ -251,17 +266,28 @@ happens from the layout's own width, so a `.sidebar` inside a narrow column stac
 even on a wide screen. There are no media queries. The sidebar always comes first, in
 the DOM and on screen.
 
+With `.sidebar-end` the roles swap: the last child has the `--sidebar-width` basis and
+the first child grows and must stay at least 50% wide. When they wrap, the main area
+is on top and the narrow column under it.
+
+Override `--sidebar-width` on the layout (`style="--sidebar-width: 20rem"`) to change
+the narrow column's width for one instance.
+
 ### Recommended uses
 
 - Settings pages and docs with a navigation list next to the content.
 - An image or avatar next to text (media object).
 - A filter panel next to search results.
+- A dashboard with the main content and a narrow activity or details column on the
+  right (`.sidebar-end`).
 
 ### Common misuses
 
 - Putting more than two children in it. Wrap extra content in one of the two children.
-- Putting the sidebar second in the DOM to show it on the right. Order is never
-  changed; the first child is always the sidebar.
+- Putting the sidebar second in the DOM to show it on the right without
+  `.sidebar-end`. Order is never changed; without the modifier the first child is
+  always the sidebar.
+- Using `order` or `*-reverse` to move the sidebar to the right. Use `.sidebar-end`.
 - Setting a fixed `width` on the main area. It sizes itself.
 
 ## `.split`
@@ -408,7 +434,8 @@ nested primitives do not leak gaps or margins into each other:
 - `npm test` runs [`scripts/check-layout.mjs`](../scripts/check-layout.mjs). It needs only
   Node.js and checks that every primitive and every `-sm`/`-lg` variant is defined, that
   no other classes, media queries or order-changing properties exist, that spacing and
-  sizes use `var(--token)` values defined in `tokens.css`, that `src/synthcss.css`
+  sizes use `var(--token)` values defined in `tokens.css`, that the `.sidebar-end` rules
+  override and reset the base sidebar rules, that `src/synthcss.css`
   bundles the file, that this page covers every primitive with all six sections and the
   vocabulary table, and that the fixture uses every class.
 - [`examples/layout.html`](../examples/layout.html) shows every primitive and variant in
@@ -417,7 +444,8 @@ nested primitives do not leak gaps or margins into each other:
   emulation in the browser developer tools). Frames can be resized by dragging their
   bottom-right corner.
 - `npm run check:layout:browser` loads the fixture in headless Chromium at 375px and
-  1280px and checks that the grid drops columns, the sidebar stacks, the cluster and
-  split wrap and nothing overflows horizontally. It needs Playwright, which is not a
+  1280px and checks that the grid drops columns, the sidebar stacks, `.sidebar-end`
+  puts a `--sidebar-width` column on the right (and the main area first when it
+  stacks), the cluster and split wrap and nothing overflows horizontally. It needs Playwright, which is not a
   dependency of this repository: run `npm install --no-save playwright` and
   `npx playwright install chromium` first.
diff --git a/docs/tokens.md b/docs/tokens.md
index 7568ea2..d39fd9e 100644
--- a/docs/tokens.md
+++ b/docs/tokens.md
@@ -104,7 +104,7 @@ Subtle and low-opacity. Use them for elevation only, not for decoration.
 | `--input-height` | `2.5rem` | Height of text inputs and selects. Keep it equal to `--control-height` so they line up. |
 | `--container-width` | `72rem` | Maximum width of the main page container (`.container`). |
 | `--content-width` | `42rem` | Maximum width of readable text blocks, about 65–75 characters per line. Used by `.center`. |
-| `--sidebar-width` | `16rem` | Preferred width of the first child of a `.sidebar` layout before it wraps. |
+| `--sidebar-width` | `16rem` | Preferred width of the narrow column of a `.sidebar` layout (the first child, or the last with `.sidebar-end`) before it wraps. |
 | `--grid-min` | `16rem` | Minimum column width of a `.grid` before it drops a column. |
 
 ### Focus
diff --git a/examples/layout.html b/examples/layout.html
index 5338634..990122f 100644
--- a/examples/layout.html
+++ b/examples/layout.html
@@ -153,6 +153,27 @@ <h3>Wide (side by side)</h3>
       </div>
     </section>
 
+    <!-- =================================================== sidebar-end -->
+    <section class="stack" id="sidebar-end">
+      <h2><code>.sidebar-end</code> (narrow column on the right)</h2>
+      <h3>Narrow (stacks, main first)</h3>
+      <div class="demo-frame demo-frame-narrow stack" data-fixture="sidebar-end-narrow">
+        <div class="sidebar sidebar-end" data-check="sidebar-end"><div class="demo-box">Main content grows to fill the rest of the row.</div><aside class="demo-box">Side</aside></div>
+        <div class="sidebar-sm sidebar-end" data-check="sidebar-end"><div class="demo-box">Main</div><aside class="demo-box">sm</aside></div>
+        <div class="sidebar-lg sidebar-end" data-check="sidebar-end"><div class="demo-box">Main</div><aside class="demo-box">lg</aside></div>
+        <div class="sidebar sidebar-end" data-check="sidebar-end-wide" style="--sidebar-width: 20rem"><div class="demo-box">Main</div><aside class="demo-box">20rem</aside></div>
+        <div class="sidebar sidebar-end" data-check="sidebar-end-only"><div class="demo-box">Only child keeps the full width.</div></div>
+      </div>
+      <h3>Wide (side by side, narrow column last)</h3>
+      <div class="demo-frame demo-frame-wide stack" data-fixture="sidebar-end-wide">
+        <div class="sidebar sidebar-end" data-check="sidebar-end"><div class="demo-box">Main content grows to fill the rest of the row.</div><aside class="demo-box">Side</aside></div>
+        <div class="sidebar-sm sidebar-end" data-check="sidebar-end"><div class="demo-box">Main</div><aside class="demo-box">sm</aside></div>
+        <div class="sidebar-lg sidebar-end" data-check="sidebar-end"><div class="demo-box">Main</div><aside class="demo-box">lg</aside></div>
+        <div class="sidebar sidebar-end" data-check="sidebar-end-wide" style="--sidebar-width: 20rem"><div class="demo-box">Main</div><aside class="demo-box">20rem</aside></div>
+        <div class="sidebar sidebar-end" data-check="sidebar-end-only"><div class="demo-box">Only child keeps the full width.</div></div>
+      </div>
+    </section>
+
     <!-- ========================================================= split -->
     <section class="stack" id="split">
       <h2><code>.split</code>, <code>.split-sm</code>, <code>.split-lg</code></h2>
diff --git a/scripts/check-layout-browser.mjs b/scripts/check-layout-browser.mjs
index 1bb83c8..1d552a9 100644
--- a/scripts/check-layout-browser.mjs
+++ b/scripts/check-layout-browser.mjs
@@ -54,6 +54,36 @@ function measure(name) {
   return out;
 }
 
+// .sidebar-end: main area first, narrow column last.
+function sidebarEndMeasure(name) {
+  const frame = document.querySelector(`[data-fixture="${name}"]`);
+  const rect = (el) => el.getBoundingClientRect();
+  const pair = (el) => {
+    const box = rect(el);
+    const [main, side] = [...el.children].map(rect);
+    const gap = parseFloat(getComputedStyle(el).columnGap);
+    // flex-basis sizes the content box (box-sizing: content-box).
+    const s = getComputedStyle(el.lastElementChild);
+    const edges = ["paddingLeft", "paddingRight", "borderLeftWidth", "borderRightWidth"].reduce((sum, k) => sum + parseFloat(s[k]), 0);
+    return {
+      stacked: side.top >= main.bottom,
+      mainFirst: main.left < side.left || side.top >= main.bottom,
+      sideWidth: side.width - edges,
+      sideAtEnd: Math.abs(box.right - side.right) < 1,
+      mainFills: Math.abs(main.width + gap + side.width - box.width) < 1,
+      fullWidth: Math.abs(main.width - box.width) < 1 && Math.abs(side.width - box.width) < 1,
+    };
+  };
+  const only = frame.querySelector('[data-check="sidebar-end-only"]');
+  return {
+    overflow: frame.scrollWidth > frame.clientWidth,
+    rem: parseFloat(getComputedStyle(document.documentElement).fontSize),
+    pairs: [...frame.querySelectorAll('[data-check="sidebar-end"]')].map(pair),
+    custom: pair(frame.querySelector('[data-check="sidebar-end-wide"]')),
+    onlyFull: Math.abs(rect(only.firstElementChild).width - rect(only).width) < 1,
+  };
+}
+
 function nestedMeasure(name) {
   const frame = document.querySelector(`[data-fixture="${name}"]`);
   const ps = [...frame.querySelectorAll(".grid-sm > .stack-sm > *")];
@@ -107,6 +137,25 @@ try {
         }
         if (p === "cover") expect(m.coverCentered, `${at}: .cover main child is not vertically centered`);
       }
+      const e = await page.evaluate(sidebarEndMeasure, `sidebar-end-${size}`);
+      expect(!e.overflow, `${at}: .sidebar-end overflows horizontally`);
+      for (const [i, pair] of [...e.pairs, e.custom].entries()) {
+        const what = `${at}: .sidebar-end layout ${i + 1}`;
+        expect(pair.stacked === narrow, `${what} ${narrow ? "should" : "should not"} stack`);
+        expect(pair.mainFirst, `${what} changed visual order`);
+        if (narrow) expect(pair.fullWidth, `${what}: stacked children are not full width`);
+        else {
+          expect(pair.sideAtEnd, `${what}: narrow column is not at the end of the row`);
+          expect(pair.mainFills, `${what}: main column does not fill the rest of the row`);
+        }
+      }
+      if (!narrow) {
+        for (const [i, pair] of e.pairs.entries()) {
+          expect(Math.abs(pair.sideWidth - 16 * e.rem) < 1, `${at}: .sidebar-end layout ${i + 1} narrow column is ${pair.sideWidth}px, expected --sidebar-width`);
+        }
+        expect(Math.abs(e.custom.sideWidth - 20 * e.rem) < 1, `${at}: inline --sidebar-width: 20rem gave a ${e.custom.sideWidth}px narrow column`);
+      }
+      expect(e.onlyFull, `${at}: .sidebar-end narrowed an only child`);
       const n = await page.evaluate(nestedMeasure, `nested-${size}`);
       expect(!n.overflow, `${at}: nested example overflows horizontally`);
       expect(n.childMargins, `${at}: nested children have block margins`);
diff --git a/scripts/check-layout.mjs b/scripts/check-layout.mjs
index bc6eae3..6310244 100644
--- a/scripts/check-layout.mjs
+++ b/scripts/check-layout.mjs
@@ -11,7 +11,15 @@ import { parseBlocks, parseDeclarations, parseTokens } from "./check-tokens.mjs"
 export const PRIMITIVES = ["container", "stack", "cluster", "grid", "sidebar", "split", "center", "cover"];
 export const GAP_PRIMITIVES = ["stack", "cluster", "grid", "sidebar", "split"];
 export const VARIANTS = GAP_PRIMITIVES.flatMap((p) => [`${p}-sm`, `${p}-lg`]);
-export const HELPER_CLASSES = ["cover-main"];
+export const HELPER_CLASSES = ["cover-main", "sidebar-end"];
+// .sidebar-end swaps which sidebar child is the narrow column. Each selector must
+// beat the base sidebar rule for the same child and set (or reset) every property
+// the base rules set on it.
+export const SIDEBAR_END_RULES = [
+  { child: ":first-child", decls: { "flex-basis": "0", "flex-grow": "999", "min-inline-size": "50%" } },
+  { child: ":last-child:not(:first-child)", decls: { "flex-basis": "var(--sidebar-width)", "flex-grow": "1", "min-inline-size": "auto" } },
+];
+const SIDEBAR_END_SELECTOR = ".sidebar-end:is(.sidebar, .sidebar-sm, .sidebar-lg) > ";
 export const DOC_SECTIONS = [
   "Purpose",
   "HTML example",
@@ -93,6 +101,19 @@ export function checkLayoutCss(layoutCss, tokensCss) {
     if (!(n(`${p}-sm`) < n(p) && n(p) < n(`${p}-lg`))) errors.push(`.${p}-sm < .${p} < .${p}-lg gap order is wrong`);
   }
 
+  for (const { child, decls } of SIDEBAR_END_RULES) {
+    const rule = rules.find((r) => r.selector === SIDEBAR_END_SELECTOR + child);
+    if (!rule) {
+      errors.push(`layout.css has no "${SIDEBAR_END_SELECTOR}${child}" rule`);
+      continue;
+    }
+    for (const [prop, value] of Object.entries(decls)) {
+      if (!rule.decls.some((d) => d.prop === prop && d.value === value)) {
+        errors.push(`${rule.selector} must set ${prop}: ${value}`);
+      }
+    }
+  }
+
   for (const { selector, decls } of rules) {
     for (const { prop, value } of decls) {
       const where = `${selector} { ${prop}: ${value} }`;
diff --git a/scripts/check-layout.test.mjs b/scripts/check-layout.test.mjs
index 73b344e..6a27595 100644
--- a/scripts/check-layout.test.mjs
+++ b/scripts/check-layout.test.mjs
@@ -55,3 +55,17 @@ test("fails when the fixture does not use a variant", () => {
   const fixture = files.fixture.replaceAll("grid-lg", "grid");
   assertError(errorsWith({ fixture }), "does not use .grid-lg");
 });
+
+test("fails when .sidebar-end is missing, incomplete or not in the fixture", () => {
+  const noReset = files.layoutCss.replace("  min-inline-size: auto;\n", "");
+  assertError(errorsWith({ layoutCss: noReset }), "must set min-inline-size: auto");
+  const weaker = files.layoutCss.replace(
+    ".sidebar-end:is(.sidebar, .sidebar-sm, .sidebar-lg) > :first-child",
+    ".sidebar-end > :first-child",
+  );
+  assertError(errorsWith({ layoutCss: weaker }), 'no ".sidebar-end:is(.sidebar, .sidebar-sm, .sidebar-lg) > :first-child" rule');
+  const reversed = files.layoutCss.replace("flex-grow: 999;\n  min-inline-size: 50%;\n}\n\n.sidebar-end", "flex-direction: row-reverse;\n}\n\n.sidebar-end");
+  assertError(errorsWith({ layoutCss: reversed }), "reading-order-changing value");
+  const fixture = files.fixture.replaceAll(" sidebar-end", "");
+  assertError(errorsWith({ fixture }), "does not use .sidebar-end");
+});
diff --git a/src/layout.css b/src/layout.css
index bf5bcbf..a4d678b 100644
--- a/src/layout.css
+++ b/src/layout.css
@@ -115,6 +115,23 @@
   min-inline-size: 50%;
 }
 
+/* .sidebar-end: the narrow column is the last child, on the right, and the
+   first child is the main area. DOM order stays the visual order; when they
+   wrap, the main area comes first. The selectors are one class more specific
+   than the rules above and reset every property those set. An only child is
+   the main area, so it is never narrowed. */
+.sidebar-end:is(.sidebar, .sidebar-sm, .sidebar-lg) > :first-child {
+  flex-basis: 0;
+  flex-grow: 999;
+  min-inline-size: 50%;
+}
+
+.sidebar-end:is(.sidebar, .sidebar-sm, .sidebar-lg) > :last-child:not(:first-child) {
+  flex-basis: var(--sidebar-width);
+  flex-grow: 1;
+  min-inline-size: auto;
+}
+
 /* Split: items pushed to opposite ends of a row; wraps when tight. */
 .split,
 .split-sm,
diff --git a/synthcss.ai.json b/synthcss.ai.json
index 07b9da8..9292379 100644
--- a/synthcss.ai.json
+++ b/synthcss.ai.json
@@ -92,6 +92,7 @@
     "sidebar": "1st child narrow side panel, 2nd child main area; stacks when narrow",
     "sidebar-sm": "sidebar with a tighter gap",
     "sidebar-lg": "sidebar with a looser gap",
+    "sidebar-end": "with a sidebar class: last child is the narrow column (right), 1st child the main area",
     "split": "two groups pushed to opposite ends of a row; wraps when tight",
     "split-sm": "split with a tighter gap",
     "split-lg": "split with a looser gap",
@@ -182,6 +183,7 @@
     { "intent": "Row of tags, buttons or links", "use": ".cluster" },
     { "intent": "Responsive cards or tiles", "use": ".grid (+ style=\"--grid-min: …\")" },
     { "intent": "Side navigation next to content", "use": ".sidebar" },
+    { "intent": "Main content with a narrow side column on the right", "use": ".sidebar (or -sm/-lg) + .sidebar-end" },
     { "intent": "Header or toolbar with two ends", "use": ".split" },
     { "intent": "Readable text column", "use": ".center" },
     { "intent": "Full-screen centered page", "use": ".cover + .cover-main" },
@@ -237,6 +239,10 @@
       {
         "html": "<section class=\"stack\">\n  <header class=\"split\">\n    <h3>Team</h3>\n    <button type=\"button\" class=\"button button-primary\">Invite</button>\n  </header>\n  <div class=\"table-wrap\" tabindex=\"0\">\n    <table class=\"table\">\n      <thead><tr><th>Name</th><th>Role</th><th>Status</th></tr></thead>\n      <tbody>\n        <tr><td>Ana</td><td>Admin</td><td><span class=\"badge badge-success\">Active</span></td></tr>\n        <tr><td>Ben</td><td>Editor</td><td><span class=\"badge badge-warning\">Invited</span></td></tr>\n      </tbody>\n    </table>\n  </div>\n</section>",
         "note": "Data view: split header with an action, scrollable table with status badges."
+      },
+      {
+        "html": "<div class=\"sidebar-lg sidebar-end\" style=\"--sidebar-width: 20rem\">\n  <main class=\"stack\">\n    <h1>Dashboard</h1>\n    <div class=\"grid\"><div class=\"card\">…</div><div class=\"card\">…</div></div>\n  </main>\n  <aside class=\"panel\">\n    <div class=\"panel-header\"><h2>Activity</h2></div>\n    <div class=\"panel-body\">Recent events.</div>\n  </aside>\n</div>",
+        "note": "Dashboard: main content first, narrow column on the right with a per-instance width."
       }
     ],
     "invalid": [
diff --git a/synthcss.llm.md b/synthcss.llm.md
index d71dd69..d43846f 100644
--- a/synthcss.llm.md
+++ b/synthcss.llm.md
@@ -86,6 +86,7 @@ Work on any element. No breakpoints: they adapt to the space they get.
 - `.sidebar` — 1st child narrow side panel, 2nd child main area; stacks when narrow
 - `.sidebar-sm` — sidebar with a tighter gap
 - `.sidebar-lg` — sidebar with a looser gap
+- `.sidebar-end` — with a sidebar class: last child is the narrow column (right), 1st child the main area
 - `.split` — two groups pushed to opposite ends of a row; wraps when tight
 - `.split-sm` — split with a tighter gap
 - `.split-lg` — split with a looser gap
@@ -142,6 +143,7 @@ Naming: component, component-variant, component-part. State comes from attribute
 | Row of tags, buttons or links | `.cluster` |
 | Responsive cards or tiles | `.grid` (+ `style="--grid-min: …"`) |
 | Side navigation next to content | `.sidebar` |
+| Main content with a narrow side column on the right | `.sidebar` (or -sm/-lg) + `.sidebar-end` |
 | Header or toolbar with two ends | `.split` |
 | Readable text column | `.center` |
 | Full-screen centered page | `.cover` + `.cover-main` |
@@ -243,6 +245,21 @@ Data view: split header with an action, scrollable table with status badges.
 </section>
 ```
 
+Dashboard: main content first, narrow column on the right with a per-instance width.
+
+```html
+<div class="sidebar-lg sidebar-end" style="--sidebar-width: 20rem">
+  <main class="stack">
+    <h1>Dashboard</h1>
+    <div class="grid"><div class="card">…</div><div class="card">…</div></div>
+  </main>
+  <aside class="panel">
+    <div class="panel-header"><h2>Activity</h2></div>
+    <div class="panel-body">Recent events.</div>
+  </aside>
+</div>
+```
+
 ## Invalid / Discouraged Examples
 
 Never generate these. Each line: wrong markup — why — what to use instead.
Acceptance · round 1
Shipped
CINo checks
Automated reviewPass with concerns

The CSS is small and correct: `.sidebar-end` uses higher-specificity selectors that reset every property the base sidebar rules set, handles the single-child case, and needs no order or reverse. The contract files, fixture, static checks, a unit test and a Playwright browser check are all updated consistently, with documentation added. The main reservation is that no CI ran, so passing tests are claimed rather than shown; a possibly stale token-count estimate is also worth a quick look.

Acceptance criteria · 7 of 8 met
  • YES.sidebar-end with each of .sidebar/.sidebar-sm/.sidebar-lg puts the last child at --sidebar-width and the first child filling the remaining space`src/layout.css` adds `.sidebar-end:is(.sidebar, .sidebar-sm, .sidebar-lg) > :first-child` (basis 0, grow 999) and `> :last-child:not(:first-child)` (basis var(--sidebar-width), grow 1), and `check-layout-browser.mjs` checks the side width, `sideAtEnd` and `mainFills` at 1280px for all three variants.
  • YESWhen the first child cannot keep 50%, the layout wraps to one column with the main child on topThe first-child rule sets `min-inline-size: 50%` and DOM order is unchanged, and the browser check asserts `stacked`, `mainFirst` and `fullWidth` in the 375px frame.
  • YESAn inline --sidebar-width override changes the narrow column's widthThe fixture has a `sidebar-end-wide` instance with `--sidebar-width: 20rem`, and the browser check asserts a 20rem content-box width.
  • YESExisting .sidebar* behaviour without .sidebar-end is unchangedThe base sidebar rules are untouched, the new selectors require the `.sidebar-end` class, and the existing browser checks are unchanged.
  • YESWith one child, .sidebar-end does not narrow that childAn only child matches only the first-child (grow 999) rule because the last-child rule is guarded with `:not(:first-child)`, and the browser check asserts `onlyFull`.
  • YESThe CSS uses no order, row-reverse or column-reverseThe new rules contain only flex-basis, flex-grow and min-inline-size, and a test confirms the existing order-changing check catches an injected `row-reverse`.
  • YES.sidebar-end appears in synthcss.llm.md (Layout Vocabulary, Intent Mapping, Valid Example) and in synthcss.ai.jsonBoth files gain the vocabulary entry, the intent-map row and the dashboard valid example matching the proposal markup.
  • UNCLEARContract sync verification and the existing test/lint suite passThe builder reports `npm test` (68 node tests) and `verify-ai-contract` passing, but no CI ran to confirm this.
Concerns
  • No CI ran on this commit. The claims that `npm test`, `verify-ai-contract` and the Playwright browser check pass rest only on the builder's report.
  • The builder summary is truncated mid-sentence about the token estimate. The showcase apparently still shows ≈2,830 tokens while `synthcss.llm.md` is now about 3,030 tokens, so a displayed estimate may be stale; worth confirming whether the README, `docs/ai-contract.md` or the showcase need updating.
  • The static check in `check-layout.mjs` matches the `.sidebar-end` selectors by exact string. Reformatting the selector (for example, whitespace inside `:is()`) would fail the check even if the CSS is still correct. This is minor and only causes false failures.
  • The documentation changes go beyond the spec (`docs/layout.md`, `docs/tokens.md`, `docs/ai-contract.md`, README). They are accurate and on-topic, so this is not a real problem, just extra surface to review.
  • The new `synthcss.llm.md` Intent Mapping row writes `(or -sm/-lg)` without code formatting, while the spec shows `-sm`/`-lg` in code. This is cosmetic.
CI details
No CI checks ran on this commit.

BackersAccepted
1 accept · 0 rebuild · 0 not voted · quorum 1 of 1
MaintainerMerge

Accepted by the backers and merged by the maintainer.

Ballots · 1
AcceptJonathan Miller

Automated review cost $0.11, counted as builder cost.

Discussion · 0

No comments yet.