ShippedLarge

Add versioned AI contract (synthcss.llm.md + synthcss.ai.json) with sync verification and showcase section

Proposed by Jonathan Miller 51 minutes agoFunding opened 51 minutes agoFunded 51 minutes agoShipped 17 minutes ago
Specification

Motivation

AI agents should be able to learn SynthCSS's full public vocabulary from one compact file in a single context window. They should not need to crawl the docs or read the source.

Scope

1. synthcss.ai.json (repo root)

A structured contract and the canonical machine-readable source.

Top-level keys:

  • synthcssVersion: must equal the version in package.json.
  • contractVersion
  • tokens: name → short purpose.
  • layouts: class → intent.
  • components: class → { intent, parts, variants }.
  • intentMap: list of { intent, use }.
  • compositionRules: { recommended[], avoid[] }.
  • generationRules[]
  • examples: { valid[], invalid[] }, each with { html, note }.

The schema must be documented in a short section of the README, or in docs/ai-contract.md if a docs folder exists.

2. synthcss.llm.md (repo root)

A prompt-ready, terse reference with one line per item, e.g. .stack — vertical flow with tokenized spacing. Sections:

  • Version header
  • Design Tokens
  • Layout Vocabulary
  • Component Vocabulary (with variants)
  • Intent Mapping table
  • Composition Rules (Recommended / Avoid)
  • AI Generation Rules: the 10 rules from the proposal
  • Valid Examples: 2–4 short snippets showing composition
  • Invalid / Discouraged Examples: at least an invented class such as flex-row-gap-large-center and an inline-style flex/gap case

The file must cover exactly the tokens, layouts and components that exist in the current CSS. Do not invent any.

Assumption: both files are shipped. The Markdown file is hand-written unless the maintainer answers otherwise.

3. Verification script

Add a script such as scripts/verify-ai-contract.*, using the repo's existing language and test runner, exposed as an npm script or test. Add it to CI if CI exists.

It fails when:

  • A class in the JSON or Markdown contract is not present as a selector in the compiled/source CSS.
  • A public layout or component class in the CSS is missing from the JSON contract. Assumption: public means every class selector in the framework CSS. Internal helpers are excluded via an explicit allowlist in the script.
  • A token name in the contract is not defined as a custom property in :root, or a :root token is missing from the contract.
  • A class used in an invalid example is a real SynthCSS class. Only classes that the example's note marks as the correct alternative are exempt.
  • The set of class and token names in the Markdown file differs from the set in the JSON file.
  • synthcssVersion does not match package.json.

The script also prints the approximate size of synthcss.llm.md in characters and estimated tokens (chars ÷ 4). It emits a warning, not a failure, when the size exceeds a configurable threshold (default 8,000 tokens).

4. Showcase

Add an "AI Contract" section to the existing showcase. It must:

  • Briefly explain the contract.
  • Link to or display synthcss.llm.md.
  • Provide a download link for synthcss.ai.json.
  • Show the approximate token size.
  • Show one static example: a natural-language prompt alongside the HTML generated from the contract, rendered with SynthCSS.

The existing Pages deploy workflow must copy both contract files into the published site.

Acceptance Criteria

  • synthcss.ai.json and synthcss.llm.md exist at the repo root and state the SynthCSS version.
  • Every current token, layout primitive, component class and variant appears in both files.
  • The Markdown file contains every section listed above, including the intent table and the 10 generation rules.
  • The verification script passes on the final tree.
  • Each of these temporary changes makes the script fail (demonstrated by its tests or fixtures):
    • adding a fake class to the contract
    • deleting a class from the contract
    • renaming a token
    • using a real class in an invalid example
  • The script prints the size estimate. The Markdown contract is under the threshold.
  • The script runs in the existing test/CI command.
  • The showcase has an AI Contract section with the view link, download link, size and example. The deploy publishes both files.
  • The README mentions the contract and that public API changes must update it in the same PR.

Out of Scope

  • Changing any CSS classes or tokens.
  • Live LLM calls in the showcase.
  • Publishing to npm or other registries beyond what already exists.
  • A real tokenizer dependency (unless the maintainer requests it).
Build #5
SUCCEEDED

Model claude-opus-5-5 · ceiling $8 · started 51 minutes ago · finished 41 minutes ago

39agent turns
3.9Mtokens
$2.55spent at list price

Builder summary
## Summary This adds a versioned AI contract: one file an AI agent can read in a single context window to learn every SynthCSS class and token. A dependency-free script checks it against the CSS, and the showcase and Pages deploy now publish it. No CSS classes or tokens were changed. ### New files - **`synthcss.ai.json`**: the canonical contract. It has `synthcssVersion` (0.1.0, the same as package.json), `contractVersion` (1.0.0), `tokens` (all 57 `:root` tokens), `layouts` (8 primitives, 10 `-sm`/`-lg` variants and `cover-main`), `components` (the 8 components as `{ intent, parts, variants }`), `intentMap` (18 rows), `compositionRules` (5 recommended, 5 avoid), `generationRules` (10) and `examples` (3 valid, 5 invalid). - `parts` and `variants` map each class to a short purpose, for example `"button-danger": "destructive action"`, instead of being plain lists. - Class names are written without the leading dot. - **`synthcss.llm.md`**: the hand-written, prompt-ready version, one line per item. It has the version header and every section the spec lists, including the intent table and the 10 rules. Its invalid examples include `flex-row-gap-large-center` and an inline `display:flex; gap:16px`. Size: 11,316 characters, about 2,829 tokens, well under the 8,000 limit. - **`docs/ai-contract.md`**: the schema, the rules for writing examples, and what the script checks. - **`scripts/verify-ai-contract.mjs`** and **`scripts/verify-ai-contract.test.mjs`**: the verifier and its 11 tests. ### What the verifier checks It fails when: - a class in either file is not a selector in the full CSS (`src/synthcss.css` with its imports inlined); - a class in the CSS is missing from the JSON. `INTERNAL_CLASSES` is the allowlist for internal helpers; it is empty because every current class is public; - a token in the contract is not on `:root`, or a `:root` token is missing from the contract; - the classes in the Markdown vocabulary sections, or the tokens in its Design Tokens section, differ from the JSON; - an invalid example uses a real class that its note doesn't name as the alternative. The note must say "Use …" followed by the alternative, and that alternative must be a real class; - `synthcssVersion`, the Markdown header or the Markdown CDN link doesn't match package.json, or the contract versions differ between the two files; - a Markdown section is missing; the intent table, composition rules or examples differ from the JSON; or there aren't exactly 10 rules; - the showcase, `pages.yml` or the README stop publishing or mentioning the contract. It always prints the size (characters, and tokens estimated as characters ÷ 4). Above the threshold it prints a warning and still passes. The threshold defaults to 8,000 and can be changed with `--max-tokens=N` or `SYNTHCSS_AI_CONTRACT_MAX_TOKENS`. ### Changed files - `package.json`: new `check:ai-contract` script; the check and its tests are added to `npm test`. The Pages and Release workflows already run `npm test`, so it runs in CI. - `showcase/index.html`: new `#ai-contract` section with a nav link. It has a short explanation, a "View synthcss.llm.md" link, a "Download synthcss.ai.json" link (with the `download` attribute), a GitHub link, the size (≈ 2,830 tokens), and a prompt shown next to the generated HTML, rendered live with a copyable snippet. The snippet must be one of the contract's valid examples. - `scripts/check-showcase.mjs`: `ai-contract` added to the required sections. - `.github/workflows/pages.yml`: copies both files into `_site/` and adds them to the push paths. - `README.md`: new "AI contract" section, which says public API changes must update both files in the same PR. - `docs/releasing.md`: a version bump must also update the contract's version fields. - `showcase/README.md`: describes the deploy and checks for the new section. ### How it was tested - `npm test`: all checks pass and all 54 tests pass. The tests show the script failing for each change the spec lists: a

Agent log
This adds a versioned AI contract: one file an AI agent can read in a single context window to learn every SynthCSS class and token. A dependency-free script checks it against the CSS, and the showcase and Pages deploy now publish it. No CSS classes or tokens were changed.

### New files
- **`synthcss.ai.json`**: the canonical contract. It has `synthcssVersion` (0.1.0, the same as package.json), `contractVersion` (1.0.0), `tokens` (all 57 `:root` tokens), `layouts` (8 primitives, 10 `-sm`/`-lg` variants and `cover-main`), `components` (the 8 components as `{ intent, parts, variants }`), `intentMap` (18 rows), `compositionRules` (5 recommended, 5 avoid), `generationRules` (10) and `examples` (3 valid, 5 invalid).
  - `parts` and `variants` map each class to a short purpose, for example `"button-danger": "destructive action"`, instead of being plain lists.
  - Class names are written without the leading dot.
- **`synthcss.llm.md`**: the hand-written, prompt-ready version, one line per item. It has the version header and every section the spec lists, including the intent table and the 10 rules. Its invalid examples include `flex-row-gap-large-center` and an inline `display:flex; gap:16px`. Size: 11,316 characters, about 2,829 tokens, well under the 8,000 limit.
- **`docs/ai-contract.md`**: the schema, the rules for writing examples, and what the script checks.
- **`scripts/verify-ai-contract.mjs`** and **`scripts/verify-ai-contract.test.mjs`**: the verifier and its 11 tests.

### What the verifier checks
It fails when:
- a class in either file is not a selector in the full CSS (`src/synthcss.css` with its imports inlined);
- a class in the CSS is missing from the JSON. `INTERNAL_CLASSES` is the allowlist for internal helpers; it is empty because every current class is public;
- a token in the contract is not on `:root`, or a `:root` token is missing from the contract;
- the classes in the Markdown vocabulary sections, or the tokens in its Design Tokens section, differ from the JSON;
- an invalid example uses a real class that its note doesn't name as the alternative. The note must say "Use …" followed by the alternative, and that alternative must be a real class;
- `synthcssVersion`, the Markdown header or the Markdown CDN link doesn't match package.json, or the contract versions differ between the two files;
- a Markdown section is missing; the intent table, composition rules or examples differ from the JSON; or there aren't exactly 10 rules;
- the showcase, `pages.yml` or the README stop publishing or mentioning the contract.

It always prints the size (characters, and tokens estimated as characters ÷ 4). Above the threshold it prints a warning and still passes. The threshold defaults to 8,000 and can be changed with `--max-tokens=N` or `SYNTHCSS_AI_CONTRACT_MAX_TOKENS`.

### Changed files
- `package.json`: new `check:ai-contract` script; the check and its tests are added to `npm test`. The Pages and Release workflows already run `npm test`, so it runs in CI.
- `showcase/index.html`: new `#ai-contract` section with a nav link. It has a short explanation, a "View synthcss.llm.md" link, a "Download synthcss.ai.json" link (with the `download` attribute), a GitHub link, the size (≈ 2,830 tokens), and a prompt shown next to the generated HTML, rendered live with a copyable snippet. The snippet must be one of the contract's valid examples.
- `scripts/check-showcase.mjs`: `ai-contract` added to the required sections.
- `.github/workflows/pages.yml`: copies both files into `_site/` and adds them to the push paths.
- `README.md`: new "AI contract" section, which says public API changes must update both files in the same PR.
- `docs/releasing.md`: a version bump must also update the contract's version fields.
- `showcase/README.md`: describes the deploy and checks for the new section.

### How it was tested
- `npm test`: all checks pass and all 54 tests pass. The tests show the script failing for each change the spec lists: a fake class added (JSON or Markdown), a class deleted, a token renamed (in the contract or the CSS), a real class in an invalid example, and a version mismatch. They also cover the warning path.
- I made one change on disk to confirm the command fails for real: renaming `--grid-min` in the JSON made `npm run check:ai-contract` exit 1. I then restored the file.
- `check-showcase-browser.mjs` (Playwright, using a global install): no horizontal overflow at 375px or 1280px. Its only failures are console errors from the external nabled.ai badge image, which the sandbox network blocks (certificate error); that badge was already on the page before this change.
- I looked at screenshots of the new section at both widths.

### For the reviewer
- **The 10 generation rules are my own wording.** The proposal they were supposed to come from wasn't available, so I wrote them from the existing AI guidance in docs/tokens.md, layout.md and components.md. Please compare them with the proposal.
- **The showcase size is hard-coded.** The tests fail if it drifts more than 10% from the real estimate, so it needs a manual update when the Markdown file changes a lot.
- **Viewing the `.md` file on Pages depends on the browser:** Pages may serve it as `text/markdown`, which some browsers download instead of showing. That's why the section also has a "Read it on GitHub" link.
- **Version bumps now touch the contract too:** `npm test` fails until `synthcssVersion` and the Markdown header/CDN link are updated in the same PR. This is documented in docs/releasing.md.
2026-10-03T11:18:02Z  [claude] I've built the AI contract and pushed it to `nabled/feature-5-job-5`; nabled will open the pull request. `npm test` passes with the new check added, including 11 new tests. No CSS classes or tokens were changed.

- **The two contract files:** `synthcss.ai.json` is the canonical source and `synthcss.llm.md` is the hand-written prompt version. Both cover all 57 tokens, every layout class and variant, and all 8 components with their parts and variants. Both state version 0.1.0. The Markdown file is about 2,829 tokens, well under the 8,000 limit.
- **The check:** `scripts/verify-ai-contract.mjs` fails on each change the spec lists: a fake class added, a class deleted, a token renamed, a real class in an invalid example, or a version that doesn't match `package.json`. It also fails when the two files disagree with each other. It prints the size every run and only warns, without failing, when the Markdown file is too big. It runs in `npm test`, which the existing Pages and Release workflows already run. I also confirmed on the real files that renaming one token makes it exit with an error.
- **Showcase:** the new AI Contract section has a link to view the Markdown file and one to download the JSON file. It also shows the size and a prompt next to the generated HTML, rendered live. I checked it with screenshots at phone and desktop widths and it doesn't overflow. The Pages deploy now copies both files into the site.
- **Docs:** the schema is in `docs/ai-contract.md`. The README now says any public API change must update both files in the same PR.

Things to review:
- **The 10 generation rules are my own wording.** The proposal they were meant to come from wasn't available, so I based them on the AI guidance already in the docs. Please compare them with the proposal.
- **Version bumps now touch the contract.** `npm test` fails until the version in both contract files is updated in the same PR. I added this to `docs/releasing.md`.
- **The size shown on the showcase is typed in.** The tests fail if it is more than 10% off, so it needs updating by hand when the Markdown file changes a lot.
- **The "View synthcss.llm.md" link may download the file instead of showing it**, depending on the browser. I added a "Read it on GitHub" link as a fallback.
- **The showcase browser check reports two failures that aren't from this change.** The sandbox network blocked the external nabled.ai badge image, which was already on the page.

Files are in `/mnt/session/outputs`:
- synthcss.llm.md
- synthcss.ai.json
- ai-contract.md
- ai-contract-1280.png
- ai-contract-375.png
2026-10-03T11:18:03Z  Changed files:
modified  .github/workflows/pages.yml  +5 -1
modified  README.md  +7 -1
added  docs/ai-contract.md  +94 -0
modified  docs/releasing.md  +4 -0
modified  package.json  +2 -1
modified  scripts/check-showcase.mjs  +1 -1
added  scripts/verify-ai-contract.mjs  +441 -0
added  scripts/verify-ai-contract.test.mjs  +132 -0
modified  showcase/README.md  +11 -2
modified  showcase/index.html  +71 -0
added  synthcss.ai.json  +247 -0
added  synthcss.llm.md  +252 -0
2026-10-03T11:18:05Z  Opened pull request https://github.com/nabledhq/synthcss/pull/8
2026-10-03T11:18:05Z  Finished: success=true turns=39 tokens(in/out)=3792892/57425 list cost=$2.55

Show patch
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 7e05dec..432d416 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -1,4 +1,4 @@
-# Publishes the static showcase (showcase/) to GitHub Pages.
+# Publishes the static showcase (showcase/) and the AI contract files to GitHub Pages.
 # One-time setup after merge: Settings → Pages → Source → "GitHub Actions".
 # The artifact mirrors the repository layout (showcase/ next to src/) so the
 # relative stylesheet link in showcase/index.html works locally and deployed.
@@ -10,6 +10,8 @@ on:
     paths:
       - "showcase/**"
       - "src/**.css"
+      - "synthcss.llm.md"
+      - "synthcss.ai.json"
       - ".github/workflows/pages.yml"
   workflow_dispatch:
 
@@ -46,6 +48,8 @@ jobs:
           mkdir -p _site/showcase _site/src
           cp showcase/index.html showcase/showcase.css showcase/showcase.js _site/showcase/
           cp src/*.css _site/src/
+          # The AI contract, linked from the showcase as ../synthcss.llm.md and ../synthcss.ai.json.
+          cp synthcss.llm.md synthcss.ai.json _site/
           # Cache busting: Pages lets browsers cache files for 10 minutes, so add the
           # commit to every local stylesheet, script and @import URL. Each deploy then
           # loads fresh files. Snippets on the page are HTML-escaped and not matched.
diff --git a/README.md b/README.md
index 5aeeec0..52dd3ba 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,12 @@ Eight semantic components in [`src/components.css`](src/components.css), include
 
 See [docs/components.md](docs/components.md) for each component's variants, composition with the layout primitives and accessibility notes, plus a compact "AI Component Reference" table to give to a model.
 
+## 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).
+
+**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.
+
 ## Showcase
 
-[`showcase/`](showcase/) is a static page built with SynthCSS that shows the tokens, layout primitives and components live, plus a composed settings screen, with copyable snippets and width-adjustable demos. Open `showcase/index.html` in a browser to preview it. It is published to GitHub Pages by [`.github/workflows/pages.yml`](.github/workflows/pages.yml). See [showcase/README.md](showcase/README.md) for local preview, deployment, the one-time Pages setting and how to add a section.
+[`showcase/`](showcase/) is a static page built with SynthCSS that shows the tokens, layout primitives and components live, plus a composed settings screen and the AI contract, with copyable snippets and width-adjustable demos. Open `showcase/index.html` in a browser to preview it. It is published to GitHub Pages by [`.github/workflows/pages.yml`](.github/workflows/pages.yml). See [showcase/README.md](showcase/README.md) for local preview, deployment, the one-time Pages setting and how to add a section.
diff --git a/docs/ai-contract.md b/docs/ai-contract.md
new file mode 100644
index 0000000..ef420fd
--- /dev/null
+++ b/docs/ai-contract.md
@@ -0,0 +1,94 @@
+# AI contract
+
+SynthCSS ships its full public vocabulary as an AI contract in two files at the
+repository root. An agent can learn every class and token from one file in one context
+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. |
+
+Both are published next to the showcase on GitHub Pages
+(`<site>/synthcss.llm.md`, `<site>/synthcss.ai.json`) and are in every release tag.
+
+## `synthcss.ai.json` schema
+
+Class names are written **without** the leading dot. Token names keep their `--`.
+
+| Key | Type | Contents |
+| --- | --- | --- |
+| `synthcssVersion` | string | The SynthCSS version the contract describes. Must equal `version` in `package.json`. |
+| `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`. |
+| `layouts` | object | Layout class → intent: the eight primitives, their `-sm` / `-lg` gap variants and `cover-main`. |
+| `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. |
+| `generationRules` | array | Exactly 10 rules an agent must follow when generating SynthCSS markup. |
+| `examples` | object | `{ valid: [], invalid: [] }`, each item `{ html, note }`. 2–4 valid examples; invalid ones show what not to generate. |
+
+```json
+{
+  "synthcssVersion": "0.1.0",
+  "contractVersion": "1.0.0",
+  "tokens": { "--space-4": "1rem spacing step (default gap)" },
+  "layouts": { "stack": "vertical flow with tokenized spacing" },
+  "components": {
+    "badge": { "intent": "short status label", "parts": {}, "variants": { "badge-success": "positive status" } }
+  },
+  "intentMap": [{ "intent": "Vertical list of blocks", "use": ".stack" }],
+  "compositionRules": { "recommended": ["…"], "avoid": ["…"] },
+  "generationRules": ["Use only the classes and tokens in this contract; never invent class names.", "…"],
+  "examples": {
+    "valid": [{ "html": "<ul class=\"cluster\" role=\"list\">…</ul>", "note": "…" }],
+    "invalid": [{ "html": "<div class=\"flex-row-gap-large-center\">…</div>", "note": "Invented class. Use .cluster-lg." }]
+  }
+}
+```
+
+In free text (intents, rules, notes) classes are written as `.name` and tokens as
+`--name`; the verifier reads those mentions too, so every one must exist.
+
+An invalid example's `note` says why the markup is wrong and then names the correct
+alternative after the word "Use". An invalid example may only contain a real SynthCSS
+class if its note names that class as the alternative (for example
+`badge badge-red` with "Use .badge and .badge-danger").
+
+## `synthcss.llm.md` layout
+
+A version header (`SynthCSS <version> · contract <version>`), then these `##` sections:
+Design Tokens, Layout Vocabulary, Component Vocabulary, Intent Mapping, Composition
+Rules (with `### Recommended` and `### Avoid`), AI Generation Rules (numbered 1–10),
+Valid Examples (one `html` code block each) and Invalid / Discouraged Examples (one
+line each: `` - `<html>` — note ``). It is written by hand; keep it in step with the
+JSON.
+
+## Changing the contract
+
+Any change to the public API (a class or token added, renamed or removed, or a version
+bump in `package.json`) must update **both** files in the same pull request.
+
+`npm test` runs [`scripts/verify-ai-contract.mjs`](../scripts/verify-ai-contract.mjs)
+(also `npm run check:ai-contract`). It needs only Node.js and fails when:
+
+- a class in either file is not a selector in the built CSS (`src/synthcss.css` with its
+  imports inlined), or a token is not defined on `:root`;
+- a class in the CSS is missing from the JSON `layouts` / `components`, or a `:root`
+  token is missing from `tokens`. Internal helper classes can be excluded through the
+  `INTERNAL_CLASSES` allowlist in the script (empty today: every class is public);
+- the classes in the Markdown Layout and Component Vocabulary, or the tokens in its
+  Design Tokens section, differ from the JSON;
+- an invalid example uses a real class that its note does not name as the alternative;
+- `synthcssVersion` or the Markdown header does not match `package.json`, or the
+  Markdown header's contract version does not match `contractVersion`;
+- a Markdown section is missing, the intent table, composition rules or valid and
+  invalid examples differ from the JSON, or there are not exactly 10 generation rules;
+- the showcase AI Contract section, the Pages workflow or the README no longer publish
+  and describe the contract.
+
+It also prints the size of `synthcss.llm.md` in characters and estimated tokens
+(characters ÷ 4, no tokenizer) and warns, without failing, above 8,000 tokens. Set
+another threshold with `node scripts/verify-ai-contract.mjs --max-tokens=6000` or the
+`SYNTHCSS_AI_CONTRACT_MAX_TOKENS` environment variable. The showcase prints the
+estimate too; update it when the file grows or shrinks by more than 10%.
diff --git a/docs/releasing.md b/docs/releasing.md
index 6b87138..48c94b7 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -49,6 +49,10 @@ From 1.0.0, breaking changes need a major bump.
    npm version minor --no-git-tag-version   # or: patch, major, or an exact 0.2.0
    ```
 
+   In the same change, set `synthcssVersion` in `synthcss.ai.json` and the version in
+   the header (and CDN link) of `synthcss.llm.md` to the new version. `npm test` fails
+   until they match (see [ai-contract.md](ai-contract.md)).
+
 2. Open a pull request and merge it into `main`.
 3. On merge, the **Release** workflow runs `npm test`, builds `dist/`, pushes the
    `vX.Y.Z` tag and creates the GitHub release. If the tag already exists it does
diff --git a/package.json b/package.json
index 89615cb..84a1a04 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
     "check:components:browser": "node scripts/check-components-browser.mjs",
     "check:showcase": "node scripts/check-showcase.mjs",
     "check:showcase:browser": "node scripts/check-showcase-browser.mjs",
-    "test": "node scripts/check-tokens.mjs && node scripts/check-layout.mjs && node scripts/check-components.mjs && node scripts/check-showcase.mjs && node --test scripts/build.test.mjs scripts/check-tokens.test.mjs scripts/check-layout.test.mjs scripts/check-components.test.mjs scripts/check-showcase.test.mjs"
+    "check:ai-contract": "node scripts/verify-ai-contract.mjs",
+    "test": "node scripts/check-tokens.mjs && node scripts/check-layout.mjs && node scripts/check-components.mjs && node scripts/check-showcase.mjs && node scripts/verify-ai-contract.mjs && node --test scripts/build.test.mjs scripts/check-tokens.test.mjs scripts/check-layout.test.mjs scripts/check-components.test.mjs scripts/check-showcase.test.mjs scripts/verify-ai-contract.test.mjs"
   }
 }
diff --git a/scripts/check-showcase.mjs b/scripts/check-showcase.mjs
index dce84b9..4fd649b 100644
--- a/scripts/check-showcase.mjs
+++ b/scripts/check-showcase.mjs
@@ -10,7 +10,7 @@ import { parseBlocks, parseDeclarations, parseTokens } from "./check-tokens.mjs"
 import { PRIMITIVES, VARIANTS, HELPER_CLASSES } from "./check-layout.mjs";
 import { COMPONENTS, COMPONENT_CLASSES } from "./check-components.mjs";
 
-export const SECTIONS = ["hero", "why", "tokens", "layouts", "responsive", "components", "composed", "ai-examples"];
+export const SECTIONS = ["hero", "why", "tokens", "layouts", "responsive", "components", "composed", "ai-examples", "ai-contract"];
 // The composed interface must use at least this many different components.
 export const MIN_COMPOSED_COMPONENTS = 6;
 export const RESPONSIVE = ["grid", "sidebar", "cluster", "split"];
diff --git a/scripts/verify-ai-contract.mjs b/scripts/verify-ai-contract.mjs
new file mode 100644
index 0000000..72476a1
--- /dev/null
+++ b/scripts/verify-ai-contract.mjs
@@ -0,0 +1,441 @@
+#!/usr/bin/env node
+// Dependency-free verification of the AI contract: synthcss.ai.json (canonical,
+// machine-readable) and synthcss.llm.md (prompt-ready) against the framework CSS,
+// package.json, each other, the showcase and the Pages workflow.
+// Usage: node scripts/verify-ai-contract.mjs [--max-tokens=8000]
+//   The size threshold can also be set with SYNTHCSS_AI_CONTRACT_MAX_TOKENS.
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+import { parseTokens } from "./check-tokens.mjs";
+import { buildBundle, classesInCss } from "./check-components.mjs";
+
+export const DEFAULT_MAX_TOKENS = 8000;
+// Class selectors in the framework CSS that are internal helpers, not public API,
+// and so are not required in the contract. Empty today: every class SynthCSS ships
+// is public.
+export const INTERNAL_CLASSES = [];
+export const JSON_KEYS = [
+  "synthcssVersion",
+  "contractVersion",
+  "tokens",
+  "layouts",
+  "components",
+  "intentMap",
+  "compositionRules",
+  "generationRules",
+  "examples",
+];
+export const MD_SECTIONS = [
+  "Design Tokens",
+  "Layout Vocabulary",
+  "Component Vocabulary",
+  "Intent Mapping",
+  "Composition Rules",
+  "AI Generation Rules",
+  "Valid Examples",
+  "Invalid / Discouraged Examples",
+];
+const INVALID_SECTION = "Invalid / Discouraged Examples";
+export const GENERATION_RULES = 10;
+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;
+export const LLM_FILE = "synthcss.llm.md";
+export const JSON_FILE = "synthcss.ai.json";
+
+// `.name` not preceded by a word character, dot, slash or dash, so file names
+// (synthcss.ai.json), URLs and "e.g." are not read as classes.
+const CLASS_MENTION = /(?<![\w./-])\.([a-z][a-z0-9-]*)/g;
+const TOKEN_MENTION = /(?<![\w-])(--[a-z][a-z0-9-]*)/g;
+const CLASS_ATTR = /\bclass="([^"]*)"/g;
+
+const sorted = (set) => [...set].sort();
+const diff = (a, b) => sorted(new Set([...a].filter((x) => !b.has(x))));
+const unescapeHtml = (s) =>
+  s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
+
+export function classAttrs(html) {
+  return new Set([...html.matchAll(CLASS_ATTR)].flatMap((m) => m[1].split(/\s+/).filter(Boolean)));
+}
+
+// Every class and token named in a piece of text: `.class` mentions, class="…"
+// attributes and `--token` mentions.
+export function mentions(text) {
+  const classes = classAttrs(text);
+  for (const m of text.matchAll(CLASS_MENTION)) classes.add(m[1]);
+  const tokens = new Set([...text.matchAll(TOKEN_MENTION)].map((m) => m[1]));
+  return { classes, tokens };
+}
+
+export const estimateTokens = (text) => Math.ceil([...text].length / 4);
+
+// Classes the note of an invalid example names as the correct alternative: the
+// ones mentioned after "Use".
+function alternativesOf(note) {
+  const i = note.search(/\buse\b/i);
+  return i === -1 ? null : mentions(note.slice(i)).classes;
+}
+
+function checkInvalidExample(where, html, note, cssClasses, cssTokens) {
+  const errors = [];
+  const alternatives = alternativesOf(note);
+  if (!alternatives) {
+    errors.push(`${where}: the note must name the correct alternative ("Use …")`);
+    return errors;
+  }
+  for (const cls of sorted(alternatives)) {
+    if (!cssClasses.has(cls)) errors.push(`${where}: the suggested alternative .${cls} is not a SynthCSS class`);
+  }
+  for (const token of sorted(mentions(note).tokens)) {
+    if (!cssTokens.has(token)) errors.push(`${where}: the note mentions ${token}, which is not a SynthCSS token`);
+  }
+  for (const cls of sorted(classAttrs(html))) {
+    if (cssClasses.has(cls) && !alternatives.has(cls)) {
+      errors.push(`${where}: uses .${cls}, a real SynthCSS class; invalid examples may only use real classes the note names as the alternative`);
+    }
+  }
+  return errors;
+}
+
+// Splits Markdown into its preamble and "## " sections (title → body text).
+export function parseMarkdown(md) {
+  const lines = md.split("\n");
+  const sections = new Map();
+  let title = null;
+  const preamble = [];
+  for (const line of lines) {
+    const m = /^##\s+(.+?)\s*$/.exec(line);
+    if (m && !line.startsWith("###")) {
+      title = m[1];
+      sections.set(title, []);
+    } else if (title === null) preamble.push(line);
+    else sections.get(title).push(line);
+  }
+  return { preamble: preamble.join("\n"), sections: new Map([...sections].map(([t, l]) => [t, l.join("\n")])) };
+}
+
+function subsection(body, title) {
+  const lines = body.split("\n");
+  const start = lines.findIndex((l) => new RegExp(`^###\\s+${title}\\s*$`, "i").test(l));
+  if (start === -1) return null;
+  let end = lines.findIndex((l, i) => i > start && /^###\s/.test(l));
+  if (end === -1) end = lines.length;
+  return lines.slice(start + 1, end);
+}
+
+const bullets = (lines) => lines.filter((l) => /^- \S/.test(l));
+
+// Every string in the JSON contract except the invalid examples.
+function jsonStrings(contract) {
+  const out = [];
+  const walk = (value, path) => {
+    if (path === "examples.invalid") return;
+    if (typeof value === "string") out.push(value);
+    else if (Array.isArray(value)) value.forEach((v) => walk(v, path));
+    else if (value && typeof value === "object") {
+      for (const [k, v] of Object.entries(value)) {
+        out.push(k);
+        walk(v, path ? `${path}.${k}` : k);
+      }
+    }
+  };
+  walk(contract, "");
+  return out;
+}
+
+const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
+const isExample = (e) => isObject(e) && typeof e.html === "string" && typeof e.note === "string";
+
+function checkJsonShape(c) {
+  const errors = [];
+  for (const key of JSON_KEYS) if (!(key in c)) errors.push(`${JSON_FILE}: missing top-level key "${key}"`);
+  for (const key of Object.keys(c)) if (!JSON_KEYS.includes(key)) errors.push(`${JSON_FILE}: unexpected top-level key "${key}"`);
+  if (errors.length) return errors;
+  if (typeof c.contractVersion !== "string" || !/^\d+\.\d+\.\d+$/.test(c.contractVersion)) {
+    errors.push(`${JSON_FILE}: contractVersion must be a semver string such as "1.0.0"`);
+  }
+  for (const key of ["tokens", "layouts"]) {
+    if (!isObject(c[key]) || !Object.values(c[key]).every((v) => typeof v === "string" && v.trim())) {
+      errors.push(`${JSON_FILE}: ${key} must map each name to a short purpose string`);
+    }
+  }
+  if (!isObject(c.components)) errors.push(`${JSON_FILE}: components must be an object`);
+  else {
+    for (const [name, comp] of Object.entries(c.components)) {
+      if (!isObject(comp) || typeof comp.intent !== "string" || !isObject(comp.parts) || !isObject(comp.variants)) {
+        errors.push(`${JSON_FILE}: components.${name} must be { intent, parts: {class: purpose}, variants: {class: purpose} }`);
+      }
+    }
+  }
+  if (!Array.isArray(c.intentMap) || !c.intentMap.every((e) => isObject(e) && typeof e.intent === "string" && typeof e.use === "string")) {
+    errors.push(`${JSON_FILE}: intentMap must be a list of { intent, use }`);
+  }
+  const cr = c.compositionRules;
+  if (!isObject(cr) || !Array.isArray(cr.recommended) || !Array.isArray(cr.avoid)) {
+    errors.push(`${JSON_FILE}: compositionRules must be { recommended: [], avoid: [] }`);
+  }
+  if (!Array.isArray(c.generationRules) || c.generationRules.length !== GENERATION_RULES) {
+    errors.push(`${JSON_FILE}: generationRules must list exactly ${GENERATION_RULES} rules`);
+  }
+  const ex = c.examples;
+  if (!isObject(ex) || !Array.isArray(ex.valid) || !Array.isArray(ex.invalid) || ![...ex.valid, ...ex.invalid].every(isExample)) {
+    errors.push(`${JSON_FILE}: examples must be { valid: [{ html, note }], invalid: [{ html, note }] }`);
+  } else {
+    const [min, max] = VALID_EXAMPLES;
+    if (ex.valid.length < min || ex.valid.length > max) errors.push(`${JSON_FILE}: needs ${min}–${max} valid examples, found ${ex.valid.length}`);
+    if (ex.invalid.length < 2) errors.push(`${JSON_FILE}: needs at least 2 invalid examples`);
+  }
+  return errors;
+}
+
+// The class names the JSON contract defines: layouts plus components with their
+// parts and variants.
+export function jsonVocabulary(c) {
+  const classes = [...Object.keys(c.layouts)];
+  for (const [name, comp] of Object.entries(c.components)) {
+    classes.push(name, ...Object.keys(comp.parts), ...Object.keys(comp.variants));
+  }
+  return classes;
+}
+
+export function verifyContract(files, { maxTokens = DEFAULT_MAX_TOKENS } = {}) {
+  const errors = [];
+  const warnings = [];
+  const md = files.md ?? "";
+  const size = { chars: [...md].length, tokens: estimateTokens(md) };
+  if (size.tokens > maxTokens) {
+    warnings.push(`${LLM_FILE} is ~${size.tokens} tokens, above the ${maxTokens}-token threshold; keep it compact`);
+  }
+
+  let contract;
+  try {
+    contract = JSON.parse(files.json);
+  } catch (err) {
+    return { errors: [`${JSON_FILE} is not valid JSON: ${err.message}`], warnings, size };
+  }
+  if (!isObject(contract)) return { errors: [`${JSON_FILE} must contain a JSON object`], warnings, size };
+  const shape = checkJsonShape(contract);
+  if (shape.length) return { errors: shape, warnings, size };
+
+  const cssClasses = classesInCss(files.builtCss);
+  const cssTokens = new Set(parseTokens(files.builtCss).root.keys());
+  const publicClasses = new Set([...cssClasses].filter((c) => !INTERNAL_CLASSES.includes(c)));
+  const { version } = JSON.parse(files.pkg);
+
+  // Versions.
+  if (contract.synthcssVersion !== version) {
+    errors.push(`${JSON_FILE}: synthcssVersion is "${contract.synthcssVersion}" but package.json is "${version}"`);
+  }
+
+  // JSON vocabulary against the CSS.
+  const vocabList = jsonVocabulary(contract);
+  const vocab = new Set(vocabList);
+  for (const cls of sorted(new Set(vocabList.filter((c, i) => vocabList.indexOf(c) !== i)))) {
+    errors.push(`${JSON_FILE}: class .${cls} is listed more than once`);
+  }
+  const jsonMentions = mentions(jsonStrings(contract).join("\n"));
+  for (const cls of sorted(new Set([...vocab, ...jsonMentions.classes]))) {
+    if (!cssClasses.has(cls)) errors.push(`${JSON_FILE}: class .${cls} is not a selector in the SynthCSS CSS`);
+  }
+  for (const cls of diff(publicClasses, vocab)) {
+    errors.push(`class .${cls} is in the SynthCSS CSS but missing from ${JSON_FILE} (add it to layouts or components, or to INTERNAL_CLASSES)`);
+  }
+  const jsonTokens = new Set(Object.keys(contract.tokens));
+  for (const token of sorted(new Set([...jsonTokens, ...jsonMentions.tokens]))) {
+    if (!cssTokens.has(token)) errors.push(`${JSON_FILE}: token ${token} is not defined on :root`);
+  }
+  for (const token of diff(cssTokens, jsonTokens)) errors.push(`token ${token} is defined on :root but missing from ${JSON_FILE}`);
+
+  contract.examples.valid.forEach((e, i) => {
+    for (const cls of sorted(classAttrs(e.html))) {
+      if (!cssClasses.has(cls)) errors.push(`${JSON_FILE}: valid example ${i + 1} uses .${cls}, which is not a SynthCSS class`);
+    }
+  });
+  contract.examples.invalid.forEach((e, i) => {
+    errors.push(...checkInvalidExample(`${JSON_FILE}: invalid example ${i + 1}`, e.html, e.note, cssClasses, cssTokens));
+  });
+
+  // Markdown.
+  const { preamble, sections } = parseMarkdown(md);
+  const header = /SynthCSS v?(\d+\.\d+\.\d+\S*)\s*·\s*contract v?(\d+\.\d+\.\d+)/.exec(preamble);
+  if (!header) errors.push(`${LLM_FILE}: the header must state "SynthCSS <version> · contract <version>"`);
+  else {
+    if (header[1] !== version) errors.push(`${LLM_FILE}: states SynthCSS ${header[1]} but package.json is ${version}`);
+    if (header[2] !== contract.contractVersion) {
+      errors.push(`${LLM_FILE}: states contract ${header[2]} but ${JSON_FILE} has ${contract.contractVersion}`);
+    }
+  }
+  for (const title of MD_SECTIONS) if (!sections.has(title)) errors.push(`${LLM_FILE}: missing section "## ${title}"`);
+  for (const m of md.matchAll(/synthcss@(\d+\.\d+\.\d+\S*?)\//g)) {
+    if (m[1] !== version) errors.push(`${LLM_FILE}: links to synthcss@${m[1]} but package.json is ${version}`);
+  }
+
+  const section = (title) => sections.get(title) ?? "";
+  const mdVocab = mentions(section("Layout Vocabulary") + "\n" + section("Component Vocabulary")).classes;
+  const mdTokens = mentions(section("Design Tokens")).tokens;
+  const mdAll = mentions([preamble, ...[...sections].filter(([t]) => t !== INVALID_SECTION).map(([, b]) => b)].join("\n"));
+  for (const cls of sorted(mdAll.classes)) {
+    if (!cssClasses.has(cls)) errors.push(`${LLM_FILE}: class .${cls} is not a selector in the SynthCSS CSS`);
+  }
+  for (const token of sorted(mdAll.tokens)) {
+    if (!cssTokens.has(token)) errors.push(`${LLM_FILE}: token ${token} is not defined on :root`);
+  }
+  for (const cls of diff(vocab, mdVocab)) errors.push(`class .${cls} is in ${JSON_FILE} but not in the ${LLM_FILE} vocabulary`);
+  for (const cls of diff(mdVocab, vocab)) errors.push(`class .${cls} is in the ${LLM_FILE} vocabulary but not in ${JSON_FILE}`);
+  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}`);
+
+  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}`);
+  }
+  for (const [title, key] of [["Recommended", "recommended"], ["Avoid", "avoid"]]) {
+    const lines = subsection(section("Composition Rules"), title);
+    if (!lines) errors.push(`${LLM_FILE}: Composition Rules has no "### ${title}"`);
+    else if (bullets(lines).length !== contract.compositionRules[key].length) {
+      errors.push(`${LLM_FILE}: Composition Rules / ${title} has ${bullets(lines).length} items, ${JSON_FILE} has ${contract.compositionRules[key].length}`);
+    }
+  }
+  const rules = section("AI Generation Rules").split("\n").filter((l) => /^\d+\.\s/.test(l));
+  if (rules.length !== GENERATION_RULES) {
+    errors.push(`${LLM_FILE}: AI Generation Rules must list exactly ${GENERATION_RULES} numbered rules, found ${rules.length}`);
+  }
+
+  const valid = [...section("Valid Examples").matchAll(/```html\n([\s\S]*?)```/g)].map((m) => m[1].trim());
+  const jsonValid = contract.examples.valid.map((e) => e.html.trim());
+  if (valid.join("\n\n") !== jsonValid.join("\n\n")) {
+    errors.push(`${LLM_FILE}: the Valid Examples html blocks must match ${JSON_FILE} examples.valid, in order`);
+  }
+
+  const invalid = [];
+  for (const line of section(INVALID_SECTION).split("\n").filter((l) => /^- /.test(l))) {
+    const m = /^-\s+`([^`]+)`\s+—\s+(.+)$/.exec(line);
+    if (!m) errors.push(`${LLM_FILE}: invalid example lines must read "- \`<html>\` — note", found: ${line}`);
+    else invalid.push({ html: m[1], note: m[2] });
+  }
+  invalid.forEach((e, i) => {
+    errors.push(...checkInvalidExample(`${LLM_FILE}: invalid example ${i + 1}`, e.html, e.note, cssClasses, cssTokens));
+  });
+  const mdInvalid = new Set(invalid.map((e) => e.html));
+  const jsonInvalid = new Set(contract.examples.invalid.map((e) => e.html));
+  for (const html of diff(jsonInvalid, mdInvalid)) errors.push(`${LLM_FILE}: missing the invalid example ${html} from ${JSON_FILE}`);
+  for (const html of diff(mdInvalid, jsonInvalid)) errors.push(`${LLM_FILE}: invalid example ${html} is not in ${JSON_FILE}`);
+  if (!invalid.some((e) => [...classAttrs(e.html)].some((c) => !cssClasses.has(c)))) {
+    errors.push(`${LLM_FILE}: needs an invalid example with an invented class`);
+  }
+  if (!invalid.some((e) => /style="[^"]*(display:\s*flex|gap:)/.test(e.html))) {
+    errors.push(`${LLM_FILE}: needs an invalid example with an inline-style flex/gap`);
+  }
+
+  errors.push(...checkPublishing(files, contract, size));
+  return { errors, warnings, size };
+}
+
+function sectionHtml(html, id) {
+  const start = html.search(new RegExp(`<section\\b[^>]*\\bid="${id}"`));
+  if (start === -1) return null;
+  const rest = html.slice(start + 1);
+  const end = rest.search(/<section\b[^>]*\bid="|<\/main>|<footer class="sc-footer"/);
+  return end === -1 ? rest : rest.slice(0, end);
+}
+
+const compactHtml = (html) => html.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
+
+// The showcase section, the Pages workflow and the docs that publish the contract.
+export function checkPublishing({ showcaseHtml = "", workflow = "", readme = "", schemaDoc = "" }, contract, size) {
+  const errors = [];
+  const section = sectionHtml(showcaseHtml, "ai-contract");
+  if (!section) errors.push('showcase/index.html: missing <section id="ai-contract">');
+  else {
+    if (!section.includes(`href="../${LLM_FILE}"`)) errors.push(`showcase AI Contract section must link to ../${LLM_FILE}`);
+    if (!new RegExp(`<a\\b[^>]*href="\\.\\./${JSON_FILE.replace(".", "\\.")}"[^>]*\\bdownload\\b`).test(section)) {
+      errors.push(`showcase AI Contract section must have a download link (<a href="../${JSON_FILE}" download>)`);
+    }
+    const shown = /≈\s*([\d,]+)\s*tokens/.exec(section);
+    if (!shown) errors.push('showcase AI Contract section must show the approximate size ("≈ N tokens")');
+    else {
+      const n = Number(shown[1].replace(/,/g, ""));
+      if (Math.abs(n - size.tokens) > size.tokens * SHOWCASE_SIZE_TOLERANCE) {
+        errors.push(`showcase AI Contract section says ≈ ${shown[1]} tokens, but ${LLM_FILE} is ~${size.tokens}`);
+      }
+    }
+    if (!/>Prompt</.test(section)) errors.push("showcase AI Contract example must show the natural-language prompt");
+    const snippet = /<pre><code>([\s\S]*?)<\/code><\/pre>/.exec(section);
+    const example = snippet && unescapeHtml(snippet[1]).trim();
+    if (!example || !contract.examples.valid.some((e) => e.html.trim() === example)) {
+      errors.push(`showcase AI Contract example snippet must be one of the ${JSON_FILE} valid examples`);
+    } else {
+      const demo = section.split('class="sc-demo"')[1]?.split('class="sc-code"')[0] ?? "";
+      if (!compactHtml(demo).includes(compactHtml(example))) {
+        errors.push("showcase AI Contract example must render the snippet's HTML live in an sc-demo");
+      }
+    }
+  }
+  if (!new RegExp(`href="#ai-contract"`).test(showcaseHtml)) errors.push("showcase navigation must link to #ai-contract");
+
+  for (const file of [LLM_FILE, JSON_FILE]) {
+    if (!new RegExp(`^\\s+cp\\b[^\\n]*\\b${file.replace(/\./g, "\\.")}\\b[^\\n]*_site/?\\s*$`, "m").test(workflow)) {
+      errors.push(`pages.yml must copy ${file} into _site/`);
+    }
+    if (!workflow.includes(`- "${file}"`)) errors.push(`pages.yml push paths must include "${file}"`);
+  }
+
+  for (const file of [LLM_FILE, JSON_FILE, "docs/ai-contract.md"]) {
+    if (!readme.includes(file)) errors.push(`README.md must mention ${file}`);
+  }
+  if (!/same (pull request|PR)/i.test(readme)) errors.push("README.md must say public API changes update the contract in the same pull request");
+  if (!schemaDoc) errors.push("docs/ai-contract.md is missing");
+  else {
+    for (const key of JSON_KEYS) {
+      if (!schemaDoc.includes(`\`${key}\``)) errors.push(`docs/ai-contract.md must document the "${key}" key`);
+    }
+  }
+  return errors;
+}
+
+export function readRepoFiles(repo) {
+  const read = (p) => {
+    try {
+      return readFileSync(resolve(repo, p), "utf8");
+    } catch {
+      return "";
+    }
+  };
+  return {
+    json: read(JSON_FILE),
+    md: read(LLM_FILE),
+    pkg: read("package.json"),
+    builtCss: buildBundle(resolve(repo, "src/synthcss.css"), (p) => readFileSync(p, "utf8")),
+    showcaseHtml: read("showcase/index.html"),
+    workflow: read(".github/workflows/pages.yml"),
+    readme: read("README.md"),
+    schemaDoc: read("docs/ai-contract.md"),
+  };
+}
+
+export function maxTokensFrom(argv, env) {
+  const arg = argv.find((a) => a.startsWith("--max-tokens="))?.split("=")[1];
+  const raw = arg ?? env.SYNTHCSS_AI_CONTRACT_MAX_TOKENS;
+  if (raw === undefined || raw === "") return DEFAULT_MAX_TOKENS;
+  const n = Number(raw);
+  if (!Number.isInteger(n) || n <= 0) throw new Error(`invalid token threshold: ${raw}`);
+  return n;
+}
+
+const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isMain) {
+  const repo = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+  const maxTokens = maxTokensFrom(process.argv.slice(2), process.env);
+  const { errors, warnings, size } = verifyContract(readRepoFiles(repo), { maxTokens });
+  console.log(`verify-ai-contract: ${LLM_FILE} is ${size.chars} characters, ~${size.tokens} tokens (chars ÷ 4; threshold ${maxTokens}).`);
+  for (const w of warnings) console.warn(`  WARN ${w}`);
+  if (errors.length) {
+    for (const e of errors) console.error(`  FAIL ${e}`);
+    console.error(`\nverify-ai-contract: ${errors.length} problem(s) found.`);
+    process.exit(1);
+  }
+  console.log(`verify-ai-contract: ${JSON_FILE} and ${LLM_FILE} match the CSS, package.json and each other.`);
+}
diff --git a/scripts/verify-ai-contract.test.mjs b/scripts/verify-ai-contract.test.mjs
new file mode 100644
index 0000000..7f380f1
--- /dev/null
+++ b/scripts/verify-ai-contract.test.mjs
@@ -0,0 +1,132 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { DEFAULT_MAX_TOKENS, estimateTokens, maxTokensFrom, readRepoFiles, verifyContract } from "./verify-ai-contract.mjs";
+
+const repo = new URL("..", import.meta.url).pathname;
+const files = readRepoFiles(repo);
+const contract = JSON.parse(files.json);
+
+const errorsWith = (changes, options) => verifyContract({ ...files, ...changes }, options).errors;
+const assertError = (errors, text) => assert.ok(errors.some((e) => e.includes(text)), `expected "${text}" in:\n${errors.join("\n")}`);
+// Edits a deep copy of the JSON contract.
+const withJson = (edit) => {
+  const c = structuredClone(contract);
+  edit(c);
+  return { json: JSON.stringify(c, null, 2) };
+};
+
+test("the repository contract passes", () => {
+  const { errors, warnings, size } = verifyContract(files);
+  assert.deepEqual(errors, []);
+  assert.deepEqual(warnings, []);
+  assert.ok(size.tokens < DEFAULT_MAX_TOKENS, `synthcss.llm.md is ~${size.tokens} tokens`);
+});
+
+test("estimates size as characters ÷ 4 and warns, without failing, above the threshold", () => {
+  assert.equal(estimateTokens("x".repeat(400)), 100);
+  assert.equal(estimateTokens("x".repeat(401)), 101);
+  const { errors, warnings, size } = verifyContract(files, { maxTokens: 100 });
+  assert.deepEqual(errors, []);
+  assert.equal(warnings.length, 1);
+  assert.match(warnings[0], new RegExp(`~${size.tokens} tokens, above the 100-token threshold`));
+  assert.equal(maxTokensFrom([], {}), 8000);
+  assert.equal(maxTokensFrom(["--max-tokens=500"], {}), 500);
+  assert.equal(maxTokensFrom([], { SYNTHCSS_AI_CONTRACT_MAX_TOKENS: "600" }), 600);
+  assert.throws(() => maxTokensFrom(["--max-tokens=lots"], {}));
+});
+
+test("fails when a fake class is added to the contract", () => {
+  const json = withJson((c) => (c.layouts["stack-xl"] = "stack with a huge gap"));
+  assertError(errorsWith(json), "synthcss.ai.json: class .stack-xl is not a selector");
+  const variant = withJson((c) => (c.components.button.variants["button-ghost"] = "subtle action"));
+  assertError(errorsWith(variant), "synthcss.ai.json: class .button-ghost is not a selector");
+  const mention = withJson((c) => c.intentMap.push({ intent: "Hero", use: ".hero" }));
+  assertError(errorsWith(mention), "synthcss.ai.json: class .hero is not a selector");
+  const md = files.md.replace("- `.center` —", "- `.middle` — centered box\n- `.center` —");
+  assertError(errorsWith({ md }), "synthcss.llm.md: class .middle is not a selector");
+  assertError(errorsWith({ md }), "class .middle is in the synthcss.llm.md vocabulary but not in synthcss.ai.json");
+});
+
+test("fails when a class is deleted from the contract", () => {
+  const layout = withJson((c) => delete c.layouts["cover-main"]);
+  assertError(errorsWith(layout), "class .cover-main is in the SynthCSS CSS but missing from synthcss.ai.json");
+  const part = withJson((c) => delete c.components.table.parts.numeric);
+  assertError(errorsWith(part), "class .numeric is in the SynthCSS CSS but missing from synthcss.ai.json");
+  const md = files.md.replace(/^- `\.split-lg` — .*\n/m, "");
+  assertError(errorsWith({ md }), "class .split-lg is in synthcss.ai.json but not in the synthcss.llm.md vocabulary");
+});
+
+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");
+});
+
+test("fails when a token is renamed", () => {
+  const json = withJson((c) => {
+    c.tokens["--space-7"] = c.tokens["--space-6"];
+    delete c.tokens["--space-6"];
+  });
+  assertError(errorsWith(json), "synthcss.ai.json: token --space-7 is not defined on :root");
+  assertError(errorsWith(json), "token --space-6 is defined on :root but missing from synthcss.ai.json");
+  const md = files.md.replace("- `--radius-full` —", "- `--radius-pill` —");
+  assertError(errorsWith({ md }), "synthcss.llm.md: token --radius-pill is not defined on :root");
+  assertError(errorsWith({ md }), "token --radius-full is in synthcss.ai.json but not in the synthcss.llm.md Design Tokens");
+  const builtCss = files.builtCss.replace("--ease-standard:", "--ease-default:");
+  assertError(errorsWith({ builtCss }), "synthcss.ai.json: token --ease-standard is not defined on :root");
+  assertError(errorsWith({ builtCss }), "token --ease-default is defined on :root but missing from synthcss.ai.json");
+});
+
+test("fails when an invalid example uses a real class it does not name as the alternative", () => {
+  const json = withJson((c) => (c.examples.invalid[0].html = '<div class="cluster flex-row-gap-large-center">…</div>'));
+  assertError(errorsWith(json), "synthcss.ai.json: invalid example 1: uses .cluster, a real SynthCSS class");
+  const md = files.md.replace('`<p class="mt-4">Saved.</p>`', '`<p class="cluster mt-4">Saved.</p>`');
+  assertError(errorsWith({ md }), "synthcss.llm.md: invalid example 5: uses .cluster, a real SynthCSS class");
+  // .badge is real but exempt: the note names it as the correct alternative.
+  assert.ok(contract.examples.invalid.some((e) => e.html.includes('class="badge badge-red"')));
+  const noAlternative = withJson((c) => (c.examples.invalid[3].note = "Color-named variant."));
+  assertError(errorsWith(noAlternative), 'invalid example 4: the note must name the correct alternative ("Use …")');
+  const fakeAlternative = withJson((c) => (c.examples.invalid[0].note = "Invented class. Use .row."));
+  assertError(errorsWith(fakeAlternative), "the suggested alternative .row is not a SynthCSS class");
+});
+
+test("fails when the Markdown and JSON examples drift apart", () => {
+  const json = withJson((c) => c.examples.invalid.pop());
+  assertError(errorsWith(json), "is not in synthcss.ai.json");
+  const md = files.md.replace("New project</button>", "Add project</button>");
+  assertError(errorsWith({ md }), "Valid Examples html blocks must match");
+  const noInline = files.md.replace(/^- `<div style=.*\n/m, "");
+  assertError(errorsWith({ md: noInline }), "needs an invalid example with an inline-style flex/gap");
+});
+
+test("fails when versions do not match package.json", () => {
+  const json = withJson((c) => (c.synthcssVersion = "0.0.9"));
+  assertError(errorsWith(json), 'synthcssVersion is "0.0.9" but package.json is "0.1.0"');
+  const pkg = files.pkg.replace('"version": "0.1.0"', '"version": "0.2.0"');
+  assertError(errorsWith({ pkg }), "states SynthCSS 0.1.0 but package.json is 0.2.0");
+  assertError(errorsWith({ pkg }), "links to synthcss@0.1.0 but package.json is 0.2.0");
+  const contractVersion = withJson((c) => (c.contractVersion = "2.0.0"));
+  assertError(errorsWith(contractVersion), "states contract 1.0.0 but synthcss.ai.json has 2.0.0");
+});
+
+test("fails when the JSON shape or Markdown sections are wrong", () => {
+  assertError(errorsWith({ json: "{" }), "synthcss.ai.json is not valid JSON");
+  assertError(errorsWith(withJson((c) => delete c.intentMap)), 'missing top-level key "intentMap"');
+  assertError(errorsWith(withJson((c) => c.generationRules.pop())), "generationRules must list exactly 10 rules");
+  const noIntent = files.md.replace("## Intent Mapping", "## Intents");
+  assertError(errorsWith({ md: noIntent }), 'missing section "## Intent Mapping"');
+  const nineRules = files.md.replace(/^10\. .*\n/m, "");
+  assertError(errorsWith({ md: nineRules }), "exactly 10 numbered rules, found 9");
+  const noAvoid = files.md.replace("### Avoid", "### Don't");
+  assertError(errorsWith({ md: noAvoid }), 'Composition Rules has no "### Avoid"');
+});
+
+test("fails when the showcase, Pages workflow or README stop publishing the contract", () => {
+  const noDownload = files.showcaseHtml.replace('href="../synthcss.ai.json" download', 'href="../synthcss.ai.json"');
+  assertError(errorsWith({ showcaseHtml: noDownload }), "must have a download link");
+  const wrongSize = files.showcaseHtml.replace(/≈ [\d,]+ tokens/, "≈ 9,000 tokens");
+  assertError(errorsWith({ showcaseHtml: wrongSize }), "says ≈ 9,000 tokens");
+  const workflow = files.workflow.replace("cp synthcss.llm.md synthcss.ai.json _site/", "cp synthcss.llm.md _site/");
+  assertError(errorsWith({ workflow }), "pages.yml must copy synthcss.ai.json into _site/");
+  const readme = files.readme.replace(/same pull request/g, "next release");
+  assertError(errorsWith({ readme }), "same pull request");
+});
diff --git a/showcase/README.md b/showcase/README.md
index 5dde406..a62026f 100644
--- a/showcase/README.md
+++ b/showcase/README.md
@@ -36,7 +36,8 @@ python3 -m http.server 8000
 [`.github/workflows/pages.yml`](../.github/workflows/pages.yml) deploys the page with the
 official `actions/configure-pages`, `actions/upload-pages-artifact` and
 `actions/deploy-pages` actions. It runs on every push to `main` that changes
-`showcase/**`, a framework stylesheet (`src/**.css`) or the workflow itself, and can be
+`showcase/**`, a framework stylesheet (`src/**.css`), an AI contract file
+(`synthcss.llm.md`, `synthcss.ai.json`) or the workflow itself, and can be
 started by hand from the Actions tab (`workflow_dispatch`).
 
 The build job runs `npm test`, then assembles a temporary `_site/` folder that mirrors
@@ -45,6 +46,8 @@ the repository layout:
 ```text
 _site/
   index.html        generated redirect to showcase/
+  synthcss.llm.md   the AI contract, linked from the AI Contract section
+  synthcss.ai.json
   showcase/         index.html, showcase.css, showcase.js
   src/              tokens.css, layout.css, components.css, synthcss.css
 ```
@@ -100,7 +103,8 @@ every section and hero item exists, that every token is rendered with `var(--…
 every layout primitive has a demo, description and snippet, that grid, sidebar, cluster
 and split have width-adjustable frames, that every component has an article with a live
 demo of all its classes and a snippet, that the composed interface (`data-composed`)
-uses only SynthCSS classes and at least six components, that there are at least three intent examples,
+uses only SynthCSS classes and at least six components, that there are at least three intent examples
+and an AI Contract section,
 that snippets are short and use only real classes and tokens, that `showcase.css` only
 styles `sc-` classes with no hard-coded colors or token values, and that the workflow
 and this README cover the required steps.
@@ -108,6 +112,11 @@ and this README cover the required steps.
 [`scripts/check-components.mjs`](../scripts/check-components.mjs) also checks that every
 class on the page and in its snippets exists in the built CSS.
 
+[`scripts/verify-ai-contract.mjs`](../scripts/verify-ai-contract.mjs) checks that the AI
+Contract section links to `../synthcss.llm.md`, offers `../synthcss.ai.json` as a download,
+prints a size within 10% of the real estimate, and that its example is one of the
+contract's valid examples, rendered live. See [docs/ai-contract.md](../docs/ai-contract.md).
+
 `npm run check:components:browser` tabs through the component demos in headless Chromium
 and checks focus rings, states and that tables scroll inside `.table-wrap` at 375px.
 
diff --git a/showcase/index.html b/showcase/index.html
index 817b23b..a985371 100644
--- a/showcase/index.html
+++ b/showcase/index.html
@@ -31,6 +31,7 @@
             <li><a href="#components">Components</a></li>
             <li><a href="#composed">Composed</a></li>
             <li><a href="#ai-examples">AI examples</a></li>
+            <li><a href="#ai-contract">AI contract</a></li>
           </ul>
         </nav>
       </header>
@@ -923,6 +924,76 @@ <h2 id="ai-title">AI-friendly examples</h2>
         </ul>
       </div>
     </section>
+
+    <section id="ai-contract" class="sc-band sc-band-alt" aria-labelledby="ai-contract-title">
+      <div class="container stack-lg">
+        <div class="stack-sm">
+          <h2 id="ai-contract-title">AI Contract</h2>
+          <p class="sc-muted">
+            One compact file teaches a model the whole public vocabulary of SynthCSS: every
+            token, layout primitive, component, part and variant, an intent table, composition
+            rules, ten generation rules and valid and invalid examples. Paste
+            <code>synthcss.llm.md</code> into a prompt, or read the same contract as structured
+            data from <code>synthcss.ai.json</code>. Both are versioned with the framework and
+            checked against the CSS on every change.
+          </p>
+        </div>
+        <div class="cluster">
+          <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>
+        </div>
+        <p class="sc-muted">
+          Size estimated as characters ÷ 4 (about 11,300 characters), small enough for one
+          context window with room to spare.
+        </p>
+        <article class="sc-card sidebar-lg">
+          <div class="stack-sm">
+            <p class="sc-label">Prompt</p>
+            <p>"Build a team page: a title with an invite button and a table of members with
+              their role and status."</p>
+            <p class="sc-muted">Given only <code>synthcss.llm.md</code>, a model answers with
+              classes from the contract and nothing else.</p>
+          </div>
+          <div class="stack">
+            <p class="sc-label">Generated HTML, rendered with SynthCSS</p>
+            <div class="sc-demo">
+              <section class="stack">
+                <header class="split">
+                  <h3>Team</h3>
+                  <button type="button" class="button button-primary">Invite</button>
+                </header>
+                <div class="table-wrap" tabindex="0">
+                  <table class="table">
+                    <thead><tr><th>Name</th><th>Role</th><th>Status</th></tr></thead>
+                    <tbody>
+                      <tr><td>Ana</td><td>Admin</td><td><span class="badge badge-success">Active</span></td></tr>
+                      <tr><td>Ben</td><td>Editor</td><td><span class="badge badge-warning">Invited</span></td></tr>
+                    </tbody>
+                  </table>
+                </div>
+              </section>
+            </div>
+            <div class="sc-code"><pre><code>&lt;section class="stack"&gt;
+  &lt;header class="split"&gt;
+    &lt;h3&gt;Team&lt;/h3&gt;
+    &lt;button type="button" class="button button-primary"&gt;Invite&lt;/button&gt;
+  &lt;/header&gt;
+  &lt;div class="table-wrap" tabindex="0"&gt;
+    &lt;table class="table"&gt;
+      &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Name&lt;/th&gt;&lt;th&gt;Role&lt;/th&gt;&lt;th&gt;Status&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
+      &lt;tbody&gt;
+        &lt;tr&gt;&lt;td&gt;Ana&lt;/td&gt;&lt;td&gt;Admin&lt;/td&gt;&lt;td&gt;&lt;span class="badge badge-success"&gt;Active&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
+        &lt;tr&gt;&lt;td&gt;Ben&lt;/td&gt;&lt;td&gt;Editor&lt;/td&gt;&lt;td&gt;&lt;span class="badge badge-warning"&gt;Invited&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
+      &lt;/tbody&gt;
+    &lt;/table&gt;
+  &lt;/div&gt;
+&lt;/section&gt;</code></pre></div>
+          </div>
+        </article>
+      </div>
+    </section>
   </main>
 
   <footer class="sc-footer">
diff --git a/synthcss.ai.json b/synthcss.ai.json
new file mode 100644
index 0000000..b9ded1f
--- /dev/null
+++ b/synthcss.ai.json
@@ -0,0 +1,247 @@
+{
+  "synthcssVersion": "0.1.0",
+  "contractVersion": "1.0.0",
+  "tokens": {
+    "--color-background": "page background",
+    "--color-surface": "subtle background for panels, table heads, footers",
+    "--color-surface-elevated": "background of raised surfaces (cards, buttons)",
+    "--color-text": "main text",
+    "--color-text-secondary": "body text of cards and panels",
+    "--color-text-muted": "hints, metadata, placeholders",
+    "--color-border": "default border color",
+    "--color-primary": "brand accent; main actions",
+    "--color-primary-hover": "hover state of primary elements",
+    "--color-on-primary": "text on primary or danger fills",
+    "--color-success": "success status",
+    "--color-warning": "warning status",
+    "--color-danger": "errors and destructive actions",
+    "--color-info": "neutral informational status",
+    "--space-1": "0.25rem spacing step",
+    "--space-2": "0.5rem spacing step (-sm gaps)",
+    "--space-3": "0.75rem spacing step",
+    "--space-4": "1rem spacing step (default gap)",
+    "--space-5": "1.5rem spacing step",
+    "--space-6": "2rem spacing step (-lg gaps)",
+    "--font-sans": "UI font stack",
+    "--font-mono": "code font stack",
+    "--text-sm": "small text",
+    "--text-base": "body text size",
+    "--text-lg": "large text, card titles",
+    "--text-xl": "small headings",
+    "--text-2xl": "section headings",
+    "--text-3xl": "page titles",
+    "--weight-normal": "regular weight",
+    "--weight-medium": "labels and buttons",
+    "--weight-semibold": "headings",
+    "--weight-bold": "strong emphasis",
+    "--leading-tight": "line height for headings and controls",
+    "--leading-normal": "line height for body text",
+    "--leading-relaxed": "line height for long-form text",
+    "--radius-sm": "small corner radius",
+    "--radius-md": "default radius (controls, panels)",
+    "--radius-lg": "card radius",
+    "--radius-full": "pills and circles",
+    "--border-width": "default border width",
+    "--border-color": "default border color (follows --color-border)",
+    "--shadow-sm": "subtle elevation",
+    "--shadow-md": "card elevation",
+    "--shadow-lg": "overlay elevation",
+    "--control-height": "button height",
+    "--input-height": "text input height",
+    "--container-width": "max width of .container",
+    "--content-width": "readable text width (.center)",
+    "--sidebar-width": "sidebar basis in .sidebar",
+    "--grid-min": "minimum column width in .grid; override inline per grid",
+    "--focus-color": "focus ring color",
+    "--focus-width": "focus ring width",
+    "--focus-offset": "focus ring offset",
+    "--duration-fast": "hover and state transitions",
+    "--duration-normal": "standard transitions",
+    "--duration-slow": "larger movements; spinner speed",
+    "--ease-standard": "default easing curve"
+  },
+  "layouts": {
+    "container": "centered page-width wrapper with side padding",
+    "stack": "vertical flow with tokenized spacing",
+    "stack-sm": "stack with a tighter gap",
+    "stack-lg": "stack with a looser gap",
+    "cluster": "wrapping row of small items (tags, buttons, links)",
+    "cluster-sm": "cluster with a tighter gap",
+    "cluster-lg": "cluster with a looser gap",
+    "grid": "responsive equal columns, as many as fit (min --grid-min)",
+    "grid-sm": "grid with a tighter gap",
+    "grid-lg": "grid with a looser gap",
+    "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",
+    "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",
+    "center": "readable, horizontally centered column of text",
+    "cover": "full-viewport-height section, main child vertically centered",
+    "cover-main": "the child of .cover that is vertically centered"
+  },
+  "components": {
+    "button": {
+      "intent": "an action, on <button> or <a href>",
+      "parts": {},
+      "variants": {
+        "button-primary": "main action of a form or page",
+        "button-secondary": "alternative or cancel action",
+        "button-danger": "destructive action (delete, remove)",
+        "button-sm": "smaller button",
+        "button-lg": "larger button",
+        "button-icon": "square icon-only button; needs aria-label"
+      }
+    },
+    "field": {
+      "intent": "one labeled form control with help or error text",
+      "parts": {
+        "field-label": "the <label> of the control",
+        "field-help": "hint text under the control",
+        "field-error": "error message; pair with aria-invalid=\"true\""
+      },
+      "variants": {}
+    },
+    "card": {
+      "intent": "raised self-contained item (project, product, user)",
+      "parts": {
+        "card-header": "title area",
+        "card-body": "main content; grows to align footers",
+        "card-footer": "bottom bar on the surface color",
+        "card-media": "full-width image or video",
+        "card-actions": "wrapping row of buttons or links"
+      },
+      "variants": {}
+    },
+    "badge": {
+      "intent": "short status label",
+      "parts": {},
+      "variants": {
+        "badge-success": "positive status",
+        "badge-warning": "needs attention",
+        "badge-danger": "failed or blocked",
+        "badge-info": "neutral information"
+      }
+    },
+    "alert": {
+      "intent": "message box; role=\"status\" or role=\"alert\"",
+      "parts": {},
+      "variants": {
+        "alert-info": "neutral information",
+        "alert-success": "action succeeded",
+        "alert-warning": "needs attention",
+        "alert-danger": "error"
+      }
+    },
+    "panel": {
+      "intent": "flat bordered group of secondary content",
+      "parts": {
+        "panel-header": "title area with a bottom border",
+        "panel-body": "content area"
+      },
+      "variants": {}
+    },
+    "table": {
+      "intent": "tabular data on a native <table>",
+      "parts": {
+        "table-wrap": "bordered wrapper that scrolls wide tables; add tabindex=\"0\"",
+        "numeric": "right-aligned tabular-number cell"
+      },
+      "variants": {
+        "table-hover": "highlight the hovered row"
+      }
+    },
+    "empty-state": {
+      "intent": "nothing to show yet: icon, heading, text, action",
+      "parts": {},
+      "variants": {}
+    }
+  },
+  "intentMap": [
+    { "intent": "Page or section wrapper", "use": ".container" },
+    { "intent": "Vertical list of blocks", "use": ".stack" },
+    { "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": "Header or toolbar with two ends", "use": ".split" },
+    { "intent": "Readable text column", "use": ".center" },
+    { "intent": "Full-screen centered page", "use": ".cover + .cover-main" },
+    { "intent": "Tighter or looser spacing", "use": "-sm / -lg on stack, cluster, grid, sidebar, split" },
+    { "intent": "Main action", "use": ".button button-primary" },
+    { "intent": "Destructive action", "use": ".button button-danger" },
+    { "intent": "Labeled input with help or error", "use": ".field + .field-label + .field-help / .field-error" },
+    { "intent": "Self-contained item", "use": ".card + parts" },
+    { "intent": "Status label", "use": ".badge + .badge-success / -warning / -danger / -info" },
+    { "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" }
+  ],
+  "compositionRules": {
+    "recommended": [
+      "Wrap each page in .container and give it a .stack or .stack-lg.",
+      "Nest primitives: .split for headers, .cluster for button rows, .grid of .card for collections.",
+      "Combine a component part with a layout primitive on one element, e.g. class=\"card-footer split\".",
+      "Put components on native elements: <button>, <a href>, <table>, <label> + <input>.",
+      "Wrap every .table in .table-wrap."
+    ],
+    "avoid": [
+      "Margins between siblings; spacing comes from gap variants.",
+      "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."
+    ]
+  },
+  "generationRules": [
+    "Use only the classes and tokens in this contract; never invent class names.",
+    "Build structure from layout primitives and nest them; never write custom flex or grid CSS.",
+    "Set spacing with the -sm / -lg gap variants, never with margins or utility classes.",
+    "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\".",
+    "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."
+  ],
+  "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": "<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>",
+        "note": "Form: field with an error from aria-invalid, actions in a cluster."
+      },
+      {
+        "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."
+      }
+    ],
+    "invalid": [
+      {
+        "html": "<div class=\"flex-row-gap-large-center\">…</div>",
+        "note": "Invented class. Use .cluster-lg."
+      },
+      {
+        "html": "<div style=\"display:flex; gap:16px\">…</div>",
+        "note": "Inline flex and gap. Use .cluster."
+      },
+      {
+        "html": "<button class=\"btn btn-primary\">Save</button>",
+        "note": "Another framework's names. Use .button and .button-primary."
+      },
+      {
+        "html": "<span class=\"badge badge-red\">Failed</span>",
+        "note": "Color-named variant. Use .badge and .badge-danger."
+      },
+      {
+        "html": "<p class=\"mt-4\">Saved.</p>",
+        "note": "Spacing utility. Use .stack on the parent."
+      }
+    ]
+  }
+}
diff --git a/synthcss.llm.md b/synthcss.llm.md
new file mode 100644
index 0000000..06ca647
--- /dev/null
+++ b/synthcss.llm.md
@@ -0,0 +1,252 @@
+# SynthCSS AI Contract
+
+Version: SynthCSS 0.1.0 · contract 1.0.0 · machine-readable twin: synthcss.ai.json
+
+The complete public vocabulary of SynthCSS. Use only the classes and tokens listed here; anything else does not exist.
+Load: `<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/nabledhq/synthcss@0.1.0/dist/synthcss.min.css">`
+
+## Design Tokens
+
+CSS custom properties on `:root`. Use them through `var()`; restyle by overriding them on `:root`.
+
+- `--color-background` — page background
+- `--color-surface` — subtle background for panels, table heads, footers
+- `--color-surface-elevated` — background of raised surfaces (cards, buttons)
+- `--color-text` — main text
+- `--color-text-secondary` — body text of cards and panels
+- `--color-text-muted` — hints, metadata, placeholders
+- `--color-border` — default border color
+- `--color-primary` — brand accent; main actions
+- `--color-primary-hover` — hover state of primary elements
+- `--color-on-primary` — text on primary or danger fills
+- `--color-success` — success status
+- `--color-warning` — warning status
+- `--color-danger` — errors and destructive actions
+- `--color-info` — neutral informational status
+- `--space-1` — 0.25rem spacing step
+- `--space-2` — 0.5rem spacing step (-sm gaps)
+- `--space-3` — 0.75rem spacing step
+- `--space-4` — 1rem spacing step (default gap)
+- `--space-5` — 1.5rem spacing step
+- `--space-6` — 2rem spacing step (-lg gaps)
+- `--font-sans` — UI font stack
+- `--font-mono` — code font stack
+- `--text-sm` — small text
+- `--text-base` — body text size
+- `--text-lg` — large text, card titles
+- `--text-xl` — small headings
+- `--text-2xl` — section headings
+- `--text-3xl` — page titles
+- `--weight-normal` — regular weight
+- `--weight-medium` — labels and buttons
+- `--weight-semibold` — headings
+- `--weight-bold` — strong emphasis
+- `--leading-tight` — line height for headings and controls
+- `--leading-normal` — line height for body text
+- `--leading-relaxed` — line height for long-form text
+- `--radius-sm` — small corner radius
+- `--radius-md` — default radius (controls, panels)
+- `--radius-lg` — card radius
+- `--radius-full` — pills and circles
+- `--border-width` — default border width
+- `--border-color` — default border color (follows `--color-border`)
+- `--shadow-sm` — subtle elevation
+- `--shadow-md` — card elevation
+- `--shadow-lg` — overlay elevation
+- `--control-height` — button height
+- `--input-height` — text input height
+- `--container-width` — max width of `.container`
+- `--content-width` — readable text width (.center)
+- `--sidebar-width` — sidebar basis in `.sidebar`
+- `--grid-min` — minimum column width in `.grid`; override inline per grid
+- `--focus-color` — focus ring color
+- `--focus-width` — focus ring width
+- `--focus-offset` — focus ring offset
+- `--duration-fast` — hover and state transitions
+- `--duration-normal` — standard transitions
+- `--duration-slow` — larger movements; spinner speed
+- `--ease-standard` — default easing curve
+
+## Layout Vocabulary
+
+Work on any element. No breakpoints: they adapt to the space they get.
+
+- `.container` — centered page-width wrapper with side padding
+- `.stack` — vertical flow with tokenized spacing
+- `.stack-sm` — stack with a tighter gap
+- `.stack-lg` — stack with a looser gap
+- `.cluster` — wrapping row of small items (tags, buttons, links)
+- `.cluster-sm` — cluster with a tighter gap
+- `.cluster-lg` — cluster with a looser gap
+- `.grid` — responsive equal columns, as many as fit (min `--grid-min`)
+- `.grid-sm` — grid with a tighter gap
+- `.grid-lg` — grid with a looser gap
+- `.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
+- `.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
+- `.center` — readable, horizontally centered column of text
+- `.cover` — full-viewport-height section, main child vertically centered
+- `.cover-main` — the child of `.cover` that is vertically centered
+
+## Component Vocabulary
+
+Naming: component, component-variant, component-part. State comes from attributes, never classes.
+
+- `.button` — an action, on `<button>` or `<a href>`
+  - variant `.button-primary` — main action of a form or page
+  - variant `.button-secondary` — alternative or cancel action
+  - variant `.button-danger` — destructive action (delete, remove)
+  - variant `.button-sm` — smaller button
+  - variant `.button-lg` — larger button
+  - variant `.button-icon` — square icon-only button; needs `aria-label`
+- `.field` — one labeled form control with help or error text
+  - part `.field-label` — the `<label>` of the control
+  - part `.field-help` — hint text under the control
+  - part `.field-error` — error message; pair with `aria-invalid="true"`
+- `.card` — raised self-contained item (project, product, user)
+  - part `.card-header` — title area
+  - part `.card-body` — main content; grows to align footers
+  - part `.card-footer` — bottom bar on the surface color
+  - part `.card-media` — full-width image or video
+  - part `.card-actions` — wrapping row of buttons or links
+- `.badge` — short status label
+  - variant `.badge-success` — positive status
+  - variant `.badge-warning` — needs attention
+  - variant `.badge-danger` — failed or blocked
+  - variant `.badge-info` — neutral information
+- `.alert` — message box; `role="status"` or `role="alert"`
+  - variant `.alert-info` — neutral information
+  - variant `.alert-success` — action succeeded
+  - variant `.alert-warning` — needs attention
+  - variant `.alert-danger` — error
+- `.panel` — flat bordered group of secondary content
+  - part `.panel-header` — title area with a bottom border
+  - part `.panel-body` — content area
+- `.table` — tabular data on a native `<table>`
+  - part `.table-wrap` — bordered wrapper that scrolls wide tables; add `tabindex="0"`
+  - part `.numeric` — right-aligned tabular-number cell
+  - variant `.table-hover` — highlight the hovered row
+- `.empty-state` — nothing to show yet: icon, heading, text, action
+
+## Intent Mapping
+
+| Intent | Use |
+| --- | --- |
+| Page or section wrapper | `.container` |
+| Vertical list of blocks | `.stack` |
+| Row of tags, buttons or links | `.cluster` |
+| Responsive cards or tiles | `.grid` (+ `style="--grid-min: …"`) |
+| Side navigation next to content | `.sidebar` |
+| Header or toolbar with two ends | `.split` |
+| Readable text column | `.center` |
+| Full-screen centered page | `.cover` + `.cover-main` |
+| Tighter or looser spacing | -sm / -lg on stack, cluster, grid, sidebar, split |
+| Main action | `.button button-primary` |
+| Destructive action | `.button button-danger` |
+| Labeled input with help or error | `.field` + `.field-label` + `.field-help` / `.field-error` |
+| Self-contained item | `.card` + parts |
+| Status label | `.badge` + `.badge-success` / -warning / -danger / -info |
+| Message or notification | `.alert` + `.alert-info` / -success / -warning / -danger |
+| Secondary grouped content | `.panel` + `.panel-header` / `.panel-body` |
+| Tabular data | `.table-wrap` > `.table`; `.table-hover`, `.numeric` |
+| No data yet | `.empty-state` |
+
+## Composition Rules
+
+### Recommended
+
+- Wrap each page in `.container` and give it a `.stack` or `.stack-lg`.
+- Nest primitives: `.split` for headers, `.cluster` for button rows, `.grid` of `.card` for collections.
+- Combine a component part with a layout primitive on one element, e.g. `class="card-footer split"`.
+- Put components on native elements: `<button>`, `<a href>`, `<table>`, `<label>` + `<input>`.
+- Wrap every `.table` in `.table-wrap`.
+
+### Avoid
+
+- Margins between siblings; spacing comes from gap variants.
+- 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.
+
+## AI Generation Rules
+
+1. Use only the classes and tokens in this contract; never invent class names.
+2. Build structure from layout primitives and nest them; never write custom flex or grid CSS.
+3. Set spacing with the -sm / -lg gap variants, never with margins or utility classes.
+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"`.
+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.
+
+```html
+<main class="container stack-lg">
+  <header class="split">
+    <h1>Projects</h1>
+    <button type="button" class="button button-primary">New project</button>
+  </header>
+  <ul class="grid" role="list">
+    <li class="card">
+      <div class="card-header"><h2>Atlas</h2></div>
+      <div class="card-body">Design system migration.</div>
+      <div class="card-footer split-sm"><span class="badge badge-success">Active</span></div>
+    </li>
+  </ul>
+</main>
+```
+
+Form: field with an error from aria-invalid, actions in a cluster.
+
+```html
+<form class="card">
+  <div class="field">
+    <label class="field-label" for="email">Email</label>
+    <input id="email" type="email" aria-invalid="true" aria-describedby="email-error">
+    <p class="field-error" id="email-error">Enter a full email address.</p>
+  </div>
+  <div class="cluster-sm">
+    <button type="submit" class="button button-primary">Save</button>
+    <button type="button" class="button">Cancel</button>
+  </div>
+</form>
+```
+
+Data view: split header with an action, scrollable table with status badges.
+
+```html
+<section class="stack">
+  <header class="split">
+    <h3>Team</h3>
+    <button type="button" class="button button-primary">Invite</button>
+  </header>
+  <div class="table-wrap" tabindex="0">
+    <table class="table">
+      <thead><tr><th>Name</th><th>Role</th><th>Status</th></tr></thead>
+      <tbody>
+        <tr><td>Ana</td><td>Admin</td><td><span class="badge badge-success">Active</span></td></tr>
+        <tr><td>Ben</td><td>Editor</td><td><span class="badge badge-warning">Invited</span></td></tr>
+      </tbody>
+    </table>
+  </div>
+</section>
+```
+
+## Invalid / Discouraged Examples
+
+Never generate these. Each line: wrong markup — why — what to use instead.
+
+- `<div class="flex-row-gap-large-center">…</div>` — Invented class. Use .cluster-lg.
+- `<div style="display:flex; gap:16px">…</div>` — Inline flex and gap. Use .cluster.
+- `<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.
Acceptance · round 1
Shipped
CINo checks
Automated reviewPass with concerns

This delivers a thorough AI contract: a canonical JSON file and a hand-written Markdown twin covering every token, layout, component part and variant, plus schema docs. A dependency-free verifier with targeted tests covers each required failure mode, and the showcase section and Pages deploy publish both files. The content and tests match the spec closely, and no CSS was changed. No CI ran, so a passing final tree is unconfirmed, and the verifier also checks README prose and showcase markup in ways that may prove brittle.

Acceptance criteria · 8 of 9 met
  • YESsynthcss.ai.json and synthcss.llm.md exist at the repo root and state the SynthCSS versionBoth files are added at the root; the JSON has `"synthcssVersion": "0.1.0"` and the Markdown header reads `Version: SynthCSS 0.1.0 · contract 1.0.0`.
  • YESEvery current token, layout primitive, component class and variant appears in both filesThe JSON lists 57 tokens, 19 layout classes and 8 components with their parts and variants, mirrored line for line in the Markdown. verifyContract cross-checks both against the built CSS `:root` and its selectors, though no CI run confirms the result.
  • YESMarkdown contains every listed section including the intent table and the 10 generation rulessynthcss.llm.md has a version header, Design Tokens, Layout and Component Vocabulary, an 18-row Intent Mapping table, Composition Rules with Recommended/Avoid, 10 numbered AI Generation Rules, 3 valid examples and 5 invalid ones, including flex-row-gap-large-center and inline display:flex/gap.
  • UNCLEARThe verification script passes on the final treeThe test 'the repository contract passes' asserts no errors and the builder reports a pass, but no CI checks ran to confirm it.
  • YESFake class, deleted class, renamed token, and real class in an invalid example each make the script fail (tests/fixtures)verify-ai-contract.test.mjs has a dedicated test for each case ('fake class is added', 'class is deleted', 'token is renamed', 'invalid example uses a real class'), covering both the JSON and the Markdown.
  • YESScript prints the size estimate; Markdown is under the thresholdThe main block logs chars and ~tokens (chars ÷ 4) and warns without failing above the threshold (`--max-tokens` or an env var, default 8000). The file is about 2,829 tokens.
  • YESScript runs in the existing test/CI commandpackage.json `test` now runs `node scripts/verify-ai-contract.mjs` and its test file. The Pages and Release workflows already run `npm test`.
  • YESShowcase has an AI Contract section with view link, download link, size and example; deploy publishes both filesshowcase/index.html adds #ai-contract with a ../synthcss.llm.md link, a download link for ../synthcss.ai.json, '≈ 2,830 tokens', and a prompt next to rendered HTML. pages.yml copies both files to _site/ and adds them to the push paths.
  • YESREADME mentions the contract and that public API changes must update it in the same PRThe new README section 'AI contract' says, in bold, that public API changes must update both contract files in the same pull request.
Concerns
  • No CI ran on this commit, so nothing independently confirms that `npm test`, including the new verifier and its 11 tests, passes on the final tree. The builder summary claiming 54 passing tests is also cut off.
  • The verifier goes beyond the spec: it also checks the README wording (a 'same pull request' regex), the pages.yml `cp` line format and a hard-coded '≈ 2,830 tokens' badge in the showcase, which must stay within 10%. These ties to prose and markup make the check brittle under routine doc and showcase edits.
  • The test file builds the repo path with `new URL('..', import.meta.url).pathname`. That breaks on Windows and on paths containing spaces or percent-encoded characters; `fileURLToPath` would be the safer choice.
  • The showcase AI Contract snippet is 15 lines and adds new classes such as `sc-label` and `sc-button-secondary`. The diff does not show whether the existing check-showcase.mjs 'snippets are short' and sc-class checks accept them, and without CI this is unconfirmed.
  • The showcase hard-codes a GitHub URL (`nabledhq/synthcss`) that the verifier does not check. Markdown invalid examples are matched to the JSON only by their html, so a note can drift between the two files without failing.
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.21, counted as builder cost.

Discussion · 0

No comments yet.