ShippedMedium

Add .nav/.nav-link and .tabs/.tabs-item components with ARIA-driven state and AI contract entries

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

Add navigation links (.nav) and tabs / segmented control (.tabs)

Motivation

In benchmark runs, every agent-generated page with navigation hand-wrote nav-link CSS. Every team-screen run also hand-wrote a segmented control. The selected-state attribute was inconsistent: aria-selected in some runs, aria-pressed in another. SynthCSS should provide both components and name one state attribute for each in the AI contract.

Scope

1. .nav / .nav-link

  • .nav is a list reset: no bullets, margin or padding. Layout comes from combining it with .stack-* (vertical) or .cluster (horizontal).
  • .nav-link:
    • is an inline-flex row with a gap between an optional leading svg and the label;
    • has padding from spacing tokens and a --radius-md border radius;
    • has no underline and inherits the text color.
  • Hover: background --color-surface.
  • Current page: .nav-link[aria-current="page"] gets a --color-primary tint (background and/or text). No state classes are used.
  • :focus-visible shows a focus ring using the existing --focus-* tokens.

2. .tabs / .tabs-item

  • .tabs is an inline-flex group with a --color-surface track background, a small inner padding and a radius.
  • .tabs-item:
    • is a button reset with padding;
    • uses a transparent background by default.
  • Selected: .tabs-item[aria-selected="true"] gets a --color-surface-elevated background with a raised look (shadow or border token).
  • :focus-visible shows a focus ring using the --focus-* tokens.
  • Narrow screens: .tabs must not overflow its container.
    • Assumption: use flex-wrap: wrap.
  • Assumption: the components are CSS-only. Keyboard arrow navigation and panel switching are the author's responsibility, and the contract says so.

3. Contract and docs

  • Update synthcss.llm.md:
    • add component entries;
    • state the single state attribute for each: aria-current="page" for nav, aria-selected="true" with role="tab" for tabs;
    • say explicitly not to use aria-pressed or state classes.
  • Add Intent Mapping rows:
    • "Navigation links → .nav + .nav-link"
    • "Tabs or segmented filter → .tabs + .tabs-item"
  • Update synthcss.ai.json to match, so the existing sync verification passes.
  • Use the proposer's markup examples in the docs.
  • Assumption: add a short demo to the showcase only if the existing component docs/showcase pattern requires it for every component.

4. Placement

  • Follow the file and layer structure used for the components added in #4, including the build entry point.

Acceptance criteria

  • .nav-link[aria-current="page"] is styled distinctly from the default and hover states, and no state classes exist.
  • .tabs-item[aria-selected="true"] is styled distinctly, and aria-selected="false" items use the default style.
  • .nav and .tabs render correctly inside .stack (vertical) and .cluster (horizontal).
  • :focus-visible on .nav-link and .tabs-item uses the --focus-* tokens.
  • .tabs does not cause horizontal overflow at 320px width.
  • All colors, radii and spacing come from existing tokens, with no hard-coded values.
  • Both Intent Mapping rows are present in synthcss.llm.md, the components appear in synthcss.ai.json, and the contract sync check passes.
  • Existing tests, lint and build pass, and new tests or fixtures are added in the same style as the existing component tests.

Out of scope

  • JavaScript for tab switching or keyboard roving focus.
  • Tab panels (role="tabpanel") styling.
  • Dropdown or nested navigation, breadcrumbs, pagination.
  • New design tokens.
Build #8
SUCCEEDED

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

39agent turns
3.7Mtokens
$2.15spent at list price

Builder summary
## What this adds Two new components, with no JavaScript, no new tokens and no state classes: - **`.nav` / `.nav-link`**: `.nav` only resets the list (no bullets, margin or padding) and sets no `display`. Direction comes from adding `.stack-*` (vertical) or `.cluster-*` (horizontal). - `.nav-link` is an inline-flex row with a `--space-2` gap between an optional leading `svg`/`img` (sized to 1em) and the label. It has `--space-2`/`--space-3` padding, `--radius-md` corners, no underline and inherits the text colour. - Hover uses a `--color-surface` background. - The current page is `[aria-current="page"]`: a `color-mix()` of `--color-primary` and `--color-background` as background, with `--color-primary` text. - **`.tabs` / `.tabs-item`**: `.tabs` is an inline-flex track on `--color-surface` with a border, `--space-1` padding and gap, and `--radius-md`. It uses `flex-wrap: wrap` and `inline-size: fit-content; max-inline-size: 100%`, so it doesn't stretch inside `.stack` and wraps instead of overflowing. - `.tabs-item` is a button reset with a transparent background. - The selected item is `[aria-selected="true"]`: `--color-surface-elevated` background, `--border-color` border and `--shadow-sm`. `aria-selected="false"` items keep the default look. - Both `.nav-link` and `.tabs-item` get a `:focus-visible` ring built from the `--focus-*` tokens. ### Placement Both components are in `src/components.css`, following the structure of the #4 components. The bundle and build entry points are unchanged because they already include `components.css`. ## AI contract (`synthcss.llm.md` and `synthcss.ai.json`) - Vocabulary entries for `.nav`, `.nav-link`, `.tabs` and `.tabs-item`. - One state attribute each: `aria-current="page"` for nav, and `role="tab"` + `aria-selected="true"` for tabs. The contract says never to use `aria-pressed` or state classes. - It also says the tabs are CSS-only: arrow-key navigation and panel switching are the author's script. - Intent Mapping rows, exactly as specified: "Navigation links → `.nav` + `.nav-link`" and "Tabs or segmented filter → `.tabs` + `.tabs-item`". - Generation rule 7 now lists both attributes. There is a new "Avoid" composition rule and two new invalid examples (a state class on `.nav-link`, and `aria-pressed` on `.tabs-item`). - Valid examples were already at the maximum of 4, so I added the nav and tabs to the existing "Page" example instead of adding a new one. - `contractVersion` stays at 1.1.0. The schema didn't change, and #6 also left it alone when it only added vocabulary. - The size quoted in the showcase, README and `docs/ai-contract.md` went from about 2,800 to about 3,500 tokens. The showcase figure has to stay within 10% for the sync check to pass. ## Checks and tests - **`scripts/check-components.mjs`**: `nav` and `tabs` added to `COMPONENTS`. New checks: - `.nav` removes bullets and resets margin and padding to 0, and sets no `display`. - `.nav-link:hover` uses `--color-surface`, and the `[aria-current="page"]` rule changes the colour or background. - `.tabs-item` is transparent by default, and `[aria-selected="true"]` uses `--color-surface-elevated` plus a shadow or border. - `.tabs` sets `flex-wrap: wrap`, and nothing styles `aria-pressed`. - Both new parts have focus rings. - Contrast is at least 4.5:1 for the current nav link and for default and selected tabs (new `STATE_COLORS` list). - **`scripts/verify-ai-contract.mjs`**: new `STATE_ATTRIBUTES` check. The `.nav-link` and `.tabs-item` entries in both files must name their attribute, and the Markdown must say never to use `aria-pressed`. - **Tests**: new cases in `check-components.test.mjs` and `verify-ai-contract.test.mjs`, written in the same style as the existing ones. They cover the intent rows and each failure path. - **`scripts/check-components-browser.mjs`** now also checks: - the current link and the selected tab look different from the others, and the `aria-selected="false"` items all lo

Agent log
  - `.tabs-item` is transparent by default, and `[aria-selected="true"]` uses `--color-surface-elevated` plus a shadow or border.
  - `.tabs` sets `flex-wrap: wrap`, and nothing styles `aria-pressed`.
  - Both new parts have focus rings.
  - Contrast is at least 4.5:1 for the current nav link and for default and selected tabs (new `STATE_COLORS` list).
- **`scripts/verify-ai-contract.mjs`**: new `STATE_ATTRIBUTES` check. The `.nav-link` and `.tabs-item` entries in both files must name their attribute, and the Markdown must say never to use `aria-pressed`.
- **Tests**: new cases in `check-components.test.mjs` and `verify-ai-contract.test.mjs`, written in the same style as the existing ones. They cover the intent rows and each failure path.
- **`scripts/check-components-browser.mjs`** now also checks:
  - the current link and the selected tab look different from the others, and the `aria-selected="false"` items all look the same;
  - the list reset works, and the nav lays out vertically in `.stack-sm` and in one row in `.cluster-sm`;
  - both new parts show focus rings;
  - at 320px, every `.tabs` stays inside its container and a long tab list wraps onto more rows.

## Docs and showcase
- `docs/components.md`: new `.nav` and `.tabs` sections with every required subsection, plus updates to the intro, the state and focus notes, and the verification notes.
  - The AI Component Reference table has a 15-row limit, so I merged the "Invalid input" row into the field row to make room for the two new rows.
- `README.md`: component list updated.
- Showcase: the existing check requires a demo for every component, so there are now `.nav` and `.tabs` articles. The composed settings screen uses `.nav` for its sidebar and a `.tabs` filter on the Members panel.

## Verification
- `npm test`: 75/75 pass, including the contract sync check. `node scripts/build.mjs` runs cleanly.
- Ran the browser checks with a Playwright install that was already on the machine (linked in temporarily, not committed):
  - The components, layout and base checks pass.
  - I broke the CSS on purpose (`flex-wrap: nowrap`, and the selected-tab rule renamed) and the components check failed as it should.
  - `check:showcase:browser` fails here, on `main` as well, because the sandbox blocks the external badge image at `https://nabled.ai/api/badges/button.svg`. That isn't caused by this change.
- Looked at screenshots at 1280px and 320px.

## Worth a closer look
- **Markup examples:** the "proposer's markup examples" weren't included in the task, so I wrote examples that follow the spec (`ul.nav.stack-sm` / `cluster-sm` > `a.nav-link`, and `div.tabs[role=tablist]` > `button.tabs-item[role=tab][aria-selected]`). Please swap in the proposer's markup if it differs.
- **Additions the spec didn't ask for:** a border on the `.tabs` track so it stays visible on `--color-surface` backgrounds such as `.panel`, and the `fit-content` width that keeps the track from stretching inside `.stack`.
- **Already broken at 320px:** the showcase hero overflows at that width on `main` too. That's why the 320px browser check measures the tabs' own boxes rather than the whole page width.
2026-10-03T13:43:20Z  [claude] I've added `.nav`/`.nav-link` and `.tabs`/`.tabs-item` and pushed them on `nabled/feature-9-job-8`. `npm test` passes (75/75), including the contract sync check, and the build runs cleanly.

**What the components do:**
- **`.nav`** only removes the list bullets, margin and padding. Adding `.stack-*` makes it vertical and `.cluster-*` makes it horizontal.
- **`.nav-link`**: hover gives a `--color-surface` background, and the link with `aria-current="page"` gets a primary-colour tint.
- **`.tabs`** is a track on `--color-surface` that wraps onto more lines on narrow screens instead of overflowing. Items are transparent by default. The one with `aria-selected="true"` gets a `--color-surface-elevated` background, a border and `--shadow-sm`.
- Both links and tabs get a `:focus-visible` ring from the `--focus-*` tokens. All values come from existing tokens, and there are no state classes.

**AI contract (`synthcss.llm.md` and `synthcss.ai.json`):**
- New entries for all four classes and the two Intent Mapping rows, worded exactly as in the spec.
- It names one state attribute for each: `aria-current="page"` for nav, `role="tab"` with `aria-selected="true"` for tabs. It says never to use `aria-pressed` or state classes, and that arrow-key handling and panel switching are the author's job.
- The contract already had the maximum of 4 valid examples, so I added nav and tabs to the existing "Page" example rather than adding a fifth.
- The contract sync check now also fails if either state attribute stops being named, or if the "never `aria-pressed`" line is removed.

**Tests and checks:** new static checks and unit tests cover the list reset, the attribute-driven states, wrapping, focus rings and text contrast. The browser check now also covers:
- distinct current and selected styles;
- layout inside `.stack` and `.cluster`;
- no tab overflow at 320px.

I made the CSS deliberately wrong and the browser check failed as expected.

**Docs and showcase:** `docs/components.md` has full `.nav` and `.tabs` sections. The existing showcase check requires a demo for every component, so both have one, and the settings screen in the showcase now uses them. The reference table in the docs has a 15-row limit, so I folded the "Invalid input" row into the field row to make room.

**What couldn't be fully checked here:**
- The showcase browser check fails in this sandbox because an external badge image is blocked. It fails the same way on `main`, so it isn't caused by this change.
- The showcase's opening section already overflows at 320px on `main`. The 320px check therefore measures only the tabs, not the whole page width.

**For you to review:**
- **Markup examples:** the proposer's markup examples weren't in the task, so I wrote my own that follow the spec. Swap them in if theirs differ.
- **Extra styling:**
  - I gave the tabs track a border so it stays visible on surface-coloured backgrounds such as `.panel`.
  - I made it only as wide as its items so it doesn't stretch to full width inside `.stack`.

**Other numbers:** the contract version stays at 1.1.0 because its structure didn't change, matching what #6 did. The token count shown in the showcase, README and `docs/ai-contract.md` went from about 2,800 to about 3,500.
2026-10-03T13:43:24Z  Changed files:
modified  README.md  +2 -2
modified  docs/ai-contract.md  +1 -1
modified  docs/components.md  +175 -12
modified  scripts/check-components-browser.mjs  +73 -2
modified  scripts/check-components.mjs  +59 -7
modified  scripts/check-components.test.mjs  +46 -0
modified  scripts/verify-ai-contract.mjs  +14 -0
modified  scripts/verify-ai-contract.test.mjs  +22 -0
modified  showcase/index.html  +70 -8
modified  src/components.css  +127 -3
modified  synthcss.ai.json  +30 -5
modified  synthcss.llm.md  +25 -3
2026-10-03T13:43:25Z  Opened pull request https://github.com/nabledhq/synthcss/pull/13
2026-10-03T13:43:25Z  Finished: success=true turns=39 tokens(in/out)=3627975/39929 list cost=$2.15

Show patch
diff --git a/README.md b/README.md
index b69991e..b21ca51 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ See [docs/layout.md](docs/layout.md) for each primitive and a compact "AI Layout
 
 ## Components
 
-Eight semantic components in [`src/components.css`](src/components.css), included in the main bundle: `.button`, `.field`, `.card`, `.badge`, `.alert`, `.panel`, `.table` and `.empty-state`, with a small set of variants (`.button-primary`, `.badge-success`, `.alert-danger`, …) and parts (`.card-header`, `.field-error`, …). They use only tokens, take their state from native attributes (`disabled`, `aria-busy="true"`, `aria-invalid="true"`) and need no JavaScript:
+Ten semantic components in [`src/components.css`](src/components.css), included in the main bundle: `.button`, `.field`, `.card`, `.badge`, `.alert`, `.panel`, `.table`, `.empty-state`, `.nav` and `.tabs`, with a small set of variants (`.button-primary`, `.badge-success`, `.alert-danger`, …) and parts (`.card-header`, `.field-error`, `.nav-link`, `.tabs-item`, …). They use only tokens, take their state from native attributes (`disabled`, `aria-busy="true"`, `aria-invalid="true"`, `aria-current="page"` on the current `.nav-link`, `aria-selected="true"` on the selected `.tabs-item`) and need no JavaScript:
 
 ```html
 <form class="card">
@@ -63,7 +63,7 @@ See [docs/components.md](docs/components.md) for each component's variants, comp
 
 ## AI contract
 
-[`synthcss.llm.md`](synthcss.llm.md) is the whole public vocabulary in one prompt-ready file of about 2,800 tokens: every token, layout primitive, component, part and variant, an intent table, composition rules, ten generation rules and valid and invalid examples. Paste it into a model's context. [`synthcss.ai.json`](synthcss.ai.json) is the same contract as structured data and the canonical source. Both state the SynthCSS version they describe; the schema is in [docs/ai-contract.md](docs/ai-contract.md).
+[`synthcss.llm.md`](synthcss.llm.md) is the whole public vocabulary in one prompt-ready file of about 3,500 tokens: every token, layout primitive, component, part and variant, an intent table, composition rules, ten generation rules and valid and invalid examples. Paste it into a model's context. [`synthcss.ai.json`](synthcss.ai.json) is the same contract as structured data and the canonical source. Both state the SynthCSS version they describe; the schema is in [docs/ai-contract.md](docs/ai-contract.md).
 
 **Any change to the public API (a class or token added, renamed or removed, or a new version) must update both contract files in the same pull request.** `npm test` runs `scripts/verify-ai-contract.mjs`, which fails when the contract and the CSS or `package.json` disagree.
 
diff --git a/docs/ai-contract.md b/docs/ai-contract.md
index cca1731..1c88943 100644
--- a/docs/ai-contract.md
+++ b/docs/ai-contract.md
@@ -7,7 +7,7 @@ window, with no need to crawl these docs or read `src/`.
 | File | For | Notes |
 | --- | --- | --- |
 | [`synthcss.ai.json`](../synthcss.ai.json) | Tools and agents that read structured data | The canonical, machine-readable contract. |
-| [`synthcss.llm.md`](../synthcss.llm.md) | Pasting into a prompt | A terse hand-written twin of the JSON, one line per item, about 2,800 tokens. |
+| [`synthcss.llm.md`](../synthcss.llm.md) | Pasting into a prompt | A terse hand-written twin of the JSON, one line per item, about 3,500 tokens. |
 
 Both are published next to the showcase on GitHub Pages
 (`<site>/synthcss.llm.md`, `<site>/synthcss.ai.json`) and are in every release tag.
diff --git a/docs/components.md b/docs/components.md
index 268b692..cbd0ac8 100644
--- a/docs/components.md
+++ b/docs/components.md
@@ -1,9 +1,10 @@
 # Components
 
-SynthCSS ships eight semantic components in [`src/components.css`](../src/components.css):
-`.button`, `.field`, `.card`, `.badge`, `.alert`, `.panel`, `.table` and `.empty-state`.
-Each one names a UI intent, so a model can map a request ("a delete button", "an
-error message", "a table of invoices") to one predictable class.
+SynthCSS ships ten semantic components in [`src/components.css`](../src/components.css):
+`.button`, `.field`, `.card`, `.badge`, `.alert`, `.panel`, `.table`, `.empty-state`,
+`.nav` and `.tabs`. Each one names a UI intent, so a model can map a request ("a delete
+button", "an error message", "a table of invoices", "a segmented filter") to one
+predictable class.
 
 They are part of the main bundle, together with the [design tokens](tokens.md) and the
 [layout primitives](layout.md):
@@ -22,12 +23,14 @@ How the components behave:
   (`.button-danger`) and `.component-part` for a part (`.card-header`). The one
   exception is the `.numeric` table cell.
 - **State from native attributes, not classes.** Use `disabled`,
-  `aria-disabled="true"`, `aria-busy="true"` and `aria-invalid="true"`. There is no
-  `.is-disabled`, `.button-loading` or `.field-invalid`.
+  `aria-disabled="true"`, `aria-busy="true"`, `aria-invalid="true"`,
+  `aria-current="page"` (current nav link) and `aria-selected="true"` (selected tab).
+  There is no `.is-disabled`, `.button-loading`, `.field-invalid`, `.is-active` or
+  `.tabs-item-selected`, and SynthCSS does not use `aria-pressed`.
 - **Native elements.** Put `.button` on `<button>` or `<a>`, `.table` on `<table>`, and
   native `input`, `select` and `textarea` inside `.field`. Native focus and keyboard
   behavior are kept.
-- **Focus.** Buttons, field controls and a focusable `.table-wrap` show a
+- **Focus.** Buttons, field controls, nav links, tabs and a focusable `.table-wrap` show a
   `:focus-visible` ring made of `--focus-width`, `--focus-color` and `--focus-offset`.
 - **Disabled.** Disabled buttons and controls are faded and show a `not-allowed`
   cursor. The variant color stays recognizable, so a disabled danger button still looks
@@ -54,14 +57,15 @@ Paste this table into a model's context together with the
 | Icon-only action | `.button button-icon` + `aria-label="…"` |
 | Smaller or larger button | add `.button-sm` / `.button-lg` |
 | Unavailable or in-progress action | `disabled` or `aria-disabled="true"`; `aria-busy="true"` |
-| Labeled input with help or error text | `.field` > `.field-label` + native control + `.field-help` / `.field-error` |
-| Invalid input | `aria-invalid="true"` on the control + `.field-error` text |
+| Labeled input with help or error text | `.field` > `.field-label` + native control + `.field-help` / `.field-error`; `aria-invalid="true"` on an invalid control |
 | Self-contained item (project, product, user) | `.card` with `.card-header`, `.card-body`, `.card-footer`, `.card-media`, `.card-actions` |
 | Short status label | `.badge` + `.badge-success` / `-warning` / `-danger` / `-info` |
 | Message or notification | `.alert` + `.alert-info` / `-success` / `-warning` / `-danger`, `role="status"` or `role="alert"` |
 | Flat group of secondary content | `.panel` with `.panel-header`, `.panel-body` |
 | Tabular data | `<table class="table">` inside `.table-wrap`; `.table-hover`, `.numeric` cells |
 | Nothing to show yet | `.empty-state` with heading, text and an optional action |
+| Navigation links | `<ul class="nav stack-sm">` or `nav cluster-sm` > `<a class="nav-link">`; `aria-current="page"` on the current one |
+| Tabs or segmented filter | `.tabs` + `role="tablist"` > `<button class="tabs-item" role="tab">`; `aria-selected="true"` / `"false"` |
 
 Rules: state comes from attributes, never from classes; put components on native
 elements; arrange them with `.stack`, `.cluster`, `.split` and `.grid`.
@@ -638,6 +642,159 @@ what to do next.
 - Using it for errors; use an `.alert`.
 - Leaving a list or table blank instead of showing an empty state.
 
+## `.nav`
+
+### Purpose
+
+A list of navigation links: main navigation, a settings menu, a section's sub-pages.
+`.nav` goes on the `<ul>` and only resets the list (no bullets, margin or padding), so
+the direction comes from a layout primitive: `.stack-*` for a vertical list,
+`.cluster-*` for a horizontal one. Each link is an `<a class="nav-link">`. The current
+page is marked with `aria-current="page"`, the single state attribute for navigation.
+
+### Example
+
+```html
+<nav aria-label="Settings">
+  <ul class="nav stack-sm" role="list">
+    <li><a class="nav-link" href="/settings/general" aria-current="page">General</a></li>
+    <li><a class="nav-link" href="/settings/members">Members</a></li>
+    <li><a class="nav-link" href="/settings/billing">Billing</a></li>
+  </ul>
+</nav>
+```
+
+### Variants
+
+| Class or attribute | Use |
+| --- | --- |
+| `.nav` | On the `<ul>` or `<ol>`: removes bullets, margin and padding. Sets no layout. |
+| `.nav-link` | On each `<a href>`: an inline-flex row with `--space-2` between an optional leading `<svg>` and the label, `--space-2` / `--space-3` padding, `--radius-md` corners, no underline, inherited text color. Hover shows `--color-surface`. |
+| `aria-current="page"` | On the link to the current page: a `--color-primary` tint and `--color-primary` text. |
+
+There are no state classes: never add `.is-active`, `.active` or `.nav-link-current`;
+put `aria-current="page"` on the link instead.
+
+An icon before the label is sized to `1em`:
+
+```html
+<a class="nav-link" href="/projects">
+  <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">…</svg>
+  Projects
+</a>
+```
+
+### Composition
+
+Vertical in a `.sidebar` with `.stack-sm`, or horizontal in a header with `.cluster-sm`
+inside `.split`:
+
+```html
+<header class="split">
+  <a href="/">Acme</a>
+  <nav aria-label="Main">
+    <ul class="nav cluster-sm" role="list">
+      <li><a class="nav-link" href="/projects" aria-current="page">Projects</a></li>
+      <li><a class="nav-link" href="/team">Team</a></li>
+      <li><a class="nav-link" href="/settings">Settings</a></li>
+    </ul>
+  </nav>
+</header>
+```
+
+### Accessibility
+
+- Wrap the list in `<nav>` with an `aria-label` when the page has more than one
+  navigation region.
+- Use `aria-current="page"` on exactly one link. Screen readers announce it as
+  "current page", and SynthCSS styles it from the same attribute, so the visual and
+  announced state never disagree.
+- Add `role="list"` to keep list semantics in Safari/VoiceOver after the bullets are
+  removed.
+- Links show a `:focus-visible` ring from `--focus-width`, `--focus-color` and
+  `--focus-offset`.
+
+### Recommended use
+
+Main and secondary navigation between pages, settings menus, documentation sidebars.
+
+### Misuse
+
+- Buttons that act on the current page; use `.button`.
+- Switching views inside one page; use `.tabs`.
+- State classes for the current link; use `aria-current="page"`.
+
+## `.tabs`
+
+### Purpose
+
+A tab list or segmented control: an inline group of options on a `--color-surface`
+track, where one option is selected. Each option is a `<button class="tabs-item"
+role="tab">`; the selected one has `aria-selected="true"` and the others
+`aria-selected="false"`. That is the single state attribute for tabs.
+
+The component is CSS only. Moving focus with the arrow keys, updating `aria-selected`
+and showing the matching panel are up to your own script.
+
+### Example
+
+```html
+<div class="tabs" role="tablist" aria-label="Members">
+  <button type="button" class="tabs-item" role="tab" aria-selected="true">All</button>
+  <button type="button" class="tabs-item" role="tab" aria-selected="false">Admins</button>
+  <button type="button" class="tabs-item" role="tab" aria-selected="false">Invited</button>
+</div>
+```
+
+### Variants
+
+| Class or attribute | Use |
+| --- | --- |
+| `.tabs` | The track: inline-flex, `--color-surface` background, a border, `--space-1` inner padding and gap, `--radius-md` corners. It wraps onto more lines instead of overflowing a narrow container. |
+| `.tabs-item` | One option: a button reset with `--space-1` / `--space-3` padding, `--text-sm`, a transparent background and `--color-text-secondary` text. |
+| `aria-selected="true"` | The selected option: `--color-surface-elevated` background, `--border-color` border and `--shadow-sm`, in `--color-text`. `aria-selected="false"` keeps the default look. |
+
+Do not use `aria-pressed` or classes such as `.is-selected` for the selected option.
+
+### Composition
+
+Put a filter next to a heading with `.split`, or above content in a `.stack`. Inside
+`.cluster` it sits in the row with other controls:
+
+```html
+<section class="stack">
+  <header class="split">
+    <h2>Members</h2>
+    <div class="tabs" role="tablist" aria-label="Filter members">
+      <button type="button" class="tabs-item" role="tab" aria-selected="true">Active</button>
+      <button type="button" class="tabs-item" role="tab" aria-selected="false">Invited</button>
+    </div>
+  </header>
+  <div class="table-wrap" role="region" aria-label="Members" tabindex="0">…</div>
+</section>
+```
+
+### Accessibility
+
+- Give the `.tabs` element `role="tablist"` and an `aria-label`, and each item
+  `role="tab"` with `aria-selected="true"` or `"false"`.
+- When the tabs switch panels, link each tab to its panel with `aria-controls` and give
+  the panel `role="tabpanel"` and `aria-labelledby`. Panels are not styled by SynthCSS.
+- Keyboard: the recommended pattern is a roving `tabindex` (only the selected tab is
+  in the tab order) with arrow keys moving between tabs. That script is the author's.
+- Items show a `:focus-visible` ring from the `--focus-*` tokens.
+
+### Recommended use
+
+Switching between views of the same content (All / Active / Archived), segmented
+filters, and tabbed sections on one page.
+
+### Misuse
+
+- Navigating to other pages; use `.nav` with `aria-current="page"`.
+- Toggle buttons with `aria-pressed`; SynthCSS tabs use `aria-selected`.
+- A single on/off option; use a checkbox in a `.field`.
+
 ## Verification
 
 - `npm test` runs [`scripts/check-components.mjs`](../scripts/check-components.mjs). It
@@ -645,15 +802,21 @@ what to do next.
   (`src/synthcss.css` with its imports inlined) and no other class is added; that every
   declaration uses tokens defined in `tokens.css`, with no hex, `rgb()`, `hsl()` or named
   color literals and no hard-coded lengths; that text on every button, badge, alert,
-  card and panel background meets 4.5:1 contrast; that buttons, field controls and
-  `.table-wrap` have `:focus-visible` rings; that buttons and fields have disabled
+  card and panel background, and on the current nav link and default and selected tabs,
+  meets 4.5:1 contrast; that buttons, field controls, nav links, tabs and `.table-wrap`
+  have `:focus-visible` rings; that `.nav` resets the list without setting a layout,
+  the current nav link (`aria-current="page"`) and selected tab
+  (`aria-selected="true"`) are styled from those attributes, `aria-pressed` is not used
+  and `.tabs` wraps; that buttons and fields have disabled
   styles, buttons a loading style, and the field error state changes the border width
   and adds a marker; that `.table-wrap` scrolls horizontally; that this page covers
   every component and has the reference table; and that every class in an `html` example
   in `README.md` and `docs/`, and on the showcase page, exists in the CSS.
 - `npm run check:components:browser` loads the showcase in headless Chromium at 375px
   and 1280px and checks that the components render with their tokens, that tables
-  scroll inside `.table-wrap` without the page overflowing, and that focus rings show.
+  scroll inside `.table-wrap` without the page overflowing, that the current nav link
+  and the selected tab look different from the others, that `.tabs` does not overflow
+  at 320px, and that focus rings show.
   It needs Playwright: run `npm install --no-save playwright` and
   `npx playwright install chromium` first.
 - The [showcase](../showcase/index.html) shows every component and a composed settings
diff --git a/scripts/check-components-browser.mjs b/scripts/check-components-browser.mjs
index a80628d..665f0f2 100644
--- a/scripts/check-components-browser.mjs
+++ b/scripts/check-components-browser.mjs
@@ -1,6 +1,7 @@
 #!/usr/bin/env node
 // Optional browser check of the components on showcase/index.html at 375px and
-// 1280px viewport width: tokens applied, states, focus rings and table scrolling.
+// 1280px viewport width: tokens applied, states, focus rings and table scrolling,
+// plus .tabs at 320px.
 // Needs Playwright, which is not a dependency of this repository:
 //   npm install --no-save playwright && npx playwright install chromium
 // Usage: node scripts/check-components-browser.mjs
@@ -70,6 +71,37 @@ try {
         overflowX: getComputedStyle(w).overflowX,
         scrolls: w.scrollWidth > w.clientWidth,
       }));
+      // Nav and tabs: attribute-driven states and layout inside .stack / .cluster.
+      const look = (el) => {
+        const s = getComputedStyle(el);
+        return [s.backgroundColor, s.color, s.boxShadow, s.borderTopColor].join("|");
+      };
+      function navAndTabs() {
+        const links = [...document.querySelectorAll("#component-nav ~ .sc-demo .nav-link")];
+        const current = links.find((a) => a.getAttribute("aria-current") === "page");
+        const plain = links.find((a) => !a.hasAttribute("aria-current"));
+        const items = [...document.querySelectorAll("#component-tabs ~ .sc-demo .tabs-item")];
+        const selected = items.find((b) => b.getAttribute("aria-selected") === "true");
+        const unselected = items.filter((b) => b.getAttribute("aria-selected") === "false");
+        const lists = [...document.querySelectorAll("#component-nav ~ .sc-demo .nav")];
+        const vertical = lists.find((l) => l.classList.contains("stack-sm"));
+        const horizontal = lists.find((l) => l.classList.contains("cluster-sm"));
+        const top = (el) => el.getBoundingClientRect().top;
+        const rows = (list) => new Set([...list.children].map((li) => Math.round(top(li)))).size;
+        const ls = getComputedStyle(lists[0]);
+        return {
+          navCurrentColor: getComputedStyle(current).color === token("--color-primary"),
+          navCurrentDistinct: look(current) !== look(plain),
+          navUnderline: getComputedStyle(plain).textDecorationLine,
+          navReset: ls.listStyleType === "none" && ls.paddingInlineStart === "0px" && ls.marginTop === "0px",
+          navVertical: rows(vertical) === vertical.children.length,
+          navHorizontal: rows(horizontal) === 1,
+          tabSelectedBg: getComputedStyle(selected).backgroundColor === token("--color-surface-elevated"),
+          tabSelectedDistinct: unselected.every((b) => look(b) !== look(selected)),
+          tabUnselectedSame: unselected.every((b) => look(b) === look(unselected[0])),
+          tabTrackBg: getComputedStyle(selected.parentElement).backgroundColor === token("--color-surface"),
+        };
+      }
       const result = {
         overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
         primaryBg: css("#components .button-primary", "background-color") === token("--color-primary"),
@@ -86,6 +118,7 @@ try {
         numericAlign: css("#components .table .numeric", "text-align"),
         emptyAlign: css("#components .empty-state", "text-align"),
         wraps,
+        ...navAndTabs(),
       };
       probe.remove();
       return result;
@@ -106,6 +139,13 @@ try {
     expect(m.emptyAlign === "center", `${width}px: .empty-state is not centered`);
     expect(m.wraps.length > 0 && m.wraps.every((w) => w.overflowX === "auto"), `${width}px: .table-wrap does not scroll horizontally`);
     if (width === 375) expect(m.wraps.some((w) => w.scrolls), "375px: no table scrolls inside .table-wrap at phone width");
+    expect(m.navCurrentColor, `${width}px: .nav-link[aria-current="page"] text is not --color-primary`);
+    expect(m.navCurrentDistinct, `${width}px: .nav-link[aria-current="page"] looks like the other links`);
+    expect(m.navUnderline === "none", `${width}px: .nav-link is underlined`);
+    expect(m.navReset, `${width}px: .nav does not reset bullets, padding and margin`);
+    expect(m.navVertical && m.navHorizontal, `${width}px: .nav does not follow .stack-sm (vertical) and .cluster-sm (horizontal)`);
+    expect(m.tabSelectedBg && m.tabTrackBg, `${width}px: .tabs track or selected .tabs-item does not use its surface tokens`);
+    expect(m.tabSelectedDistinct && m.tabUnselectedSame, `${width}px: aria-selected="true" is not distinct, or aria-selected="false" items differ`);
 
     for (const [what, sel] of [
       ["button", "#components .button"],
@@ -113,12 +153,43 @@ try {
       ["select", "#components .field select"],
       ["checkbox", "#components .field input[type=checkbox]"],
       ["table-wrap", "#components .table-wrap"],
+      ["nav-link", "#components .nav-link"],
+      ["tabs-item", "#components .tabs-item"],
     ]) {
       const ring = await tabTo(page, sel);
       expect(ring && ring.style === "solid" && ring.width > 0, `${width}px: ${what} has no visible :focus-visible ring`);
     }
     await page.close();
   }
+
+  // At 320px every .tabs on the page stays inside its container (it wraps).
+  const narrow = await browser.newPage({ viewport: { width: 320, height: 800 } });
+  await narrow.goto(pageUrl, { waitUntil: "load" });
+  const tabs = await narrow.evaluate(() =>
+    [...document.querySelectorAll(".tabs")].map((t) => {
+      const box = t.getBoundingClientRect();
+      const parent = t.parentElement.getBoundingClientRect();
+      return { overflows: t.scrollWidth > t.clientWidth || box.right > parent.right + 0.5, label: t.getAttribute("aria-label") };
+    }),
+  );
+  expect(tabs.length > 0, "320px: no .tabs found on the showcase");
+  for (const t of tabs) expect(!t.overflows, `320px: .tabs "${t.label}" overflows its container`);
+  // A long tab list at 320px must wrap onto more rows rather than overflow. (The
+  // showcase hero is measured separately; here only the tabs' own box counts.)
+  const wide = await narrow.evaluate(() => {
+    const t = document.querySelector("#components .tabs").cloneNode(true);
+    for (let i = 0; i < 6; i++) t.append(t.firstElementChild.cloneNode(true));
+    document.body.prepend(t);
+    const right = Math.max(t.getBoundingClientRect().right, ...[...t.children].map((b) => b.getBoundingClientRect().right));
+    const r = {
+      overflow: t.scrollWidth > t.clientWidth || right > document.body.getBoundingClientRect().right + 0.5,
+      rows: new Set([...t.children].map((b) => Math.round(b.getBoundingClientRect().top))).size,
+    };
+    t.remove();
+    return r;
+  });
+  expect(!wide.overflow && wide.rows > 1, `320px: a long .tabs does not wrap (rows: ${wide.rows}, overflows: ${wide.overflow})`);
+  await narrow.close();
 } finally {
   await browser.close();
 }
@@ -128,4 +199,4 @@ if (errors.length) {
   console.error(`\ncheck-components-browser: ${errors.length} problem(s) found.`);
   process.exit(1);
 }
-console.log("check-components-browser: components render with tokens, states, focus rings and scrolling tables at 375px and 1280px.");
+console.log("check-components-browser: components render with tokens, states, focus rings and scrolling tables at 375px and 1280px; .tabs wraps at 320px.");
diff --git a/scripts/check-components.mjs b/scripts/check-components.mjs
index a4cf490..6f84519 100644
--- a/scripts/check-components.mjs
+++ b/scripts/check-components.mjs
@@ -19,6 +19,8 @@ export const COMPONENTS = {
   panel: ["panel", "panel-header", "panel-body"],
   table: ["table", "table-wrap", "table-hover", "numeric"],
   "empty-state": ["empty-state"],
+  nav: ["nav", "nav-link"],
+  tabs: ["tabs", "tabs-item"],
 };
 export const COMPONENT_NAMES = Object.keys(COMPONENTS);
 export const COMPONENT_CLASSES = Object.values(COMPONENTS).flat();
@@ -44,6 +46,15 @@ const COLOR_VARIANTS = [
   ["panel", "panel"],
 ];
 
+// Attribute-driven states: [label, selectors merged in order]. The text color
+// is checked against the last opaque background (transparent items show the
+// track behind them).
+const STATE_COLORS = [
+  ['.nav-link[aria-current="page"]', [".nav-link", '.nav-link[aria-current="page"]']],
+  [".tabs-item", [".tabs", ".tabs-item"]],
+  ['.tabs-item[aria-selected="true"]', [".tabs", ".tabs-item", '.tabs-item[aria-selected="true"]']],
+];
+
 export const DOC_SECTIONS = ["Purpose", "Example", "Variants", "Composition", "Accessibility", "Recommended use"];
 export const MAX_REFERENCE_ROWS = 15;
 
@@ -200,6 +211,8 @@ export function checkComponentsCss(componentsCss, tokensCss) {
     ["field controls (textarea)", /\.field\b.*\btextarea\b.*:focus-visible/],
     ["field controls (select)", /\.field\b.*\bselect\b.*:focus-visible/],
     ["table-wrap", /\.table-wrap:focus-visible/],
+    ["nav-link", /^\.nav-link:focus-visible$/],
+    ["tabs-item", /^\.tabs-item:focus-visible$/],
   ];
   for (const [what, re] of focusTargets) {
     if (!has((r) => sel(r, re) && focusRing(r))) {
@@ -237,15 +250,54 @@ export function checkComponentsCss(componentsCss, tokensCss) {
     errors.push(".numeric cells must be right-aligned with tabular-nums");
   }
 
-  // Contrast of text on each variant's background.
-  const plainDecls = (cls) => rules.filter((r) => r.selectors.includes(`.${cls}`)).flatMap((r) => r.decls);
+  // Nav and tabs: list reset, attribute-driven state, wrapping track.
+  const declsOf = (selector) => rules.filter((r) => r.selectors.includes(selector)).flatMap((r) => r.decls);
+  const navDecls = declsOf(".nav");
+  if (!navDecls.some((d) => d.prop === "list-style" && /^none\b/.test(d.value))) errors.push(".nav must remove list bullets (list-style: none)");
+  for (const prop of ["margin", "padding"]) {
+    if (!navDecls.some((d) => d.prop === prop && d.value === "0")) errors.push(`.nav must reset the list ${prop} to 0`);
+  }
+  if (navDecls.some((d) => d.prop === "display")) errors.push(".nav must not set display; layout comes from .stack-* or .cluster-*");
+  const stateDecls = (selector) => declsOf(selector).filter((d) => /^(background(-color)?|color)$/.test(d.prop));
+  if (!stateDecls('.nav-link[aria-current="page"]').length) {
+    errors.push('.nav-link[aria-current="page"] must change the background or text color');
+  }
+  if (!has((r) => sel(r, /^\.nav-link:hover$/) && decl(r, "background", /^var\(--color-surface\)$/))) {
+    errors.push(".nav-link:hover must use background: var(--color-surface)");
+  }
+  const selected = declsOf('.tabs-item[aria-selected="true"]');
+  if (!selected.some((d) => /^background(-color)?$/.test(d.prop) && d.value === "var(--color-surface-elevated)")) {
+    errors.push('.tabs-item[aria-selected="true"] must use background: var(--color-surface-elevated)');
+  }
+  if (!selected.some((d) => (d.prop === "box-shadow" && d.value !== "none") || /^border(-[\w-]+)?-color$|^border$/.test(d.prop))) {
+    errors.push('.tabs-item[aria-selected="true"] must look raised (a shadow or border token)');
+  }
+  if (!declsOf(".tabs-item").some((d) => /^background(-color)?$/.test(d.prop) && d.value === "transparent")) {
+    errors.push(".tabs-item must have a transparent background by default");
+  }
+  if (!declsOf(".tabs").some((d) => d.prop === "flex-wrap" && d.value === "wrap")) {
+    errors.push(".tabs must set flex-wrap: wrap so it never overflows narrow containers");
+  }
+  if (rules.some((r) => r.selectors.some((s) => /\[aria-pressed\b/.test(s)))) {
+    errors.push('components.css must not style aria-pressed; tabs use aria-selected="true"');
+  }
+
+  // Contrast of text on each variant's and state's background.
+  const plainDecls = (cls) => declsOf(`.${cls}`);
   const last = (decls, props) => decls.filter((d) => props.includes(d.prop)).at(-1)?.value;
-  for (const [variant, base] of COLOR_VARIANTS) {
-    const decls = variant === base ? plainDecls(base) : [...plainDecls(base), ...plainDecls(variant)];
+  const pairs = [
+    ...COLOR_VARIANTS.map(([variant, base]) => [
+      `.${variant}`,
+      variant === base ? plainDecls(base) : [...plainDecls(base), ...plainDecls(variant)],
+    ]),
+    ...STATE_COLORS.map(([label, selectors]) => [label, selectors.flatMap(declsOf)]),
+  ];
+  for (const [label, allDecls] of pairs) {
+    const decls = allDecls.filter((d) => d.value !== "transparent");
     const fg = last(decls, ["color"]);
     const bg = last(decls, ["background", "background-color"]);
     if (!fg || !bg) {
-      errors.push(`.${variant} must set both color and background`);
+      errors.push(`${label} must set both color and background`);
       continue;
     }
     try {
@@ -253,9 +305,9 @@ export function checkComponentsCss(componentsCss, tokensCss) {
       const b = colorOf(bg, tokens);
       if (!a || !b) throw new Error("could not resolve colors");
       const ratio = contrastRatio(a, b);
-      if (ratio < 4.5) errors.push(`contrast too low in .${variant}: ${ratio.toFixed(2)}:1 (min 4.5:1)`);
+      if (ratio < 4.5) errors.push(`contrast too low in ${label}: ${ratio.toFixed(2)}:1 (min 4.5:1)`);
     } catch (err) {
-      errors.push(`contrast of .${variant}: ${err.message}`);
+      errors.push(`contrast of ${label}: ${err.message}`);
     }
   }
   return errors;
diff --git a/scripts/check-components.test.mjs b/scripts/check-components.test.mjs
index 8151fa5..b709e7f 100644
--- a/scripts/check-components.test.mjs
+++ b/scripts/check-components.test.mjs
@@ -73,6 +73,52 @@ test("fails when disabled or loading selectors are missing", () => {
   assertError(errorsWith(noSpinner), "loading indicator");
 });
 
+test("fails when nav or tabs lose their focus ring", () => {
+  const nav = withCss((css) => css.replace(".nav-link:focus-visible {", ".nav-link:focus {"));
+  assertError(errorsWith(nav), "nav-link needs a :focus-visible rule");
+  const tabs = withCss((css) => css.replace(".tabs-item:focus-visible {", ".tabs-item:focus {"));
+  assertError(errorsWith(tabs), "tabs-item needs a :focus-visible rule");
+});
+
+test("fails when .nav does not reset the list or sets a layout", () => {
+  const bullets = withCss((css) => css.replace(/(\.nav \{[^}]*)list-style: none;/, "$1"));
+  assertError(errorsWith(bullets), ".nav must remove list bullets");
+  const padding = withCss((css) => css.replace(/(\.nav \{[^}]*)padding: 0;/, "$1"));
+  assertError(errorsWith(padding), ".nav must reset the list padding to 0");
+  const display = withCss((css) => css.replace(".nav {\n", ".nav {\n  display: flex;\n"));
+  assertError(errorsWith(display), ".nav must not set display");
+});
+
+test("fails when nav and tabs state does not come from aria-current and aria-selected", () => {
+  const current = withCss((css) => css.replaceAll('.nav-link[aria-current="page"]', '.nav-link[data-current]'));
+  assertError(errorsWith(current), '.nav-link[aria-current="page"] must change the background or text color');
+  const hover = withCss((css) => css.replace(/(\.nav-link:hover \{\n  background: )var\(--color-surface\)/, "$1var(--color-surface-elevated)"));
+  assertError(errorsWith(hover), ".nav-link:hover must use background: var(--color-surface)");
+  const pressed = withCss((css) => css.replaceAll('.tabs-item[aria-selected="true"]', '.tabs-item[aria-pressed="true"]'));
+  assertError(errorsWith(pressed), '.tabs-item[aria-selected="true"] must use background: var(--color-surface-elevated)');
+  assertError(errorsWith(pressed), "must not style aria-pressed");
+  const flat = withCss((css) =>
+    css.replace(/(\.tabs-item\[aria-selected="true"\] \{\n)  border-color: var\(--border-color\);\n([^}]*)  box-shadow: var\(--shadow-sm\);\n/, "$1$2"),
+  );
+  assertError(errorsWith(flat), "must look raised");
+  const opaque = withCss((css) => css.replace(/(\.tabs-item \{[^}]*background: )transparent;/, "$1var(--color-surface);"));
+  assertError(errorsWith(opaque), ".tabs-item must have a transparent background by default");
+  const state = withCss((css) => css + '\n.tabs-item-selected { background: var(--color-surface-elevated); color: var(--color-text); }\n');
+  assertError(errorsWith(state), "unexpected class .tabs-item-selected");
+});
+
+test("fails when .tabs can overflow instead of wrapping", () => {
+  const nowrap = withCss((css) => css.replace(/(\.tabs \{[^}]*)flex-wrap: wrap;/, "$1flex-wrap: nowrap;"));
+  assertError(errorsWith(nowrap), ".tabs must set flex-wrap: wrap");
+});
+
+test("fails when the current nav link or a tab has too little contrast", () => {
+  const nav = withCss((css) => css.replace("var(--color-primary) 10%, var(--color-background)", "var(--color-primary) 70%, var(--color-background)"));
+  assertError(errorsWith(nav), 'contrast too low in .nav-link[aria-current="page"]');
+  const tab = withCss((css) => css.replace(/(\.tabs-item \{[^}]*color: )var\(--color-text-secondary\)/, "$1var(--color-border)"));
+  assertError(errorsWith(tab), "contrast too low in .tabs-item");
+});
+
 test("fails when the field error state relies on color alone", () => {
   const colorOnly = withCss((css) =>
     css.replace(/(\)\[aria-invalid="true"\] \{\n  border-color: var\(--color-danger\);\n)[^}]*\}/, "$1}"),
diff --git a/scripts/verify-ai-contract.mjs b/scripts/verify-ai-contract.mjs
index 5a6820c..5b0ef83 100644
--- a/scripts/verify-ai-contract.mjs
+++ b/scripts/verify-ai-contract.mjs
@@ -41,6 +41,10 @@ export const MD_SECTIONS = [
 ];
 const INVALID_SECTION = "Invalid / Discouraged Examples";
 export const GENERATION_RULES = 10;
+// Parts whose state comes from one ARIA attribute. The contract must name that
+// attribute in the part's entry (JSON and Markdown), and the Markdown must rule
+// out aria-pressed, so models do not pick a different attribute per run.
+export const STATE_ATTRIBUTES = { "nav-link": 'aria-current="page"', "tabs-item": 'aria-selected="true"' };
 export const VALID_EXAMPLES = [2, 4];
 // The showcase may round the size it prints; it must stay within this fraction.
 export const SHOWCASE_SIZE_TOLERANCE = 0.1;
@@ -315,6 +319,16 @@ export function verifyContract(files, { maxTokens = DEFAULT_MAX_TOKENS } = {}) {
   for (const token of diff(jsonTokens, mdTokens)) errors.push(`token ${token} is in ${JSON_FILE} but not in the ${LLM_FILE} Design Tokens`);
   for (const token of diff(mdTokens, jsonTokens)) errors.push(`token ${token} is in the ${LLM_FILE} Design Tokens but not in ${JSON_FILE}`);
 
+  for (const [part, attr] of Object.entries(STATE_ATTRIBUTES)) {
+    const owner = Object.values(contract.components).find((comp) => part in comp.parts);
+    if (owner && !owner.parts[part].includes(attr)) errors.push(`${JSON_FILE}: the .${part} entry must name its state attribute ${attr}`);
+    const line = section("Component Vocabulary").split("\n").find((l) => l.includes(`\`.${part}\``));
+    if (line && !line.includes(attr)) errors.push(`${LLM_FILE}: the .${part} entry must name its state attribute ${attr}`);
+  }
+  if (!/never\b[^\n]*`aria-pressed`/i.test(section("Component Vocabulary"))) {
+    errors.push(`${LLM_FILE}: the Component Vocabulary must say never to use \`aria-pressed\``);
+  }
+
   const rows = section("Intent Mapping").split("\n").filter((l) => /^\s*\|/.test(l)).slice(2);
   if (rows.length !== contract.intentMap.length) {
     errors.push(`${LLM_FILE}: the Intent Mapping table has ${rows.length} rows, ${JSON_FILE} has ${contract.intentMap.length}`);
diff --git a/scripts/verify-ai-contract.test.mjs b/scripts/verify-ai-contract.test.mjs
index 04adc60..761147d 100644
--- a/scripts/verify-ai-contract.test.mjs
+++ b/scripts/verify-ai-contract.test.mjs
@@ -56,6 +56,28 @@ test("fails when a class is deleted from the contract", () => {
   assertError(errorsWith({ md }), "class .split-lg is in synthcss.ai.json but not in the synthcss.llm.md vocabulary");
 });
 
+test("lists nav and tabs with their intent rows and one state attribute each", () => {
+  assert.ok(contract.components.nav.parts["nav-link"]);
+  assert.ok(contract.components.tabs.parts["tabs-item"]);
+  assert.ok(contract.intentMap.some((e) => e.intent === "Navigation links" && e.use === ".nav + .nav-link"));
+  assert.ok(contract.intentMap.some((e) => e.intent === "Tabs or segmented filter" && e.use === ".tabs + .tabs-item"));
+  assert.match(files.md, /^\| Navigation links \| `\.nav` \+ `\.nav-link` \|$/m);
+  assert.match(files.md, /^\| Tabs or segmented filter \| `\.tabs` \+ `\.tabs-item` \|$/m);
+  const tabs = withJson((c) => delete c.components.tabs);
+  assertError(errorsWith(tabs), "class .tabs-item is in the SynthCSS CSS but missing from synthcss.ai.json");
+  const md = files.md.replace("| Navigation links | `.nav` + `.nav-link` |\n", "");
+  assertError(errorsWith({ md }), "the Intent Mapping table has");
+});
+
+test("fails when the contract drops or changes a state attribute, or allows aria-pressed", () => {
+  const json = withJson((c) => (c.components.tabs.parts["tabs-item"] = "<button role=\"tab\">; selected: aria-pressed=\"true\""));
+  assertError(errorsWith(json), 'synthcss.ai.json: the .tabs-item entry must name its state attribute aria-selected="true"');
+  const md = files.md.replace(/^(  - part `\.nav-link` — .*?); current page: `aria-current="page"`$/m, "$1");
+  assertError(errorsWith({ md }), 'synthcss.llm.md: the .nav-link entry must name its state attribute aria-current="page"');
+  const pressed = files.md.replace("Never `aria-pressed` or state classes", "Avoid state classes");
+  assertError(errorsWith({ md: pressed }), "never to use `aria-pressed`");
+});
+
 test("fails when a class is added to the CSS but not to the contract", () => {
   const builtCss = files.builtCss + "\n.button-ghost { color: var(--color-text); }\n";
   assertError(errorsWith({ builtCss }), "class .button-ghost is in the SynthCSS CSS but missing from synthcss.ai.json");
diff --git a/showcase/index.html b/showcase/index.html
index a985371..7519bf9 100644
--- a/showcase/index.html
+++ b/showcase/index.html
@@ -452,9 +452,10 @@ <h3 id="responsive-split"><code>.split</code>: adapts</h3>
         <div class="stack-sm">
           <h2 id="components-title">Components</h2>
           <p class="sc-muted">
-            Eight semantic components built only from tokens. State comes from native
-            attributes such as <code>disabled</code>, <code>aria-busy</code> and
-            <code>aria-invalid</code>. Press <kbd>Tab</kbd> to see the focus rings.
+            Ten semantic components built only from tokens. State comes from native
+            attributes such as <code>disabled</code>, <code>aria-busy</code>,
+            <code>aria-invalid</code>, <code>aria-current</code> and
+            <code>aria-selected</code>. Press <kbd>Tab</kbd> to see the focus rings.
           </p>
         </div>
         <div class="grid-lg" style="--grid-min: 22rem">
@@ -711,6 +712,62 @@ <h4>No projects yet</h4>
 &lt;/div&gt;</code></pre></div>
           </article>
 
+          <article class="sc-card stack" aria-labelledby="component-nav">
+            <h3 id="component-nav"><code>.nav</code></h3>
+            <p>Navigation links. <code>.nav</code> resets the list; <code>.stack-sm</code> or <code>.cluster-sm</code> sets the direction. The current page is <code>aria-current="page"</code>.</p>
+            <div class="sc-demo">
+              <div class="stack">
+                <nav aria-label="Demo main navigation">
+                  <ul class="nav cluster-sm" role="list">
+                    <li><a class="nav-link" href="#component-nav" aria-current="page">Projects</a></li>
+                    <li><a class="nav-link" href="#component-nav">Team</a></li>
+                    <li><a class="nav-link" href="#component-nav">Settings</a></li>
+                  </ul>
+                </nav>
+                <nav aria-label="Demo settings navigation">
+                  <ul class="nav stack-sm" role="list">
+                    <li><a class="nav-link" href="#component-nav"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="2" fill="none"/></svg> General</a></li>
+                    <li><a class="nav-link" href="#component-nav" aria-current="page"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2 14c0-3 3-5 6-5s6 2 6 5M8 7a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z" stroke="currentColor" stroke-width="2" fill="none"/></svg> Members</a></li>
+                    <li><a class="nav-link" href="#component-nav"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2 4h12v8H2zM2 7h12" stroke="currentColor" stroke-width="2" fill="none"/></svg> Billing</a></li>
+                  </ul>
+                </nav>
+              </div>
+            </div>
+            <div class="sc-code"><pre><code>&lt;nav aria-label="Main"&gt;
+  &lt;ul class="nav cluster-sm" role="list"&gt;
+    &lt;li&gt;&lt;a class="nav-link" href="/projects" aria-current="page"&gt;Projects&lt;/a&gt;&lt;/li&gt;
+    &lt;li&gt;&lt;a class="nav-link" href="/team"&gt;Team&lt;/a&gt;&lt;/li&gt;
+  &lt;/ul&gt;
+&lt;/nav&gt;</code></pre></div>
+          </article>
+
+          <article class="sc-card stack" aria-labelledby="component-tabs">
+            <h3 id="component-tabs"><code>.tabs</code></h3>
+            <p>A tab list or segmented filter. The selected item is <code>aria-selected="true"</code> with <code>role="tab"</code>; switching panels is up to your script.</p>
+            <div class="sc-demo">
+              <div class="stack">
+                <div class="tabs" role="tablist" aria-label="Demo member filter">
+                  <button type="button" class="tabs-item" role="tab" aria-selected="true">All</button>
+                  <button type="button" class="tabs-item" role="tab" aria-selected="false">Admins</button>
+                  <button type="button" class="tabs-item" role="tab" aria-selected="false">Invited</button>
+                </div>
+                <div class="cluster-sm">
+                  <div class="tabs" role="tablist" aria-label="Demo period">
+                    <button type="button" class="tabs-item" role="tab" aria-selected="false">Day</button>
+                    <button type="button" class="tabs-item" role="tab" aria-selected="true">Week</button>
+                    <button type="button" class="tabs-item" role="tab" aria-selected="false">Month</button>
+                  </div>
+                  <button type="button" class="button button-sm">Export</button>
+                </div>
+              </div>
+            </div>
+            <div class="sc-code"><pre><code>&lt;div class="tabs" role="tablist" aria-label="Members"&gt;
+  &lt;button type="button" class="tabs-item" role="tab" aria-selected="true"&gt;All&lt;/button&gt;
+  &lt;button type="button" class="tabs-item" role="tab" aria-selected="false"&gt;Admins&lt;/button&gt;
+  &lt;button type="button" class="tabs-item" role="tab" aria-selected="false"&gt;Invited&lt;/button&gt;
+&lt;/div&gt;</code></pre></div>
+          </article>
+
         </div>
       </div>
     </section>
@@ -729,10 +786,10 @@ <h2 id="composed-title">Composed interface</h2>
         <div class="sc-frame" id="frame-composed">
           <div class="sidebar" data-composed>
             <nav class="panel" aria-label="Project settings">
-              <ul class="stack-sm" role="list">
-                <li><a href="#composed">General</a></li>
-                <li><a href="#composed">Members</a></li>
-                <li><a href="#composed">Billing</a></li>
+              <ul class="nav stack-sm" role="list">
+                <li><a class="nav-link" href="#composed" aria-current="page">General</a></li>
+                <li><a class="nav-link" href="#composed">Members</a></li>
+                <li><a class="nav-link" href="#composed">Billing</a></li>
               </ul>
             </nav>
             <div class="stack-lg">
@@ -809,6 +866,11 @@ <h4 id="composed-envs">Environments</h4>
                   <h4 id="composed-members">Members</h4>
                   <button type="button" class="button button-sm">Invite</button>
                 </header>
+                <div class="tabs" role="tablist" aria-label="Filter members">
+                  <button type="button" class="tabs-item" role="tab" aria-selected="true">All</button>
+                  <button type="button" class="tabs-item" role="tab" aria-selected="false">Owners</button>
+                  <button type="button" class="tabs-item" role="tab" aria-selected="false">Developers</button>
+                </div>
                 <div class="table-wrap" role="region" aria-labelledby="composed-members" tabindex="0">
                   <table class="table table-hover">
                     <thead>
@@ -942,7 +1004,7 @@ <h2 id="ai-contract-title">AI Contract</h2>
           <a class="sc-button" href="../synthcss.llm.md">View synthcss.llm.md</a>
           <a class="sc-button sc-button-secondary" href="../synthcss.ai.json" download>Download synthcss.ai.json</a>
           <a href="https://github.com/nabledhq/synthcss/blob/main/synthcss.llm.md">Read it on GitHub</a>
-          <span class="badge badge-info">≈ 2,830 tokens</span>
+          <span class="badge badge-info">≈ 3,490 tokens</span>
         </div>
         <p class="sc-muted">
           Size estimated as characters ÷ 4 (about 11,300 characters), small enough for one
diff --git a/src/components.css b/src/components.css
index 636984c..69c67a2 100644
--- a/src/components.css
+++ b/src/components.css
@@ -1,15 +1,17 @@
 /*
  * SynthCSS components
  *
- * Eight semantic components on top of the design tokens and layout
- * primitives: button, field, card, badge, alert, panel, table, empty-state.
+ * Ten semantic components on top of the design tokens and layout
+ * primitives: button, field, card, badge, alert, panel, table, empty-state,
+ * nav and tabs.
  *
  * Rules:
  * - Every color, space, radius, font size, weight and shadow is a token from
  *   tokens.css. No color literals; tints are color-mix() of two tokens.
  * - Naming is .component, .component-variant, .component-part.
  * - State comes from native attributes, not classes: :disabled,
- *   [aria-disabled="true"], [aria-busy="true"], [aria-invalid="true"].
+ *   [aria-disabled="true"], [aria-busy="true"], [aria-invalid="true"],
+ *   [aria-current="page"] (nav) and [aria-selected="true"] (tabs).
  * - Components never set `display` on parts meant to be combined with a
  *   layout primitive (for example `class="card-footer split"`).
  * - Child margin resets use :where() so any author rule wins.
@@ -709,3 +711,125 @@
   block-size: auto;
   color: var(--color-text-muted);
 }
+
+/* -------------------------------------------------------------------------
+   Nav: a list of navigation links. .nav only resets the list; add .stack-*
+   for a vertical list or .cluster-* for a horizontal one. The current page
+   is marked with aria-current="page", never with a class.
+   ------------------------------------------------------------------------- */
+.nav {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.nav-link {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--space-2);
+  box-sizing: border-box;
+  max-inline-size: 100%;
+  padding-block: var(--space-2);
+  padding-inline: var(--space-3);
+  border-radius: var(--radius-md);
+  color: inherit;
+  font-weight: var(--weight-medium);
+  line-height: var(--leading-tight);
+  text-decoration: none;
+  transition:
+    background-color var(--duration-fast) var(--ease-standard),
+    color var(--duration-fast) var(--ease-standard);
+}
+
+.nav-link > :where(svg, img) {
+  flex-shrink: 0;
+  inline-size: 1em;
+  block-size: 1em;
+}
+
+.nav-link:hover {
+  background: var(--color-surface);
+}
+
+.nav-link[aria-current="page"] {
+  background: color-mix(in srgb, var(--color-primary) 10%, var(--color-background));
+  color: var(--color-primary);
+}
+
+.nav-link:focus-visible {
+  outline: var(--focus-width) solid var(--focus-color);
+  outline-offset: var(--focus-offset);
+}
+
+/* -------------------------------------------------------------------------
+   Tabs: a segmented control or tab list on a surface track. The selected
+   item is marked with aria-selected="true" (with role="tab"), never with
+   aria-pressed or a class. CSS only: arrow-key focus and panel switching are
+   up to the page's own script. The track wraps instead of overflowing.
+   ------------------------------------------------------------------------- */
+.tabs {
+  display: inline-flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: var(--space-1);
+  box-sizing: border-box;
+  inline-size: fit-content;
+  max-inline-size: 100%;
+  padding: var(--space-1);
+  border: var(--border-width) solid var(--border-color);
+  border-radius: var(--radius-md);
+  background: var(--color-surface);
+  color: var(--color-text);
+  vertical-align: middle;
+}
+
+.tabs-item {
+  -webkit-appearance: none;
+  appearance: none;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: var(--space-2);
+  box-sizing: border-box;
+  max-inline-size: 100%;
+  margin: 0;
+  padding-block: var(--space-1);
+  padding-inline: var(--space-3);
+  border: var(--border-width) solid transparent;
+  border-radius: var(--radius-sm);
+  background: transparent;
+  color: var(--color-text-secondary);
+  font-family: inherit;
+  font-size: var(--text-sm);
+  font-weight: var(--weight-medium);
+  line-height: var(--leading-normal);
+  text-align: center;
+  text-decoration: none;
+  cursor: pointer;
+  transition:
+    background-color var(--duration-fast) var(--ease-standard),
+    border-color var(--duration-fast) var(--ease-standard),
+    color var(--duration-fast) var(--ease-standard);
+}
+
+.tabs-item > :where(svg, img) {
+  flex-shrink: 0;
+  inline-size: 1em;
+  block-size: 1em;
+}
+
+.tabs-item:hover {
+  color: var(--color-text);
+}
+
+.tabs-item[aria-selected="true"] {
+  border-color: var(--border-color);
+  background: var(--color-surface-elevated);
+  box-shadow: var(--shadow-sm);
+  color: var(--color-text);
+}
+
+.tabs-item:focus-visible {
+  outline: var(--focus-width) solid var(--focus-color);
+  outline-offset: var(--focus-offset);
+}
diff --git a/synthcss.ai.json b/synthcss.ai.json
index ce9e7c1..aebe302 100644
--- a/synthcss.ai.json
+++ b/synthcss.ai.json
@@ -175,6 +175,20 @@
       "intent": "nothing to show yet: icon, heading, text, action",
       "parts": {},
       "variants": {}
+    },
+    "nav": {
+      "intent": "list reset for navigation links on <ul>; add .stack-sm (vertical) or .cluster-sm (horizontal)",
+      "parts": {
+        "nav-link": "an <a href> in .nav, optional leading <svg>; current page: aria-current=\"page\""
+      },
+      "variants": {}
+    },
+    "tabs": {
+      "intent": "tab list or segmented filter; role=\"tablist\" + aria-label; wraps when narrow. CSS only: arrow keys and panel switching are your script",
+      "parts": {
+        "tabs-item": "<button role=\"tab\">; selected: aria-selected=\"true\", others aria-selected=\"false\""
+      },
+      "variants": {}
     }
   },
   "intentMap": [
@@ -196,7 +210,9 @@
     { "intent": "Message or notification", "use": ".alert + .alert-info / -success / -warning / -danger" },
     { "intent": "Secondary grouped content", "use": ".panel + .panel-header / .panel-body" },
     { "intent": "Tabular data", "use": ".table-wrap > .table; .table-hover, .numeric" },
-    { "intent": "No data yet", "use": ".empty-state" }
+    { "intent": "No data yet", "use": ".empty-state" },
+    { "intent": "Navigation links", "use": ".nav + .nav-link" },
+    { "intent": "Tabs or segmented filter", "use": ".tabs + .tabs-item" }
   ],
   "compositionRules": {
     "recommended": [
@@ -211,7 +227,8 @@
       "Wrapper <div>s that add no layout intent.",
       "Restyling a component with extra CSS; override tokens on :root instead.",
       "Classes for state (is-active, disabled); use attributes.",
-      "Visual reordering (order, *-reverse); DOM order is visual order."
+      "Visual reordering (order, *-reverse); DOM order is visual order.",
+      "aria-pressed or classes for the current link or selected tab; use aria-current=\"page\" on .nav-link, aria-selected=\"true\" on .tabs-item."
     ]
   },
   "generationRules": [
@@ -221,7 +238,7 @@
     "Never use inline styles except a token override such as style=\"--grid-min: 12rem\".",
     "In any custom CSS, reference tokens with var(); never hard-code colors, px/rem sizes, shadows or durations.",
     "Put components on semantic native elements and keep one component per element.",
-    "Express state with attributes: disabled, aria-disabled=\"true\", aria-busy=\"true\", aria-invalid=\"true\".",
+    "Express state with attributes: disabled, aria-disabled=\"true\", aria-busy=\"true\", aria-invalid=\"true\", aria-current=\"page\" (nav), aria-selected=\"true\" (tabs).",
     "Pick variants by meaning, not look: button-danger for destructive actions, badge-success for success.",
     "Write no media queries or breakpoint classes; primitives adapt to the space they get.",
     "Keep it accessible: aria-label on .button-icon, role=\"status\" or role=\"alert\" on .alert, a <label for> on every control."
@@ -229,8 +246,8 @@
   "examples": {
     "valid": [
       {
-        "html": "<main class=\"container stack-lg\">\n  <header class=\"split\">\n    <h1>Projects</h1>\n    <button type=\"button\" class=\"button button-primary\">New project</button>\n  </header>\n  <ul class=\"grid\" role=\"list\">\n    <li class=\"card\">\n      <div class=\"card-header\"><h2>Atlas</h2></div>\n      <div class=\"card-body\">Design system migration.</div>\n      <div class=\"card-footer split-sm\"><span class=\"badge badge-success\">Active</span></div>\n    </li>\n  </ul>\n</main>",
-        "note": "Page: container + stack, split header, grid of cards with a status badge."
+        "html": "<main class=\"container stack-lg\">\n  <header class=\"split\">\n    <h1>Projects</h1>\n    <nav aria-label=\"Main\">\n      <ul class=\"nav cluster-sm\" role=\"list\">\n        <li><a class=\"nav-link\" href=\"/projects\" aria-current=\"page\">Projects</a></li>\n        <li><a class=\"nav-link\" href=\"/team\">Team</a></li>\n      </ul>\n    </nav>\n  </header>\n  <div class=\"split\">\n    <div class=\"tabs\" role=\"tablist\" aria-label=\"Filter projects\">\n      <button type=\"button\" class=\"tabs-item\" role=\"tab\" aria-selected=\"true\">Active</button>\n      <button type=\"button\" class=\"tabs-item\" role=\"tab\" aria-selected=\"false\">Archived</button>\n    </div>\n    <button type=\"button\" class=\"button button-primary\">New project</button>\n  </div>\n  <ul class=\"grid\" role=\"list\">\n    <li class=\"card\">\n      <div class=\"card-header\"><h2>Atlas</h2></div>\n      <div class=\"card-body\">Design system migration.</div>\n      <div class=\"card-footer split-sm\"><span class=\"badge badge-success\">Active</span></div>\n    </li>\n  </ul>\n</main>",
+        "note": "Page: container + stack, split header with nav, tabs filter beside the main action, grid of cards with a status badge."
       },
       {
         "html": "<form class=\"card\">\n  <div class=\"field\">\n    <label class=\"field-label\" for=\"email\">Email</label>\n    <input id=\"email\" type=\"email\" aria-invalid=\"true\" aria-describedby=\"email-error\">\n    <p class=\"field-error\" id=\"email-error\">Enter a full email address.</p>\n  </div>\n  <div class=\"cluster-sm\">\n    <button type=\"submit\" class=\"button button-primary\">Save</button>\n    <button type=\"button\" class=\"button\">Cancel</button>\n  </div>\n</form>",
@@ -265,6 +282,14 @@
       {
         "html": "<p class=\"mt-4\">Saved.</p>",
         "note": "Spacing utility. Use .stack on the parent."
+      },
+      {
+        "html": "<a class=\"nav-link active\" href=\"/team\">Team</a>",
+        "note": "State class. Use .nav-link with aria-current=\"page\"."
+      },
+      {
+        "html": "<button class=\"tabs-item\" aria-pressed=\"true\">Week</button>",
+        "note": "aria-pressed for a tab. Use .tabs-item with role=\"tab\" and aria-selected=\"true\"."
       }
     ]
   }
diff --git a/synthcss.llm.md b/synthcss.llm.md
index ce3d211..8bc115f 100644
--- a/synthcss.llm.md
+++ b/synthcss.llm.md
@@ -97,6 +97,7 @@ Work on any element. No breakpoints: they adapt to the space they get.
 ## Component Vocabulary
 
 Naming: component, component-variant, component-part. State comes from attributes, never classes.
+One state attribute each: current nav link `aria-current="page"`; selected tab `role="tab"` + `aria-selected="true"` (others `"false"`). Never `aria-pressed` or state classes (active, is-active, selected).
 
 - `.button` — an action, on `<button>` or `<a href>`
   - variant `.button-primary` — main action of a form or page
@@ -133,6 +134,10 @@ Naming: component, component-variant, component-part. State comes from attribute
   - part `.numeric` — right-aligned tabular-number cell
   - variant `.table-hover` — highlight the hovered row
 - `.empty-state` — nothing to show yet: icon, heading, text, action
+- `.nav` — list reset for navigation links on `<ul>`; add `.stack-sm` (vertical) or `.cluster-sm` (horizontal)
+  - part `.nav-link` — an `<a href>` in `.nav`, optional leading `<svg>`; current page: `aria-current="page"`
+- `.tabs` — tab list or segmented filter; `role="tablist"` + `aria-label`; wraps when narrow. CSS only: arrow keys and panel switching are your script
+  - part `.tabs-item` — `<button role="tab">`; selected: `aria-selected="true"`, others `aria-selected="false"`
 
 ## Intent Mapping
 
@@ -157,6 +162,8 @@ Naming: component, component-variant, component-part. State comes from attribute
 | Secondary grouped content | `.panel` + `.panel-header` / `.panel-body` |
 | Tabular data | `.table-wrap` > `.table`; `.table-hover`, `.numeric` |
 | No data yet | `.empty-state` |
+| Navigation links | `.nav` + `.nav-link` |
+| Tabs or segmented filter | `.tabs` + `.tabs-item` |
 
 ## Composition Rules
 
@@ -175,6 +182,7 @@ Naming: component, component-variant, component-part. State comes from attribute
 - Restyling a component with extra CSS; override tokens on `:root` instead.
 - Classes for state (is-active, disabled); use attributes.
 - Visual reordering (order, *-reverse); DOM order is visual order.
+- `aria-pressed` or classes for the current link or selected tab; use `aria-current="page"` on `.nav-link`, `aria-selected="true"` on `.tabs-item`.
 
 ## AI Generation Rules
 
@@ -184,21 +192,33 @@ Naming: component, component-variant, component-part. State comes from attribute
 4. Never use inline styles except a token override such as `style="--grid-min: 12rem"`.
 5. In any custom CSS, reference tokens with `var()`; never hard-code colors, px/rem sizes, shadows or durations.
 6. Put components on semantic native elements and keep one component per element.
-7. Express state with attributes: `disabled`, `aria-disabled="true"`, `aria-busy="true"`, `aria-invalid="true"`.
+7. Express state with attributes: `disabled`, `aria-disabled="true"`, `aria-busy="true"`, `aria-invalid="true"`, `aria-current="page"` (nav), `aria-selected="true"` (tabs).
 8. Pick variants by meaning, not look: `button-danger` for destructive actions, `badge-success` for success.
 9. Write no media queries or breakpoint classes; primitives adapt to the space they get.
 10. Keep it accessible: `aria-label` on `.button-icon`, `role="status"` or `role="alert"` on `.alert`, a `<label for>` on every control.
 
 ## Valid Examples
 
-Page: container + stack, split header, grid of cards with a status badge.
+Page: container + stack, split header with nav, tabs filter beside the main action, grid of cards with a status badge.
 
 ```html
 <main class="container stack-lg">
   <header class="split">
     <h1>Projects</h1>
-    <button type="button" class="button button-primary">New project</button>
+    <nav aria-label="Main">
+      <ul class="nav cluster-sm" role="list">
+        <li><a class="nav-link" href="/projects" aria-current="page">Projects</a></li>
+        <li><a class="nav-link" href="/team">Team</a></li>
+      </ul>
+    </nav>
   </header>
+  <div class="split">
+    <div class="tabs" role="tablist" aria-label="Filter projects">
+      <button type="button" class="tabs-item" role="tab" aria-selected="true">Active</button>
+      <button type="button" class="tabs-item" role="tab" aria-selected="false">Archived</button>
+    </div>
+    <button type="button" class="button button-primary">New project</button>
+  </div>
   <ul class="grid" role="list">
     <li class="card">
       <div class="card-header"><h2>Atlas</h2></div>
@@ -269,3 +289,5 @@ Never generate these. Each line: wrong markup — why — what to use instead.
 - `<button class="btn btn-primary">Save</button>` — Another framework's names. Use .button and .button-primary.
 - `<span class="badge badge-red">Failed</span>` — Color-named variant. Use .badge and .badge-danger.
 - `<p class="mt-4">Saved.</p>` — Spacing utility. Use .stack on the parent.
+- `<a class="nav-link active" href="/team">Team</a>` — State class. Use .nav-link with aria-current="page".
+- `<button class="tabs-item" aria-pressed="true">Week</button>` — aria-pressed for a tab. Use .tabs-item with role="tab" and aria-selected="true".
Acceptance · round 1
Shipped
CINo checks
Automated reviewPass with concerns

The PR delivers token-only, CSS-only `.nav`/`.nav-link` and `.tabs`/`.tabs-item` components with ARIA-driven state, focus rings and wrapping tabs. It also adds AI contract entries naming one state attribute each and explicitly banning `aria-pressed`, docs, showcase demos, and solid static and browser checks. The implementation looks correct and closely follows the spec and repo conventions. However, no CI ran, so passing tests and the contract sync check are unverified, and the showcase's character count is now stale next to the updated token figure.

Acceptance criteria · 4 of 8 met
  • YES`.nav-link[aria-current="page"]` is styled distinctly from default and hover, and no state classes existIn `src/components.css`, `.nav-link[aria-current="page"]` sets a color-mix primary tint background and `--color-primary` text, and comes after `:hover` at equal specificity so it wins. No state classes are added, and the class whitelist test rejects `.tabs-item-selected`.
  • YES`.tabs-item[aria-selected="true"]` is styled distinctly, and `aria-selected="false"` items use the default styleThe selected rule sets `--color-surface-elevated`, `--border-color` and `--shadow-sm`. There is no `[aria-selected="false"]` rule, and the browser check asserts that unselected items all look the same.
  • PARTIAL`.nav` and `.tabs` render correctly inside `.stack` (vertical) and `.cluster` (horizontal)`.nav` sets no display, and `.tabs` uses `fit-content` with `max-inline-size: 100%`. The showcase demos both layouts and the browser script checks row counts, but that script is optional and did not run in CI.
  • YES`:focus-visible` on `.nav-link` and `.tabs-item` uses the `--focus-*` tokensBoth rules set `outline: var(--focus-width) solid var(--focus-color); outline-offset: var(--focus-offset)`, enforced by new `focusTargets` entries and tests.
  • PARTIAL`.tabs` does not cause horizontal overflow at 320px`.tabs` has `flex-wrap: wrap` and `max-inline-size: 100%`, the static check requires `flex-wrap: wrap`, and a 320px browser test with a long-tabs clone exists but was not run.
  • YESAll colors, radii and spacing come from existing tokens, with no hard-coded valuesThe new CSS uses only `var(--…)` tokens, `color-mix` of two tokens, `transparent`, `0` and `1em` icon sizing, and the existing token-only checker covers `components.css`.
  • PARTIALBoth Intent Mapping rows are in `synthcss.llm.md`, the components are in `synthcss.ai.json`, and the sync check passesThe rows and the JSON `nav`/`tabs` entries and `intentMap` entries are added verbatim, with a new `STATE_ATTRIBUTES` check, but the sync check was not run because there was no CI.
  • UNCLEARExisting tests, lint and build pass, and new tests are added in the existing styleNew cases in `check-components.test.mjs` and `verify-ai-contract.test.mjs` follow the existing `withCss`/`assertError` style, but no CI ran to confirm that anything passes.
Concerns
  • No CI checks ran on this commit, so nothing confirms that the existing tests, lint, build and the contract sync check (`verify-ai-contract.mjs`) actually pass; the size-tolerance and row-count checks are especially easy to trip.
  • The showcase badge was changed to '≈ 3,490 tokens', but the adjacent sentence 'about 11,300 characters' was left unchanged. At characters ÷ 4 the new figure implies roughly 14,000 characters, so the page now contradicts itself.
  • An unrelated edit in `docs/components.md`: the existing 'Invalid input' row was merged into the 'Labeled input' row, apparently to stay under `MAX_REFERENCE_ROWS = 15`. It is reasonable, but it changes existing documentation without being called out.
  • The 320px no-overflow check and the stack/cluster layout checks live only in the optional Playwright script (`check-components-browser.mjs`), which is not part of `npm test`. Those criteria are verified only if someone runs it manually.
  • The spec asks for the proposer's markup examples in the docs. The diff cannot show whether the examples used are the proposer's or new ones written by the builder.
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.17, counted as builder cost.

Discussion · 0

No comments yet.