In reviewMedium

Add a static SynthCSS showcase site (showcase/) with automated GitHub Pages deployment via Actions

Proposed by Jonathan Miller 3 hours agoFunding opened 3 hours agoFunded 3 hours ago
Specification

Motivation

Give visitors a single static page that shows what SynthCSS is, why it is AI-first, and live demos of its existing tokens and layout primitives. The page is published via GitHub Pages and built with SynthCSS itself.

Scope

Structure

  • Create a new showcase/ directory at the repo root, separate from the framework source.
  • showcase/index.html holds the single page.
  • showcase/showcase.css holds only showcase-specific styles, kept minimal.
  • showcase/showcase.js is optional and holds small vanilla JS.
  • Each section is a self-contained <section id="..."> block so new primitives and components can be added by appending a section. Document this pattern in the README.
  • The page must load the real SynthCSS stylesheet(s) from the repo. Do not copy or recreate token values.
    • Assumption: there is no build step. The workflow copies the framework CSS into the Pages artifact next to the showcase, and links in index.html resolve both locally (relative path) and when deployed.

Sections

  1. Hero
    • Project name.
    • Tagline: "CSS designed to be written by machines and used by humans."
    • Short description.
    • An explicit statement that SynthCSS targets AI-generated interfaces.
    • Link to the GitHub repo (https://github.com/nabledhq/synthcss).
    • Link to documentation. Assumption: the docs link points to the repo's README/docs on GitHub.
  2. Why SynthCSS
    • Four short points, as cards or a list, no long prose:
      • Traditional frameworks are optimized for humans.
      • AI output produces inconsistent CSS.
      • SynthCSS offers a small, semantic, predictable vocabulary.
      • It is designed around model ergonomics.
  3. Design tokens
    • Live swatches and specimens rendered with the actual custom properties for: colors, typography, spacing, radius, shadows, focus states and motion.
    • Only include categories whose tokens exist in the repo. List any missing category in the PR description rather than inventing tokens.
  4. Layouts
    • One example each for .container, .stack, .cluster, .grid, .sidebar, .split, .center and .cover, limited to those that exist.
    • Each example has: a live demo, the class name, a one-sentence description, and a minimal HTML snippet of 15 lines or fewer.
  5. Responsive demos
    • For grid, sidebar, cluster and split, a demo container whose width visitors can change (CSS resize: horizontal or a range input) so they can see:
      • grid columns reduce;
      • sidebar stacks;
      • cluster wraps;
      • split adapts.
  6. AI-friendly examples
    • At least 3 pairs of "Intent" (plain English) and "SynthCSS" (snippet).
    • Include the cluster and grid examples from the proposal.
  7. Code samples (applies site-wide)
    • Snippets are short, use only real SynthCSS classes and tokens, and each has a copy-to-clipboard button.
    • Syntax highlighting only if it can be done without adding a heavy dependency. Assumption: use plain <pre><code> if no highlighter is approved.

Visual style

  • Built primarily with SynthCSS classes and tokens.
  • No Bootstrap, Tailwind, Material UI or similar framework.

Deployment

  • Add .github/workflows/pages.yml using the official actions/configure-pages, actions/upload-pages-artifact and actions/deploy-pages actions.
  • Trigger on push to main (paths: showcase/**, framework CSS, and the workflow file) plus workflow_dispatch.
  • Set the required pages: write and id-token: write permissions.
  • Do not commit generated output.

Documentation

  • Add showcase/README.md, linked from the main README, covering:
    • local preview (open the file or use a simple static server);
    • how deployment works;
    • the one-time manual step: set Settings → Pages → Source to "GitHub Actions";
    • how to add a new section.

Acceptance criteria

  • showcase/index.html exists and opens locally with SynthCSS styles applied, with no console errors.
  • Hero contains the project name, tagline, AI-first statement, repo link and docs link.
  • The "Why" section covers the four points concisely.
  • Every existing token category is visibly rendered using var(--...) from the framework. No hardcoded duplicate token values appear in showcase.css.
  • Every existing layout primitive has a demo, class name, description and HTML snippet.
  • Grid, sidebar, cluster and split each have a width-adjustable demo.
  • At least 3 intent-to-SynthCSS examples are present.
  • Copy buttons copy the snippet text.
  • The page has no horizontal page overflow at a 375px viewport and lays out correctly at 1280px or wider.
  • No third-party CSS framework is referenced.
  • showcase.css contains only showcase-specific rules.
  • A Pages workflow exists, is valid YAML, uses the official Pages actions, and runs on push to main.
  • showcase/README.md documents preview, deployment, the manual Pages setting and extension.

Out of scope

  • New framework components, tokens or primitives.
  • Theming toggles.
  • Static site generators or JS frameworks.
  • Custom domain.
  • Search.
  • Rendering the full docs into the site.
  • Actually enabling Pages in repo settings; a maintainer does this once after merge.
Build #3
SUCCEEDED

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

46agent turns
3.6Mtokens
$2.58spent at list price

Builder summary
## Summary This adds a static showcase page in `showcase/`, built with SynthCSS itself. It deploys to GitHub Pages through a new Actions workflow. There is no build step and nothing generated is committed. ### Files - **`showcase/index.html`**: the page, made of self-contained `<section id>` blocks: - **`hero`**: name, tagline, short description, the statement that SynthCSS targets AI-generated interfaces, a repo link and a docs link (`…/synthcss#readme`). The hero itself is a live `.container.cover` with a `.split` header. - **`why`**: the four points as cards in a `.grid`. - **`tokens`**: live swatches and specimens for every token category in `src/tokens.css`: colors, typography (families, sizes, weights, line heights), spacing, radius, shadows, borders, focus, motion (a "Play motion" button animates each duration) and sizing. Everything is drawn with `var(--…)`. The values shown next to each specimen are read from the stylesheet at runtime, so no token value is copied. - **`layouts`**: one card for each of the 8 primitives (`.container`, `.stack`, `.cluster`, `.grid`, `.sidebar`, `.split`, `.center`, `.cover`). Each card has a live demo, the class name, one sentence and a snippet of 6 lines or fewer. - **`responsive`**: width-adjustable frames for grid, sidebar, cluster and split. Each frame has a range slider (works on touch screens) and also `resize: horizontal`. - **`ai-examples`**: 6 Intent → SynthCSS pairs, including the cluster and grid ones. I didn't have the proposal text, so those two are written from the spec's description of them. - **`showcase/showcase.css`**: showcase-only rules. Every selector is scoped to an `sc-` class, and every value is a token. - **`showcase/showcase.js`**: small vanilla JS for the copy buttons (Clipboard API with a fallback), the frame sliders, the motion demo and the live token values. The page still works without it. - **`.github/workflows/pages.yml`**: - Runs on push to `main` (paths `showcase/**`, `src/**.css`, the workflow file) and on `workflow_dispatch`. - Permissions are `pages: write`, `id-token: write` and `contents: read`. - The build job runs `npm test`, then assembles `_site/` as `showcase/` + `src/*.css` + a generated root `index.html` that redirects to `showcase/`. - Uses `actions/configure-pages@v5`, `actions/upload-pages-artifact@v4` and `actions/deploy-pages@v4`. - **`showcase/README.md`** (linked from the main `README.md`): local preview, how deployment works, the one-time Settings → Pages → Source = "GitHub Actions" step, and how to add a section. - **`scripts/check-showcase.mjs`** + **`scripts/check-showcase.test.mjs`**: a dependency-free check, added to `npm test`. It checks: - the page loads `../src/synthcss.css` and no other stylesheet, and no third-party framework is referenced; - all sections and hero items are present, and every token is rendered; - each primitive has a demo, description and snippet, and the four responsive demos have sliders; - there are at least 3 intent pairs; - snippets are 15 lines or fewer and use only real classes and tokens; - `showcase.css` has no hard-coded colors or token values and no rules for framework classes; - the workflow and READMEs contain the required items. - **`scripts/check-showcase-browser.mjs`**: optional Playwright check, like the existing `check-layout-browser`. ### Token categories All token categories in the repo are shown, so none are missing. Borders and sizing were added on top of the categories the spec lists, because they exist in `tokens.css`. ### Verification - `npm test`: 25/25 tests pass, and the token, layout and showcase checks pass. - Playwright's own browser download is blocked in this sandbox. I ran the browser checks with a Chromium binary from the npm package `@sparticuz/chromium` instead, outside the repo, with no repo changes: - `check-showcase-browser` passes at 375px and 1280px: - no console errors and no horizontal page overflow; - styles and tok

Agent log
  - **`ai-examples`**: 6 Intent → SynthCSS pairs, including the cluster and grid ones. I didn't have the proposal text, so those two are written from the spec's description of them.
- **`showcase/showcase.css`**: showcase-only rules. Every selector is scoped to an `sc-` class, and every value is a token.
- **`showcase/showcase.js`**: small vanilla JS for the copy buttons (Clipboard API with a fallback), the frame sliders, the motion demo and the live token values. The page still works without it.
- **`.github/workflows/pages.yml`**:
  - Runs on push to `main` (paths `showcase/**`, `src/**.css`, the workflow file) and on `workflow_dispatch`.
  - Permissions are `pages: write`, `id-token: write` and `contents: read`.
  - The build job runs `npm test`, then assembles `_site/` as `showcase/` + `src/*.css` + a generated root `index.html` that redirects to `showcase/`.
  - Uses `actions/configure-pages@v5`, `actions/upload-pages-artifact@v4` and `actions/deploy-pages@v4`.
- **`showcase/README.md`** (linked from the main `README.md`): local preview, how deployment works, the one-time Settings → Pages → Source = "GitHub Actions" step, and how to add a section.
- **`scripts/check-showcase.mjs`** + **`scripts/check-showcase.test.mjs`**: a dependency-free check, added to `npm test`. It checks:
  - the page loads `../src/synthcss.css` and no other stylesheet, and no third-party framework is referenced;
  - all sections and hero items are present, and every token is rendered;
  - each primitive has a demo, description and snippet, and the four responsive demos have sliders;
  - there are at least 3 intent pairs;
  - snippets are 15 lines or fewer and use only real classes and tokens;
  - `showcase.css` has no hard-coded colors or token values and no rules for framework classes;
  - the workflow and READMEs contain the required items.
- **`scripts/check-showcase-browser.mjs`**: optional Playwright check, like the existing `check-layout-browser`.

### Token categories
All token categories in the repo are shown, so none are missing. Borders and sizing were added on top of the categories the spec lists, because they exist in `tokens.css`.

### Verification
- `npm test`: 25/25 tests pass, and the token, layout and showcase checks pass.
- Playwright's own browser download is blocked in this sandbox. I ran the browser checks with a Chromium binary from the npm package `@sparticuz/chromium` instead, outside the repo, with no repo changes:
  - `check-showcase-browser` passes at 375px and 1280px:
    - no console errors and no horizontal page overflow;
    - styles and tokens are applied;
    - every snippet has a copy button, and the clipboard holds the exact snippet text;
    - in the narrow frames the grid drops to 1 column, the sidebar stacks and the cluster and split wrap;
    - in the wide frames they stay on one row.
  - The existing `check-layout-browser` still passes.
- I also reviewed full-page screenshots at both widths.
- The workflow YAML parses with PyYAML. It has not been run on GitHub.

### Worth a close look
- **Deployed URL:** to keep the stylesheet link working both locally and on Pages, the artifact mirrors the repo layout. The page is therefore served at `/<repo>/showcase/`, and the site root redirects there.
- **Local overrides:** some demos change a token on a single element with an inline style (e.g. `style="--grid-min: 6rem"`), the way `docs/layout.md` describes. The check rejects any custom property set inside `showcase.css`.
- **No highlighting:** code samples use plain `<pre><code>`, since no highlighter dependency was approved.
- **After merge:** a maintainer still needs to set Settings → Pages → Source to "GitHub Actions" once.
2026-10-03T00:23:38Z  [claude] I built the showcase page and the GitHub Pages deployment, and pushed them to `nabled/feature-3-job-3`; nabled will open the pull request. `npm test` passes. A headless-browser run passed at 375px and 1280px wide: no console errors, no sideways scrolling, and the copy buttons put the exact snippet text on the clipboard. The deployment workflow file is valid YAML but has not been run on GitHub yet.

Before the site goes live, a maintainer has to open Settings → Pages once after merge and set Source to "GitHub Actions".

**What's in `showcase/`**
- **The page (`index.html`):** it loads the real stylesheet from `src/` and has the six sections from the spec:
  - **Hero:** name, tagline, the statement about AI-generated interfaces, and links to the repo and the README.
  - **Why:** the four points as cards.
  - **Tokens:** swatches for every token group. Borders and sizing are also included because they exist in the repo, so no group is missing. The values shown next to each swatch are read from the stylesheet when the page loads, so none are copied into the page.
  - **Layouts:** a demo, description and short snippet for each of the eight layout classes.
  - **Responsive demos:** grid, sidebar, cluster and split each have a width slider (which works on phones) and a drag handle.
  - **AI-friendly examples:** six plain-English-to-SynthCSS pairs. I didn't have the original proposal, so the cluster and grid pairs are my own wording based on the spec.
- **Styles and script:**
  - `showcase.css` only styles its own `sc-` prefixed classes and uses token variables for every value.
  - `showcase.js` adds the copy buttons, the sliders and a small motion demo; the page still works without it.
- **`showcase/README.md`:** covers local preview, how deployment works, the Pages setting and how to add a section. It's linked from the main README.

**Deployment (`.github/workflows/pages.yml`)**
It runs on pushes to `main` that touch the showcase, the framework CSS or the workflow, and can also be started by hand. It runs `npm test`, then publishes the showcase and `src/` side by side. That keeps the same stylesheet path working both locally and online. The catch is the address: the page lives at `/synthcss/showcase/`, and the site root redirects there.

**Checks added**
I added a check to `npm test` that fails if the page drifts from the spec, for example if a section is missing, a snippet uses a class or token that doesn't exist, or `showcase.css` hard-codes a value. There is also an optional browser check, like the existing layout one. Playwright couldn't download its browser here, so I ran that check with a substitute Chromium kept outside the repo.

Code samples are plain text with no syntax highlighting, since no highlighter was approved.

Full-page screenshots at both widths are in `/mnt/session/outputs/`. The 375px one shows the hero section repeated partway down. As far as I can tell that comes from the screenshot tool (the hero is set to fill the screen height), not from the page itself; the overflow check passed.
2026-10-03T00:23:40Z  Changed files:
added  .github/workflows/pages.yml  +79 -0
modified  README.md  +4 -0
modified  package.json  +3 -1
added  scripts/check-showcase-browser.mjs  +127 -0
added  scripts/check-showcase.mjs  +287 -0
added  scripts/check-showcase.test.mjs  +71 -0
added  showcase/README.md  +103 -0
added  showcase/index.html  +520 -0
added  showcase/showcase.css  +316 -0
added  showcase/showcase.js  +70 -0
2026-10-03T00:23:42Z  Opened pull request https://github.com/nabledhq/synthcss/pull/3
2026-10-03T00:23:42Z  Finished: success=true turns=46 tokens(in/out)=3546165/58427 list cost=$2.58

Show patch
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..b84d318
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,79 @@
+# Publishes the static showcase (showcase/) 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.
+name: Deploy showcase to GitHub Pages
+
+on:
+  push:
+    branches: [main]
+    paths:
+      - "showcase/**"
+      - "src/**.css"
+      - ".github/workflows/pages.yml"
+  workflow_dispatch:
+
+permissions:
+  contents: read
+  pages: write
+  id-token: write
+
+# Allow one deployment at a time; let an in-progress deployment finish.
+concurrency:
+  group: pages
+  cancel-in-progress: false
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check out
+        uses: actions/checkout@v4
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: 20
+
+      - name: Run checks
+        run: npm test
+
+      - name: Configure Pages
+        uses: actions/configure-pages@v5
+
+      - name: Assemble site
+        run: |
+          mkdir -p _site/showcase _site/src
+          cp showcase/index.html showcase/showcase.css showcase/showcase.js _site/showcase/
+          cp src/*.css _site/src/
+          # The site root forwards to the showcase page.
+          cat > _site/index.html <<'HTML'
+          <!doctype html>
+          <html lang="en">
+          <head>
+            <meta charset="utf-8">
+            <title>SynthCSS</title>
+            <meta http-equiv="refresh" content="0; url=showcase/">
+            <link rel="canonical" href="showcase/">
+          </head>
+          <body>
+            <p><a href="showcase/">Open the SynthCSS showcase</a></p>
+          </body>
+          </html>
+          HTML
+
+      - name: Upload artifact
+        uses: actions/upload-pages-artifact@v4
+        with:
+          path: _site
+
+  deploy:
+    needs: build
+    runs-on: ubuntu-latest
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}showcase/
+    steps:
+      - name: Deploy to GitHub Pages
+        id: deployment
+        uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
index 3d1a1bc..e82e5be 100644
--- a/README.md
+++ b/README.md
@@ -20,3 +20,7 @@ Eight intent-named layout classes in [`src/layout.css`](src/layout.css): `.conta
 ```
 
 See [docs/layout.md](docs/layout.md) for each primitive and a compact "AI Layout Vocabulary" table to give to a model, and [examples/layout.html](examples/layout.html) for a fixture page showing every primitive at narrow and wide widths.
+
+## Showcase
+
+[`showcase/`](showcase/) is a static page built with SynthCSS that shows the tokens and layout primitives live, 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/package.json b/package.json
index e97a08e..2bd4a31 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,8 @@
     "check:tokens": "node scripts/check-tokens.mjs",
     "check:layout": "node scripts/check-layout.mjs",
     "check:layout:browser": "node scripts/check-layout-browser.mjs",
-    "test": "node scripts/check-tokens.mjs && node scripts/check-layout.mjs && node --test scripts/check-tokens.test.mjs scripts/check-layout.test.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-showcase.mjs && node --test scripts/check-tokens.test.mjs scripts/check-layout.test.mjs scripts/check-showcase.test.mjs"
   }
 }
diff --git a/scripts/check-showcase-browser.mjs b/scripts/check-showcase-browser.mjs
new file mode 100644
index 0000000..609c998
--- /dev/null
+++ b/scripts/check-showcase-browser.mjs
@@ -0,0 +1,127 @@
+#!/usr/bin/env node
+// Optional browser check of showcase/index.html at 375px and 1280px viewport width.
+// Needs Playwright, which is not a dependency of this repository:
+//   npm install --no-save playwright && npx playwright install chromium
+// Usage: node scripts/check-showcase-browser.mjs
+
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { dirname, resolve } from "node:path";
+
+let chromium;
+try {
+  ({ chromium } = await import("playwright"));
+} catch {
+  console.error(
+    "check-showcase-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 pageUrl = pathToFileURL(resolve(repo, "showcase/index.html")).href;
+
+const errors = [];
+const expect = (ok, msg) => {
+  if (!ok) errors.push(msg);
+};
+
+const browser = await chromium.launch();
+try {
+  for (const width of [375, 1280]) {
+    const context = await browser.newContext({ viewport: { width, height: 800 } });
+    await context.grantPermissions(["clipboard-read", "clipboard-write"]).catch(() => {});
+    const page = await context.newPage();
+    const consoleErrors = [];
+    page.on("console", (msg) => msg.type() === "error" && consoleErrors.push(msg.text()));
+    page.on("pageerror", (err) => consoleErrors.push(err.message));
+    page.on("requestfailed", (req) => consoleErrors.push(`failed to load ${req.url()}`));
+    await page.goto(pageUrl, { waitUntil: "load" });
+
+    const m = await page.evaluate(() => {
+      const root = document.documentElement;
+      const css = (sel, prop) => getComputedStyle(document.querySelector(sel)).getPropertyValue(prop);
+      const cols = (sel) => getComputedStyle(document.querySelector(sel)).gridTemplateColumns.split(" ").length;
+      return {
+        overflow: root.scrollWidth > root.clientWidth,
+        tokens: getComputedStyle(root).getPropertyValue("--color-primary").trim() !== "",
+        gridDisplay: css("#why .grid", "display"),
+        whyColumns: cols("#why .grid"),
+        copyButtons: document.querySelectorAll(".sc-code .sc-copy").length,
+        codeBlocks: document.querySelectorAll(".sc-code").length,
+        tokenValues: [...document.querySelectorAll("[data-token]")].every((el) => el.textContent.trim() !== ""),
+      };
+    });
+    expect(consoleErrors.length === 0, `${width}px: console errors: ${consoleErrors.join("; ")}`);
+    expect(!m.overflow, `${width}px: page overflows horizontally`);
+    expect(m.tokens, `${width}px: SynthCSS tokens are not loaded`);
+    expect(m.gridDisplay === "grid", `${width}px: SynthCSS layout classes are not applied`);
+    if (width === 375) expect(m.whyColumns === 1, `${width}px: "Why" grid has ${m.whyColumns} columns, expected 1`);
+    else expect(m.whyColumns >= 3, `${width}px: "Why" grid has ${m.whyColumns} columns, expected 3 or more`);
+    expect(m.copyButtons === m.codeBlocks && m.codeBlocks > 0, `${width}px: not every code sample has a copy button`);
+    expect(m.tokenValues, `${width}px: some live token values are empty`);
+
+    // Responsive frames: shrink each frame with its slider and check the layout responds.
+    const frames = await page.evaluate(() => {
+      const measure = () => {
+        const grid = document.querySelector("#frame-grid .grid-sm");
+        const [a, b] = [...document.querySelector("#frame-sidebar .sidebar").children].map((c) => c.getBoundingClientRect());
+        const tops = [...document.querySelector("#frame-cluster .cluster-sm").children].map((c) => Math.round(c.getBoundingClientRect().top));
+        const [s1, s2] = [...document.querySelector("#frame-split .split").children].map((c) => c.getBoundingClientRect());
+        return {
+          gridColumns: getComputedStyle(grid).gridTemplateColumns.split(" ").length,
+          sidebarStacked: b.top >= a.bottom,
+          clusterRows: new Set(tops).size,
+          splitWrapped: s2.top >= s1.bottom,
+        };
+      };
+      const setAll = (value) => {
+        for (const input of document.querySelectorAll("input[data-frame]")) {
+          input.value = value;
+          input.dispatchEvent(new Event("input"));
+        }
+      };
+      setAll(100);
+      const wide = measure();
+      setAll(20);
+      const narrow = measure();
+      setAll(100);
+      return { wide, narrow };
+    });
+    expect(frames.narrow.gridColumns < frames.wide.gridColumns || width === 375, `${width}px: grid columns did not reduce`);
+    expect(frames.narrow.gridColumns === 1, `${width}px: narrow grid frame has ${frames.narrow.gridColumns} columns`);
+    expect(frames.narrow.sidebarStacked, `${width}px: sidebar did not stack in the narrow frame`);
+    expect(frames.narrow.clusterRows > 1, `${width}px: cluster did not wrap in the narrow frame`);
+    expect(frames.narrow.splitWrapped, `${width}px: split did not wrap in the narrow frame`);
+    if (width === 1280) {
+      expect(!frames.wide.sidebarStacked, "1280px: sidebar is stacked in the wide frame");
+      expect(frames.wide.clusterRows === 1, "1280px: cluster wrapped in the wide frame");
+      expect(!frames.wide.splitWrapped, "1280px: split wrapped in the wide frame");
+    }
+
+    // Copy button copies the snippet text.
+    const copied = await page.evaluate(async () => {
+      const block = document.querySelector(".sc-code");
+      block.querySelector(".sc-copy").click();
+      await new Promise((r) => setTimeout(r, 100));
+      let text = null;
+      try {
+        text = await navigator.clipboard.readText();
+      } catch {}
+      return { text, expected: block.querySelector("code").textContent, label: block.querySelector(".sc-copy").textContent };
+    });
+    expect(copied.label === "Copied", `${width}px: copy button reported "${copied.label}"`);
+    if (copied.text !== null) expect(copied.text === copied.expected, `${width}px: clipboard text does not match the snippet`);
+
+    await context.close();
+  }
+} finally {
+  await browser.close();
+}
+
+if (errors.length) {
+  for (const e of errors) console.error(`  FAIL ${e}`);
+  console.error(`\ncheck-showcase-browser: ${errors.length} problem(s) found.`);
+  process.exit(1);
+}
+console.log("check-showcase-browser: showcase renders at 375px and 1280px with no console errors or horizontal overflow.");
diff --git a/scripts/check-showcase.mjs b/scripts/check-showcase.mjs
new file mode 100644
index 0000000..5064c96
--- /dev/null
+++ b/scripts/check-showcase.mjs
@@ -0,0 +1,287 @@
+#!/usr/bin/env node
+// Dependency-free verification of the static showcase site (showcase/) and its
+// GitHub Pages workflow.
+// Usage: node scripts/check-showcase.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 { PRIMITIVES, VARIANTS, HELPER_CLASSES } from "./check-layout.mjs";
+
+export const SECTIONS = ["hero", "why", "tokens", "layouts", "responsive", "ai-examples"];
+export const RESPONSIVE = ["grid", "sidebar", "cluster", "split"];
+export const TAGLINE = "CSS designed to be written by machines and used by humans.";
+export const REPO_URL = "https://github.com/nabledhq/synthcss";
+export const PAGES_ACTIONS = ["actions/configure-pages@", "actions/upload-pages-artifact@", "actions/deploy-pages@"];
+export const MAX_SNIPPET_LINES = 15;
+
+const FRAMEWORK_CLASSES = new Set([...PRIMITIVES, ...VARIANTS, ...HELPER_CLASSES]);
+const THIRD_PARTY = /\b(bootstrap|tailwind|bulma|foundation|materialize|material-ui|@mui|daisyui|pico\.css)\b/i;
+const COLOR_LITERAL = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i;
+
+const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, "");
+const unescapeHtml = (s) =>
+  s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
+
+// Returns the HTML of <section id="..."> up to the next top-level section/footer.
+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\b/);
+  return end === -1 ? rest : rest.slice(0, end);
+}
+
+// Splits a selector list on top-level commas (not the ones inside :where()).
+function splitSelectors(prelude) {
+  const out = [];
+  let depth = 0;
+  let current = "";
+  for (const ch of prelude) {
+    if (ch === "(") depth++;
+    if (ch === ")") depth--;
+    if (ch === "," && depth === 0) {
+      out.push(current.trim());
+      current = "";
+    } else current += ch;
+  }
+  out.push(current.trim());
+  return out;
+}
+
+const classesIn = (html) => [...html.matchAll(/\bclass="([^"]*)"/g)].flatMap((m) => m[1].split(/\s+/).filter(Boolean));
+const snippetsIn = (html) => [...html.matchAll(/<pre><code>([\s\S]*?)<\/code><\/pre>/g)].map((m) => unescapeHtml(m[1]));
+
+export function checkHtml(html, tokens, showcaseClasses) {
+  const errors = [];
+  const tokenNames = [...tokens.keys()];
+
+  if (!/<link rel="stylesheet" href="\.\.\/src\/synthcss\.css">/.test(html)) {
+    errors.push('index.html must load the framework with <link rel="stylesheet" href="../src/synthcss.css">');
+  }
+  if (!/<link rel="stylesheet" href="showcase\.css">/.test(html)) errors.push("index.html must load showcase.css");
+  for (const m of html.matchAll(/<link\b[^>]*rel="stylesheet"[^>]*href="([^"]+)"/g)) {
+    if (!["../src/synthcss.css", "showcase.css"].includes(m[1])) errors.push(`index.html loads an extra stylesheet: ${m[1]}`);
+  }
+  if (/<style\b/i.test(html)) errors.push("index.html must not contain <style> blocks; put showcase styles in showcase.css");
+  if (/<script\b[^>]*\bsrc="(?:https?:)?\/\//i.test(html)) errors.push("index.html must not load third-party scripts");
+  if (THIRD_PARTY.test(html)) errors.push("index.html references a third-party CSS framework");
+
+  for (const id of SECTIONS) {
+    if (!sectionHtml(html, id)) errors.push(`missing <section id="${id}">`);
+  }
+
+  const hero = sectionHtml(html, "hero") ?? "";
+  if (!/<h1\b[^>]*>SynthCSS<\/h1>/.test(hero)) errors.push("hero must have the project name in an <h1>");
+  if (!hero.includes(TAGLINE)) errors.push("hero must contain the tagline");
+  if (!/AI-generated interfaces/.test(hero)) errors.push("hero must state that SynthCSS targets AI-generated interfaces");
+  if (!hero.includes(`href="${REPO_URL}"`)) errors.push("hero must link to the GitHub repository");
+  if (!hero.includes(`href="${REPO_URL}#readme"`)) errors.push("hero must link to the documentation");
+
+  const why = sectionHtml(html, "why") ?? "";
+  const whyCards = (why.match(/<li\b/g) ?? []).length;
+  if (whyCards !== 4) errors.push(`"Why SynthCSS" must have exactly 4 points, found ${whyCards}`);
+
+  // Every token must be rendered with var(--token) somewhere on the page.
+  const tokenSection = sectionHtml(html, "tokens") ?? "";
+  for (const name of tokenNames) {
+    if (!tokenSection.includes(`var(${name})`) && !tokenSection.includes(`<code>${name}</code>`)) {
+      errors.push(`token ${name} is not shown in the tokens section`);
+    }
+  }
+
+  const layouts = sectionHtml(html, "layouts") ?? "";
+  const articles = layouts.split(/<article\b/).slice(1);
+  for (const p of PRIMITIVES) {
+    const article = articles.find((a) => a.includes(`id="layout-${p}"`));
+    if (!article) {
+      errors.push(`layouts: missing example for .${p}`);
+      continue;
+    }
+    if (!article.includes(`<code>.${p}</code>`)) errors.push(`layouts: .${p} example must show its class name`);
+    if (!/<\/h3>\s*<p>[^<]+(<code>[^<]+<\/code>[^<]*)*<\/p>/.test(article)) {
+      errors.push(`layouts: .${p} example must have a one-sentence description after its heading`);
+    }
+    const demo = article.split('class="sc-code"')[0];
+    if (!classesIn(demo.split('class="sc-demo"')[1] ?? "").some((c) => c === p || c.startsWith(`${p}-`))) {
+      errors.push(`layouts: .${p} example has no live demo using .${p}`);
+    }
+    const snippet = snippetsIn(article)[0];
+    if (!snippet || !new RegExp(`class="([^"]*\\s)?${p}(\\s[^"]*)?"`).test(snippet)) {
+      errors.push(`layouts: .${p} example needs an HTML snippet that uses .${p}`);
+    }
+  }
+
+  const responsive = sectionHtml(html, "responsive") ?? "";
+  for (const p of RESPONSIVE) {
+    const frame = responsive.split(`id="frame-${p}"`)[1];
+    if (!frame || !/class="sc-frame"/.test(responsive)) {
+      errors.push(`responsive: missing width-adjustable frame for .${p}`);
+      continue;
+    }
+    if (!responsive.includes(`data-frame="frame-${p}"`)) errors.push(`responsive: .${p} frame has no width slider`);
+    const inner = frame.split(/<\/article>/)[0];
+    if (!classesIn(inner).some((c) => c === p || c.startsWith(`${p}-`))) {
+      errors.push(`responsive: .${p} frame does not use .${p}`);
+    }
+  }
+
+  const ai = sectionHtml(html, "ai-examples") ?? "";
+  const pairs = ai.split(/<li class="sc-card/).slice(1).filter((c) => c.includes(">Intent<") && c.includes(">SynthCSS<") && c.includes("<pre><code>"));
+  if (pairs.length < 3) errors.push(`ai-examples: need at least 3 Intent / SynthCSS pairs, found ${pairs.length}`);
+  for (const p of ["cluster", "grid"]) {
+    if (!pairs.some((c) => snippetsIn(c).some((s) => s.includes(`class="${p}"`)))) {
+      errors.push(`ai-examples: missing the .${p} example`);
+    }
+  }
+
+  // Code samples: short, and only real SynthCSS classes and tokens.
+  for (const [i, snippet] of snippetsIn(html).entries()) {
+    const label = `snippet ${i + 1} (${snippet.split("\n")[0].trim()})`;
+    const lines = snippet.split("\n").length;
+    if (lines > MAX_SNIPPET_LINES) errors.push(`${label} has ${lines} lines, max ${MAX_SNIPPET_LINES}`);
+    for (const cls of classesIn(snippet)) {
+      if (!FRAMEWORK_CLASSES.has(cls)) errors.push(`${label} uses .${cls}, which is not a SynthCSS class`);
+    }
+    for (const m of snippet.matchAll(/(--[\w-]+)/g)) {
+      if (!tokens.has(m[1])) errors.push(`${label} uses ${m[1]}, which is not a SynthCSS token`);
+    }
+  }
+
+  // Inline styles may only use or locally override existing tokens.
+  for (const m of html.matchAll(/\bstyle="([^"]*)"/g)) {
+    for (const v of m[1].matchAll(/(--[\w-]+)/g)) {
+      if (!tokens.has(v[1])) errors.push(`inline style uses unknown token ${v[1]}`);
+    }
+    if (COLOR_LITERAL.test(m[1])) errors.push(`inline style hard-codes a color: ${m[1]}`);
+  }
+
+  for (const cls of new Set(classesIn(html))) {
+    if (!FRAMEWORK_CLASSES.has(cls) && !showcaseClasses.has(cls)) {
+      errors.push(`index.html uses .${cls}, which is neither a SynthCSS class nor defined in showcase.css`);
+    }
+  }
+  return errors;
+}
+
+export function checkShowcaseCss(rawCss, tokens) {
+  const errors = [];
+  const css = stripComments(rawCss);
+  const classes = new Set();
+  const rawValues = new Set([...tokens.values()].map((v) => v.toLowerCase()));
+
+  if (/@import\b/i.test(css)) errors.push("showcase.css must not @import other stylesheets");
+  if (THIRD_PARTY.test(css)) errors.push("showcase.css references a third-party CSS framework");
+
+  let blocks;
+  try {
+    blocks = parseBlocks(css);
+  } catch (err) {
+    return { errors: [`showcase.css: ${err.message}`], classes };
+  }
+  for (const { prelude, body } of blocks) {
+    if (prelude.startsWith("@")) {
+      errors.push(`showcase.css: at-rules are not allowed (${prelude}); keep the showcase intrinsic like the framework`);
+      continue;
+    }
+    for (const selector of splitSelectors(prelude)) {
+      const own = [...selector.matchAll(/\.([a-zA-Z][\w-]*)/g)].map((m) => m[1]);
+      own.forEach((c) => c.startsWith("sc-") && classes.add(c));
+      if (!own.some((c) => c.startsWith("sc-"))) {
+        errors.push(`showcase.css: selector "${selector}" must be scoped with an sc- class`);
+      }
+      for (const c of own) {
+        if (!c.startsWith("sc-")) errors.push(`showcase.css: selector "${selector}" targets the framework class .${c}`);
+      }
+    }
+    for (const { prop, value } of parseDeclarations(body)) {
+      if (!prop) continue;
+      if (prop.startsWith("--")) errors.push(`showcase.css: must not define custom properties (${prop})`);
+      if (COLOR_LITERAL.test(value)) errors.push(`showcase.css: hard-coded color in ${prop}: ${value}`);
+      for (const m of value.matchAll(/var\((--[\w-]+)/g)) {
+        if (!tokens.has(m[1])) errors.push(`showcase.css: ${prop} uses unknown token ${m[1]}`);
+      }
+      const parts = value.toLowerCase().replace(/var\([^)]*\)/g, " ").split(/[\s,()*+/]+/).filter(Boolean);
+      for (const part of parts) {
+        if (rawValues.has(part)) errors.push(`showcase.css: ${prop} hard-codes the token value ${part}; use var(--token)`);
+      }
+    }
+  }
+  return { errors, classes };
+}
+
+export function checkWorkflow(yaml) {
+  const errors = [];
+  if (!yaml) return [".github/workflows/pages.yml is missing"];
+  for (const action of PAGES_ACTIONS) {
+    if (!yaml.includes(`uses: ${action}`)) errors.push(`pages.yml must use ${action.slice(0, -1)}`);
+  }
+  if (!/^\s+pages: write$/m.test(yaml)) errors.push("pages.yml must grant pages: write");
+  if (!/^\s+id-token: write$/m.test(yaml)) errors.push("pages.yml must grant id-token: write");
+  if (!/^\s+push:\s*\n\s+branches: \[main\]/m.test(yaml)) errors.push("pages.yml must run on push to main");
+  if (!/^\s+workflow_dispatch:/m.test(yaml)) errors.push("pages.yml must allow workflow_dispatch");
+  for (const path of ['"showcase/**"', '"src/**.css"', '".github/workflows/pages.yml"']) {
+    if (!yaml.includes(`- ${path}`)) errors.push(`pages.yml push paths must include ${path}`);
+  }
+  if (/\t/.test(yaml)) errors.push("pages.yml must not contain tabs");
+  return errors;
+}
+
+export function checkDocs(readme, showcaseReadme) {
+  const errors = [];
+  if (!/\]\(showcase\/README\.md\)/.test(readme)) errors.push("README.md must link to showcase/README.md");
+  if (!showcaseReadme) return [...errors, "showcase/README.md is missing"];
+  for (const [text, what] of [
+    [/python3 -m http\.server|npx serve/, "local preview with a static server"],
+    [/GitHub Actions/, "the GitHub Actions Pages source setting"],
+    [/Settings → Pages/, "the Settings → Pages step"],
+    [/pages\.yml/, "how deployment works (pages.yml)"],
+    [/<section id=/, "how to add a new section"],
+  ]) {
+    if (!text.test(showcaseReadme)) errors.push(`showcase/README.md must document ${what}`);
+  }
+  return errors;
+}
+
+export function checkShowcase({ html, showcaseCss, tokensCss, workflow, readme, showcaseReadme }) {
+  const { root: tokens } = parseTokens(tokensCss);
+  const css = checkShowcaseCss(showcaseCss, tokens);
+  return [
+    ...checkHtml(html, tokens, css.classes),
+    ...css.errors,
+    ...checkWorkflow(workflow),
+    ...checkDocs(readme, showcaseReadme),
+  ];
+}
+
+export function readRepoFiles(repo) {
+  const read = (p) => {
+    try {
+      return readFileSync(resolve(repo, p), "utf8");
+    } catch {
+      return "";
+    }
+  };
+  return {
+    html: read("showcase/index.html"),
+    showcaseCss: read("showcase/showcase.css"),
+    tokensCss: read("src/tokens.css"),
+    workflow: read(".github/workflows/pages.yml"),
+    readme: read("README.md"),
+    showcaseReadme: read("showcase/README.md"),
+  };
+}
+
+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 = checkShowcase(readRepoFiles(repo));
+  if (errors.length) {
+    for (const e of errors) console.error(`  FAIL ${e}`);
+    console.error(`\ncheck-showcase: ${errors.length} problem(s) found.`);
+    process.exit(1);
+  }
+  console.log("check-showcase: showcase page, styles, Pages workflow and docs are consistent with the framework.");
+}
diff --git a/scripts/check-showcase.test.mjs b/scripts/check-showcase.test.mjs
new file mode 100644
index 0000000..04d5f8e
--- /dev/null
+++ b/scripts/check-showcase.test.mjs
@@ -0,0 +1,71 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { checkShowcase, readRepoFiles } from "./check-showcase.mjs";
+
+const repo = new URL("..", import.meta.url).pathname;
+const files = readRepoFiles(repo);
+
+const errorsWith = (changes) => checkShowcase({ ...files, ...changes });
+const assertError = (errors, text) => assert.ok(errors.some((e) => e.includes(text)), errors.join("\n"));
+
+test("repository showcase files pass", () => {
+  assert.deepEqual(errorsWith({}), []);
+});
+
+test("fails when the framework stylesheet is not loaded or a third-party one is", () => {
+  const noBundle = files.html.replace('href="../src/synthcss.css"', 'href="synthcss.css"');
+  assertError(errorsWith({ html: noBundle }), "must load the framework");
+  const cdn = files.html.replace(
+    '<link rel="stylesheet" href="showcase.css">',
+    '<link rel="stylesheet" href="showcase.css">\n  <link rel="stylesheet" href="https://cdn.example.com/bootstrap.min.css">',
+  );
+  assertError(errorsWith({ html: cdn }), "extra stylesheet");
+  assertError(errorsWith({ html: cdn }), "third-party CSS framework");
+});
+
+test("fails when a section, hero item or layout example is missing", () => {
+  assertError(errorsWith({ html: files.html.replace('id="responsive"', 'id="other"') }), 'missing <section id="responsive">');
+  assertError(errorsWith({ html: files.html.replaceAll("CSS designed to be written", "CSS written") }), "tagline");
+  assertError(errorsWith({ html: files.html.replace('id="layout-cover"', 'id="layout-x"') }), "missing example for .cover");
+});
+
+test("fails when a token is not rendered", () => {
+  const html = files.html.replaceAll("var(--shadow-lg)", "none").replaceAll("<code>--shadow-lg</code>", "");
+  assertError(errorsWith({ html }), "token --shadow-lg is not shown");
+});
+
+test("fails when a responsive demo has no width control", () => {
+  const html = files.html.replace('data-frame="frame-split"', 'data-frame="frame-x"');
+  assertError(errorsWith({ html }), ".split frame has no width slider");
+});
+
+test("fails when snippets use made-up classes or tokens, or are too long", () => {
+  const madeUp = files.html.replace('&lt;ul class="cluster" role="list"&gt;', '&lt;ul class="flex-row" role="list"&gt;');
+  assertError(errorsWith({ html: madeUp }), "uses .flex-row, which is not a SynthCSS class");
+  const token = files.html.replace("--radius-md: 0.5rem;", "--corner: 0.5rem;");
+  assertError(errorsWith({ html: token }), "uses --corner, which is not a SynthCSS token");
+  const long = files.html.replace("&lt;li&gt;Design&lt;/li&gt;", "&lt;li&gt;Design&lt;/li&gt;\n".repeat(16));
+  assertError(errorsWith({ html: long }), "lines, max 15");
+});
+
+test("fails when showcase.css hard-codes values or restyles framework classes", () => {
+  const color = files.showcaseCss.replace("background: var(--color-surface);", "background: #f6f7f9;");
+  assertError(errorsWith({ showcaseCss: color }), "hard-coded color");
+  const value = files.showcaseCss.replace("padding: var(--space-3);", "padding: 0.75rem;");
+  assertError(errorsWith({ showcaseCss: value }), "hard-codes the token value 0.75rem");
+  const framework = files.showcaseCss + "\n.stack { gap: var(--space-1); }\n";
+  assertError(errorsWith({ showcaseCss: framework }), "targets the framework class .stack");
+  const redefine = files.showcaseCss + "\n.sc-page { --color-primary: var(--color-info); }\n";
+  assertError(errorsWith({ showcaseCss: redefine }), "must not define custom properties");
+});
+
+test("fails when the workflow misses a Pages action or permission", () => {
+  assertError(errorsWith({ workflow: files.workflow.replace("actions/deploy-pages@", "someone/deploy@") }), "actions/deploy-pages");
+  assertError(errorsWith({ workflow: files.workflow.replace("id-token: write", "id-token: read") }), "id-token: write");
+  assertError(errorsWith({ workflow: "" }), "pages.yml is missing");
+});
+
+test("fails when the docs miss the manual Pages step", () => {
+  const showcaseReadme = files.showcaseReadme.replaceAll("Settings → Pages", "settings");
+  assertError(errorsWith({ showcaseReadme }), "Settings → Pages");
+});
diff --git a/showcase/README.md b/showcase/README.md
new file mode 100644
index 0000000..860f90e
--- /dev/null
+++ b/showcase/README.md
@@ -0,0 +1,103 @@
+# SynthCSS showcase
+
+A single static page that shows what SynthCSS is, why it is AI-first, and live demos
+of its design tokens and layout primitives. It is built with SynthCSS itself and
+published to GitHub Pages.
+
+| File | Purpose |
+| --- | --- |
+| `index.html` | The page. Loads the real framework from `../src/synthcss.css`. |
+| `showcase.css` | Showcase-only styles (base typography, demo boxes, frames, copy buttons). Every class starts with `sc-`, and every value is a `var(--token)` from `src/tokens.css`. |
+| `showcase.js` | Optional vanilla JS: copy buttons, frame-width sliders, the motion demo and live token values. The page works without it. |
+
+There is no build step and no dependency. Token values are never copied into the
+showcase: swatches and specimens use `var(--…)`, and the values printed next to them
+are read from the stylesheet at runtime.
+
+## Local preview
+
+Open `showcase/index.html` directly in a browser. The stylesheet link is relative
+(`../src/synthcss.css`), so it works from a checkout.
+
+Clipboard access is more reliable over HTTP, so you can also run a static server from
+the **repository root** (not from `showcase/`, or `../src/` will not resolve):
+
+```sh
+python3 -m http.server 8000
+# then open http://localhost:8000/showcase/
+```
+
+`npx serve .` works too. Run `npm test` to check the page against the framework
+(see [Checks](#checks)).
+
+## Deployment
+
+[`.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
+started by hand from the Actions tab (`workflow_dispatch`).
+
+The build job runs `npm test`, then assembles a temporary `_site/` folder that mirrors
+the repository layout:
+
+```text
+_site/
+  index.html        generated redirect to showcase/
+  showcase/         index.html, showcase.css, showcase.js
+  src/              tokens.css, layout.css, synthcss.css
+```
+
+Because `showcase/` sits next to `src/`, the same relative link works locally and on
+Pages. The page is served at `https://<owner>.github.io/synthcss/showcase/` and the site
+root forwards there. Nothing generated is committed.
+
+### One-time setup
+
+A maintainer must do this once after the workflow is merged: in the repository open
+**Settings → Pages** and set **Source** to **GitHub Actions**. Then push to `main` or run
+the workflow manually.
+
+## Adding a section
+
+Each part of the page is one self-contained `<section id="...">` inside `<main>`. To show
+a new primitive or component, append a section:
+
+```html
+<section id="buttons" class="sc-band" aria-labelledby="buttons-title">
+  <div class="container stack-lg">
+    <h2 id="buttons-title">Buttons</h2>
+    <article class="sc-card stack">
+      <div class="sc-demo">…live demo using the real classes…</div>
+      <div class="sc-code"><pre><code>&lt;button class="…"&gt;Save&lt;/button&gt;</code></pre></div>
+    </article>
+  </div>
+</section>
+```
+
+- Lay it out with SynthCSS primitives and tokens. Only add rules to `showcase.css` for
+  things the framework does not ship, prefix their classes with `sc-` and use
+  `var(--token)` for every value.
+- Put every snippet in `<div class="sc-code"><pre><code>`, HTML-escaped. `showcase.js`
+  adds the copy button. Keep snippets to 15 lines or fewer and use only real SynthCSS
+  classes and tokens.
+- Add a link to the new section in the hero navigation if it is a main section.
+- New framework files under `src/` are published automatically; load them through
+  `src/synthcss.css`.
+
+## Checks
+
+`npm test` runs [`scripts/check-showcase.mjs`](../scripts/check-showcase.mjs) (Node.js
+only). It checks that the page loads the framework bundle and no other stylesheet, that
+every section and hero item exists, that every token is rendered with `var(--…)`, that
+every layout primitive has a demo, description and snippet, that grid, sidebar, cluster
+and split have width-adjustable frames, that there are at least three intent examples,
+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.
+
+`npm run check:showcase:browser` loads the page in headless Chromium at 375px and 1280px
+and checks for console errors, horizontal page overflow, applied styles, copy buttons
+and the responsive frames. It needs Playwright, which is not a dependency of this
+repository: run `npm install --no-save playwright` and `npx playwright install chromium`
+first.
diff --git a/showcase/index.html b/showcase/index.html
new file mode 100644
index 0000000..b255001
--- /dev/null
+++ b/showcase/index.html
@@ -0,0 +1,520 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>SynthCSS: CSS designed to be written by machines and used by humans</title>
+  <meta name="description" content="SynthCSS is a small, semantic CSS framework for AI-generated interfaces: design tokens and intent-named layout primitives.">
+  <!-- The real framework bundle. The relative path works from a checkout and
+       on GitHub Pages, where the workflow publishes src/ next to showcase/. -->
+  <link rel="stylesheet" href="../src/synthcss.css">
+  <link rel="stylesheet" href="showcase.css">
+  <script src="showcase.js" defer></script>
+</head>
+<body class="sc-page">
+  <!--
+    Every section below is a self-contained <section id="..."> block.
+    To show a new primitive or component, append a new section to <main>.
+    See showcase/README.md.
+  -->
+
+  <section id="hero" class="sc-hero" aria-labelledby="hero-title">
+    <div class="container cover sc-hero-cover">
+      <header class="split">
+        <a class="sc-brand" href="#hero">SynthCSS</a>
+        <nav aria-label="Sections">
+          <ul class="cluster-sm" role="list">
+            <li><a href="#why">Why</a></li>
+            <li><a href="#tokens">Tokens</a></li>
+            <li><a href="#layouts">Layouts</a></li>
+            <li><a href="#responsive">Responsive</a></li>
+            <li><a href="#ai-examples">AI examples</a></li>
+          </ul>
+        </nav>
+      </header>
+
+      <div class="cover-main center stack-lg">
+        <h1 id="hero-title" class="sc-hero-title">SynthCSS</h1>
+        <p class="sc-tagline">CSS designed to be written by machines and used by humans.</p>
+        <p class="sc-lead">
+          A lightweight, opinionated CSS framework: a small set of design tokens and
+          intent-named layout primitives that respond to the space they are given.
+        </p>
+        <p class="sc-callout">
+          <strong>Built for AI-generated interfaces.</strong>
+          SynthCSS targets interfaces written by AI agents and code generators, so its
+          vocabulary is small, semantic and predictable.
+        </p>
+        <div class="cluster">
+          <a class="sc-button" href="https://github.com/nabledhq/synthcss">View on GitHub</a>
+          <a class="sc-button sc-button-secondary" href="https://github.com/nabledhq/synthcss#readme">Read the docs</a>
+        </div>
+      </div>
+
+      <p class="sc-muted">This page is built with SynthCSS itself.</p>
+    </div>
+  </section>
+
+  <main>
+    <section id="why" class="sc-band" aria-labelledby="why-title">
+      <div class="container stack-lg">
+        <h2 id="why-title">Why SynthCSS</h2>
+        <ul class="grid" role="list">
+          <li class="sc-card stack-sm">
+            <h3>Frameworks are built for humans</h3>
+            <p>Traditional frameworks are optimized for people hand-writing every class.</p>
+          </li>
+          <li class="sc-card stack-sm">
+            <h3>AI output is inconsistent</h3>
+            <p>Models generating free-form CSS produce different values and patterns every time.</p>
+          </li>
+          <li class="sc-card stack-sm">
+            <h3>A small, predictable vocabulary</h3>
+            <p>SynthCSS offers a few semantic tokens and classes that mean one thing each.</p>
+          </li>
+          <li class="sc-card stack-sm">
+            <h3>Designed for model ergonomics</h3>
+            <p>Class names state intent, so a model can map a request to markup reliably.</p>
+          </li>
+        </ul>
+      </div>
+    </section>
+
+    <section id="tokens" class="sc-band sc-band-alt" aria-labelledby="tokens-title">
+      <div class="container stack-lg">
+        <div class="stack-sm">
+          <h2 id="tokens-title">Design tokens</h2>
+          <p class="sc-muted">
+            Every swatch and specimen below is drawn with the framework's own custom
+            properties from <code>src/tokens.css</code>. Values are read live from the
+            stylesheet.
+          </p>
+        </div>
+
+        <div class="sc-card stack">
+          <h3>Colors</h3>
+          <ul class="grid-sm" role="list" style="--grid-min: 8rem">
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-background)"></span><code>--color-background</code><code class="sc-value" data-token="--color-background"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-surface)"></span><code>--color-surface</code><code class="sc-value" data-token="--color-surface"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-surface-elevated)"></span><code>--color-surface-elevated</code><code class="sc-value" data-token="--color-surface-elevated"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-text)"></span><code>--color-text</code><code class="sc-value" data-token="--color-text"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-text-secondary)"></span><code>--color-text-secondary</code><code class="sc-value" data-token="--color-text-secondary"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-text-muted)"></span><code>--color-text-muted</code><code class="sc-value" data-token="--color-text-muted"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-border)"></span><code>--color-border</code><code class="sc-value" data-token="--color-border"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-primary)"></span><code>--color-primary</code><code class="sc-value" data-token="--color-primary"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-primary-hover)"></span><code>--color-primary-hover</code><code class="sc-value" data-token="--color-primary-hover"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-on-primary)"></span><code>--color-on-primary</code><code class="sc-value" data-token="--color-on-primary"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-success)"></span><code>--color-success</code><code class="sc-value" data-token="--color-success"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-warning)"></span><code>--color-warning</code><code class="sc-value" data-token="--color-warning"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-danger)"></span><code>--color-danger</code><code class="sc-value" data-token="--color-danger"></code></li>
+            <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-info)"></span><code>--color-info</code><code class="sc-value" data-token="--color-info"></code></li>
+          </ul>
+        </div>
+
+        <div class="sc-card stack">
+          <h3>Typography</h3>
+          <div class="grid">
+            <div class="stack-sm">
+              <h4>Families</h4>
+              <p style="font-family: var(--font-sans)"><code>--font-sans</code> The quick brown fox jumps over the lazy dog.</p>
+              <p style="font-family: var(--font-mono)"><code>--font-mono</code> The quick brown fox jumps over the lazy dog.</p>
+            </div>
+            <div class="stack-sm">
+              <h4>Weights</h4>
+              <p style="font-weight: var(--weight-normal)">Normal <code>--weight-normal</code></p>
+              <p style="font-weight: var(--weight-medium)">Medium <code>--weight-medium</code></p>
+              <p style="font-weight: var(--weight-semibold)">Semibold <code>--weight-semibold</code></p>
+              <p style="font-weight: var(--weight-bold)">Bold <code>--weight-bold</code></p>
+            </div>
+          </div>
+          <div class="stack-sm">
+            <h4>Sizes</h4>
+            <p class="sc-specimen" style="font-size: var(--text-3xl)">Aa Interface <code>--text-3xl</code></p>
+            <p class="sc-specimen" style="font-size: var(--text-2xl)">Aa Interface <code>--text-2xl</code></p>
+            <p class="sc-specimen" style="font-size: var(--text-xl)">Aa Interface <code>--text-xl</code></p>
+            <p class="sc-specimen" style="font-size: var(--text-lg)">Aa Interface <code>--text-lg</code></p>
+            <p class="sc-specimen" style="font-size: var(--text-base)">Aa Interface <code>--text-base</code></p>
+            <p class="sc-specimen" style="font-size: var(--text-sm)">Aa Interface <code>--text-sm</code></p>
+          </div>
+          <div class="stack-sm">
+            <h4>Line heights</h4>
+            <div class="grid">
+              <p class="sc-stage" style="line-height: var(--leading-tight)"><code>--leading-tight</code> Short headings and labels use a tight line height so stacked lines stay together.</p>
+              <p class="sc-stage" style="line-height: var(--leading-normal)"><code>--leading-normal</code> Body text uses the normal line height for comfortable reading at any width.</p>
+              <p class="sc-stage" style="line-height: var(--leading-relaxed)"><code>--leading-relaxed</code> Long-form text can use a relaxed line height for extra breathing room.</p>
+            </div>
+          </div>
+        </div>
+
+        <div class="grid">
+          <div class="sc-card stack">
+            <h3>Spacing</h3>
+            <ul class="stack-sm" role="list">
+              <li class="cluster-sm"><span class="sc-bar" style="inline-size: var(--space-1)"></span><code>--space-1</code><code class="sc-value" data-token="--space-1"></code></li>
+              <li class="cluster-sm"><span class="sc-bar" style="inline-size: var(--space-2)"></span><code>--space-2</code><code class="sc-value" data-token="--space-2"></code></li>
+              <li class="cluster-sm"><span class="sc-bar" style="inline-size: var(--space-3)"></span><code>--space-3</code><code class="sc-value" data-token="--space-3"></code></li>
+              <li class="cluster-sm"><span class="sc-bar" style="inline-size: var(--space-4)"></span><code>--space-4</code><code class="sc-value" data-token="--space-4"></code></li>
+              <li class="cluster-sm"><span class="sc-bar" style="inline-size: var(--space-5)"></span><code>--space-5</code><code class="sc-value" data-token="--space-5"></code></li>
+              <li class="cluster-sm"><span class="sc-bar" style="inline-size: var(--space-6)"></span><code>--space-6</code><code class="sc-value" data-token="--space-6"></code></li>
+            </ul>
+          </div>
+
+          <div class="sc-card stack">
+            <h3>Radius</h3>
+            <ul class="cluster" role="list">
+              <li class="stack-sm"><span class="sc-tile" style="border-radius: var(--radius-sm)"></span><code>--radius-sm</code></li>
+              <li class="stack-sm"><span class="sc-tile" style="border-radius: var(--radius-md)"></span><code>--radius-md</code></li>
+              <li class="stack-sm"><span class="sc-tile" style="border-radius: var(--radius-lg)"></span><code>--radius-lg</code></li>
+              <li class="stack-sm"><span class="sc-tile" style="border-radius: var(--radius-full)"></span><code>--radius-full</code></li>
+            </ul>
+          </div>
+
+          <div class="sc-card stack">
+            <h3>Shadows</h3>
+            <ul class="cluster-lg sc-stage" role="list">
+              <li class="stack-sm"><span class="sc-tile sc-tile-raised" style="box-shadow: var(--shadow-sm)"></span><code>--shadow-sm</code></li>
+              <li class="stack-sm"><span class="sc-tile sc-tile-raised" style="box-shadow: var(--shadow-md)"></span><code>--shadow-md</code></li>
+              <li class="stack-sm"><span class="sc-tile sc-tile-raised" style="box-shadow: var(--shadow-lg)"></span><code>--shadow-lg</code></li>
+            </ul>
+          </div>
+
+          <div class="sc-card stack">
+            <h3>Borders</h3>
+            <p class="sc-bordered">
+              <code>--border-width</code> <code class="sc-value" data-token="--border-width"></code>
+              in <code>--border-color</code>, which points at <code>--color-border</code>.
+            </p>
+          </div>
+
+          <div class="sc-card stack">
+            <h3>Focus states</h3>
+            <p class="sc-muted">Press <kbd>Tab</kbd> to move focus. The ring uses <code>--focus-color</code>, <code>--focus-width</code> and <code>--focus-offset</code>.</p>
+            <div class="cluster">
+              <button type="button" class="sc-button">Focus me</button>
+              <span class="sc-button sc-button-secondary sc-focus-preview">Focus ring preview</span>
+            </div>
+          </div>
+
+          <div class="sc-card stack">
+            <h3>Motion</h3>
+            <p class="sc-muted">Easing <code>--ease-standard</code>. Durations drop to 0ms when reduced motion is requested.</p>
+            <ul class="stack-sm" role="list" data-motion>
+              <li class="stack-sm"><code>--duration-fast</code><span class="sc-track"><span class="sc-dot" style="transition-duration: var(--duration-fast)"></span></span></li>
+              <li class="stack-sm"><code>--duration-normal</code><span class="sc-track"><span class="sc-dot" style="transition-duration: var(--duration-normal)"></span></span></li>
+              <li class="stack-sm"><code>--duration-slow</code><span class="sc-track"><span class="sc-dot" style="transition-duration: var(--duration-slow)"></span></span></li>
+            </ul>
+            <div class="cluster">
+              <button type="button" class="sc-button" data-motion-toggle aria-pressed="false">Play motion</button>
+            </div>
+          </div>
+        </div>
+
+        <div class="sc-card stack">
+          <h3>Sizing</h3>
+          <div class="cluster">
+            <button type="button" class="sc-button" style="block-size: var(--control-height)"><code>--control-height</code></button>
+            <label class="cluster-sm">
+              <span><code>--input-height</code></span>
+              <input class="sc-input" type="text" value="Text input" style="block-size: var(--input-height)">
+            </label>
+          </div>
+          <ul class="stack-sm" role="list">
+            <li class="stack-sm"><span class="sc-bar" style="inline-size: var(--grid-min)"></span><span><code>--grid-min</code> <code class="sc-value" data-token="--grid-min"></code></span></li>
+            <li class="stack-sm"><span class="sc-bar" style="inline-size: var(--sidebar-width)"></span><span><code>--sidebar-width</code> <code class="sc-value" data-token="--sidebar-width"></code></span></li>
+            <li class="stack-sm"><span class="sc-bar" style="inline-size: var(--content-width)"></span><span><code>--content-width</code> <code class="sc-value" data-token="--content-width"></code></span></li>
+            <li class="stack-sm"><span class="sc-bar" style="inline-size: var(--container-width)"></span><span><code>--container-width</code> <code class="sc-value" data-token="--container-width"></code> (bars are capped at the card width)</span></li>
+          </ul>
+        </div>
+      </div>
+    </section>
+
+    <section id="layouts" class="sc-band" aria-labelledby="layouts-title">
+      <div class="container stack-lg">
+        <div class="stack-sm">
+          <h2 id="layouts-title">Layouts</h2>
+          <p class="sc-muted">Eight intent-named primitives. No media queries, no breakpoint classes. Dashed outlines show each primitive's box.</p>
+        </div>
+        <div class="grid-lg" style="--grid-min: 22rem">
+
+          <article class="sc-card stack" aria-labelledby="layout-container">
+            <h3 id="layout-container"><code>.container</code></h3>
+            <p>A centered page-width wrapper with fluid side padding.</p>
+            <div class="sc-demo">
+              <div class="container sc-outline"><div class="sc-box">Page content</div></div>
+            </div>
+            <div class="sc-code"><pre><code>&lt;main class="container"&gt;
+  &lt;h1&gt;Settings&lt;/h1&gt;
+&lt;/main&gt;</code></pre></div>
+          </article>
+
+          <article class="sc-card stack" aria-labelledby="layout-stack">
+            <h3 id="layout-stack"><code>.stack</code></h3>
+            <p>Children placed one below the other with an even gap.</p>
+            <div class="sc-demo">
+              <div class="stack sc-outline">
+                <div class="sc-box">First</div>
+                <div class="sc-box">Second</div>
+                <div class="sc-box">Third</div>
+              </div>
+            </div>
+            <div class="sc-code"><pre><code>&lt;form class="stack"&gt;
+  &lt;label&gt;Email &lt;input type="email"&gt;&lt;/label&gt;
+  &lt;button type="submit"&gt;Sign in&lt;/button&gt;
+&lt;/form&gt;</code></pre></div>
+          </article>
+
+          <article class="sc-card stack" aria-labelledby="layout-cluster">
+            <h3 id="layout-cluster"><code>.cluster</code></h3>
+            <p>A row of small items that wraps onto more lines when it runs out of space.</p>
+            <div class="sc-demo">
+              <ul class="cluster-sm sc-outline" role="list">
+                <li class="sc-tag">design</li>
+                <li class="sc-tag">tokens</li>
+                <li class="sc-tag">layout</li>
+                <li class="sc-tag">ai</li>
+                <li class="sc-tag">css</li>
+              </ul>
+            </div>
+            <div class="sc-code"><pre><code>&lt;ul class="cluster" role="list"&gt;
+  &lt;li&gt;&lt;a href="/docs"&gt;Docs&lt;/a&gt;&lt;/li&gt;
+  &lt;li&gt;&lt;a href="/blog"&gt;Blog&lt;/a&gt;&lt;/li&gt;
+&lt;/ul&gt;</code></pre></div>
+          </article>
+
+          <article class="sc-card stack" aria-labelledby="layout-grid">
+            <h3 id="layout-grid"><code>.grid</code></h3>
+            <p>As many equal columns as fit, each at least <code>--grid-min</code> wide.</p>
+            <div class="sc-demo">
+              <ul class="grid-sm sc-outline" role="list" style="--grid-min: 6rem">
+                <li class="sc-box">1</li>
+                <li class="sc-box">2</li>
+                <li class="sc-box">3</li>
+                <li class="sc-box">4</li>
+              </ul>
+            </div>
+            <div class="sc-code"><pre><code>&lt;ul class="grid" role="list"&gt;
+  &lt;li&gt;…&lt;/li&gt;
+  &lt;li&gt;…&lt;/li&gt;
+  &lt;li&gt;…&lt;/li&gt;
+&lt;/ul&gt;</code></pre></div>
+          </article>
+
+          <article class="sc-card stack" aria-labelledby="layout-sidebar">
+            <h3 id="layout-sidebar"><code>.sidebar</code></h3>
+            <p>A narrow first child next to a main area; they stack when space runs out.</p>
+            <div class="sc-demo">
+              <div class="sidebar-sm sc-outline" style="--sidebar-width: 6rem">
+                <nav class="sc-box" aria-label="Demo sidebar">Sidebar</nav>
+                <div class="sc-box">Main content</div>
+              </div>
+            </div>
+            <div class="sc-code"><pre><code>&lt;div class="sidebar"&gt;
+  &lt;nav class="stack"&gt;…&lt;/nav&gt;
+  &lt;main class="stack"&gt;…&lt;/main&gt;
+&lt;/div&gt;</code></pre></div>
+          </article>
+
+          <article class="sc-card stack" aria-labelledby="layout-split">
+            <h3 id="layout-split"><code>.split</code></h3>
+            <p>Two groups pushed to opposite ends of a row.</p>
+            <div class="sc-demo">
+              <div class="split sc-outline">
+                <strong>Brand</strong>
+                <div class="cluster-sm"><span class="sc-tag">Docs</span><span class="sc-tag">Login</span></div>
+              </div>
+            </div>
+            <div class="sc-code"><pre><code>&lt;header class="split"&gt;
+  &lt;a href="/"&gt;Brand&lt;/a&gt;
+  &lt;nav class="cluster"&gt;…&lt;/nav&gt;
+&lt;/header&gt;</code></pre></div>
+          </article>
+
+          <article class="sc-card stack" aria-labelledby="layout-center">
+            <h3 id="layout-center"><code>.center</code></h3>
+            <p>A readable column, never wider than <code>--content-width</code>, centered horizontally.</p>
+            <div class="sc-demo">
+              <div class="center sc-outline" style="--content-width: 12rem">
+                <p class="sc-box">A readable, centered column of text.</p>
+              </div>
+            </div>
+            <div class="sc-code"><pre><code>&lt;article class="center stack"&gt;
+  &lt;h1&gt;Release notes&lt;/h1&gt;
+  &lt;p&gt;…&lt;/p&gt;
+&lt;/article&gt;</code></pre></div>
+          </article>
+
+          <article class="sc-card stack" aria-labelledby="layout-cover">
+            <h3 id="layout-cover"><code>.cover</code></h3>
+            <p>At least one viewport tall, with its main child vertically centered.</p>
+            <div class="sc-demo">
+              <div class="cover sc-outline sc-cover-demo">
+                <div class="sc-box">Header</div>
+                <div class="cover-main sc-box">Centered main</div>
+                <div class="sc-box">Footer</div>
+              </div>
+            </div>
+            <div class="sc-code"><pre><code>&lt;section class="cover"&gt;
+  &lt;header&gt;…&lt;/header&gt;
+  &lt;div class="cover-main"&gt;…&lt;/div&gt;
+  &lt;footer&gt;…&lt;/footer&gt;
+&lt;/section&gt;</code></pre></div>
+          </article>
+
+        </div>
+      </div>
+    </section>
+
+    <section id="responsive" class="sc-band sc-band-alt" aria-labelledby="responsive-title">
+      <div class="container stack-lg">
+        <div class="stack-sm">
+          <h2 id="responsive-title">Responsive demos</h2>
+          <p class="sc-muted">
+            Change the width of each frame with its slider, or drag its bottom-right corner.
+            The primitives respond to the width of their container, not the viewport.
+          </p>
+        </div>
+
+        <article class="sc-card stack" aria-labelledby="responsive-grid">
+          <h3 id="responsive-grid"><code>.grid</code>: columns reduce</h3>
+          <label class="sc-range">Frame width <input type="range" min="20" max="100" value="100" data-frame="frame-grid"></label>
+          <div class="sc-frame" id="frame-grid">
+            <ul class="grid-sm" role="list" style="--grid-min: 10rem">
+              <li class="sc-box">Card 1</li>
+              <li class="sc-box">Card 2</li>
+              <li class="sc-box">Card 3</li>
+              <li class="sc-box">Card 4</li>
+              <li class="sc-box">Card 5</li>
+              <li class="sc-box">Card 6</li>
+            </ul>
+          </div>
+        </article>
+
+        <article class="sc-card stack" aria-labelledby="responsive-sidebar">
+          <h3 id="responsive-sidebar"><code>.sidebar</code>: stacks when narrow</h3>
+          <label class="sc-range">Frame width <input type="range" min="20" max="100" value="100" data-frame="frame-sidebar"></label>
+          <div class="sc-frame" id="frame-sidebar">
+            <div class="sidebar">
+              <nav class="sc-box stack-sm" aria-label="Demo navigation">
+                <strong>Sidebar</strong>
+                <span>Settings</span>
+                <span>Billing</span>
+              </nav>
+              <div class="sc-box stack-sm">
+                <strong>Main area</strong>
+                <p>Takes the remaining space and must be at least half the width, otherwise it moves under the sidebar.</p>
+              </div>
+            </div>
+          </div>
+        </article>
+
+        <article class="sc-card stack" aria-labelledby="responsive-cluster">
+          <h3 id="responsive-cluster"><code>.cluster</code>: wraps</h3>
+          <label class="sc-range">Frame width <input type="range" min="20" max="100" value="100" data-frame="frame-cluster"></label>
+          <div class="sc-frame" id="frame-cluster">
+            <ul class="cluster-sm" role="list">
+              <li class="sc-tag">accessible</li>
+              <li class="sc-tag">semantic</li>
+              <li class="sc-tag">predictable</li>
+              <li class="sc-tag">token-based</li>
+              <li class="sc-tag">intrinsic</li>
+              <li class="sc-tag">no breakpoints</li>
+              <li class="sc-tag">AI-first</li>
+            </ul>
+          </div>
+        </article>
+
+        <article class="sc-card stack" aria-labelledby="responsive-split">
+          <h3 id="responsive-split"><code>.split</code>: adapts</h3>
+          <label class="sc-range">Frame width <input type="range" min="20" max="100" value="100" data-frame="frame-split"></label>
+          <div class="sc-frame" id="frame-split">
+            <div class="split">
+              <strong>Project dashboard</strong>
+              <div class="cluster-sm">
+                <span class="sc-button">New report</span>
+                <span class="sc-button sc-button-secondary">Export</span>
+              </div>
+            </div>
+          </div>
+        </article>
+      </div>
+    </section>
+
+    <section id="ai-examples" class="sc-band" aria-labelledby="ai-title">
+      <div class="container stack-lg">
+        <div class="stack-sm">
+          <h2 id="ai-title">AI-friendly examples</h2>
+          <p class="sc-muted">A plain-English intent maps to one predictable class.</p>
+        </div>
+        <ul class="grid-lg" role="list" style="--grid-min: 22rem">
+          <li class="sc-card stack">
+            <p class="sc-label">Intent</p>
+            <p>"Show these tags in a row that wraps on small screens."</p>
+            <p class="sc-label">SynthCSS</p>
+            <div class="sc-code"><pre><code>&lt;ul class="cluster" role="list"&gt;
+  &lt;li&gt;Design&lt;/li&gt;
+  &lt;li&gt;AI&lt;/li&gt;
+  &lt;li&gt;CSS&lt;/li&gt;
+&lt;/ul&gt;</code></pre></div>
+          </li>
+          <li class="sc-card stack">
+            <p class="sc-label">Intent</p>
+            <p>"Show the products as responsive cards, as many per row as fit."</p>
+            <p class="sc-label">SynthCSS</p>
+            <div class="sc-code"><pre><code>&lt;ul class="grid" role="list"&gt;
+  &lt;li class="stack-sm"&gt;…&lt;/li&gt;
+  &lt;li class="stack-sm"&gt;…&lt;/li&gt;
+  &lt;li class="stack-sm"&gt;…&lt;/li&gt;
+&lt;/ul&gt;</code></pre></div>
+          </li>
+          <li class="sc-card stack">
+            <p class="sc-label">Intent</p>
+            <p>"Put the settings navigation next to the form, stacked on phones."</p>
+            <p class="sc-label">SynthCSS</p>
+            <div class="sc-code"><pre><code>&lt;div class="sidebar"&gt;
+  &lt;nav class="stack-sm"&gt;…&lt;/nav&gt;
+  &lt;form class="stack"&gt;…&lt;/form&gt;
+&lt;/div&gt;</code></pre></div>
+          </li>
+          <li class="sc-card stack">
+            <p class="sc-label">Intent</p>
+            <p>"A header with the logo on the left and actions on the right."</p>
+            <p class="sc-label">SynthCSS</p>
+            <div class="sc-code"><pre><code>&lt;header class="container split"&gt;
+  &lt;a href="/"&gt;Logo&lt;/a&gt;
+  &lt;div class="cluster-sm"&gt;…&lt;/div&gt;
+&lt;/header&gt;</code></pre></div>
+          </li>
+          <li class="sc-card stack">
+            <p class="sc-label">Intent</p>
+            <p>"A full-screen sign-in page with the form in the middle."</p>
+            <p class="sc-label">SynthCSS</p>
+            <div class="sc-code"><pre><code>&lt;main class="cover"&gt;
+  &lt;form class="cover-main center stack"&gt;…&lt;/form&gt;
+&lt;/main&gt;</code></pre></div>
+          </li>
+          <li class="sc-card stack">
+            <p class="sc-label">Intent</p>
+            <p>"Restyle the whole interface with our brand color and rounder corners."</p>
+            <p class="sc-label">SynthCSS</p>
+            <div class="sc-code"><pre><code>:root {
+  --color-primary: #0b6e4f;
+  --radius-md: 0.5rem;
+}</code></pre></div>
+          </li>
+        </ul>
+      </div>
+    </section>
+  </main>
+
+  <footer class="sc-footer">
+    <div class="container split">
+      <p>SynthCSS is MIT licensed.</p>
+      <ul class="cluster-sm" role="list">
+        <li><a href="https://github.com/nabledhq/synthcss">GitHub</a></li>
+        <li><a href="https://github.com/nabledhq/synthcss/blob/main/docs/tokens.md">Token reference</a></li>
+        <li><a href="https://github.com/nabledhq/synthcss/blob/main/docs/layout.md">Layout reference</a></li>
+      </ul>
+    </div>
+  </footer>
+</body>
+</html>
diff --git a/showcase/showcase.css b/showcase/showcase.css
new file mode 100644
index 0000000..98b066b
--- /dev/null
+++ b/showcase/showcase.css
@@ -0,0 +1,316 @@
+/*
+ * Showcase-only styles.
+ *
+ * Layout comes from the SynthCSS primitives in ../src/layout.css and every
+ * value comes from a token in ../src/tokens.css. This file only adds what the
+ * framework does not ship yet: base typography, demo boxes, frames and the
+ * copy buttons. Every class here starts with `sc-` so it never restyles a
+ * framework class. Checked by `npm run check:showcase`.
+ */
+
+/* Base page typography. */
+.sc-page {
+  margin: 0;
+  background: var(--color-background);
+  color: var(--color-text);
+  font-family: var(--font-sans);
+  font-size: var(--text-base);
+  line-height: var(--leading-normal);
+}
+
+.sc-page :where(h1, h2, h3, h4) {
+  margin: 0;
+  line-height: var(--leading-tight);
+  font-weight: var(--weight-semibold);
+}
+
+.sc-page h2 { font-size: var(--text-3xl); }
+.sc-page h3 { font-size: var(--text-xl); }
+.sc-page h4 { font-size: var(--text-base); color: var(--color-text-secondary); }
+
+.sc-page :where(h1, h2, h3, h4) code { font-size: inherit; }
+
+.sc-page :where(p) { margin: 0; }
+
+.sc-page :where(code, kbd, pre) {
+  font-family: var(--font-mono);
+  font-size: var(--text-sm);
+}
+
+.sc-page :where(a) { color: var(--color-primary); }
+.sc-page :where(a:hover) { color: var(--color-primary-hover); }
+
+.sc-page :focus-visible,
+.sc-focus-preview {
+  outline: var(--focus-width) solid var(--focus-color);
+  outline-offset: var(--focus-offset);
+}
+
+.sc-muted { color: var(--color-text-muted); }
+
+/* Hero */
+.sc-hero {
+  background: var(--color-surface);
+  border-block-end: var(--border-width) solid var(--border-color);
+}
+
+/* .container sets box-sizing: border-box, so this padding stays inside the
+   one-viewport-tall .cover. */
+.sc-hero-cover { padding-block: var(--space-4) var(--space-6); }
+
+.sc-brand {
+  font-size: var(--text-xl);
+  font-weight: var(--weight-bold);
+  text-decoration: none;
+}
+
+.sc-hero-title {
+  font-size: calc(var(--text-3xl) * 2);
+  font-weight: var(--weight-bold);
+}
+
+.sc-tagline {
+  font-size: var(--text-2xl);
+  font-weight: var(--weight-medium);
+  line-height: var(--leading-tight);
+}
+
+.sc-lead {
+  font-size: var(--text-lg);
+  color: var(--color-text-secondary);
+}
+
+.sc-callout {
+  padding: var(--space-4);
+  background: var(--color-surface-elevated);
+  border-inline-start: calc(var(--border-width) * 4) solid var(--color-primary);
+  border-radius: var(--radius-md);
+  box-shadow: var(--shadow-sm);
+}
+
+/* Section bands */
+.sc-band { padding-block: var(--space-6); }
+.sc-band-alt { background: var(--color-surface); }
+
+.sc-footer {
+  padding-block: var(--space-5);
+  border-block-start: var(--border-width) solid var(--border-color);
+  color: var(--color-text-muted);
+}
+
+/* Cards and demo pieces */
+.sc-card {
+  min-inline-size: 0;
+  padding: var(--space-5);
+  background: var(--color-surface-elevated);
+  border: var(--border-width) solid var(--border-color);
+  border-radius: var(--radius-lg);
+  box-shadow: var(--shadow-sm);
+}
+
+.sc-label {
+  color: var(--color-text-muted);
+  font-size: var(--text-sm);
+  font-weight: var(--weight-semibold);
+  text-transform: uppercase;
+}
+
+.sc-button {
+  display: inline-flex;
+  align-items: center;
+  box-sizing: border-box;
+  min-block-size: var(--control-height);
+  padding-inline: var(--space-4);
+  border: var(--border-width) solid var(--color-primary);
+  border-radius: var(--radius-md);
+  background: var(--color-primary);
+  color: var(--color-on-primary);
+  font: inherit;
+  font-weight: var(--weight-medium);
+  text-decoration: none;
+  cursor: pointer;
+  transition: background-color var(--duration-fast) var(--ease-standard);
+}
+
+.sc-page a.sc-button { color: var(--color-on-primary); }
+.sc-button:hover { background: var(--color-primary-hover); }
+
+.sc-button-secondary,
+.sc-page a.sc-button-secondary {
+  background: var(--color-surface-elevated);
+  color: var(--color-primary);
+}
+
+.sc-button-secondary:hover { background: var(--color-surface); }
+
+.sc-input {
+  box-sizing: border-box;
+  max-inline-size: 100%;
+  padding-inline: var(--space-3);
+  border: var(--border-width) solid var(--border-color);
+  border-radius: var(--radius-md);
+  font: inherit;
+}
+
+.sc-box {
+  box-sizing: border-box;
+  padding: var(--space-3);
+  background: var(--color-surface);
+  border: var(--border-width) solid var(--border-color);
+  border-radius: var(--radius-md);
+}
+
+.sc-tag {
+  padding: var(--space-1) var(--space-3);
+  background: var(--color-surface);
+  border: var(--border-width) solid var(--border-color);
+  border-radius: var(--radius-full);
+  font-size: var(--text-sm);
+}
+
+.sc-outline { outline: var(--border-width) dashed var(--color-primary); }
+
+.sc-demo {
+  padding: var(--space-3);
+  background: var(--color-background);
+  border: var(--border-width) solid var(--border-color);
+  border-radius: var(--radius-md);
+}
+
+/* The real .cover is at least one viewport tall; the demo is shorter. */
+.sc-cover-demo { min-block-size: calc(var(--space-6) * 6); }
+
+/* Width-adjustable frames for the responsive demos. */
+.sc-frame {
+  box-sizing: border-box;
+  inline-size: 100%;
+  max-inline-size: 100%;
+  min-inline-size: calc(var(--space-6) * 3);
+  padding: var(--space-3);
+  overflow: auto;
+  resize: horizontal;
+  background: var(--color-background);
+  border: var(--border-width) dashed var(--color-text-muted);
+  border-radius: var(--radius-md);
+}
+
+.sc-range {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: var(--space-3);
+  font-size: var(--text-sm);
+  color: var(--color-text-secondary);
+}
+
+/* Token specimens */
+.sc-swatch {
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-1);
+  overflow-wrap: anywhere;
+}
+
+.sc-swatch-chip {
+  block-size: calc(var(--space-6) * 2);
+  border: var(--border-width) solid var(--border-color);
+  border-radius: var(--radius-md);
+}
+
+.sc-value { color: var(--color-text-muted); }
+
+.sc-specimen { overflow-wrap: anywhere; }
+
+/* A tinted backdrop for specimens that are hard to see on white. */
+.sc-stage {
+  padding: var(--space-4);
+  background: var(--color-surface);
+  border-radius: var(--radius-md);
+}
+
+.sc-bar {
+  display: block;
+  flex-shrink: 0;
+  max-inline-size: 100%;
+  block-size: var(--space-4);
+  background: var(--color-primary);
+  border-radius: var(--radius-sm);
+}
+
+.sc-tile {
+  display: block;
+  inline-size: calc(var(--space-6) * 2);
+  block-size: calc(var(--space-6) * 2);
+  background: var(--color-primary);
+}
+
+.sc-tile-raised {
+  background: var(--color-surface-elevated);
+  border-radius: var(--radius-md);
+}
+
+.sc-bordered {
+  padding: var(--space-4);
+  border: var(--border-width) solid var(--border-color);
+  border-radius: var(--radius-md);
+}
+
+.sc-track {
+  position: relative;
+  display: block;
+  block-size: var(--space-5);
+  background: var(--color-surface);
+  border-radius: var(--radius-full);
+}
+
+.sc-dot {
+  position: absolute;
+  inset-block-start: 0;
+  inset-inline-start: 0;
+  inline-size: var(--space-5);
+  block-size: var(--space-5);
+  background: var(--color-primary);
+  border-radius: var(--radius-full);
+  transition-property: inset-inline-start;
+  transition-timing-function: var(--ease-standard);
+}
+
+[data-motion].sc-is-playing .sc-dot { inset-inline-start: calc(100% - var(--space-5)); }
+
+/* Code samples. `contain: inline-size` stops long lines from widening the
+   grid or flex item around them; the block scrolls instead. */
+.sc-code {
+  position: relative;
+  contain: inline-size;
+}
+
+.sc-code pre {
+  margin: 0;
+  padding: var(--space-4);
+  padding-block-start: calc(var(--space-6) + var(--space-3));
+  overflow-x: auto;
+  background: var(--color-text);
+  color: var(--color-background);
+  border-radius: var(--radius-md);
+  line-height: var(--leading-normal);
+}
+
+.sc-copy {
+  position: absolute;
+  inset-block-start: var(--space-2);
+  inset-inline-end: var(--space-2);
+  padding: var(--space-1) var(--space-3);
+  border: var(--border-width) solid var(--color-text-muted);
+  border-radius: var(--radius-sm);
+  background: var(--color-text-secondary);
+  color: var(--color-background);
+  font: inherit;
+  font-size: var(--text-sm);
+  cursor: pointer;
+}
+
+.sc-copy:hover { background: var(--color-text-muted); }
+
+/* The copy button sits on the dark code block, so its ring uses the light
+   background color to stay visible. */
+.sc-copy:focus-visible { outline-color: var(--color-background); }
diff --git a/showcase/showcase.js b/showcase/showcase.js
new file mode 100644
index 0000000..71d9cc6
--- /dev/null
+++ b/showcase/showcase.js
@@ -0,0 +1,70 @@
+// Small progressive enhancements for the showcase. The page works without it.
+
+// Show the live value of each token next to its specimen, read from the
+// framework stylesheet so the page never repeats token values.
+const rootStyle = getComputedStyle(document.documentElement);
+for (const el of document.querySelectorAll("[data-token]")) {
+  el.textContent = rootStyle.getPropertyValue(el.dataset.token).trim();
+}
+
+// Copy-to-clipboard buttons on every code sample.
+async function copyText(text) {
+  if (navigator.clipboard && window.isSecureContext) {
+    await navigator.clipboard.writeText(text);
+    return;
+  }
+  // Fallback for contexts without the async Clipboard API.
+  const area = document.createElement("textarea");
+  area.value = text;
+  area.setAttribute("readonly", "");
+  area.style.position = "fixed";
+  area.style.opacity = "0";
+  document.body.append(area);
+  area.select();
+  const ok = document.execCommand("copy");
+  area.remove();
+  if (!ok) throw new Error("copy failed");
+}
+
+for (const block of document.querySelectorAll(".sc-code")) {
+  const code = block.querySelector("code");
+  const button = document.createElement("button");
+  button.type = "button";
+  button.className = "sc-copy";
+  button.textContent = "Copy";
+  button.setAttribute("aria-live", "polite");
+  let timer;
+  button.addEventListener("click", async () => {
+    try {
+      await copyText(code.textContent);
+      button.textContent = "Copied";
+    } catch {
+      button.textContent = "Press Ctrl+C";
+      getSelection().selectAllChildren(code);
+    }
+    clearTimeout(timer);
+    timer = setTimeout(() => {
+      button.textContent = "Copy";
+    }, 2000);
+  });
+  block.prepend(button);
+}
+
+// Range inputs change the width of their responsive demo frame.
+for (const input of document.querySelectorAll("input[type=range][data-frame]")) {
+  const frame = document.getElementById(input.dataset.frame);
+  const update = () => {
+    frame.style.inlineSize = `${input.value}%`;
+  };
+  input.addEventListener("input", update);
+  update();
+}
+
+// Motion demo: move the dots with each duration token.
+for (const toggle of document.querySelectorAll("[data-motion-toggle]")) {
+  const demo = toggle.closest(".sc-card").querySelector("[data-motion]");
+  toggle.addEventListener("click", () => {
+    const playing = demo.classList.toggle("sc-is-playing");
+    toggle.setAttribute("aria-pressed", String(playing));
+  });
+}
Acceptance · round 1
Voting open
CINo checks
Automated reviewPass with concerns

The PR delivers a complete static showcase covering every spec section. It uses real framework tokens and primitives, scopes all showcase styles to `sc-` classes, and adds a correct GitHub Pages workflow and thorough README docs. A dependency-free checker is wired into `npm test`. No CI ran, so the runtime criteria (no console errors, no overflow at 375px, copy behaviour) rest on the builder's own sandbox browser run. One AI example restates a token value, so a quick manual check is advisable before accepting.

Acceptance criteria · 11 of 13 met
  • UNCLEARshowcase/index.html exists and opens locally with SynthCSS styles applied, with no console errors`showcase/index.html` links `../src/synthcss.css` relatively, and `showcase.js` has no obvious runtime errors; the no-errors claim is backed only by the builder's sandbox browser run, not CI.
  • YESHero contains the project name, tagline, AI-first statement, repo link and docs link`#hero` has `<h1>SynthCSS</h1>`, the exact tagline, the 'Built for AI-generated interfaces' callout, the repo link and the `#readme` docs link.
  • YESThe "Why" section covers the four points concisely`#why` has four `sc-card` list items, one per spec point, each with a one-line paragraph.
  • YESEvery existing token category is visibly rendered using var(--...) from the framework; no hardcoded duplicate token values in showcase.cssThe tokens section renders colors, typography, spacing, radius, shadows, borders, focus, motion and sizing via `var(--…)`. `showcase.css` uses only `var()` values, and `checkShowcaseCss` rejects raw token values and color literals.
  • YESEvery existing layout primitive has a demo, class name, description and HTML snippet`#layouts` has eight `<article>` cards, each with a live demo, a `<code>.name</code>` heading, one sentence and a snippet of 3–5 lines.
  • YESGrid, sidebar, cluster and split each have a width-adjustable demo`#responsive` has four `.sc-frame` frames with `resize: horizontal` plus range inputs wired in `showcase.js`.
  • YESAt least 3 intent-to-SynthCSS examples are present`#ai-examples` has six Intent/SynthCSS pairs, including cluster and grid.
  • YESCopy buttons copy the snippet text`showcase.js` adds a button to each `.sc-code` that copies `code.textContent` via the Clipboard API, falling back to `execCommand`; the browser script verifies this, but only the builder ran it.
  • UNCLEARNo horizontal page overflow at 375px and correct layout at 1280px+`check-showcase-browser.mjs` tests both widths and the builder reports it passing, but nothing was verified in CI.
  • YESNo third-party CSS framework is referencedOnly `../src/synthcss.css` and `showcase.css` are linked, and the checker enforces this with a third-party regex.
  • YESshowcase.css contains only showcase-specific rulesEvery selector in `showcase.css` is scoped to an `sc-` class, with no framework class selectors or custom property definitions.
  • YESA Pages workflow exists, is valid YAML, uses the official Pages actions, and runs on push to main`.github/workflows/pages.yml` uses configure-pages@v5, upload-pages-artifact@v4 and deploy-pages@v4. It sets `pages: write` and `id-token: write`, and triggers on push to main (with the required paths) plus workflow_dispatch.
  • YESshowcase/README.md documents preview, deployment, the manual Pages setting and extension`showcase/README.md` has Local preview, Deployment, One-time setup (Settings → Pages → GitHub Actions) and Adding a section, and is linked from the main `README.md`.
Concerns
  • No CI ran on this commit. The no-console-errors, 375px overflow and copy-button criteria rest only on the builder's sandbox Playwright run, which used a substitute Chromium binary. A maintainer should open the page at 375px and 1280px before merging.
  • The sixth AI example hard-codes `--color-primary: #0b6e4f; --radius-md: 0.5rem;`. The test file implies `0.5rem` is already the real `--radius-md` value, so this 'rounder corners' override changes nothing. It also restates a token value the spec asked not to copy (in `index.html`, not `showcase.css`).
  • Several demos use `--grid-min: 22rem` cards (`#layouts`, `#ai-examples`). Whether these overflow at 375px depends on the framework's `.grid` clamping with `min()`. The diff does not show `layout.css`, so this relies on the builder's browser check.
  • `check-showcase.test.mjs` resolves the repo path with `new URL('..', import.meta.url).pathname`. This breaks on Windows and on paths with URL-encoded characters such as spaces; `fileURLToPath` would be safer.
  • The static checker is regex-based and tied closely to the current markup (exact `<link>` attribute order, `<li class="sc-card`, an exact docs URL). Future harmless edits to the page may trip `npm test`, which now also gates deployment.
CI details
No CI checks ran on this commit.

BackersWaiting for votes
0 accept · 0 rebuild · 1 not voted · quorum 1 of 1
MaintainerNot decided yet

Both keys are needed: a majority of backers to accept and the maintainer to merge. The window closes in 6 days. Below quorum, the automated verdict decides for the backers. Rebuilds used: 0 of 2.

Automated review cost $0.21, counted as builder cost.

Discussion · 0

No comments yet.