ShippedMedium

Add zero-specificity base layer applying font, colour and heading type tokens to html and h1–h4

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

Motivation

SynthCSS defines font, colour and type tokens but never applies them to the document. Pages that follow the contract render in the browser's default serif font, and overriding --font-sans on :root has no visible effect. Benchmark evidence: 5 of 6 agent-built pages rendered in serif.

Scope

  1. Add a base layer @layer synth.base containing zero-specificity (:where) rules:
    • :where(html) sets:
      • font-family: var(--font-sans)
      • font-size: 100%
      • line-height: var(--leading-normal)
      • color: var(--color-text)
      • background: var(--color-background)
    • :where(h1,h2,h3,h4) sets line-height: var(--leading-tight) and font-weight: var(--weight-semibold).
    • Heading sizes:
      • h1 uses --text-3xl
      • h2 uses --text-2xl
      • h3 uses --text-xl
      • h4 uses --text-lg
    • :where(code,kbd,pre,samp) sets font-family: var(--font-mono).
  2. Include the base layer in the main synthcss.css bundle.
    • The modular files (tokens, layout, components) must still work when loaded without it.
  3. Contract wording:
    • Add to the Design Tokens section of synthcss.llm.md: "Base styles apply --font-sans, --color-text and --color-background to the page and the --text-* scale to h1–h4. Override the tokens to restyle."
    • Reflect the same information in synthcss.ai.json.

Assumptions

  • Missing tokens:
    • Before use, verify that --leading-tight, --weight-semibold and --font-mono exist.
    • If any is missing, add it to the tokens file with a sensible value and document it.
  • Layer order:
    • If the project already uses @layer, declare synth.base first in the layer order.
    • Existing component rules (e.g. .card-header :where(h1…h6)) must keep precedence over base heading rules.
    • If the project does not use layers, wrap only the base rules in synth.base. This is acceptable because unlayered rules always beat layered ones.
  • Bundle placement: the base layer goes in the bundle after tokens and before layout and components, as its own source file (e.g. base.css) if the build concatenates modules.

Acceptance criteria

  • A test or fixture page using only the bundle, :root { --font-sans: "Inter", sans-serif } and plain markup has computed font-family on body and p resolving to the --font-sans value.
  • With no author CSS, a page <h1> has computed font-size equal to --text-3xl. Likewise h2 matches --text-2xl, h3 matches --text-xl and h4 matches --text-lg.
  • Author rules still win over the base layer:
    • An author rule body { font-family: serif } overrides it.
    • An author rule h1 { font-size: 10px } overrides it.
  • Existing component heading styles (e.g. card header headings) render unchanged.
  • Loading only the modular tokens/layout/components files applies no base styles.
  • synthcss.llm.md and synthcss.ai.json describe the base styles.
  • The existing contract sync verification and all tests pass.

Out of scope

  • CSS resets or normalisation (margins, box-sizing, etc.).
  • Styling h5/h6, links or lists.
  • New theme variants.
  • Showcase redesign beyond any regressions caused by this change.
Build #6
SUCCEEDED

Model claude-opus-5-5 · ceiling $5 · started 2 hours ago · finished 2 hours ago

33agent turns
2.5Mtokens
$1.58spent at list price

Builder summary
## What changed SynthCSS now applies its font, colour and heading-size tokens to plain markup. A page that only links the bundle renders in `--font-sans`, uses the token colours and gets the `--text-*` sizes on `h1`–`h4`. Overriding `--font-sans` on `:root` now changes the page font. ### New: `src/base.css` - All rules sit inside one `@layer synth.base { … }` block, and every selector is a single `:where(…)`, so they have zero specificity. - `:where(html)` sets `font-family: var(--font-sans)`, `font-size: 100%`, `line-height: var(--leading-normal)`, `color: var(--color-text)` and `background: var(--color-background)`. - `:where(h1, h2, h3, h4)` sets `line-height: var(--leading-tight)` and `font-weight: var(--weight-semibold)`. - Heading sizes: `h1` uses `--text-3xl`, `h2` uses `--text-2xl`, `h3` uses `--text-xl`, `h4` uses `--text-lg`. - `:where(code, kbd, pre, samp)` sets `font-family: var(--font-mono)`. - There are no resets (margins, box-sizing, `h5`/`h6`, links and lists keep browser defaults). - The project used no layers before, so only the base rules are layered. Unlayered author and component rules always beat them. - The three tokens the spec asked to check (`--leading-tight`, `--weight-semibold`, `--font-mono`) already existed, so no tokens were added. ### Bundle and build - `src/synthcss.css` imports `base.css` after `tokens.css` and before `layout.css` and `components.css`. - `scripts/build.mjs` adds `base.css` to `OUTPUTS`, so `dist/base.css` is published as an opt-in standalone file. - `release.yml` lists `base.min.css` in the release notes and purges it from the jsDelivr cache. - The modular files (`tokens.css`, `layout.css`, `components.css`) do not include base styles. ### Contract (`synthcss.llm.md`, `synthcss.ai.json`) - The Design Tokens section of `synthcss.llm.md` now has the sentence from the spec, word for word. - `synthcss.ai.json` has a new top-level key `baseStyles: { note, rules }`. `note` is the same sentence; `rules` lists each selector with its declarations. - Because this adds a key to the schema, `contractVersion` goes from **1.0.0 to 1.1.0**, and the Markdown header changes with it. - `scripts/verify-ai-contract.mjs` now: - checks the shape of `baseStyles`; - requires `baseStyles.rules` to match the rules parsed from `src/base.css`; - requires the Markdown Design Tokens section to contain `baseStyles.note`; - no longer reads a wildcard such as `--text-*` as a token name. Without this, the required sentence failed the existing check as an unknown token `--text-`. - `docs/ai-contract.md` documents the new key and checks. ### Checks and tests - `scripts/check-base.mjs` (`npm run check:base`, now part of `npm test`) checks that: - `base.css` is one `synth.base` layer of single-`:where()` selectors with exactly the specified declarations, and only tokens that exist; - the bundle imports it in the right place; - no modular file imports `base.css` or uses `@layer`. - `scripts/check-base.test.mjs` has 8 unit tests for that check. - `scripts/check-base-browser.mjs` (`npm run check:base:browser`, needs Playwright like the other browser checks) writes fixture pages and reads computed styles in Chromium. It covers every acceptance criterion: - `:root { --font-sans: "Inter", sans-serif }` makes `body` and `p` use that font; - with no author CSS, `h1`–`h4` font sizes equal `--text-3xl`, `--text-2xl`, `--text-xl` and `--text-lg`; - `html` colour and background equal the tokens, and `code` uses `--font-mono`; - author rules `body { font-family: serif }` and `h1 { font-size: 10px }` win; - card, alert, panel and empty-state headings have the same size, weight, line height and colour with and without the base layer; - the modular files alone apply no base styles. - Docs: a new "Base styles" section in `docs/tokens.md`, plus updates to `README.md` (new paragraph and file table) and `docs/releasing.md`. ## How it was verified - `npm test` passes: all static checks, the cont

Agent log
### Bundle and build
- `src/synthcss.css` imports `base.css` after `tokens.css` and before `layout.css` and `components.css`.
- `scripts/build.mjs` adds `base.css` to `OUTPUTS`, so `dist/base.css` is published as an opt-in standalone file.
- `release.yml` lists `base.min.css` in the release notes and purges it from the jsDelivr cache.
- The modular files (`tokens.css`, `layout.css`, `components.css`) do not include base styles.

### Contract (`synthcss.llm.md`, `synthcss.ai.json`)
- The Design Tokens section of `synthcss.llm.md` now has the sentence from the spec, word for word.
- `synthcss.ai.json` has a new top-level key `baseStyles: { note, rules }`. `note` is the same sentence; `rules` lists each selector with its declarations.
- Because this adds a key to the schema, `contractVersion` goes from **1.0.0 to 1.1.0**, and the Markdown header changes with it.
- `scripts/verify-ai-contract.mjs` now:
  - checks the shape of `baseStyles`;
  - requires `baseStyles.rules` to match the rules parsed from `src/base.css`;
  - requires the Markdown Design Tokens section to contain `baseStyles.note`;
  - no longer reads a wildcard such as `--text-*` as a token name. Without this, the required sentence failed the existing check as an unknown token `--text-`.
- `docs/ai-contract.md` documents the new key and checks.

### Checks and tests
- `scripts/check-base.mjs` (`npm run check:base`, now part of `npm test`) checks that:
  - `base.css` is one `synth.base` layer of single-`:where()` selectors with exactly the specified declarations, and only tokens that exist;
  - the bundle imports it in the right place;
  - no modular file imports `base.css` or uses `@layer`.
- `scripts/check-base.test.mjs` has 8 unit tests for that check.
- `scripts/check-base-browser.mjs` (`npm run check:base:browser`, needs Playwright like the other browser checks) writes fixture pages and reads computed styles in Chromium. It covers every acceptance criterion:
  - `:root { --font-sans: "Inter", sans-serif }` makes `body` and `p` use that font;
  - with no author CSS, `h1`–`h4` font sizes equal `--text-3xl`, `--text-2xl`, `--text-xl` and `--text-lg`;
  - `html` colour and background equal the tokens, and `code` uses `--font-mono`;
  - author rules `body { font-family: serif }` and `h1 { font-size: 10px }` win;
  - card, alert, panel and empty-state headings have the same size, weight, line height and colour with and without the base layer;
  - the modular files alone apply no base styles.
- Docs: a new "Base styles" section in `docs/tokens.md`, plus updates to `README.md` (new paragraph and file table) and `docs/releasing.md`.

## How it was verified
- `npm test` passes: all static checks, the contract check and 67 node tests.
- `check-base-browser`, `check-layout-browser` and `check-components-browser` pass in Chromium 141 (a Playwright browser already in the sandbox).
- I temporarily removed `base.css` from the bundle and `check-base-browser` reported the failures you'd expect.
- `npm run build` writes `dist/base.css`, and `synth.base` appears only in `synthcss.css` and `base.css`.
- `check-showcase-browser` fails in this sandbox in both viewports, only because an external badge image (`https://nabled.ai/api/badges/button.svg`) can't load here (certificate error). There is no other failure.

## For the maintainer to look at
- **Contract version bump and new key.** Bumping to 1.1.0 and adding a `baseStyles` key is my choice for "reflect the same information in `synthcss.ai.json`". The alternative was to fold it into the token descriptions.
- **Change to the token-mention regex** in the verifier, which now skips `--name-*` wildcards.
- **Visible change for existing users:** `code` and `pre` now use the `--font-mono` stack. Chrome's smaller default size for plain `monospace` no longer applies, so code text may render somewhat larger.
- **Showcase:** its own `.sc-page` rules are unlayered and still win, and the hero `h1` has its own class, so there is no visible change.
2026-10-03T13:03:00Z  [claude] The base styles are in and pushed to `nabled/feature-7-job-6`. A page that only links the bundle now uses `--font-sans`, the token colours, and the token sizes on `h1`–`h4`. `npm test` passes, and so does a new Chromium check covering every acceptance criterion. The showcase browser check fails here, but only because an external badge image can't load in this sandbox.

- **New `src/base.css`:** it holds the rules from the spec, all at zero specificity (`:where`) inside one `@layer synth.base` block. Author rules such as `body { font-family: serif }` and `h1 { font-size: 10px }` override it. The card, alert, panel and empty-state headings look exactly as they did before. There are no resets.
- **Bundle:** `synthcss.css` loads `base.css` after the tokens and before layout and components. Loading `tokens.css`, `layout.css` and `components.css` on their own applies no base styles. The build also publishes `dist/base.css` as a separate file for people who want it with the modular files.
- **Tokens:** `--leading-tight`, `--weight-semibold` and `--font-mono` already existed, so I added none.
- **Contract:** `synthcss.llm.md` has the sentence from the spec, word for word. `synthcss.ai.json` gets a new `baseStyles` key with the same sentence and the rules. The contract check now fails if that key drifts from `base.css` or the Markdown.
- **Tests:** a new `check-base` script with 8 unit tests runs in `npm test`. A new browser check, `check-base-browser`, renders test pages in Chromium and compares the computed styles to the tokens. When I removed `base.css` from the bundle, it failed as it should.

Things to look at in review:
- **Contract version bump:** adding the `baseStyles` key changes the JSON format, so I raised `contractVersion` from 1.0.0 to 1.1.0. Putting the information into the existing token descriptions instead would have avoided the bump.
- **Contract check change:** the required sentence contains `--text-*`, and the existing check read that as an unknown token `--text-`. I changed it to ignore wildcard names like that.
- **Code text may look bigger:** `code` and `pre` now use the `--font-mono` font list. Chrome shrinks text set in its plain default monospace font, and that no longer happens, so code may render somewhat larger than before.

The new files are `src/base.css`, `scripts/check-base.mjs`, `scripts/check-base.test.mjs` and `scripts/check-base-browser.mjs`. I also updated the contract files, the build and release workflow, `README.md` and the docs.
2026-10-03T13:03:03Z  Changed files:
modified  .github/workflows/release.yml  +2 -1
modified  README.md  +3 -2
modified  docs/ai-contract.md  +6 -1
modified  docs/releasing.md  +1 -1
modified  docs/tokens.md  +24 -0
modified  package.json  +3 -1
modified  scripts/build.mjs  +1 -1
modified  scripts/build.test.mjs  +1 -0
added  scripts/check-base-browser.mjs  +163 -0
added  scripts/check-base.mjs  +145 -0
added  scripts/check-base.test.mjs  +60 -0
modified  scripts/verify-ai-contract.mjs  +30 -1
modified  scripts/verify-ai-contract.test.mjs  +18 -1
added  src/base.css  +42 -0
modified  src/synthcss.css  +3 -0
modified  synthcss.ai.json  +19 -1
modified  synthcss.llm.md  +3 -1
2026-10-03T13:03:05Z  Opened pull request https://github.com/nabledhq/synthcss/pull/11
2026-10-03T13:03:05Z  Finished: success=true turns=33 tokens(in/out)=2478176/28996 list cost=$1.58

Show patch
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b0109cc..30c9d36 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -140,6 +140,7 @@ jobs:
 
           - Unminified bundle: ${cdn}/dist/synthcss.css
           - Tokens only: ${cdn}/dist/tokens.min.css
+          - Base styles only (load tokens first): ${cdn}/dist/base.min.css
           - Layout only (load tokens first): ${cdn}/dist/layout.min.css
           - Components only (load tokens first): ${cdn}/dist/components.min.css
           - AI contract: ${cdn}/synthcss.llm.md and ${cdn}/synthcss.ai.json
@@ -159,7 +160,7 @@ jobs:
           major=${VERSION%%.*}
           minor=${VERSION%.*}
           for alias in "$major" "$minor"; do
-            for file in synthcss tokens layout components; do
+            for file in synthcss tokens base layout components; do
               for ext in css min.css; do
                 curl -fsS "https://purge.jsdelivr.net/gh/${GITHUB_REPOSITORY}@${alias}/dist/${file}.${ext}" >/dev/null
               done
diff --git a/README.md b/README.md
index f1f5c1c..5580623 100644
--- a/README.md
+++ b/README.md
@@ -16,8 +16,9 @@ Load the bundle from the jsDelivr CDN, pinned to a [release](https://github.com/
 
 | File | Contents |
 | --- | --- |
-| `dist/synthcss.css` | Everything: tokens, layout primitives and components in one file. |
+| `dist/synthcss.css` | Everything: tokens, base styles, layout primitives and components in one file. |
 | `dist/tokens.css` | Design tokens only. |
+| `dist/base.css` | Base styles only (page font and colors, `h1`–`h4` sizes). Load `tokens.css` first. |
 | `dist/layout.css` | Layout primitives only. Load `tokens.css` first. |
 | `dist/components.css` | Components only. Load `tokens.css` first. |
 
@@ -31,7 +32,7 @@ All visual decisions (color, spacing, typography, radius, borders, shadows, sizi
 :root { --color-primary: #YOUR_COLOR; --radius-md: 0.5rem; --space-3: 0.75rem; }
 ```
 
-See [docs/tokens.md](docs/tokens.md) for the full token reference, override examples, guidance for AI agents and reduced-motion behavior. Run `npm test` to check that the tokens, the docs and the contrast requirements are in sync. It needs only Node.js 18 or later.
+The main bundle also applies the tokens to plain markup: `--font-sans`, `--color-text` and `--color-background` on the page and the `--text-*` scale on `h1`–`h4`, in a zero-specificity `synth.base` cascade layer that any rule of your own overrides ([`src/base.css`](src/base.css)). See [docs/tokens.md](docs/tokens.md) for the full token reference, override examples, guidance for AI agents and reduced-motion behavior. Run `npm test` to check that the tokens, the docs and the contrast requirements are in sync. It needs only Node.js 18 or later.
 
 ## Layout primitives
 
diff --git a/docs/ai-contract.md b/docs/ai-contract.md
index 3ef2533..9e696cd 100644
--- a/docs/ai-contract.md
+++ b/docs/ai-contract.md
@@ -21,6 +21,7 @@ Class names are written **without** the leading dot. Token names keep their `--`
 | `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`. |
+| `baseStyles` | object | `{ note, rules }`: what the base styles in `src/base.css` apply. `note` is a one-sentence summary that the Markdown Design Tokens section repeats; `rules` maps each selector (without `:where()`) to its declarations, exactly as in `src/base.css`. |
 | `layouts` | object | Layout class → intent: the eight primitives, their `-sm` / `-lg` gap variants and `cover-main`. |
 | `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. |
@@ -33,6 +34,7 @@ Class names are written **without** the leading dot. Token names keep their `--`
   "synthcssVersion": "0.1.0",
   "contractVersion": "1.0.0",
   "tokens": { "--space-4": "1rem spacing step (default gap)" },
+  "baseStyles": { "note": "Base styles apply --font-sans, … Override the tokens to restyle.", "rules": { "h1": { "font-size": "var(--text-3xl)" } } },
   "layouts": { "stack": "vertical flow with tokenized spacing" },
   "components": {
     "badge": { "intent": "short status label", "parts": {}, "variants": { "badge-success": "positive status" } }
@@ -48,7 +50,8 @@ Class names are written **without** the leading dot. Token names keep their `--`
 ```
 
 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.
+`--name`; the verifier reads those mentions too, so every one must exist. A token
+family written as `--text-*` is not read as a token.
 
 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
@@ -79,6 +82,8 @@ workflow updates `synthcssVersion` and the Markdown header with
 - 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);
+- `baseStyles.rules` differs from the rules in `src/base.css`, or the Markdown Design
+  Tokens section does not state `baseStyles.note`;
 - 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;
diff --git a/docs/releasing.md b/docs/releasing.md
index 1fa91ca..bbfbf3f 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -9,7 +9,7 @@ npm account.
 
 - **`dist/`** holds the distributable stylesheets. `npm run build` writes them from
   `src/`: `dist/synthcss.css` is the bundle with every `@import` inlined, and
-  `dist/tokens.css`, `dist/layout.css` and `dist/components.css` are the parts on
+  `dist/tokens.css`, `dist/base.css`, `dist/layout.css` and `dist/components.css` are the parts on
   their own. A new file in `src/` must be added to `OUTPUTS` in `scripts/build.mjs`
   to be published on its own; anything imported by `src/synthcss.css` is always in
   the bundle. Each file starts
diff --git a/docs/tokens.md b/docs/tokens.md
index 9a53fcb..7568ea2 100644
--- a/docs/tokens.md
+++ b/docs/tokens.md
@@ -172,6 +172,30 @@ If you change `--color-primary`, check that `--color-on-primary` still contrasts
 least 4.5:1 with it, and change `--color-primary-hover` to match. See
 [`examples/tokens.html`](../examples/tokens.html) for a working demo.
 
+## Base styles
+
+The main bundle (`synthcss.css`) also includes [`src/base.css`](../src/base.css), which
+applies the tokens to the page so plain markup picks them up:
+
+| Element | Properties |
+| --- | --- |
+| `html` | `font-family: var(--font-sans)`, `font-size: 100%`, `line-height: var(--leading-normal)`, `color: var(--color-text)`, `background: var(--color-background)` |
+| `h1`–`h4` | `line-height: var(--leading-tight)`, `font-weight: var(--weight-semibold)` |
+| `h1` / `h2` / `h3` / `h4` | `font-size`: `var(--text-3xl)` / `var(--text-2xl)` / `var(--text-xl)` / `var(--text-lg)` |
+| `code`, `kbd`, `pre`, `samp` | `font-family: var(--font-mono)` |
+
+Override the tokens to restyle, for example `:root { --font-sans: "Inter", sans-serif; }`.
+Every selector is wrapped in `:where()` (zero specificity) and the rules sit in the
+`synth.base` cascade layer. Unlayered rules always beat layered ones, so any rule of
+your own (`body { font-family: serif; }`, `h1 { font-size: 2.5rem; }`) and the
+component heading styles (such as `.card-header h2`) win without `!important`. There are
+no resets: margins, `box-sizing`, `h5`/`h6`, links and lists keep the browser defaults.
+
+The modular files (`tokens.css`, `layout.css`, `components.css`) do not include the
+base styles. Load `base.css` after `tokens.css` to opt in. `npm run check:base` checks
+`src/base.css` and its place in the bundle. `npm run check:base:browser` checks the
+computed styles of fixture pages in Chromium. It needs Playwright.
+
 ## For AI agents
 
 - Always write `var(--token)` instead of raw values. Do not hard-code hex colors, `px`
diff --git a/package.json b/package.json
index 49678d0..ee2f257 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,8 @@
   "scripts": {
     "build": "node scripts/build.mjs",
     "check:tokens": "node scripts/check-tokens.mjs",
+    "check:base": "node scripts/check-base.mjs",
+    "check:base:browser": "node scripts/check-base-browser.mjs",
     "check:layout": "node scripts/check-layout.mjs",
     "check:layout:browser": "node scripts/check-layout-browser.mjs",
     "check:components": "node scripts/check-components.mjs",
@@ -18,6 +20,6 @@
     "check:showcase": "node scripts/check-showcase.mjs",
     "check:showcase:browser": "node scripts/check-showcase-browser.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/bump-version.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"
+    "test": "node scripts/check-tokens.mjs && node scripts/check-base.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/bump-version.test.mjs scripts/check-tokens.test.mjs scripts/check-base.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/build.mjs b/scripts/build.mjs
index 324ab99..8c075c7 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -10,7 +10,7 @@ import { dirname, resolve } from "node:path";
 
 export const REPO_URL = "https://github.com/nabledhq/synthcss";
 // Standalone files. synthcss.css is the bundle with every @import inlined.
-export const OUTPUTS = ["synthcss.css", "tokens.css", "layout.css", "components.css"];
+export const OUTPUTS = ["synthcss.css", "tokens.css", "base.css", "layout.css", "components.css"];
 
 const IMPORT = /^@import\s+url\(\s*["']?([^"')]+)["']?\s*\)\s*;[ \t]*$/gm;
 const toLf = (text) => text.replace(/\r\n/g, "\n");
diff --git a/scripts/build.test.mjs b/scripts/build.test.mjs
index 82b813c..29d782a 100644
--- a/scripts/build.test.mjs
+++ b/scripts/build.test.mjs
@@ -5,6 +5,7 @@ import { build, inlineImports, OUTPUTS } from "./build.mjs";
 const fakeSrc = {
   "synthcss.css": '/* bundle */\r\n@import url("tokens.css");\r\n@import url(\'layout.css\');\r\n',
   "tokens.css": ":root { --space-1: 0.25rem; }\n",
+  "base.css": "@layer synth.base { :where(html) { color: red; } }\n",
   "layout.css": ".stack { gap: var(--space-1); }\n",
   "components.css": ".card { padding: var(--space-1); }\n",
 };
diff --git a/scripts/check-base-browser.mjs b/scripts/check-base-browser.mjs
new file mode 100644
index 0000000..0cf384c
--- /dev/null
+++ b/scripts/check-base-browser.mjs
@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+// Optional browser check of the base styles: fixture pages that load the bundle
+// (src/synthcss.css) or only the modular files, measured with computed styles.
+// Needs Playwright, which is not a dependency of this repository:
+//   npm install --no-save playwright && npx playwright install chromium
+// Usage: node scripts/check-base-browser.mjs
+
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { dirname, join, resolve } from "node:path";
+
+let chromium;
+try {
+  ({ chromium } = await import("playwright"));
+} catch {
+  console.error(
+    "check-base-browser: Playwright is not installed.\n" +
+      "Run `npm install --no-save playwright && npx playwright install chromium`, then try again.",
+  );
+  process.exit(2);
+}
+
+const repo = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const src = (file) => pathToFileURL(resolve(repo, "src", file)).href;
+const BUNDLE = [src("synthcss.css")];
+const MODULAR = ["tokens.css", "layout.css", "components.css"].map(src);
+
+const MARKUP = `
+  <h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4>
+  <p>Plain paragraph with <code>code</code>.</p>
+  <div class="card"><div class="card-header"><h2>Card title</h2></div><div class="card-body">Body</div></div>
+  <div class="alert alert-info" role="status"><h3>Alert title</h3><p>Text</p></div>
+  <section class="panel"><div class="panel-header"><h2>Panel title</h2></div><div class="panel-body">Body</div></section>
+  <div class="empty-state"><h2>Nothing yet</h2><p>Text</p></div>`;
+
+const FIXTURES = {
+  bundle: { sheets: BUNDLE, css: "" },
+  inter: { sheets: BUNDLE, css: ':root { --font-sans: "Inter", sans-serif; }' },
+  author: { sheets: BUNDLE, css: "body { font-family: serif; } h1 { font-size: 10px; }" },
+  modular: { sheets: MODULAR, css: "" },
+};
+
+const page = ({ sheets, css }) => `<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <title>SynthCSS base fixture</title>
+${sheets.map((href) => `  <link rel="stylesheet" href="${href}">`).join("\n")}
+  <style>${css}</style>
+</head>
+<body>${MARKUP}
+</body>
+</html>
+`;
+
+// Runs in the page.
+function measure() {
+  const root = document.documentElement;
+  const cs = (el) => getComputedStyle(el);
+  const $ = (sel) => document.querySelector(sel);
+  // Resolve a token to a computed value through a probe element.
+  const probe = document.createElement("div");
+  document.body.append(probe);
+  const token = (prop, name) => {
+    probe.style.setProperty(prop, `var(${name})`);
+    const value = cs(probe).getPropertyValue(prop);
+    probe.style.removeProperty(prop);
+    return value;
+  };
+  const heading = (sel) => {
+    const s = cs($(sel));
+    return { fontSize: s.fontSize, fontWeight: s.fontWeight, lineHeight: s.lineHeight, color: s.color };
+  };
+  const out = {
+    tokens: {
+      fontSans: token("font-family", "--font-sans"),
+      fontMono: token("font-family", "--font-mono"),
+      text: token("color", "--color-text"),
+      background: token("background-color", "--color-background"),
+      sizes: Object.fromEntries(["--text-3xl", "--text-2xl", "--text-xl", "--text-lg"].map((t) => [t, token("font-size", t)])),
+      weight: token("font-weight", "--weight-semibold"),
+    },
+    html: { fontFamily: cs(root).fontFamily, fontSize: cs(root).fontSize, color: cs(root).color, background: cs(root).backgroundColor },
+    body: { fontFamily: cs(document.body).fontFamily },
+    p: { fontFamily: cs($("body > p")).fontFamily },
+    code: { fontFamily: cs($("code")).fontFamily },
+    headings: Object.fromEntries(["h1", "h2", "h3", "h4"].map((h) => [h, heading(`body > ${h}`)])),
+    components: Object.fromEntries(
+      [".card-header h2", ".alert h3", ".panel-header h2", ".empty-state h2"].map((sel) => [sel, heading(sel)]),
+    ),
+  };
+  probe.remove();
+  return out;
+}
+
+const errors = [];
+const expect = (ok, msg) => {
+  if (!ok) errors.push(msg);
+};
+const fonts = (value) => value.replace(/["']/g, "").replace(/\s*,\s*/g, ",").trim();
+
+const dir = mkdtempSync(join(tmpdir(), "synthcss-base-"));
+const browser = await chromium.launch();
+try {
+  const results = {};
+  for (const [name, fixture] of Object.entries(FIXTURES)) {
+    const file = join(dir, `${name}.html`);
+    writeFileSync(file, page(fixture));
+    const tab = await browser.newPage();
+    const failed = [];
+    tab.on("requestfailed", (req) => failed.push(req.url()));
+    await tab.goto(pathToFileURL(file).href, { waitUntil: "load" });
+    expect(failed.length === 0, `${name}: failed to load ${failed.join(", ")}`);
+    results[name] = await tab.evaluate(measure);
+    await tab.close();
+  }
+  const { bundle, inter, author, modular } = results;
+
+  // The bundle alone applies the tokens to the page and the headings.
+  expect(fonts(bundle.body.fontFamily) === fonts(bundle.tokens.fontSans), `bundle: body font-family is ${bundle.body.fontFamily}, not --font-sans`);
+  expect(bundle.html.fontSize === "16px", `bundle: html font-size is ${bundle.html.fontSize}, expected 100% (16px)`);
+  expect(bundle.html.color === bundle.tokens.text, "bundle: html color is not --color-text");
+  expect(bundle.html.background === bundle.tokens.background, "bundle: html background is not --color-background");
+  expect(fonts(bundle.code.fontFamily) === fonts(bundle.tokens.fontMono), `bundle: code font-family is ${bundle.code.fontFamily}, not --font-mono`);
+  for (const [h, t] of [["h1", "--text-3xl"], ["h2", "--text-2xl"], ["h3", "--text-xl"], ["h4", "--text-lg"]]) {
+    const got = bundle.headings[h];
+    expect(got.fontSize === bundle.tokens.sizes[t], `bundle: ${h} font-size is ${got.fontSize}, expected ${t} (${bundle.tokens.sizes[t]})`);
+    expect(got.fontWeight === bundle.tokens.weight, `bundle: ${h} font-weight is ${got.fontWeight}, expected --weight-semibold`);
+  }
+
+  // Overriding --font-sans on :root restyles the page.
+  for (const el of ["body", "p"]) {
+    expect(fonts(inter[el].fontFamily) === "Inter,sans-serif", `inter: ${el} font-family is ${inter[el].fontFamily}, expected the --font-sans override`);
+  }
+
+  // Author rules beat the base layer.
+  expect(fonts(author.body.fontFamily) === "serif", `author: body { font-family: serif } lost, got ${author.body.fontFamily}`);
+  expect(author.headings.h1.fontSize === "10px", `author: h1 { font-size: 10px } lost, got ${author.headings.h1.fontSize}`);
+
+  // Component headings render the same with and without the base layer.
+  for (const [sel, got] of Object.entries(bundle.components)) {
+    const before = modular.components[sel];
+    expect(JSON.stringify(got) === JSON.stringify(before), `${sel} changed: ${JSON.stringify(before)} → ${JSON.stringify(got)}`);
+  }
+
+  // The modular files alone apply no base styles.
+  expect(fonts(modular.body.fontFamily) !== fonts(modular.tokens.fontSans), "modular: body uses --font-sans without the base layer");
+  expect(modular.headings.h1.fontSize !== modular.tokens.sizes["--text-3xl"], "modular: h1 uses --text-3xl without the base layer");
+  expect(modular.headings.h1.fontWeight !== modular.tokens.weight, "modular: h1 uses --weight-semibold without the base layer");
+} finally {
+  await browser.close();
+  rmSync(dir, { recursive: true, force: true });
+}
+
+if (errors.length) {
+  for (const e of errors) console.error(`  FAIL ${e}`);
+  console.error(`\ncheck-base-browser: ${errors.length} problem(s) found.`);
+  process.exit(1);
+}
+console.log(
+  "check-base-browser: the bundle applies --font-sans, colors and the heading scale; author rules and component headings win; modular files apply no base styles.",
+);
diff --git a/scripts/check-base.mjs b/scripts/check-base.mjs
new file mode 100644
index 0000000..c030aca
--- /dev/null
+++ b/scripts/check-base.mjs
@@ -0,0 +1,145 @@
+#!/usr/bin/env node
+// Dependency-free verification of the base styles in src/base.css: zero-specificity
+// rules inside @layer synth.base that apply the font, color and heading tokens, bundled
+// in src/synthcss.css after tokens.css and before layout.css, and absent from the
+// modular files.
+// Usage: node scripts/check-base.mjs
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+import { parseBlocks, parseDeclarations, parseTokens } from "./check-tokens.mjs";
+import { splitSelectors } from "./check-components.mjs";
+
+export const BASE_LAYER = "synth.base";
+// Element selector (without :where()) → the declarations it must have, and nothing else.
+export const BASE_RULES = {
+  html: {
+    "font-family": "var(--font-sans)",
+    "font-size": "100%",
+    "line-height": "var(--leading-normal)",
+    color: "var(--color-text)",
+    background: "var(--color-background)",
+  },
+  "h1, h2, h3, h4": { "line-height": "var(--leading-tight)", "font-weight": "var(--weight-semibold)" },
+  h1: { "font-size": "var(--text-3xl)" },
+  h2: { "font-size": "var(--text-2xl)" },
+  h3: { "font-size": "var(--text-xl)" },
+  h4: { "font-size": "var(--text-lg)" },
+  "code, kbd, pre, samp": { "font-family": "var(--font-mono)" },
+};
+export const MODULAR_FILES = ["tokens.css", "layout.css", "components.css"];
+
+const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, "");
+const ZERO_SPECIFICITY = /^:where\(([^()]+)\)$/;
+
+// Parses base.css into { layer, rules: Map(elements → { prop: value }), errors }.
+// `elements` is the selector list inside :where(), normalized to "a, b".
+export function parseBase(css) {
+  const errors = [];
+  const rules = new Map();
+  const blocks = parseBlocks(stripComments(css));
+  if (blocks.length !== 1 || !/^@layer\s/.test(blocks[0].prelude)) {
+    return { errors: [`base.css must contain exactly one @layer ${BASE_LAYER} { … } block and nothing else`], rules };
+  }
+  const layer = blocks[0].prelude.replace(/^@layer\s+/, "").trim();
+  if (layer !== BASE_LAYER) errors.push(`base.css must use @layer ${BASE_LAYER}, found @layer ${layer}`);
+  for (const { prelude, body } of parseBlocks(blocks[0].body)) {
+    const selectors = splitSelectors(prelude);
+    const m = selectors.length === 1 && ZERO_SPECIFICITY.exec(selectors[0]);
+    if (!m) {
+      errors.push(`base.css: selector "${prelude}" must be a single :where(…) so it has zero specificity`);
+      continue;
+    }
+    const elements = splitSelectors(m[1]).join(", ");
+    if (rules.has(elements)) errors.push(`base.css: :where(${elements}) is defined more than once`);
+    rules.set(elements, Object.fromEntries(parseDeclarations(body).filter((d) => d.prop).map((d) => [d.prop, d.value])));
+  }
+  return { errors, rules };
+}
+
+export function checkBaseCss(baseCss, tokensCss) {
+  let parsed;
+  try {
+    parsed = parseBase(baseCss);
+  } catch (err) {
+    return [`base.css: ${err.message}`];
+  }
+  const { rules } = parsed;
+  const errors = [...parsed.errors];
+  const tokens = parseTokens(tokensCss).root;
+  for (const [elements, decls] of Object.entries(BASE_RULES)) {
+    const actual = rules.get(elements);
+    if (!actual) {
+      errors.push(`base.css has no :where(${elements}) rule`);
+      continue;
+    }
+    for (const [prop, value] of Object.entries(decls)) {
+      if (actual[prop] !== value) errors.push(`base.css: :where(${elements}) must set ${prop}: ${value}, found ${actual[prop] ?? "nothing"}`);
+    }
+    for (const prop of Object.keys(actual)) {
+      if (!(prop in decls)) errors.push(`base.css: :where(${elements}) sets ${prop}, which is not a base style (no resets)`);
+    }
+  }
+  for (const elements of rules.keys()) {
+    if (!(elements in BASE_RULES)) errors.push(`base.css: unexpected rule :where(${elements})`);
+  }
+  for (const [elements, decls] of rules) {
+    for (const [prop, value] of Object.entries(decls)) {
+      for (const m of value.matchAll(/var\(\s*(--[\w-]+)/g)) {
+        if (!tokens.has(m[1])) errors.push(`base.css: :where(${elements}) { ${prop} } references ${m[1]}, which is not defined in tokens.css`);
+      }
+    }
+  }
+  return errors;
+}
+
+const importsOf = (css) => [...stripComments(css).matchAll(/@import\s+(?:url\()?\s*["']?([^"')\s]+)/g)].map((m) => m[1]);
+
+export function checkBundle(bundleCss) {
+  const imports = importsOf(bundleCss);
+  const [t, b, l, c] = ["tokens.css", "base.css", "layout.css", "components.css"].map((f) => imports.indexOf(f));
+  if (b === -1) return ["src/synthcss.css does not import base.css"];
+  const errors = [];
+  if (t === -1 || b < t) errors.push("src/synthcss.css must import base.css after tokens.css");
+  if ((l !== -1 && b > l) || (c !== -1 && b > c)) errors.push("src/synthcss.css must import base.css before layout.css and components.css");
+  return errors;
+}
+
+// The modular files must work without the base layer and must not pull it in.
+export function checkModular(modular) {
+  const errors = [];
+  for (const [file, css] of Object.entries(modular)) {
+    const code = stripComments(css);
+    if (importsOf(code).includes("base.css")) errors.push(`${file} must not import base.css`);
+    if (/@layer\b/.test(code)) errors.push(`${file} must not use @layer; base styles live only in base.css`);
+  }
+  return errors;
+}
+
+export function checkBase({ baseCss, tokensCss, bundleCss, modular }) {
+  return [...checkBaseCss(baseCss, tokensCss), ...checkBundle(bundleCss), ...checkModular(modular)];
+}
+
+export function readRepoFiles(repo) {
+  const read = (p) => readFileSync(resolve(repo, p), "utf8");
+  return {
+    baseCss: read("src/base.css"),
+    tokensCss: read("src/tokens.css"),
+    bundleCss: read("src/synthcss.css"),
+    modular: Object.fromEntries(MODULAR_FILES.map((f) => [f, read(`src/${f}`)])),
+  };
+}
+
+const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isMain) {
+  const repo = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+  const errors = checkBase(readRepoFiles(repo));
+  if (errors.length) {
+    for (const e of errors) console.error(`  FAIL ${e}`);
+    console.error(`\ncheck-base: ${errors.length} problem(s) found.`);
+    process.exit(1);
+  }
+  console.log(`check-base: ${Object.keys(BASE_RULES).length} zero-specificity rules in @layer ${BASE_LAYER}, bundled after tokens.css and absent from the modular files.`);
+}
diff --git a/scripts/check-base.test.mjs b/scripts/check-base.test.mjs
new file mode 100644
index 0000000..2ac931d
--- /dev/null
+++ b/scripts/check-base.test.mjs
@@ -0,0 +1,60 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { checkBase, parseBase, readRepoFiles } from "./check-base.mjs";
+
+const repo = new URL("..", import.meta.url).pathname;
+const files = readRepoFiles(repo);
+
+const errorsWith = (changes) => checkBase({ ...files, ...changes });
+const assertError = (errors, text) => assert.ok(errors.some((e) => e.includes(text)), errors.join("\n"));
+
+test("repository base files pass", () => {
+  assert.deepEqual(errorsWith({}), []);
+});
+
+test("parses the zero-specificity rules of the layer", () => {
+  const { errors, rules } = parseBase(files.baseCss);
+  assert.deepEqual(errors, []);
+  assert.equal(rules.get("h1")["font-size"], "var(--text-3xl)");
+  assert.equal(rules.get("html")["font-family"], "var(--font-sans)");
+});
+
+test("fails when the rules are not wrapped in @layer synth.base", () => {
+  const unlayered = files.baseCss.replace(/@layer synth\.base \{([\s\S]*)\}\s*$/, "$1");
+  assertError(errorsWith({ baseCss: unlayered }), "exactly one @layer synth.base");
+  const renamed = files.baseCss.replace("@layer synth.base", "@layer base");
+  assertError(errorsWith({ baseCss: renamed }), "must use @layer synth.base");
+});
+
+test("fails on a selector with specificity", () => {
+  const baseCss = files.baseCss.replace(":where(h1) {", "h1 {");
+  assertError(errorsWith({ baseCss }), 'selector "h1" must be a single :where(…)');
+});
+
+test("fails when a heading size or page style is wrong or missing", () => {
+  const wrong = files.baseCss.replace("var(--text-2xl)", "var(--text-xl)");
+  assertError(errorsWith({ baseCss: wrong }), ":where(h2) must set font-size: var(--text-2xl)");
+  const missing = files.baseCss.replace("color: var(--color-text);", "");
+  assertError(errorsWith({ baseCss: missing }), ":where(html) must set color: var(--color-text)");
+});
+
+test("fails on resets and extra rules", () => {
+  const reset = files.baseCss.replace("font-size: 100%;", "font-size: 100%;\n    margin: 0;");
+  assertError(errorsWith({ baseCss: reset }), "sets margin, which is not a base style");
+  const extra = files.baseCss.replace(/\}\s*$/, "  :where(h5) { font-size: var(--text-base); }\n}\n");
+  assertError(errorsWith({ baseCss: extra }), "unexpected rule :where(h5)");
+});
+
+test("fails when the bundle misses base.css or loads it in the wrong place", () => {
+  const without = files.bundleCss.replace('@import url("base.css");', "");
+  assertError(errorsWith({ bundleCss: without }), "does not import base.css");
+  const late = without.replace('@import url("components.css");', '@import url("components.css");\n@import url("base.css");');
+  assertError(errorsWith({ bundleCss: late }), "before layout.css and components.css");
+  const early = without.replace('@import url("tokens.css");', '@import url("base.css");\n@import url("tokens.css");');
+  assertError(errorsWith({ bundleCss: early }), "after tokens.css");
+});
+
+test("fails when a modular file pulls in base styles", () => {
+  const modular = { ...files.modular, "layout.css": '@import url("base.css");\n' + files.modular["layout.css"] };
+  assertError(errorsWith({ modular }), "layout.css must not import base.css");
+});
diff --git a/scripts/verify-ai-contract.mjs b/scripts/verify-ai-contract.mjs
index 72476a1..5a6820c 100644
--- a/scripts/verify-ai-contract.mjs
+++ b/scripts/verify-ai-contract.mjs
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
 import { dirname, resolve } from "node:path";
 import { parseTokens } from "./check-tokens.mjs";
 import { buildBundle, classesInCss } from "./check-components.mjs";
+import { parseBase } from "./check-base.mjs";
 
 export const DEFAULT_MAX_TOKENS = 8000;
 // Class selectors in the framework CSS that are internal helpers, not public API,
@@ -20,6 +21,7 @@ export const JSON_KEYS = [
   "synthcssVersion",
   "contractVersion",
   "tokens",
+  "baseStyles",
   "layouts",
   "components",
   "intentMap",
@@ -48,7 +50,8 @@ 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;
+// A token family written as `--text-*` is not a token mention.
+const TOKEN_MENTION = /(?<![\w-])(--[a-z][a-z0-9-]*)(?![\w*-])/g;
 const CLASS_ATTR = /\bclass="([^"]*)"/g;
 
 const sorted = (set) => [...set].sort();
@@ -161,6 +164,15 @@ function checkJsonShape(c) {
       errors.push(`${JSON_FILE}: ${key} must map each name to a short purpose string`);
     }
   }
+  const base = c.baseStyles;
+  if (
+    !isObject(base) ||
+    typeof base.note !== "string" ||
+    !isObject(base.rules) ||
+    !Object.values(base.rules).every((d) => isObject(d) && Object.values(d).every((v) => typeof v === "string"))
+  ) {
+    errors.push(`${JSON_FILE}: baseStyles must be { note, rules: { selector: { property: value } } }`);
+  }
   if (!isObject(c.components)) errors.push(`${JSON_FILE}: components must be an object`);
   else {
     for (const [name, comp] of Object.entries(c.components)) {
@@ -248,6 +260,18 @@ export function verifyContract(files, { maxTokens = DEFAULT_MAX_TOKENS } = {}) {
   }
   for (const token of diff(cssTokens, jsonTokens)) errors.push(`token ${token} is defined on :root but missing from ${JSON_FILE}`);
 
+  // Base styles against src/base.css.
+  const baseRules = parseBase(files.baseCss ?? "").rules;
+  const jsonBase = contract.baseStyles.rules;
+  for (const [selector, decls] of baseRules) {
+    if (JSON.stringify(jsonBase[selector]) !== JSON.stringify(decls)) {
+      errors.push(`${JSON_FILE}: baseStyles.rules["${selector}"] must be ${JSON.stringify(decls)} as in src/base.css`);
+    }
+  }
+  for (const selector of Object.keys(jsonBase)) {
+    if (!baseRules.has(selector)) errors.push(`${JSON_FILE}: baseStyles.rules["${selector}"] is not a rule in src/base.css`);
+  }
+
   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`);
@@ -284,6 +308,10 @@ export function verifyContract(files, { maxTokens = DEFAULT_MAX_TOKENS } = {}) {
   }
   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}`);
+  // The Markdown may wrap names in backticks.
+  if (!section("Design Tokens").replace(/`/g, "").replace(/\s+/g, " ").includes(contract.baseStyles.note.replace(/\s+/g, " "))) {
+    errors.push(`${LLM_FILE}: the Design Tokens section must state the ${JSON_FILE} baseStyles note`);
+  }
   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}`);
 
@@ -408,6 +436,7 @@ export function readRepoFiles(repo) {
     md: read(LLM_FILE),
     pkg: read("package.json"),
     builtCss: buildBundle(resolve(repo, "src/synthcss.css"), (p) => readFileSync(p, "utf8")),
+    baseCss: read("src/base.css"),
     showcaseHtml: read("showcase/index.html"),
     workflow: read(".github/workflows/pages.yml"),
     readme: read("README.md"),
diff --git a/scripts/verify-ai-contract.test.mjs b/scripts/verify-ai-contract.test.mjs
index 4be9256..04adc60 100644
--- a/scripts/verify-ai-contract.test.mjs
+++ b/scripts/verify-ai-contract.test.mjs
@@ -107,7 +107,7 @@ test("fails when versions do not match package.json", () => {
   assertError(errorsWith({ pkg }), `states SynthCSS ${version} but package.json is 99.0.0`);
   assertError(errorsWith({ pkg }), `links to synthcss@${version} but package.json is 99.0.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");
+  assertError(errorsWith(contractVersion), `states contract ${contract.contractVersion} but synthcss.ai.json has 2.0.0`);
 });
 
 test("fails when the JSON shape or Markdown sections are wrong", () => {
@@ -122,6 +122,23 @@ test("fails when the JSON shape or Markdown sections are wrong", () => {
   assertError(errorsWith({ md: noAvoid }), 'Composition Rules has no "### Avoid"');
 });
 
+test("fails when the base styles drift from src/base.css or the Markdown", () => {
+  const size = withJson((c) => (c.baseStyles.rules.h1["font-size"] = "var(--text-2xl)"));
+  assertError(errorsWith(size), 'baseStyles.rules["h1"] must be {"font-size":"var(--text-3xl)"}');
+  const extra = withJson((c) => (c.baseStyles.rules.h5 = { "font-size": "var(--text-base)" }));
+  assertError(errorsWith(extra), 'baseStyles.rules["h5"] is not a rule in src/base.css');
+  const baseCss = files.baseCss.replace("var(--text-lg)", "var(--text-base)");
+  assertError(errorsWith({ baseCss }), 'baseStyles.rules["h4"] must be {"font-size":"var(--text-base)"}');
+  assertError(errorsWith(withJson((c) => delete c.baseStyles.note)), "baseStyles must be { note, rules");
+  const md = files.md.replace(/^Base styles apply .*$/m, "");
+  assertError(errorsWith({ md }), "Design Tokens section must state the synthcss.ai.json baseStyles note");
+});
+
+test("reads `--text-*` as a token family, not a token", () => {
+  assert.deepEqual(errorsWith({ md: files.md.replace("## Layout Vocabulary", "The `--space-*` scale.\n\n## Layout Vocabulary") }), []);
+  assertError(errorsWith({ md: files.md.replace("## Layout Vocabulary", "Use `--space-7`.\n\n## Layout Vocabulary") }), "token --space-7");
+});
+
 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");
diff --git a/src/base.css b/src/base.css
new file mode 100644
index 0000000..11a15f9
--- /dev/null
+++ b/src/base.css
@@ -0,0 +1,42 @@
+/*
+ * SynthCSS base styles
+ *
+ * Applies the type and color tokens to the page and to h1–h4, so a page that
+ * only links the bundle renders in --font-sans with the token colors and the
+ * --text-* heading scale. Override the tokens on :root to restyle.
+ *
+ * - Every selector is wrapped in :where(), so it has zero specificity.
+ * - Everything sits in the synth.base cascade layer. Unlayered rules always
+ *   beat layered ones, so any author rule (body { font-family: serif }) and
+ *   every component rule (.card-header h2) wins over these defaults.
+ * - No resets: margins, box-sizing and lists keep the browser defaults.
+ *
+ * Part of the main bundle (synthcss.css), loaded after tokens.css. The modular
+ * files (tokens.css, layout.css, components.css) do not include it.
+ *
+ * Reference: docs/tokens.md#base-styles
+ */
+
+@layer synth.base {
+  :where(html) {
+    font-family: var(--font-sans);
+    font-size: 100%;
+    line-height: var(--leading-normal);
+    color: var(--color-text);
+    background: var(--color-background);
+  }
+
+  :where(h1, h2, h3, h4) {
+    line-height: var(--leading-tight);
+    font-weight: var(--weight-semibold);
+  }
+
+  :where(h1) { font-size: var(--text-3xl); }
+  :where(h2) { font-size: var(--text-2xl); }
+  :where(h3) { font-size: var(--text-xl); }
+  :where(h4) { font-size: var(--text-lg); }
+
+  :where(code, kbd, pre, samp) {
+    font-family: var(--font-mono);
+  }
+}
diff --git a/src/synthcss.css b/src/synthcss.css
index 36474cc..9071fc5 100644
--- a/src/synthcss.css
+++ b/src/synthcss.css
@@ -5,10 +5,13 @@
  *   <link rel="stylesheet" href="synthcss/src/synthcss.css">
  *
  * Tokens must load first: every other file reads them through var(--token).
+ * Base styles come next: zero-specificity page and heading defaults in the
+ * synth.base layer, which every later or author rule overrides.
  * Components load last so they can be combined with layout primitives on the
  * same element (for example class="card-footer split").
  */
 
 @import url("tokens.css");
+@import url("base.css");
 @import url("layout.css");
 @import url("components.css");
diff --git a/synthcss.ai.json b/synthcss.ai.json
index 850d454..ad02f66 100644
--- a/synthcss.ai.json
+++ b/synthcss.ai.json
@@ -1,6 +1,6 @@
 {
   "synthcssVersion": "0.2.0",
-  "contractVersion": "1.0.0",
+  "contractVersion": "1.1.0",
   "tokens": {
     "--color-background": "page background",
     "--color-surface": "subtle background for panels, table heads, footers",
@@ -60,6 +60,24 @@
     "--duration-slow": "larger movements; spinner speed",
     "--ease-standard": "default easing curve"
   },
+  "baseStyles": {
+    "note": "Base styles apply --font-sans, --color-text and --color-background to the page and the --text-* scale to h1–h4. Override the tokens to restyle.",
+    "rules": {
+      "html": {
+        "font-family": "var(--font-sans)",
+        "font-size": "100%",
+        "line-height": "var(--leading-normal)",
+        "color": "var(--color-text)",
+        "background": "var(--color-background)"
+      },
+      "h1, h2, h3, h4": { "line-height": "var(--leading-tight)", "font-weight": "var(--weight-semibold)" },
+      "h1": { "font-size": "var(--text-3xl)" },
+      "h2": { "font-size": "var(--text-2xl)" },
+      "h3": { "font-size": "var(--text-xl)" },
+      "h4": { "font-size": "var(--text-lg)" },
+      "code, kbd, pre, samp": { "font-family": "var(--font-mono)" }
+    }
+  },
   "layouts": {
     "container": "centered page-width wrapper with side padding",
     "stack": "vertical flow with tokenized spacing",
diff --git a/synthcss.llm.md b/synthcss.llm.md
index 495fea0..0dd68b9 100644
--- a/synthcss.llm.md
+++ b/synthcss.llm.md
@@ -1,6 +1,6 @@
 # SynthCSS AI Contract
 
-Version: SynthCSS 0.2.0 · contract 1.0.0 · machine-readable twin: synthcss.ai.json
+Version: SynthCSS 0.2.0 · contract 1.1.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.2.0/dist/synthcss.min.css">`
@@ -9,6 +9,8 @@ Load: `<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/nabledhq/synthcs
 
 CSS custom properties on `:root`. Use them through `var()`; restyle by overriding them on `:root`.
 
+Base styles apply `--font-sans`, `--color-text` and `--color-background` to the page and the `--text-*` scale to `h1`–`h4`. Override the tokens to restyle.
+
 - `--color-background` — page background
 - `--color-surface` — subtle background for panels, table heads, footers
 - `--color-surface-elevated` — background of raised surfaces (cards, buttons)
Acceptance · round 1
Shipped
CINo checks
Automated reviewPass with concerns

The implementation matches the spec closely. `src/base.css` is a single `@layer synth.base` of `:where()` rules with exactly the requested declarations, bundled after tokens and before layout and components. Both contract files are updated, and the sync verifier now enforces them against `base.css`. Tests are thorough on paper: static checks run in `npm test`, plus a browser check covering every acceptance criterion. However, no CI ran and the browser check is optional, so the computed-style criteria and the test suite passing are not demonstrated.

Acceptance criteria · 5 of 7 met
  • YESA bundle-only fixture page with `:root { --font-sans: "Inter", sans-serif }` has body and p font-family resolving to --font-sans`src/base.css` sets `:where(html){font-family: var(--font-sans)}`, which body and p inherit; the `inter` fixture in `check-base-browser.mjs` asserts body and p equal `Inter,sans-serif`, but that check is optional and not in `npm test`.
  • YESWith no author CSS, h1–h4 font-size equals --text-3xl/2xl/xl/lg`base.css` sets the four `:where(hN)` font-size rules; `check-base.mjs` checks them statically and the browser fixture compares computed sizes to the resolved tokens.
  • YESAuthor rules `body { font-family: serif }` and `h1 { font-size: 10px }` override the base layerAll base rules are layered `:where()` selectors, so unlayered author rules win; the `author` fixture in `check-base-browser.mjs` asserts both overrides.
  • UNCLEARExisting component heading styles (e.g. card header headings) render unchangedThe browser check compares size, weight, line-height and colour of card, alert, panel and empty-state headings between the bundle and modular-only pages; the design (layered, zero specificity) supports this, but the test is optional and not shown to have run.
  • YESLoading only the modular tokens/layout/components files applies no base styles`checkModular` in `check-base.mjs` forbids `base.css` imports and `@layer` in the modular files; the browser `modular` fixture asserts that neither the font nor the h1 size and weight are applied.
  • YESsynthcss.llm.md and synthcss.ai.json describe the base stylesThe spec sentence is added verbatim to the Design Tokens section of `synthcss.llm.md`; `synthcss.ai.json` gains a `baseStyles` key with that note and rules, and the verifier checks both stay in sync.
  • UNCLEARThe existing contract sync verification and all tests passThe verifier and its tests are updated (including the `--text-*` wildcard fix), but no CI ran, so passing cannot be confirmed.
Concerns
  • No CI ran on this commit, so the claim that `npm test` passes is unverified. The builder summary is also cut off in its 'How it was verified' section.
  • Every computed-style acceptance criterion (Inter font on body/p, h1–h4 sizes, author overrides, unchanged component headings, no base styles from modular files) is only checked by `scripts/check-base-browser.mjs`. That script needs Playwright, which is not a dependency, and it is not part of `npm test`. Nothing shows it was actually run.
  • In `verifyContract`, `contract.baseStyles.rules` and `contract.baseStyles.note` are read without a guard. If `baseStyles` is missing entirely from the JSON, this could throw a TypeError instead of reporting the shape error, unless the function returns early on shape errors (not visible in the diff).
  • Scope goes slightly beyond the spec. `base.css` is published as a standalone `dist/base.css`, and `release.yml` now purges it from the CDN cache. This is reasonable and documented but not requested, and it adds a public artifact to maintain.
  • `contractVersion` goes from 1.0.0 to 1.1.0 and the `TOKEN_MENTION` regex in the contract verifier is loosened. Both are justified and tested, but they change shared contract tooling, which backers may want to note.
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.13, counted as builder cost.

Discussion · 0

No comments yet.