Add core semantic components (button, field, card, badge, alert, panel, table, empty-state) with docs, showcase and tests
Motivation
Give AI agents a small, semantic, predictable component vocabulary on top of the existing SynthCSS tokens and layout primitives, so they can build a common app UI without composing low-level utility classes.
Scope
Components
All components live in the existing CSS source. Each uses only existing SynthCSS custom properties for color, spacing, radius, typography and shadows; no hard-coded colors. Naming follows .component, .component-variant, .component-part.
- Button:
.buttonplus.button-primary,.button-secondaryand.button-danger.- Assumption:
.button-dangeris canonical, matching the spec's examples;.button-destructiveis not added. - Sizes:
.button-smand.button-lg. - Disabled: styled via
:disabledand[aria-disabled="true"]. - Loading: styled via
[aria-busy="true"], which shows reduced interactivity plus a CSS-only indicator.- Assumption: no
.button-loadingclass is added.
- Assumption: no
- Icon + text: an inline
svgor element inside the button is aligned with a gap. - Icon-only:
.button-icon, a square button; documentation requiresaria-label.
- Assumption:
- Form controls:
.fieldwrapper,.field-label,.field-help,.field-error.- Native
inputtypestext,email,passwordandnumber, plustextarea,select,checkboxandradio, are styled when inside.field.- Assumption: scoped under
.fieldto avoid global resets; document this.
- Assumption: scoped under
- Disabled fields are styled via
:disabled. - Error state is triggered by
[aria-invalid="true"]. It must change more than color, for example a thicker border plus a visible error text or icon marker.
- Native
- Card:
.cardwith optional.card-header,.card-body,.card-footer,.card-mediaand.card-actions. - Badge:
.badgeplus.badge-success,.badge-warning,.badge-dangerand.badge-info. - Alert:
.alertplus.alert-info,.alert-success,.alert-warningand.alert-danger. Arbitrary headings, paragraphs and buttons inside must render sensibly without a special structure. - Panel:
.panel(optionally.panel-headerand.panel-body), visually lighter than.card. - Table:
.tableapplied to native<table>, with an optional.table-wrapwrapper for horizontal scroll on narrow screens.- Header and body row styling, borders.
- Hover rows via
.table-hover. .numericcell class for right-aligned tabular numbers.
- Empty state:
.empty-state, a centered stack for heading, description, an optional icon or illustration, and an optional action.
Shared requirements
- Visible
:focus-visiblestyles on all interactive components. - Consistent disabled treatment that is still distinguishable.
- No JavaScript.
- Responsive by default.
- Native semantics preserved; do not remove native focus or keyboard behavior.
Documentation
- One doc section or file per component, covering:
- purpose
- minimal example
- variants
- composition with
.stack,.cluster,.splitand.grid - accessibility notes
- recommended use
- misuse examples where useful
- A compact intent-to-class reference table, of about 15 rows or fewer, as in the proposal.
- Alert examples use
role="alert"orrole="status"appropriately.
Showcase
- Add a section for each component to the existing
showcase/site. - Add at least one composed mini-interface, for example a settings form or a project card grid with badges and an alert, built only from SynthCSS classes.
Verification
Extend the existing test or fixture setup. Assumption: static checks only, with no screenshot tooling unless it already exists. Tests must check that:
- every listed component class exists in the built CSS;
- component rules reference
var(--…)tokens and contain no raw hex, rgb or hsl color literals; - interactive components define
:focus-visiblestyles; - disabled selectors exist for buttons and fields;
- every class used in doc examples and the showcase exists in the CSS;
- the showcase contains each component.
Acceptance Criteria
- All classes listed above exist in the built CSS output.
- Component color declarations use only SynthCSS tokens (verified by test).
- Buttons and form controls have
:focus-visibleand disabled styles (verified by test). - Field error state does not rely on color alone.
- Tables scroll horizontally inside
.table-wrapon narrow viewports. - Docs cover every component and include the compact AI reference table.
- Doc and showcase class usage validated against the CSS (test).
- Showcase demonstrates all eight components plus at least one composed interface.
- The existing test suite and build pass.
- No third-party CSS framework is added.
Out of Scope
- Dark theme implementation (components must merely consume semantic tokens).
- JavaScript-driven components (modals, dropdowns, tabs).
- Additional color or size variants beyond those listed.
- Icon sets.
Model claude-opus-5-5 · ceiling $11.25 · started 1 hour ago · finished 1 hour ago
### Docs: `docs/components.md` - One section per component: purpose, example, variants, composition with `.stack`/`.cluster`/`.split`/`.grid`, accessibility, recommended use and misuse. - A 14-row **AI Component Reference** table. - The alert examples use `role="alert"` and `role="status"`, and icon-only buttons must have `aria-label`. - The README has a new Components section. ### Showcase - A `#components` section with one article per component, each with a live demo of every class and a snippet. - A `#composed` section: a project settings screen built only from SynthCSS classes, in a frame you can resize. - Two component examples added to the AI examples section. - `showcase/README.md` is updated. ### Tests - **`scripts/check-components.mjs`** (in `npm test`, with 13 unit tests) checks: - every listed class exists in the built CSS (`synthcss.css` with its imports inlined) and no extra classes are added; - no hex, `rgb`, `hsl` or named color literals, and no custom properties; - color properties use tokens or a `color-mix()` of two tokens, and every `var()` points to a defined token; - spacing, font size and radius use tokens, with no fixed lengths; - text on every button, badge, alert, card and panel variant meets 4.5:1 contrast; - `:focus-visible` rings exist for buttons, inputs, selects, textareas and `.table-wrap`; - disabled, loading and error rules exist, and the error state changes the border width and adds a marker; - `.table-wrap` scrolls horizontally; - the docs cover every component and the reference table has 15 rows or fewer; - every class in an html example in `README.md` and `docs/*.md`, and every class on the showcase page, exists in the CSS. - **`check-showcase.mjs`** now requires the components and composed sections. The composed screen must use only SynthCSS classes and at least six components. Two new tests cover this. - **`scripts/check-components-browser.mjs`** is optional and needs Playwright, like the existing browser checks. It loads the showcase at 375px and 1280px and confirms the components pick up their tokens, the states render, Tab-key focus rings show and tables scroll inside `.table-wrap` without the page overflowing. It passed here. ### Verification - `npm test` passes. - `check:components:browser` and `check:layout:browser` pass in headless Chromium. - `check:showcase:browser` fails in this sandbox only because the external nabled.ai badge image can't load (a TLS certificate error). That image was there before this change. - I checked screenshots at 375px and 1280px by eye. That turned up one bug, now fixed: `<img class="card-media">` used directly did not span the full card width. ### Please check - **`showcase/showcase.css`:** I lowered the specificity of the page's heading and link rules by wrapping them in `:where(.sc-page)`. Without this they would override component styles, for example turning the text on a link button the wrong color. - **`check-showcase.mjs`:** `sectionHtml` used to end a section at any `<footer`. It now ends only at the page footer (`sc-footer`), because cards contain `<footer>` elements. - **Newer CSS features:** - Tints use `color-mix()`. Badges fall back to the plain background color in browsers without it. Alerts fall back to the base `.alert` surface, but keep their colored border. - Muting the label of a disabled field uses `:has()`. Older browsers skip only that. - **Busy and `aria-disabled` buttons:** CSS cannot stop keyboard activation of these, so the app has to ignore it. The docs say this. - **Git identity:** an identity was already configured in the sandbox, so I committed with it instead of "nabled builder". 2026-10-03T10:37:53Z [claude] I've built all eight components (button, field, card, badge, alert, panel, table, empty-state) with docs, a showcase section for each and a composed example screen. Everything is pushed to `nabled/feature-4-job-4`. `npm test` passes (40 tests, up from 25), and so do the new browser check and the existing layout browser check. The existing showcase browser check fails here only because an external nabled.ai badge image can't load in this sandbox; that image was already on the page. - **Components** (`src/components.css`, loaded by the main bundle): all colors, spacing, sizes and shadows come from existing tokens, with no color values typed in and no JavaScript. - Buttons and form controls have disabled styles and keyboard focus rings. - Busy buttons (`aria-busy="true"`) show a spinner that stops when the user asks for reduced motion. - An invalid field (`aria-invalid="true"`) gets a thicker border plus a "!" marker on its error text, so the error doesn't rely on color alone. - Wide tables scroll sideways inside `.table-wrap`. - **Docs** (`docs/components.md`): one section per component with the parts the spec asked for, and a 14-row reference table for AI models. The README has a new Components section. - **Showcase**: one live demo and code snippet per component, plus a project settings screen built only from SynthCSS classes. It fits at phone (375px) and desktop (1280px) widths. - **Tests**: a new check script covers all six verification points in the spec, plus a text contrast check (4.5:1) on every colored variant. An optional browser check confirms the rendering, focus rings and table scrolling at both widths. While checking screenshots I found and fixed one bug: an image with `card-media` placed directly in a card didn't span the full card width. Things to look at when reviewing: - **Showcase styles:** I made the page's own heading and link rules weaker in `showcase/showcase.css`, because they were overriding component styles. - **Existing showcase check:** it used to end a section at any `<footer>`, which broke on cards that contain one. It now stops only at the page footer. - **Older browsers:** without `color-mix()` support, badges lose their tinted background and alerts fall back to a plain background, but alerts keep their colored border. Without `:has()`, only the muted label on a disabled field is lost. - **Busy and `aria-disabled` buttons:** CSS can't stop keyboard users from activating these, so the app has to ignore those clicks. The docs say so. - **Commit author:** commits use the git identity already configured in the sandbox, not "nabled builder", which the instructions only called for when none was set. Screenshots of the components and the composed screen are in `/mnt/session/outputs`. 2026-10-03T10:37:55Z Changed files: modified README.md +18 -1 added docs/components.md +660 -0 modified package.json +3 -1 added scripts/check-components-browser.mjs +131 -0 added scripts/check-components.mjs +403 -0 added scripts/check-components.test.mjs +117 -0 modified scripts/check-showcase.mjs +46 -4 modified scripts/check-showcase.test.mjs +13 -0 modified showcase/README.md +12 -3 modified showcase/index.html +418 -2 modified showcase/showcase.css +8 -6 added src/components.css +711 -0 modified src/synthcss.css +3 -0 2026-10-03T10:37:56Z Opened pull request https://github.com/nabledhq/synthcss/pull/5 2026-10-03T10:37:56Z Finished: success=true turns=40 tokens(in/out)=4455842/83711 list cost=$3.33
Show patch
diff --git a/README.md b/README.md
index 2c9fcff..619317e 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,23 @@ 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.
+## Components
+
+Eight semantic components in [`src/components.css`](src/components.css), included in the main bundle: `.button`, `.field`, `.card`, `.badge`, `.alert`, `.panel`, `.table` and `.empty-state`, with a small set of variants (`.button-primary`, `.badge-success`, `.alert-danger`, …) and parts (`.card-header`, `.field-error`, …). They use only tokens, take their state from native attributes (`disabled`, `aria-busy="true"`, `aria-invalid="true"`) and need no JavaScript:
+
+```html
+<form class="card">
+ <div class="field">
+ <label class="field-label" for="email">Email</label>
+ <input id="email" type="email" aria-invalid="true" aria-describedby="email-error">
+ <p class="field-error" id="email-error">Enter a full email address.</p>
+ </div>
+ <div class="cluster-sm"><button type="submit" class="button button-primary">Save</button></div>
+</form>
+```
+
+See [docs/components.md](docs/components.md) for each component's variants, composition with the layout primitives and accessibility notes, plus a compact "AI Component Reference" table to give to a model.
+
## 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.
+[`showcase/`](showcase/) is a static page built with SynthCSS that shows the tokens, layout primitives and components live, plus a composed settings screen, with copyable snippets and width-adjustable demos. Open `showcase/index.html` in a browser to preview it. It is published to GitHub Pages by [`.github/workflows/pages.yml`](.github/workflows/pages.yml). See [showcase/README.md](showcase/README.md) for local preview, deployment, the one-time Pages setting and how to add a section.
diff --git a/docs/components.md b/docs/components.md
new file mode 100644
index 0000000..268b692
--- /dev/null
+++ b/docs/components.md
@@ -0,0 +1,660 @@
+# Components
+
+SynthCSS ships eight semantic components in [`src/components.css`](../src/components.css):
+`.button`, `.field`, `.card`, `.badge`, `.alert`, `.panel`, `.table` and `.empty-state`.
+Each one names a UI intent, so a model can map a request ("a delete button", "an
+error message", "a table of invoices") to one predictable class.
+
+They are part of the main bundle, together with the [design tokens](tokens.md) and the
+[layout primitives](layout.md):
+
+```html
+<link rel="stylesheet" href="synthcss/src/synthcss.css">
+```
+
+How the components behave:
+
+- **Token-based.** Every color, space, radius, font size, weight and shadow is a
+ `var(--token)` from `tokens.css`. There are no color literals: status tints such as the
+ badge and alert backgrounds are a `color-mix()` of two tokens. Override tokens on
+ `:root` to restyle every component at once.
+- **Naming.** `.component` for the base, `.component-variant` for a variant
+ (`.button-danger`) and `.component-part` for a part (`.card-header`). The one
+ exception is the `.numeric` table cell.
+- **State from native attributes, not classes.** Use `disabled`,
+ `aria-disabled="true"`, `aria-busy="true"` and `aria-invalid="true"`. There is no
+ `.is-disabled`, `.button-loading` or `.field-invalid`.
+- **Native elements.** Put `.button` on `<button>` or `<a>`, `.table` on `<table>`, and
+ native `input`, `select` and `textarea` inside `.field`. Native focus and keyboard
+ behavior are kept.
+- **Focus.** Buttons, field controls and a focusable `.table-wrap` show a
+ `:focus-visible` ring made of `--focus-width`, `--focus-color` and `--focus-offset`.
+- **Disabled.** Disabled buttons and controls are faded and show a `not-allowed`
+ cursor. The variant color stays recognizable, so a disabled danger button still looks
+ like a danger button.
+- **Responsive.** Components size to their container. Buttons and badges never grow
+ wider than their parent, fields fill their column, and tables scroll inside
+ `.table-wrap`. There are no media queries.
+- **Composable.** Parts never set a layout, so you can add a layout primitive to them:
+ `class="card-footer split"`, `class="card-body stack"`. Arbitrary headings and
+ paragraphs inside cards, panels, alerts and empty states lose their outer margins and
+ are spaced by the component.
+- **No JavaScript.**
+
+## AI Component Reference
+
+Paste this table into a model's context together with the
+[AI Layout Vocabulary](layout.md#ai-layout-vocabulary).
+
+| Intent | Markup |
+| --- | --- |
+| Main action of a form or page | `<button class="button button-primary">` |
+| Secondary or cancel action | `.button button-secondary`, or plain `.button` |
+| Destructive action (delete, remove) | `.button button-danger` |
+| Icon-only action | `.button button-icon` + `aria-label="…"` |
+| Smaller or larger button | add `.button-sm` / `.button-lg` |
+| Unavailable or in-progress action | `disabled` or `aria-disabled="true"`; `aria-busy="true"` |
+| Labeled input with help or error text | `.field` > `.field-label` + native control + `.field-help` / `.field-error` |
+| Invalid input | `aria-invalid="true"` on the control + `.field-error` text |
+| Self-contained item (project, product, user) | `.card` with `.card-header`, `.card-body`, `.card-footer`, `.card-media`, `.card-actions` |
+| Short status label | `.badge` + `.badge-success` / `-warning` / `-danger` / `-info` |
+| Message or notification | `.alert` + `.alert-info` / `-success` / `-warning` / `-danger`, `role="status"` or `role="alert"` |
+| Flat group of secondary content | `.panel` with `.panel-header`, `.panel-body` |
+| Tabular data | `<table class="table">` inside `.table-wrap`; `.table-hover`, `.numeric` cells |
+| Nothing to show yet | `.empty-state` with heading, text and an optional action |
+
+Rules: state comes from attributes, never from classes; put components on native
+elements; arrange them with `.stack`, `.cluster`, `.split` and `.grid`.
+
+## `.button`
+
+### Purpose
+
+An action. Put it on a native `<button>` for actions and on `<a href>` for navigation
+that should look like a button.
+
+### Example
+
+```html
+<div class="cluster-sm">
+ <button type="submit" class="button button-primary">Save</button>
+ <button type="button" class="button button-secondary">Cancel</button>
+</div>
+```
+
+### Variants
+
+| Class or attribute | Use |
+| --- | --- |
+| `.button` | Neutral default: bordered, on the elevated surface. |
+| `.button-primary` | The main action. Filled with `--color-primary`. |
+| `.button-secondary` | An alternative action. Outlined in `--color-primary`. |
+| `.button-danger` | A destructive action. Filled with `--color-danger`. |
+| `.button-sm`, `.button-lg` | 0.8× and 1.2× `--control-height`, with smaller or larger text. |
+| `.button-icon` | A square, icon-only button. Combines with the sizes and colors. |
+| `disabled` / `aria-disabled="true"` | Faded, `not-allowed` cursor, no hover change. |
+| `aria-busy="true"` | Loading: ignores pointer input and shows a spinning ring after the label. |
+
+Icons: an inline `<svg>` (or `<img>`) inside the button is sized to `1em` and separated
+from the label by `--space-2`:
+
+```html
+<button type="button" class="button button-primary">
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">…</svg>
+ New project
+</button>
+<button type="button" class="button button-icon" aria-label="Delete project">
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">…</svg>
+</button>
+<button type="submit" class="button button-primary" aria-busy="true">Saving…</button>
+```
+
+### Composition
+
+Group buttons with `.cluster` (wrapping row) and push a group to the end of a bar with
+`.split`:
+
+```html
+<header class="split">
+ <h1>Projects</h1>
+ <div class="cluster-sm">
+ <a class="button button-secondary" href="/import">Import</a>
+ <button type="button" class="button button-primary">New project</button>
+ </div>
+</header>
+```
+
+### Accessibility
+
+- `.button-icon` has no visible text, so it must have an `aria-label` (or visually
+ hidden text). Mark the icon `aria-hidden="true"`.
+- `disabled` removes the button from the tab order. Use `aria-disabled="true"` when the
+ button must stay focusable (for example to show a tooltip explaining why). CSS cannot
+ stop activation of an `aria-disabled` button or of a busy button by keyboard, so the
+ app must ignore it; on a link, also remove `href`.
+- Keep a text label while loading (for example "Saving…"); the spinner is decorative and
+ stays still when the user asks for reduced motion.
+- Use `<button type="button">` for actions that are not form submissions.
+
+### Recommended use
+
+- One `.button-primary` per form or view; everything else secondary or default.
+- `.button-danger` only for actions that destroy or remove data.
+- Start labels with a verb: "Save changes", "Delete project".
+
+### Misuse
+
+- `<div class="button">` or `<span class="button">`: not focusable and not announced as a
+ button. Use `<button>`.
+- Classes that do not exist, such as `button-destructive` or `button-loading`: use
+ `.button-danger` and `aria-busy="true"`.
+- An icon-only button without `aria-label`.
+
+## `.field`
+
+### Purpose
+
+One form control with its label, optional help text and optional error message.
+Native controls are only styled **inside** `.field`: SynthCSS does not restyle form
+elements globally. Styled controls: `input` with no type or with type `text`, `email`,
+`password` or `number`, plus `textarea`, `select`, `checkbox` and `radio`.
+
+### Example
+
+```html
+<div class="field">
+ <label class="field-label" for="email">Email</label>
+ <input id="email" type="email" autocomplete="email" aria-describedby="email-help">
+ <p class="field-help" id="email-help">We send the receipt here.</p>
+</div>
+```
+
+### Variants
+
+| Part or state | Use |
+| --- | --- |
+| `.field-label` | The `<label>` (or `<legend>` in a fieldset). |
+| `.field-help` | Hint text under the control, in `--color-text-muted`. |
+| `.field-error` | Error message in `--color-danger`, with a "!" marker in front. |
+| `aria-invalid="true"` | Error state of the control: a thicker danger border with a heavy start edge. |
+| `disabled` | Faded control on `--color-surface` with a `not-allowed` cursor; the label is muted. |
+
+The error state never relies on color alone: the border gets thicker, and the
+`.field-error` message adds text and a marker.
+
+```html
+<div class="field">
+ <label class="field-label" for="name">Name</label>
+ <input id="name" type="text" aria-invalid="true" aria-describedby="name-error">
+ <p class="field-error" id="name-error">Enter your name.</p>
+</div>
+```
+
+Checkboxes and radios go inside their label. Group several with
+`<fieldset class="field">` and a `<legend class="field-label">`:
+
+```html
+<fieldset class="field">
+ <legend class="field-label">Visibility</legend>
+ <label><input type="radio" name="visibility" checked> Private</label>
+ <label><input type="radio" name="visibility"> Public</label>
+</fieldset>
+```
+
+### Composition
+
+Put fields in a `.stack` form, place short fields side by side with `.grid`, and the
+buttons in a `.cluster`:
+
+```html
+<form class="stack">
+ <div class="grid" style="--grid-min: 14rem">
+ <div class="field"><label class="field-label" for="first">First name</label><input id="first" type="text"></div>
+ <div class="field"><label class="field-label" for="last">Last name</label><input id="last" type="text"></div>
+ </div>
+ <div class="cluster-sm"><button type="submit" class="button button-primary">Save</button></div>
+</form>
+```
+
+### Accessibility
+
+- Every control needs a label: `<label for>` pointing at the control's `id`, or a label
+ wrapping a checkbox or radio.
+- Connect help and error text with `aria-describedby`.
+- Set `aria-invalid="true"` only after the user has entered or submitted a value, and
+ remove it when the value is fixed.
+- Controls keep their native focus and keyboard behavior; the focus ring uses
+ `--focus-color`.
+
+### Recommended use
+
+- One `.field` per control (or per checkbox or radio group).
+- Use the matching input `type` and `autocomplete` value; mobile keyboards depend on it.
+
+### Misuse
+
+- A control outside `.field`: it keeps the browser's default look.
+- Using a placeholder instead of a `.field-label`.
+- Showing an error only by making the border red: add `aria-invalid="true"` and a
+ `.field-error` message.
+
+## `.card`
+
+### Purpose
+
+A raised, self-contained block for one item: a project, a product, a user, a summary.
+Optional parts, in any order: `.card-media`, `.card-header`, `.card-body`,
+`.card-actions`, `.card-footer`.
+
+### Example
+
+```html
+<article class="card">
+ <header class="card-header">
+ <h3>Atlas</h3>
+ <p>Updated 2 hours ago</p>
+ </header>
+ <div class="card-body"><p>Customer analytics dashboard.</p></div>
+ <div class="card-actions">
+ <a class="button button-primary" href="/atlas">Open</a>
+ <button type="button" class="button">Share</button>
+ </div>
+ <footer class="card-footer">3 members</footer>
+</article>
+```
+
+### Variants
+
+| Part | Use |
+| --- | --- |
+| `.card` | Elevated surface, border, `--radius-lg`, `--shadow-md`, `--space-5` padding; children are spaced by `--space-4`. |
+| `.card-media` | Image or illustration across the full card width; at the top or bottom it also covers the padding. Use it on a wrapper or directly on `<img>`. |
+| `.card-header` | Title area: headings use `--text-lg`, paragraphs are small and muted. |
+| `.card-body` | Main text; grows so footers line up across a row of cards. |
+| `.card-actions` | A wrapping row of buttons or links. |
+| `.card-footer` | Full-width strip on `--color-surface` with a top border, for metadata. |
+
+A card without parts works too: `<div class="card"><h3>…</h3><p>…</p></div>`.
+
+### Composition
+
+Lay out cards with `.grid`. Parts accept layout primitives, for example a header with a
+badge on the right (`.split`) or a footer with text and actions:
+
+```html
+<ul class="grid" role="list">
+ <li class="card">
+ <div class="card-header split-sm"><h3>Production</h3><span class="badge badge-success">Healthy</span></div>
+ <p class="card-body">Deployed 2 hours ago</p>
+ <footer class="card-footer split-sm">
+ <span>v2.4.1</span>
+ <div class="card-actions"><button type="button" class="button button-sm">Logs</button></div>
+ </footer>
+ </li>
+</ul>
+```
+
+### Accessibility
+
+- Use a heading inside `.card-header` so screen reader users can jump between cards.
+- Use `<article>` for stand-alone items and `<li>` when cards are in a list
+ (`<ul class="grid" role="list">`).
+- Give `.card-media` images a meaningful `alt`, or `alt=""` when decorative.
+- Avoid making the whole card a link with nested buttons inside; put the link on the
+ heading or in `.card-actions`.
+
+### Recommended use
+
+Items in a collection, dashboards, summaries, and forms that need visual weight (a
+`<form class="card">`).
+
+### Misuse
+
+- Nesting a `.card` inside a `.card`: use a `.panel` inside instead.
+- Cards for every block on a page; plain sections or `.panel` are lighter.
+
+## `.badge`
+
+### Purpose
+
+A short, non-interactive label for status, counts or categories.
+
+### Example
+
+```html
+<span class="badge badge-success">Active</span>
+```
+
+### Variants
+
+| Class | Use |
+| --- | --- |
+| `.badge` | Neutral label (draft, category, count). |
+| `.badge-success` | Done, healthy, paid. |
+| `.badge-warning` | Needs attention soon. |
+| `.badge-danger` | Failed, overdue, error. |
+| `.badge-info` | Informational, beta, role. |
+
+Status badges use the status color for text and border on a 10% tint of it; the text
+contrast is checked by `npm test`.
+
+### Composition
+
+Put badges next to a heading with `.cluster` or at the end of a row with `.split`; list
+several in a `.cluster`:
+
+```html
+<div class="cluster-sm">
+ <h2>Atlas</h2>
+ <span class="badge badge-info">Beta</span>
+</div>
+<ul class="cluster-sm" role="list">
+ <li class="badge">design</li>
+ <li class="badge">api</li>
+</ul>
+```
+
+### Accessibility
+
+- The text carries the meaning; the color only reinforces it. "Failed" reads correctly
+ without color, a red dot does not.
+- Badges are not interactive. For a filter or a removable tag, use a `.button`.
+
+### Recommended use
+
+One or two words. Status in tables and cards, roles, counts.
+
+### Misuse
+
+- Sentences in a badge; use an `.alert`.
+- `<a class="badge">` or `<button class="badge">`: badges have no focus or hover state.
+
+## `.alert`
+
+### Purpose
+
+A message box for status, success, warnings and errors. Headings, paragraphs, lists
+and buttons can go inside in any order without special parts: children are spaced by
+`--space-2`, and buttons keep their own width.
+
+### Example
+
+```html
+<div class="alert alert-success" role="status">
+ <p>Settings saved.</p>
+</div>
+```
+
+### Variants
+
+| Class | Use | Role |
+| --- | --- | --- |
+| `.alert` | Neutral note. | none |
+| `.alert-info` | Information, tips. | `role="status"` |
+| `.alert-success` | An action succeeded. | `role="status"` |
+| `.alert-warning` | Something needs attention soon. | `role="status"` |
+| `.alert-danger` | An error the user must deal with now. | `role="alert"` |
+
+Every alert has a thick start border, so it reads as a callout even without color; the
+variant also tints the background. Body text stays `--color-text`.
+
+```html
+<div class="alert alert-danger" role="alert">
+ <h2>Payment failed</h2>
+ <p>Your card was declined. Update it to keep your projects running.</p>
+ <a class="button button-sm" href="/billing">Update card</a>
+</div>
+```
+
+### Composition
+
+Put an alert at the top of a `.stack` (page or form), and actions inside it in a
+`.cluster`:
+
+```html
+<main class="container stack">
+ <div class="alert alert-warning" role="status">
+ <p>Your trial ends in 3 days.</p>
+ <div class="cluster-sm">
+ <a class="button button-primary button-sm" href="/billing">Upgrade</a>
+ <button type="button" class="button button-sm">Remind me later</button>
+ </div>
+ </div>
+ <h1>Dashboard</h1>
+</main>
+```
+
+### Accessibility
+
+- `role="alert"` interrupts screen readers immediately; use it only for errors that
+ need attention now (`.alert-danger`).
+- `role="status"` is announced politely; use it for success, info and warnings.
+- Live regions announce content that is added to the page. Alerts that are present when
+ the page loads can omit the role.
+- Start the message with what happened ("Payment failed"), so it is clear without
+ color.
+
+### Recommended use
+
+Form submission results, page-level warnings, error summaries.
+
+### Misuse
+
+- Using an alert as a decorative box for normal content; use a `.panel`.
+- Several `role="alert"` messages at once; summarize them in one.
+
+## `.panel`
+
+### Purpose
+
+A flat, bordered group for secondary content: sidebars, settings groups, summaries
+inside a card. Visually lighter than `.card`: `--color-surface` background, no shadow,
+a smaller radius and less padding. Optional parts: `.panel-header` (with a bottom border)
+and `.panel-body`.
+
+### Example
+
+```html
+<section class="panel">
+ <header class="panel-header"><h2>Usage this month</h2></header>
+ <div class="panel-body"><p>1,204 of 5,000 build minutes used.</p></div>
+</section>
+```
+
+### Variants
+
+| Part | Use |
+| --- | --- |
+| `.panel` | Surface background, border, `--radius-md`, `--space-4` padding; children spaced by `--space-3`. |
+| `.panel-header` | Title row with a divider; headings use `--text-base`. |
+| `.panel-body` | Content in `--color-text-secondary`. |
+
+### Composition
+
+A panel header with an action uses `.split`; a panel is a good sidebar in `.sidebar`:
+
+```html
+<div class="sidebar">
+ <nav class="panel" aria-label="Settings">
+ <ul class="stack-sm" role="list"><li><a href="/general">General</a></li></ul>
+ </nav>
+ <section class="panel">
+ <header class="panel-header split-sm"><h2>Members</h2><button type="button" class="button button-sm">Invite</button></header>
+ <div class="panel-body stack-sm"><p>…</p></div>
+ </section>
+</div>
+```
+
+### Accessibility
+
+- A panel is only visual grouping. Use `<section>` with a heading (or `aria-labelledby`)
+ when it is a real region, `<nav>` with `aria-label` for navigation, and `<div>`
+ otherwise.
+
+### Recommended use
+
+Secondary information, filters, settings groups, and grouping inside a `.card`.
+
+### Misuse
+
+- A panel for the main item of a list; use a `.card` so it stands out.
+
+## `.table`
+
+### Purpose
+
+Tabular data in a native `<table>`: header and body rows, row borders, optional hover
+highlight and right-aligned numbers. Wrap it in `.table-wrap` so a wide table scrolls
+horizontally on narrow screens instead of overflowing the page.
+
+### Example
+
+```html
+<div class="table-wrap" role="region" aria-label="Invoices" tabindex="0">
+ <table class="table">
+ <thead>
+ <tr><th scope="col">Invoice</th><th scope="col">Customer</th><th scope="col" class="numeric">Amount</th></tr>
+ </thead>
+ <tbody>
+ <tr><td>INV-1042</td><td>Northwind</td><td class="numeric">1,250.00</td></tr>
+ <tr><td>INV-1043</td><td>Contoso</td><td class="numeric">89.90</td></tr>
+ </tbody>
+ </table>
+</div>
+```
+
+### Variants
+
+| Class | Use |
+| --- | --- |
+| `.table` | On `<table>`: full width, collapsed borders, `--text-sm`, header row on `--color-surface`. Styles `caption`, `thead`, `tbody` and `tfoot`. |
+| `.table-wrap` | Wrapper with `overflow-x: auto`, a border and rounded corners. |
+| `.table-hover` | Add to the table to highlight the hovered body row. |
+| `.numeric` | On `<th>` or `<td>`: right-aligned, tabular figures, no wrapping. |
+
+### Composition
+
+Put the table in a `.stack` with its heading, or in a `.panel` or `.card`. Use badges
+and small buttons in cells, grouped with `.cluster`:
+
+```html
+<section class="stack-sm">
+ <h2>Members</h2>
+ <div class="table-wrap" role="region" aria-label="Members" tabindex="0">
+ <table class="table table-hover">
+ <thead><tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Actions</th></tr></thead>
+ <tbody>
+ <tr><th scope="row">Ada</th><td><span class="badge badge-success">Active</span></td>
+ <td><div class="cluster-sm"><button type="button" class="button button-sm">Edit</button></div></td></tr>
+ </tbody>
+ </table>
+ </div>
+</section>
+```
+
+### Accessibility
+
+- Use `<th scope="col">` for column headers and `<th scope="row">` for row headers;
+ add a `<caption>` or label the wrapper.
+- A scrolling `.table-wrap` should have `tabindex="0"`, `role="region"` and an
+ `aria-label` (or `aria-labelledby`), so keyboard users can focus and scroll it; it
+ then shows a focus ring.
+- Hover is only a visual aid; do not rely on it to reveal content.
+
+### Recommended use
+
+Lists of records with comparable columns: invoices, members, logs. Apply `.numeric`
+to amounts, counts and dates you compare vertically.
+
+### Misuse
+
+- Tables for page layout; use `.grid`, `.sidebar` or `.split`.
+- A wide table without `.table-wrap`; it widens the page on phones.
+
+## `.empty-state`
+
+### Purpose
+
+What a list, table or page shows when there is nothing in it yet: a centered stack of an
+optional icon or illustration, a heading, a description and an optional action.
+
+### Example
+
+```html
+<div class="empty-state">
+ <h2>No projects yet</h2>
+ <p>Create a project to start tracking deployments.</p>
+ <a class="button button-primary" href="/projects/new">New project</a>
+</div>
+```
+
+### Variants
+
+There are no variants. A direct `<svg>` or `<img>` child is shown as a
+`calc(var(--space-6) * 2)` wide icon in `--color-text-muted`; text is limited to
+`--content-width` and centered.
+
+```html
+<div class="empty-state">
+ <svg viewBox="0 0 48 48" aria-hidden="true" focusable="false">…</svg>
+ <h2>No results</h2>
+ <p>Try a different search term.</p>
+</div>
+```
+
+### Composition
+
+Put it inside a `.card` or `.panel` where the list would be, and group several actions
+in a `.cluster`:
+
+```html
+<section class="panel">
+ <header class="panel-header"><h2>Webhooks</h2></header>
+ <div class="empty-state">
+ <h3>No webhooks yet</h3>
+ <p>Send deployment events to your own services.</p>
+ <div class="cluster-sm">
+ <button type="button" class="button button-primary">Add webhook</button>
+ <a class="button" href="/docs/webhooks">Read the docs</a>
+ </div>
+ </div>
+</section>
+```
+
+### Accessibility
+
+- Use a real heading at the right level for the page outline.
+- Mark decorative icons `aria-hidden="true"`, or give illustrations a meaningful `alt`.
+- If the empty state replaces content after a search or filter, announce it by placing
+ it inside an element with `role="status"`.
+
+### Recommended use
+
+Empty lists and tables, no search results, first-run screens. Say what is missing and
+what to do next.
+
+### Misuse
+
+- Using it for errors; use an `.alert`.
+- Leaving a list or table blank instead of showing an empty state.
+
+## Verification
+
+- `npm test` runs [`scripts/check-components.mjs`](../scripts/check-components.mjs). It
+ needs only Node.js and checks that every component class exists in the built CSS
+ (`src/synthcss.css` with its imports inlined) and no other class is added; that every
+ declaration uses tokens defined in `tokens.css`, with no hex, `rgb()`, `hsl()` or named
+ color literals and no hard-coded lengths; that text on every button, badge, alert,
+ card and panel background meets 4.5:1 contrast; that buttons, field controls and
+ `.table-wrap` have `:focus-visible` rings; that buttons and fields have disabled
+ styles, buttons a loading style, and the field error state changes the border width
+ and adds a marker; that `.table-wrap` scrolls horizontally; that this page covers
+ every component and has the reference table; and that every class in an `html` example
+ in `README.md` and `docs/`, and on the showcase page, exists in the CSS.
+- `npm run check:components:browser` loads the showcase in headless Chromium at 375px
+ and 1280px and checks that the components render with their tokens, that tables
+ scroll inside `.table-wrap` without the page overflowing, and that focus rings show.
+ It needs Playwright: run `npm install --no-save playwright` and
+ `npx playwright install chromium` first.
+- The [showcase](../showcase/index.html) shows every component and a composed settings
+ screen built only from SynthCSS classes.
diff --git a/package.json b/package.json
index 2bd4a31..3384767 100644
--- a/package.json
+++ b/package.json
@@ -12,8 +12,10 @@
"check:tokens": "node scripts/check-tokens.mjs",
"check:layout": "node scripts/check-layout.mjs",
"check:layout:browser": "node scripts/check-layout-browser.mjs",
+ "check:components": "node scripts/check-components.mjs",
+ "check:components:browser": "node scripts/check-components-browser.mjs",
"check:showcase": "node scripts/check-showcase.mjs",
"check:showcase:browser": "node scripts/check-showcase-browser.mjs",
- "test": "node scripts/check-tokens.mjs && node scripts/check-layout.mjs && node scripts/check-showcase.mjs && node --test scripts/check-tokens.test.mjs scripts/check-layout.test.mjs scripts/check-showcase.test.mjs"
+ "test": "node scripts/check-tokens.mjs && node scripts/check-layout.mjs && node scripts/check-components.mjs && node scripts/check-showcase.mjs && node --test scripts/check-tokens.test.mjs scripts/check-layout.test.mjs scripts/check-components.test.mjs scripts/check-showcase.test.mjs"
}
}
diff --git a/scripts/check-components-browser.mjs b/scripts/check-components-browser.mjs
new file mode 100644
index 0000000..a80628d
--- /dev/null
+++ b/scripts/check-components-browser.mjs
@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+// Optional browser check of the components on showcase/index.html at 375px and
+// 1280px viewport width: tokens applied, states, focus rings and table scrolling.
+// Needs Playwright, which is not a dependency of this repository:
+// npm install --no-save playwright && npx playwright install chromium
+// Usage: node scripts/check-components-browser.mjs
+
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { dirname, resolve } from "node:path";
+
+let chromium;
+try {
+ ({ chromium } = await import("playwright"));
+} catch {
+ console.error(
+ "check-components-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);
+};
+
+// Tabs through the page until the focused element matches `selector`, then
+// returns its computed outline. Keyboard focus is what triggers :focus-visible.
+async function tabTo(page, selector) {
+ await page.evaluate(() => document.activeElement?.blur());
+ await page.locator("body").focus();
+ for (let i = 0; i < 400; i++) {
+ await page.keyboard.press("Tab");
+ const outline = await page.evaluate((sel) => {
+ const el = document.activeElement;
+ if (!el?.matches(sel)) return null;
+ const s = getComputedStyle(el);
+ return { style: s.outlineStyle, width: parseFloat(s.outlineWidth) };
+ }, selector);
+ if (outline) return outline;
+ }
+ return null;
+}
+
+const browser = await chromium.launch();
+try {
+ for (const width of [375, 1280]) {
+ const page = await browser.newPage({ viewport: { width, height: 800 } });
+ const consoleErrors = [];
+ // Only local files matter; external images (badges) may be unreachable offline.
+ page.on("console", (msg) => msg.type() === "error" && !/^https?:/.test(msg.location().url ?? "") && consoleErrors.push(msg.text()));
+ page.on("pageerror", (err) => consoleErrors.push(err.message));
+ page.on("requestfailed", (req) => req.url().startsWith("file:") && consoleErrors.push(`failed to load ${req.url()}`));
+ await page.goto(pageUrl, { waitUntil: "load" });
+
+ const m = await page.evaluate(() => {
+ const $ = (sel) => document.querySelector(sel);
+ const css = (sel, prop, pseudo) => getComputedStyle($(sel), pseudo).getPropertyValue(prop);
+ // Resolve a token to its computed color through a probe element.
+ const probe = document.createElement("div");
+ document.body.append(probe);
+ const token = (name) => {
+ probe.style.color = `var(${name})`;
+ return getComputedStyle(probe).color;
+ };
+ const wraps = [...document.querySelectorAll("#components .table-wrap, #composed .table-wrap")].map((w) => ({
+ overflowX: getComputedStyle(w).overflowX,
+ scrolls: w.scrollWidth > w.clientWidth,
+ }));
+ const result = {
+ overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
+ primaryBg: css("#components .button-primary", "background-color") === token("--color-primary"),
+ dangerBg: css("#components .button-danger", "background-color") === token("--color-danger"),
+ badgeColor: css("#components .badge-success", "color") === token("--color-success"),
+ alertBorder: css("#components .alert-danger", "border-inline-start-color") === token("--color-danger"),
+ cardShadow: css("#components .card", "box-shadow") !== "none",
+ panelShadow: css("#components .panel", "box-shadow") === "none",
+ disabledOpacity: parseFloat(css("#components .button[disabled]", "opacity")),
+ busyIndicator: css('#components .button[aria-busy="true"]', "content", "::after") !== "none",
+ invalidBorder: parseFloat(css('#components [aria-invalid="true"]', "border-top-width")),
+ validBorder: parseFloat(css("#components .field input[type=text]", "border-top-width")),
+ errorMarker: css("#components .field-error", "content", "::before"),
+ numericAlign: css("#components .table .numeric", "text-align"),
+ emptyAlign: css("#components .empty-state", "text-align"),
+ wraps,
+ };
+ probe.remove();
+ return result;
+ });
+
+ expect(consoleErrors.length === 0, `${width}px: console errors: ${consoleErrors.join("; ")}`);
+ expect(!m.overflow, `${width}px: page overflows horizontally`);
+ expect(m.primaryBg, `${width}px: .button-primary is not filled with --color-primary`);
+ expect(m.dangerBg, `${width}px: .button-danger is not filled with --color-danger`);
+ expect(m.badgeColor, `${width}px: .badge-success text is not --color-success`);
+ expect(m.alertBorder, `${width}px: .alert-danger border is not --color-danger`);
+ expect(m.cardShadow && m.panelShadow, `${width}px: .card should have a shadow and .panel none`);
+ expect(m.disabledOpacity < 1, `${width}px: disabled button is not faded`);
+ expect(m.busyIndicator, `${width}px: busy button shows no loading indicator`);
+ expect(m.invalidBorder > m.validBorder, `${width}px: invalid field border is not thicker (${m.invalidBorder} vs ${m.validBorder})`);
+ expect(/!/.test(m.errorMarker), `${width}px: .field-error has no marker`);
+ expect(["end", "right"].includes(m.numericAlign), `${width}px: .numeric is not right-aligned`);
+ expect(m.emptyAlign === "center", `${width}px: .empty-state is not centered`);
+ expect(m.wraps.length > 0 && m.wraps.every((w) => w.overflowX === "auto"), `${width}px: .table-wrap does not scroll horizontally`);
+ if (width === 375) expect(m.wraps.some((w) => w.scrolls), "375px: no table scrolls inside .table-wrap at phone width");
+
+ for (const [what, sel] of [
+ ["button", "#components .button"],
+ ["text input", "#components .field input[type=text]"],
+ ["select", "#components .field select"],
+ ["checkbox", "#components .field input[type=checkbox]"],
+ ["table-wrap", "#components .table-wrap"],
+ ]) {
+ const ring = await tabTo(page, sel);
+ expect(ring && ring.style === "solid" && ring.width > 0, `${width}px: ${what} has no visible :focus-visible ring`);
+ }
+ await page.close();
+ }
+} finally {
+ await browser.close();
+}
+
+if (errors.length) {
+ for (const e of errors) console.error(` FAIL ${e}`);
+ console.error(`\ncheck-components-browser: ${errors.length} problem(s) found.`);
+ process.exit(1);
+}
+console.log("check-components-browser: components render with tokens, states, focus rings and scrolling tables at 375px and 1280px.");
diff --git a/scripts/check-components.mjs b/scripts/check-components.mjs
new file mode 100644
index 0000000..a4cf490
--- /dev/null
+++ b/scripts/check-components.mjs
@@ -0,0 +1,403 @@
+#!/usr/bin/env node
+// Dependency-free verification of the semantic components in src/components.css,
+// the built bundle (src/synthcss.css with its imports inlined), docs/components.md
+// and the class usage in every doc example and the showcase.
+// Usage: node scripts/check-components.mjs
+
+import { readFileSync, readdirSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+import { contrastRatio, parseBlocks, parseColor, parseDeclarations, parseTokens, resolveValue } from "./check-tokens.mjs";
+
+// Every class each component ships. The first entry is the base class.
+export const COMPONENTS = {
+ button: ["button", "button-primary", "button-secondary", "button-danger", "button-sm", "button-lg", "button-icon"],
+ field: ["field", "field-label", "field-help", "field-error"],
+ card: ["card", "card-header", "card-body", "card-footer", "card-media", "card-actions"],
+ badge: ["badge", "badge-success", "badge-warning", "badge-danger", "badge-info"],
+ alert: ["alert", "alert-info", "alert-success", "alert-warning", "alert-danger"],
+ panel: ["panel", "panel-header", "panel-body"],
+ table: ["table", "table-wrap", "table-hover", "numeric"],
+ "empty-state": ["empty-state"],
+};
+export const COMPONENT_NAMES = Object.keys(COMPONENTS);
+export const COMPONENT_CLASSES = Object.values(COMPONENTS).flat();
+
+// Variants that change color: [variant, base]. Their text/background pair is
+// contrast-checked with the base rule's declarations merged in.
+const COLOR_VARIANTS = [
+ ["button", "button"],
+ ["button-primary", "button"],
+ ["button-secondary", "button"],
+ ["button-danger", "button"],
+ ["badge", "badge"],
+ ["badge-success", "badge"],
+ ["badge-warning", "badge"],
+ ["badge-danger", "badge"],
+ ["badge-info", "badge"],
+ ["alert", "alert"],
+ ["alert-info", "alert"],
+ ["alert-success", "alert"],
+ ["alert-warning", "alert"],
+ ["alert-danger", "alert"],
+ ["card", "card"],
+ ["panel", "panel"],
+];
+
+export const DOC_SECTIONS = ["Purpose", "Example", "Variants", "Composition", "Accessibility", "Recommended use"];
+export const MAX_REFERENCE_ROWS = 15;
+
+const COLOR_LITERAL = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i;
+const NAMED_COLOR =
+ /(?:^|[\s,(])(white|black|red|green|blue|gray|grey|silver|yellow|orange|purple|pink|brown|navy|teal|maroon|olive|lime|aqua|cyan|magenta|fuchsia|gold|indigo|violet|crimson|tomato|coral|salmon|beige|ivory|khaki|lavender|linen|snow|azure|tan|wheat)(?=$|[\s,)])/i;
+// Properties whose whole value is one color.
+const COLOR_PROPS = /^(color|background-color|border(-(block|inline|top|right|bottom|left)(-(start|end))?)?-color|outline-color|accent-color|caret-color|fill|stroke|text-decoration-color|column-rule-color)$/;
+// A color value may only be a token, a mix of two tokens or a keyword.
+const TOKEN_COLOR = /^(var\(--[\w-]+\)|color-mix\(in srgb, var\(--[\w-]+\) \d+%, var\(--[\w-]+\)\)|transparent|currentColor|inherit)$/i;
+const SPACING_PROPS = /^(gap|row-gap|column-gap|padding|padding-[\w-]+|margin|margin-[\w-]+)$/;
+// Fixed units are not allowed; em and % (relative to the text or parent) are.
+const HARD_CODED_LENGTH = /(?:^|[^\w-])(-?\d*\.?\d+)(px|rem|ch|ex|pt|pc|cm|mm|in|q|vw|vh|vmin|vmax|svh|lvh|dvh|svw|lvw|dvw)\b/i;
+const TOKEN_PROPS = {
+ "font-size": /^var\(--text-[\w-]+\)$/,
+ "font-weight": /^var\(--weight-[\w-]+\)$/,
+ "line-height": /^var\(--leading-[\w-]+\)$/,
+ "border-radius": /^var\(--radius-[\w-]+\)$/,
+ "box-shadow": /^(var\(--shadow-[\w-]+\)|none)$/,
+ "font-family": /^(var\(--font-[\w-]+\)|inherit)$/,
+};
+
+const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, "");
+
+// Splits a selector list on top-level commas (not the ones inside :where()).
+export 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.map((s) => s.replace(/\s+/g, " ")).filter(Boolean);
+}
+
+const classesOf = (selector) => [...selector.matchAll(/\.([a-zA-Z][\w-]*)/g)].map((m) => m[1]);
+
+export function parseRules(rawCss) {
+ const rules = [];
+ const atRules = [];
+ for (const { prelude, body } of parseBlocks(stripComments(rawCss))) {
+ if (prelude.startsWith("@")) {
+ atRules.push({ prelude, body });
+ continue;
+ }
+ const selectors = splitSelectors(prelude);
+ const decls = parseDeclarations(body).filter((d) => d.prop);
+ rules.push({ selectors, selector: selectors.join(", "), decls });
+ }
+ return { rules, atRules };
+}
+
+// Inlines @import url("x.css") statements, like a bundler would.
+export function buildBundle(entry, read) {
+ const seen = new Set();
+ const inline = (path) => {
+ if (seen.has(path)) return "";
+ seen.add(path);
+ return read(path).replace(/@import\s+(?:url\()?\s*["']?([^"')\s]+)["']?\s*\)?\s*;/g, (_, file) =>
+ inline(resolve(dirname(path), file)),
+ );
+ };
+ return inline(entry);
+}
+
+export function classesInCss(css) {
+ const out = new Set();
+ for (const { selectors } of parseRules(css).rules) for (const s of selectors) classesOf(s).forEach((c) => out.add(c));
+ return out;
+}
+
+function colorOf(value, tokens) {
+ const v = value.trim();
+ let m = /^var\((--[\w-]+)\)$/.exec(v);
+ if (m) return parseColor(resolveValue(m[1], tokens));
+ m = /^color-mix\(in srgb, var\((--[\w-]+)\) (\d+)%, var\((--[\w-]+)\)\)$/.exec(v);
+ if (m) {
+ const a = parseColor(resolveValue(m[1], tokens));
+ const b = parseColor(resolveValue(m[3], tokens));
+ const p = Number(m[2]) / 100;
+ if (!a || !b) return null;
+ return { r: a.r * p + b.r * (1 - p), g: a.g * p + b.g * (1 - p), b: a.b * p + b.b * (1 - p), a: 1 };
+ }
+ return null;
+}
+
+export function checkComponentsCss(componentsCss, tokensCss) {
+ const errors = [];
+ let parsed;
+ try {
+ parsed = parseRules(componentsCss);
+ } catch (err) {
+ return [`components.css: ${err.message}`];
+ }
+ const { rules, atRules } = parsed;
+ const tokens = parseTokens(tokensCss).root;
+ const allowed = new Set(COMPONENT_CLASSES);
+ const where = (r, d) => `${r.selector} { ${d.prop}: ${d.value} }`;
+ const has = (pred) => rules.some(pred);
+ const sel = (r, re) => r.selectors.some((s) => re.test(s));
+ const decl = (r, prop, re = /./) => r.decls.some((d) => d.prop === prop && re.test(d.value));
+
+ for (const { prelude } of atRules) {
+ if (!/^@keyframes\s+synthcss-[\w-]+$/.test(prelude)) {
+ errors.push(`components.css may only use @keyframes synthcss-* at-rules (no media queries), found: ${prelude}`);
+ }
+ }
+
+ // Classes: every listed class exists, and nothing else is added.
+ const used = new Set(rules.flatMap((r) => r.selectors.flatMap(classesOf)));
+ for (const cls of COMPONENT_CLASSES) if (!used.has(cls)) errors.push(`class .${cls} is not defined in components.css`);
+ for (const cls of used) if (!allowed.has(cls)) errors.push(`unexpected class .${cls} in components.css`);
+
+ // Declarations: tokens only.
+ for (const r of rules) {
+ for (const d of r.decls) {
+ const w = where(r, d);
+ if (d.prop.startsWith("--")) errors.push(`components.css must not define custom properties: ${w}`);
+ if (COLOR_LITERAL.test(d.value)) errors.push(`hard-coded color literal in ${w}; use a var(--color-*) token`);
+ if (NAMED_COLOR.test(d.value)) errors.push(`named color in ${w}; use a var(--color-*) token`);
+ if (COLOR_PROPS.test(d.prop) && !TOKEN_COLOR.test(d.value)) {
+ errors.push(`color in ${w} must be a var(--token), a color-mix() of two tokens, transparent or currentColor`);
+ }
+ if (HARD_CODED_LENGTH.test(d.value)) errors.push(`hard-coded length in ${w}; use a var(--token)`);
+ if (SPACING_PROPS.test(d.prop) && !/^(0|auto)$/.test(d.value) && !/var\(--space-\d+\)/.test(d.value)) {
+ errors.push(`spacing in ${w} must use a var(--space-N) token`);
+ }
+ if (TOKEN_PROPS[d.prop] && !TOKEN_PROPS[d.prop].test(d.value)) errors.push(`${w} must use a matching var(--token)`);
+ for (const m of d.value.matchAll(/var\(\s*(--[\w-]+)/g)) {
+ if (!tokens.has(m[1])) errors.push(`${w} references ${m[1]}, which is not defined in tokens.css`);
+ }
+ }
+ }
+
+ // Every component styles itself through tokens.
+ for (const [name, classes] of Object.entries(COMPONENTS)) {
+ const own = rules.filter((r) => r.selectors.some((s) => classesOf(s).some((c) => classes.includes(c))));
+ if (!own.some((r) => r.decls.some((d) => /var\(--[\w-]+\)/.test(d.value)))) {
+ errors.push(`component .${name} has no rule that uses a var(--token)`);
+ }
+ }
+
+ // Focus: visible :focus-visible rings on every interactive component.
+ const focusRing = (r) => decl(r, "outline", /var\(--focus-width\).*var\(--focus-color\)/);
+ const focusTargets = [
+ ["button", /\.button\b[^\s]*:focus-visible/],
+ ["field controls (input)", /\.field\b.*\binput\b.*:focus-visible/],
+ ["field controls (textarea)", /\.field\b.*\btextarea\b.*:focus-visible/],
+ ["field controls (select)", /\.field\b.*\bselect\b.*:focus-visible/],
+ ["table-wrap", /\.table-wrap:focus-visible/],
+ ];
+ for (const [what, re] of focusTargets) {
+ if (!has((r) => sel(r, re) && focusRing(r))) {
+ errors.push(`${what} needs a :focus-visible rule with outline: var(--focus-width) solid var(--focus-color)`);
+ }
+ }
+ if (rules.some((r) => r.decls.some((d) => /^outline(-style)?$/.test(d.prop) && /^(none|0)$/.test(d.value)))) {
+ errors.push("components.css must not remove focus outlines");
+ }
+
+ // Disabled and loading states.
+ const disabled = (re) => has((r) => sel(r, re) && decl(r, "cursor", /^not-allowed$/));
+ if (!disabled(/^\.button:disabled$/)) errors.push(".button:disabled must be styled (with cursor: not-allowed)");
+ if (!disabled(/^\.button\[aria-disabled="true"\]$/)) errors.push('.button[aria-disabled="true"] must be styled (with cursor: not-allowed)');
+ if (!disabled(/^\.field\b.*:disabled$/)) errors.push(".field controls need a :disabled rule (with cursor: not-allowed)");
+ if (!has((r) => sel(r, /^\.button\[aria-busy="true"\]$/))) errors.push('.button[aria-busy="true"] must be styled');
+ if (!has((r) => sel(r, /^\.button\[aria-busy="true"\]::after$/) && decl(r, "content") && decl(r, "animation"))) {
+ errors.push('.button[aria-busy="true"]::after must draw a CSS-only loading indicator');
+ }
+
+ // Field error state must change more than color.
+ const invalid = rules.filter((r) => sel(r, /^\.field\b.*\[aria-invalid="true"\]$/));
+ if (!invalid.length) errors.push('.field controls need an [aria-invalid="true"] rule');
+ else if (!invalid.some((r) => r.decls.some((d) => /^border(-[\w-]+)?-width$/.test(d.prop)))) {
+ errors.push('.field [aria-invalid="true"] must change the border width, not only its color');
+ }
+ if (!has((r) => sel(r, /^\.field-error::before$/) && decl(r, "content", /^"[^"]+"/))) {
+ errors.push(".field-error::before must add a visible marker so errors do not rely on color alone");
+ }
+
+ // Table behavior.
+ if (!has((r) => sel(r, /^\.table-wrap$/) && decl(r, "overflow-x", /^auto$/))) errors.push(".table-wrap must set overflow-x: auto");
+ if (!has((r) => sel(r, /\.table-hover\b.*:hover/))) errors.push(".table-hover must style hovered rows");
+ if (!has((r) => sel(r, /\.numeric$/) && decl(r, "text-align", /^(end|right)$/) && decl(r, "font-variant-numeric", /tabular-nums/))) {
+ errors.push(".numeric cells must be right-aligned with tabular-nums");
+ }
+
+ // Contrast of text on each variant's background.
+ const plainDecls = (cls) => rules.filter((r) => r.selectors.includes(`.${cls}`)).flatMap((r) => r.decls);
+ const last = (decls, props) => decls.filter((d) => props.includes(d.prop)).at(-1)?.value;
+ for (const [variant, base] of COLOR_VARIANTS) {
+ const decls = variant === base ? plainDecls(base) : [...plainDecls(base), ...plainDecls(variant)];
+ const fg = last(decls, ["color"]);
+ const bg = last(decls, ["background", "background-color"]);
+ if (!fg || !bg) {
+ errors.push(`.${variant} must set both color and background`);
+ continue;
+ }
+ try {
+ const a = colorOf(fg, tokens);
+ const b = colorOf(bg, tokens);
+ if (!a || !b) throw new Error("could not resolve colors");
+ const ratio = contrastRatio(a, b);
+ if (ratio < 4.5) errors.push(`contrast too low in .${variant}: ${ratio.toFixed(2)}:1 (min 4.5:1)`);
+ } catch (err) {
+ errors.push(`contrast of .${variant}: ${err.message}`);
+ }
+ }
+ return errors;
+}
+
+export function checkBundle(bundleCss) {
+ const imports = [...stripComments(bundleCss).matchAll(/@import\s+(?:url\()?\s*["']?([^"')\s]+)/g)].map((m) => m[1]);
+ const l = imports.indexOf("layout.css");
+ const c = imports.indexOf("components.css");
+ if (c === -1) return ["src/synthcss.css does not import components.css"];
+ if (c < l) return ["src/synthcss.css must import components.css after layout.css"];
+ return [];
+}
+
+function sectionOf(lines, heading) {
+ const start = lines.findIndex((l) => heading.test(l));
+ if (start === -1) return null;
+ let end = lines.findIndex((l, i) => i > start && /^##\s/.test(l));
+ if (end === -1) end = lines.length;
+ return lines.slice(start + 1, end);
+}
+
+export function checkDocs(markdown) {
+ const errors = [];
+ const lines = markdown.split("\n");
+ for (const name of COMPONENT_NAMES) {
+ const section = sectionOf(lines, new RegExp(`^##\\s+\`\\.${name}\`\\s*$`));
+ if (!section) {
+ errors.push(`docs/components.md has no "## \`.${name}\`" section`);
+ continue;
+ }
+ for (const h of DOC_SECTIONS) {
+ if (!section.some((l) => new RegExp(`^###\\s+${h}\\s*$`, "i").test(l))) {
+ errors.push(`docs/components.md section .${name} is missing "### ${h}"`);
+ }
+ }
+ const text = section.join("\n");
+ if (!text.includes("```html")) errors.push(`docs/components.md section .${name} has no html code block`);
+ for (const cls of COMPONENTS[name]) {
+ if (!text.includes(`.${cls}`) && !new RegExp(`class="[^"]*\\b${cls}\\b`).test(text)) {
+ errors.push(`docs/components.md section .${name} does not document .${cls}`);
+ }
+ }
+ if (!/\b(stack|cluster|split|grid)\b/.test(text)) errors.push(`docs/components.md section .${name} shows no composition with a layout primitive`);
+ }
+ const alert = (sectionOf(lines, /^##\s+`\.alert`\s*$/) ?? []).join("\n");
+ if (!alert.includes('role="alert"') || !alert.includes('role="status"')) {
+ errors.push('docs/components.md .alert examples must use role="alert" and role="status"');
+ }
+ const button = (sectionOf(lines, /^##\s+`\.button`\s*$/) ?? []).join("\n");
+ if (!/button-icon[^\n]*aria-label|aria-label[^\n]*button-icon/.test(button)) {
+ errors.push("docs/components.md must require aria-label on .button-icon");
+ }
+
+ const reference = sectionOf(lines, /^##\s+AI Component Reference\s*$/i);
+ if (!reference) {
+ errors.push('docs/components.md has no "## AI Component Reference" section');
+ } else {
+ const rows = reference.filter((l) => /^\s*\|/.test(l)).slice(2);
+ if (rows.length > MAX_REFERENCE_ROWS) errors.push(`AI Component Reference has ${rows.length} rows, max ${MAX_REFERENCE_ROWS}`);
+ for (const name of COMPONENT_NAMES) {
+ if (!rows.some((r) => r.includes(`\`.${name}`) || r.includes(`class="${name}`))) {
+ errors.push(`AI Component Reference table does not mention .${name}`);
+ }
+ }
+ }
+ return errors;
+}
+
+const unescapeHtml = (s) =>
+ s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&");
+const classAttrs = (html) => [...html.matchAll(/\bclass="([^"]*)"/g)].flatMap((m) => m[1].split(/\s+/).filter(Boolean));
+
+// Every class in an ```html block of the docs and in the showcase must exist in
+// the built CSS (the showcase may also use its own sc- classes).
+export function checkClassUsage({ cssClasses, showcaseClasses, docs, showcaseHtml }) {
+ const errors = [];
+ for (const [file, markdown] of Object.entries(docs)) {
+ for (const block of markdown.matchAll(/```html\n([\s\S]*?)```/g)) {
+ for (const cls of new Set(classAttrs(block[1]))) {
+ if (!cssClasses.has(cls)) errors.push(`${file}: html example uses .${cls}, which is not in the built CSS`);
+ }
+ }
+ }
+ const snippets = [...showcaseHtml.matchAll(/<pre><code>([\s\S]*?)<\/code><\/pre>/g)].map((m) => unescapeHtml(m[1]));
+ for (const cls of new Set(snippets.flatMap(classAttrs))) {
+ if (!cssClasses.has(cls)) errors.push(`showcase snippet uses .${cls}, which is not in the built CSS`);
+ }
+ for (const cls of new Set(classAttrs(showcaseHtml))) {
+ if (!cssClasses.has(cls) && !showcaseClasses.has(cls)) {
+ errors.push(`showcase/index.html uses .${cls}, which is neither in the built CSS nor in showcase.css`);
+ }
+ }
+ return errors;
+}
+
+export function checkComponents(files) {
+ let cssClasses;
+ let showcaseClasses;
+ try {
+ cssClasses = classesInCss(files.builtCss);
+ showcaseClasses = classesInCss(files.showcaseCss);
+ } catch (err) {
+ return [`could not parse CSS: ${err.message}`];
+ }
+ const missing = COMPONENT_CLASSES.filter((c) => !cssClasses.has(c)).map((c) => `class .${c} is missing from the built CSS`);
+ return [
+ ...missing,
+ ...checkComponentsCss(files.componentsCss, files.tokensCss),
+ ...checkBundle(files.bundleCss),
+ ...checkDocs(files.componentDocs),
+ ...checkClassUsage({ cssClasses, showcaseClasses, docs: files.docs, showcaseHtml: files.showcaseHtml }),
+ ];
+}
+
+export function readRepoFiles(repo) {
+ const read = (p) => readFileSync(resolve(repo, p), "utf8");
+ const docs = { "README.md": read("README.md") };
+ for (const f of readdirSync(resolve(repo, "docs")).filter((f) => f.endsWith(".md"))) docs[`docs/${f}`] = read(`docs/${f}`);
+ return {
+ builtCss: buildBundle(resolve(repo, "src/synthcss.css"), (p) => readFileSync(p, "utf8")),
+ bundleCss: read("src/synthcss.css"),
+ componentsCss: read("src/components.css"),
+ tokensCss: read("src/tokens.css"),
+ componentDocs: docs["docs/components.md"] ?? "",
+ docs,
+ showcaseHtml: read("showcase/index.html"),
+ showcaseCss: read("showcase/showcase.css"),
+ };
+}
+
+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 = checkComponents(readRepoFiles(repo));
+ if (errors.length) {
+ for (const e of errors) console.error(` FAIL ${e}`);
+ console.error(`\ncheck-components: ${errors.length} problem(s) found.`);
+ process.exit(1);
+ }
+ console.log(
+ `check-components: ${COMPONENT_NAMES.length} components (${COMPONENT_CLASSES.length} classes) built with tokens, documented, and every doc and showcase class exists.`,
+ );
+}
diff --git a/scripts/check-components.test.mjs b/scripts/check-components.test.mjs
new file mode 100644
index 0000000..8151fa5
--- /dev/null
+++ b/scripts/check-components.test.mjs
@@ -0,0 +1,117 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { checkComponents, readRepoFiles, COMPONENT_CLASSES } from "./check-components.mjs";
+
+const repo = new URL("..", import.meta.url).pathname;
+const files = readRepoFiles(repo);
+
+const errorsWith = (changes) => checkComponents({ ...files, ...changes });
+const assertError = (errors, text) => assert.ok(errors.some((e) => e.includes(text)), errors.join("\n"));
+// Edits components.css, and the built CSS the same way.
+const withCss = (edit) => ({ componentsCss: edit(files.componentsCss), builtCss: edit(files.builtCss) });
+
+test("repository component files pass", () => {
+ assert.deepEqual(errorsWith({}), []);
+});
+
+test("every listed component class exists in the built CSS", () => {
+ for (const cls of COMPONENT_CLASSES) assert.match(files.builtCss, new RegExp(`\\.${cls}\\b`), `.${cls}`);
+});
+
+test("fails when a component class is missing or an extra variant is added", () => {
+ const missing = withCss((css) => css.replaceAll(".badge-info", ".badge-other"));
+ assertError(errorsWith(missing), "class .badge-info is missing from the built CSS");
+ assertError(errorsWith(missing), "unexpected class .badge-other");
+ const extra = withCss((css) => css + "\n.button-destructive { color: var(--color-danger); background: var(--color-background); }\n");
+ assertError(errorsWith(extra), "unexpected class .button-destructive");
+});
+
+test("fails when the bundle does not import components.css", () => {
+ const bundleCss = files.bundleCss.replace('@import url("components.css");', "");
+ assertError(errorsWith({ bundleCss }), "does not import components.css");
+});
+
+test("fails on raw color literals, named colors and custom properties", () => {
+ const hex = withCss((css) => css.replace("background: var(--color-danger);", "background: #b3362e;"));
+ assertError(errorsWith(hex), "hard-coded color literal");
+ const rgb = withCss((css) => css.replace("color: var(--color-on-primary);", "color: rgb(255 255 255);"));
+ assertError(errorsWith(rgb), "hard-coded color literal");
+ const hsl = withCss((css) => css + "\n.panel { border-color: hsl(0 0% 80%); }\n");
+ assertError(errorsWith(hsl), "hard-coded color literal");
+ const named = withCss((css) => css + "\n.badge { color: white; }\n");
+ assertError(errorsWith(named), "named color");
+ const custom = withCss((css) => css + "\n.alert { --alert-color: var(--color-info); }\n");
+ assertError(errorsWith(custom), "must not define custom properties");
+});
+
+test("fails on undefined tokens, hard-coded lengths and non-token spacing", () => {
+ const undefinedToken = withCss((css) => css.replace("var(--shadow-md)", "var(--shadow-xl)"));
+ assertError(errorsWith(undefinedToken), "--shadow-xl, which is not defined");
+ const length = withCss((css) => css + "\n.card { padding: 24px; }\n");
+ assertError(errorsWith(length), "hard-coded length");
+ const font = withCss((css) => css + "\n.badge { font-size: 0.8em; }\n");
+ assertError(errorsWith(font), "must use a matching var(--token)");
+});
+
+test("fails when focus-visible styles are missing or outlines are removed", () => {
+ const noButtonFocus = withCss((css) => css.replace(".button:focus-visible {", ".button:focus {"));
+ assertError(errorsWith(noButtonFocus), "button needs a :focus-visible rule");
+ const noFieldFocus = withCss((css) => css.replace(".field :where(input, textarea, select):focus-visible", ".field :where(input):focus-visible"));
+ assertError(errorsWith(noFieldFocus), "field controls (select) needs a :focus-visible rule");
+ const noOutline = withCss((css) => css + "\n.button { outline: none; }\n");
+ assertError(errorsWith(noOutline), "must not remove focus outlines");
+});
+
+test("fails when disabled or loading selectors are missing", () => {
+ const noDisabled = withCss((css) => css.replace(".button:disabled,\n", ""));
+ assertError(errorsWith(noDisabled), ".button:disabled must be styled");
+ const noAriaDisabled = withCss((css) => css.replace(',\n.button[aria-disabled="true"] {', " {"));
+ assertError(errorsWith(noAriaDisabled), '.button[aria-disabled="true"] must be styled');
+ const noFieldDisabled = withCss((css) => css.replace(".field :where(input, textarea, select):disabled {", ".field :where(input, textarea, select):read-only {"));
+ assertError(errorsWith(noFieldDisabled), ".field controls need a :disabled rule");
+ const noSpinner = withCss((css) => css.replace('.button[aria-busy="true"]::after', '.button[aria-busy="true"]::before'));
+ assertError(errorsWith(noSpinner), "loading indicator");
+});
+
+test("fails when the field error state relies on color alone", () => {
+ const colorOnly = withCss((css) =>
+ css.replace(/(\)\[aria-invalid="true"\] \{\n border-color: var\(--color-danger\);\n)[^}]*\}/, "$1}"),
+ );
+ assertError(errorsWith(colorOnly), "must change the border width");
+ const noMarker = withCss((css) => css.replace(".field-error::before {", ".field-error::marker {"));
+ assertError(errorsWith(noMarker), "visible marker");
+});
+
+test("fails when tables cannot scroll or numbers are not tabular", () => {
+ const noScroll = withCss((css) => css.replace("overflow-x: auto;", "overflow-x: visible;"));
+ assertError(errorsWith(noScroll), ".table-wrap must set overflow-x: auto");
+ const noNumeric = withCss((css) => css.replace("font-variant-numeric: tabular-nums;", ""));
+ assertError(errorsWith(noNumeric), ".numeric cells");
+});
+
+test("fails when a variant's text contrast is too low", () => {
+ const low = withCss((css) => css.replace("var(--color-success) 10%, var(--color-background)", "var(--color-success) 60%, var(--color-background)"));
+ assertError(errorsWith(low), "contrast too low in .badge-success");
+});
+
+test("fails when docs miss a component, a section or the reference table", () => {
+ const noEmpty = files.componentDocs.replace("## `.empty-state`", "## Empty state");
+ assertError(errorsWith({ componentDocs: noEmpty }), 'no "## `.empty-state`" section');
+ const noA11y = files.componentDocs.replace(/(## `\.table`[\s\S]*?)### Accessibility/, "$1### Notes");
+ assertError(errorsWith({ componentDocs: noA11y }), 'section .table is missing "### Accessibility"');
+ const noRef = files.componentDocs.replace("## AI Component Reference", "## Reference");
+ assertError(errorsWith({ componentDocs: noRef }), "AI Component Reference");
+ const longRef = files.componentDocs.replace("| Nothing to show yet |", "| Extra | `.x` |\n".repeat(5) + "| Nothing to show yet |");
+ assertError(errorsWith({ componentDocs: longRef }), "max 15");
+ const noRoles = files.componentDocs.replaceAll('role="alert"', 'role="note"');
+ assertError(errorsWith({ componentDocs: noRoles }), 'role="alert" and role="status"');
+});
+
+test("fails when doc examples or the showcase use classes that are not in the CSS", () => {
+ const docs = { ...files.docs, "docs/components.md": files.docs["docs/components.md"].replaceAll('class="button button-primary"', 'class="btn btn-primary"') };
+ assertError(errorsWith({ docs }), "docs/components.md: html example uses .btn");
+ const showcaseHtml = files.showcaseHtml.replace('<span class="badge badge-success">', '<span class="badge badge-ok">');
+ assertError(errorsWith({ showcaseHtml }), "showcase snippet uses .badge-ok");
+ const live = files.showcaseHtml.replace('<span class="badge badge-info">Pro plan</span>', '<span class="pill">Pro plan</span>');
+ assertError(errorsWith({ showcaseHtml: live }), "uses .pill, which is neither");
+});
diff --git a/scripts/check-showcase.mjs b/scripts/check-showcase.mjs
index 5064c96..dce84b9 100644
--- a/scripts/check-showcase.mjs
+++ b/scripts/check-showcase.mjs
@@ -8,15 +8,18 @@ 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";
+import { COMPONENTS, COMPONENT_CLASSES } from "./check-components.mjs";
-export const SECTIONS = ["hero", "why", "tokens", "layouts", "responsive", "ai-examples"];
+export const SECTIONS = ["hero", "why", "tokens", "layouts", "responsive", "components", "composed", "ai-examples"];
+// The composed interface must use at least this many different components.
+export const MIN_COMPOSED_COMPONENTS = 6;
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 FRAMEWORK_CLASSES = new Set([...PRIMITIVES, ...VARIANTS, ...HELPER_CLASSES, ...COMPONENT_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;
@@ -24,12 +27,12 @@ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, "");
const unescapeHtml = (s) =>
s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&");
-// Returns the HTML of <section id="..."> up to the next top-level section/footer.
+// Returns the HTML of <section id="..."> up to the next top-level section or the page 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/);
+ const end = rest.search(/<section\b[^>]*\bid="|<\/main>|<footer class="sc-footer"/);
return end === -1 ? rest : rest.slice(0, end);
}
@@ -127,6 +130,45 @@ export function checkHtml(html, tokens, showcaseClasses) {
}
}
+ // One article per component, each with a description, a live demo and a snippet.
+ const components = sectionHtml(html, "components") ?? "";
+ const componentArticles = components.split(/<article\b/).slice(1);
+ for (const [name, classes] of Object.entries(COMPONENTS)) {
+ const article = componentArticles.find((a) => a.includes(`id="component-${name}"`));
+ if (!article) {
+ errors.push(`components: missing example for .${name}`);
+ continue;
+ }
+ if (!article.includes(`<code>.${name}</code>`)) errors.push(`components: .${name} example must show its class name`);
+ const demo = article.split('class="sc-code"')[0].split('class="sc-demo"')[1] ?? "";
+ const demoClasses = classesIn(demo);
+ if (!demoClasses.includes(name)) errors.push(`components: .${name} example has no live demo using .${name}`);
+ for (const cls of classes) {
+ if (!demoClasses.includes(cls)) errors.push(`components: .${name} live demo does not show .${cls}`);
+ }
+ const snippet = snippetsIn(article)[0];
+ if (!snippet || !new RegExp(`class="([^"]*\\s)?${name}(\\s[^"]*)?"`).test(snippet)) {
+ errors.push(`components: .${name} example needs an HTML snippet that uses .${name}`);
+ }
+ }
+
+ // A composed interface built only from framework classes.
+ const composed = sectionHtml(html, "composed") ?? "";
+ const app = composed.split("data-composed")[1];
+ if (!app) {
+ errors.push("composed: missing the data-composed interface");
+ } else {
+ const appClasses = classesIn(app);
+ for (const cls of new Set(appClasses)) {
+ if (!FRAMEWORK_CLASSES.has(cls)) errors.push(`composed: uses .${cls}; the composed interface may only use SynthCSS classes`);
+ }
+ if (/\bstyle="(?![^"]*--grid-min)/.test(app)) errors.push("composed: inline styles other than --grid-min are not allowed");
+ const usedComponents = Object.keys(COMPONENTS).filter((n) => appClasses.includes(n));
+ if (usedComponents.length < MIN_COMPOSED_COMPONENTS) {
+ errors.push(`composed: uses ${usedComponents.length} components, needs at least ${MIN_COMPOSED_COMPONENTS}`);
+ }
+ }
+
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}`);
diff --git a/scripts/check-showcase.test.mjs b/scripts/check-showcase.test.mjs
index 04d5f8e..89ef1fc 100644
--- a/scripts/check-showcase.test.mjs
+++ b/scripts/check-showcase.test.mjs
@@ -69,3 +69,16 @@ test("fails when the docs miss the manual Pages step", () => {
const showcaseReadme = files.showcaseReadme.replaceAll("Settings → Pages", "settings");
assertError(errorsWith({ showcaseReadme }), "Settings → Pages");
});
+
+test("fails when a component is missing from the showcase", () => {
+ const html = files.html.replace('id="component-empty-state"', 'id="component-x"');
+ assertError(errorsWith({ html }), "missing example for .empty-state");
+ const noVariant = files.html.replace('<li class="badge badge-info">Beta</li>', "");
+ assertError(errorsWith({ html: noVariant }), ".badge live demo does not show .badge-info");
+});
+
+test("fails when the composed interface is missing or uses non-SynthCSS classes", () => {
+ assertError(errorsWith({ html: files.html.replace("data-composed", "data-x") }), "missing the data-composed interface");
+ const custom = files.html.replace('<div class="alert alert-warning" role="status">\n <h4>Your trial', '<div class="sc-box" role="status">\n <h4>Your trial');
+ assertError(errorsWith({ html: custom }), "composed: uses .sc-box");
+});
diff --git a/showcase/README.md b/showcase/README.md
index 860f90e..00b7d5d 100644
--- a/showcase/README.md
+++ b/showcase/README.md
@@ -1,7 +1,8 @@
# 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
+of its design tokens, layout primitives and components, plus a composed interface built
+only from SynthCSS classes. It is built with SynthCSS itself and
published to GitHub Pages.
| File | Purpose |
@@ -45,7 +46,7 @@ the repository layout:
_site/
index.html generated redirect to showcase/
showcase/ index.html, showcase.css, showcase.js
- src/ tokens.css, layout.css, synthcss.css
+ src/ tokens.css, layout.css, components.css, synthcss.css
```
Because `showcase/` sits next to `src/`, the same relative link works locally and on
@@ -91,11 +92,19 @@ a new primitive or component, append a section:
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,
+and split have width-adjustable frames, that every component has an article with a live
+demo of all its classes and a snippet, that the composed interface (`data-composed`)
+uses only SynthCSS classes and at least six components, that there are at least three intent examples,
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.
+[`scripts/check-components.mjs`](../scripts/check-components.mjs) also checks that every
+class on the page and in its snippets exists in the built CSS.
+
+`npm run check:components:browser` tabs through the component demos in headless Chromium
+and checks focus rings, states and that tables scroll inside `.table-wrap` at 375px.
+
`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
diff --git a/showcase/index.html b/showcase/index.html
index cc27d2b..817b23b 100644
--- a/showcase/index.html
+++ b/showcase/index.html
@@ -4,7 +4,7 @@
<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.">
+ <meta name="description" content="SynthCSS is a small, semantic CSS framework for AI-generated interfaces: design tokens, intent-named layout primitives and semantic components.">
<!-- 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">
@@ -28,6 +28,8 @@
<li><a href="#tokens">Tokens</a></li>
<li><a href="#layouts">Layouts</a></li>
<li><a href="#responsive">Responsive</a></li>
+ <li><a href="#components">Components</a></li>
+ <li><a href="#composed">Composed</a></li>
<li><a href="#ai-examples">AI examples</a></li>
</ul>
</nav>
@@ -38,7 +40,8 @@ <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.
+ intent-named layout primitives that respond to the space they are given, plus
+ eight semantic components for common app UI.
</p>
<p class="sc-callout">
<strong>Built for AI-generated interfaces.</strong>
@@ -443,6 +446,398 @@ <h3 id="responsive-split"><code>.split</code>: adapts</h3>
</div>
</section>
+ <section id="components" class="sc-band sc-band-alt" aria-labelledby="components-title">
+ <div class="container stack-lg">
+ <div class="stack-sm">
+ <h2 id="components-title">Components</h2>
+ <p class="sc-muted">
+ Eight semantic components built only from tokens. State comes from native
+ attributes such as <code>disabled</code>, <code>aria-busy</code> and
+ <code>aria-invalid</code>. Press <kbd>Tab</kbd> to see the focus rings.
+ </p>
+ </div>
+ <div class="grid-lg" style="--grid-min: 22rem">
+
+ <article class="sc-card stack" aria-labelledby="component-button">
+ <h3 id="component-button"><code>.button</code></h3>
+ <p>A native <code><button></code> or link styled as an action, with primary, secondary and danger variants, two sizes and disabled, loading and icon-only states.</p>
+ <div class="sc-demo">
+ <div class="stack">
+ <div class="cluster-sm">
+ <button type="button" class="button">Default</button>
+ <button type="button" class="button button-primary">Primary</button>
+ <button type="button" class="button button-secondary">Secondary</button>
+ <button type="button" class="button button-danger">Delete</button>
+ </div>
+ <div class="cluster-sm">
+ <button type="button" class="button button-primary button-sm">Small</button>
+ <button type="button" class="button button-primary">Default</button>
+ <button type="button" class="button button-primary button-lg">Large</button>
+ </div>
+ <div class="cluster-sm">
+ <button type="button" class="button button-primary" disabled>Disabled</button>
+ <button type="button" class="button button-secondary" aria-disabled="true">aria-disabled</button>
+ <button type="button" class="button button-primary" aria-busy="true">Saving…</button>
+ </div>
+ <div class="cluster-sm">
+ <button type="button" class="button button-primary"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" fill="none"/></svg> New project</button>
+ <button type="button" class="button button-icon" aria-label="Delete"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 4h10M6 4V2h4v2M4.5 4l.5 10h6l.5-10" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></button>
+ <button type="button" class="button button-icon button-danger" aria-label="Delete"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 4h10M6 4V2h4v2M4.5 4l.5 10h6l.5-10" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></button>
+ <a class="button button-secondary" href="#components">Link button</a>
+ </div>
+ </div>
+ </div>
+ <div class="sc-code"><pre><code><div class="cluster-sm">
+ <button type="submit" class="button button-primary">Save</button>
+ <button type="button" class="button button-secondary">Cancel</button>
+ <button type="button" class="button button-danger button-sm">Delete</button>
+ <button type="button" class="button" disabled>Disabled</button>
+ <button type="submit" class="button button-primary" aria-busy="true">Saving…</button>
+ <button type="button" class="button button-icon" aria-label="Delete">
+ <svg aria-hidden="true">…</svg>
+ </button>
+</div></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="component-field">
+ <h3 id="component-field"><code>.field</code></h3>
+ <p>A label, a native control, help text and an error message. Controls are only styled inside <code>.field</code>.</p>
+ <div class="sc-demo">
+ <form class="stack" action="#">
+ <div class="field">
+ <label class="field-label" for="demo-name">Project name</label>
+ <input id="demo-name" type="text" value="Atlas" aria-describedby="demo-name-help">
+ <p class="field-help" id="demo-name-help">Shown on the dashboard.</p>
+ </div>
+ <div class="field">
+ <label class="field-label" for="demo-email">Billing email</label>
+ <input id="demo-email" type="email" value="billing@" aria-invalid="true" aria-describedby="demo-email-error">
+ <p class="field-error" id="demo-email-error">Enter a full email address.</p>
+ </div>
+ <div class="field">
+ <label class="field-label" for="demo-region">Region</label>
+ <select id="demo-region"><option>Europe</option><option>North America</option></select>
+ </div>
+ <div class="field">
+ <label class="field-label" for="demo-seats">Seats</label>
+ <input id="demo-seats" type="number" value="5" min="1">
+ </div>
+ <div class="field">
+ <label class="field-label" for="demo-key">API key</label>
+ <input id="demo-key" type="password" value="secret-value" disabled>
+ </div>
+ <div class="field">
+ <label class="field-label" for="demo-notes">Notes</label>
+ <textarea id="demo-notes">Internal project.</textarea>
+ </div>
+ <fieldset class="field">
+ <legend class="field-label">Visibility</legend>
+ <label><input type="radio" name="demo-vis" checked> Private</label>
+ <label><input type="radio" name="demo-vis"> Public</label>
+ </fieldset>
+ <div class="field">
+ <label><input type="checkbox" checked> Email me about deployments</label>
+ </div>
+ </form>
+ </div>
+ <div class="sc-code"><pre><code><div class="field">
+ <label class="field-label" for="email">Email</label>
+ <input id="email" type="email" aria-describedby="email-help">
+ <p class="field-help" id="email-help">We never share it.</p>
+</div>
+<div class="field">
+ <label class="field-label" for="name">Name</label>
+ <input id="name" type="text" aria-invalid="true" aria-describedby="name-error">
+ <p class="field-error" id="name-error">Enter your name.</p>
+</div>
+<div class="field">
+ <label><input type="checkbox"> Remember me</label>
+</div></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="component-card">
+ <h3 id="component-card"><code>.card</code></h3>
+ <p>A raised, self-contained block with optional media, header, body, footer and actions parts.</p>
+ <div class="sc-demo">
+ <div class="card">
+ <div class="card-media"><svg viewBox="0 0 320 96" role="img" aria-label="Abstract cover"><rect width="320" height="96" fill="currentColor" opacity="0.15"/><circle cx="64" cy="48" r="28" fill="currentColor" opacity="0.35"/></svg></div>
+ <header class="card-header">
+ <h4>Atlas</h4>
+ <p>Updated 2 hours ago</p>
+ </header>
+ <div class="card-body">
+ <p>Customer-facing analytics dashboard.</p>
+ </div>
+ <div class="card-actions">
+ <button type="button" class="button button-primary button-sm">Open</button>
+ <button type="button" class="button button-sm">Share</button>
+ </div>
+ <footer class="card-footer">3 members</footer>
+ </div>
+ </div>
+ <div class="sc-code"><pre><code><article class="card">
+ <header class="card-header">
+ <h3>Atlas</h3>
+ <p>Updated 2 hours ago</p>
+ </header>
+ <div class="card-body"><p>Analytics dashboard.</p></div>
+ <div class="card-actions">
+ <a class="button button-primary" href="/atlas">Open</a>
+ <button type="button" class="button">Share</button>
+ </div>
+ <footer class="card-footer">3 members</footer>
+</article></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="component-badge">
+ <h3 id="component-badge"><code>.badge</code></h3>
+ <p>A short status label. The text carries the meaning; the color reinforces it.</p>
+ <div class="sc-demo">
+ <ul class="cluster-sm" role="list">
+ <li class="badge">Draft</li>
+ <li class="badge badge-success">Active</li>
+ <li class="badge badge-warning">Trial ends soon</li>
+ <li class="badge badge-danger">Failed</li>
+ <li class="badge badge-info">Beta</li>
+ </ul>
+ </div>
+ <div class="sc-code"><pre><code><span class="badge">Draft</span>
+<span class="badge badge-success">Active</span>
+<span class="badge badge-warning">Trial ends soon</span>
+<span class="badge badge-danger">Failed</span>
+<span class="badge badge-info">Beta</span></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="component-alert">
+ <h3 id="component-alert"><code>.alert</code></h3>
+ <p>A message box for status and errors. Headings, paragraphs and buttons inside are spaced automatically.</p>
+ <div class="sc-demo">
+ <div class="stack-sm">
+ <div class="alert alert-info" role="status"><p>A new version is available.</p></div>
+ <div class="alert alert-success" role="status"><p>Settings saved.</p></div>
+ <div class="alert alert-warning" role="status">
+ <h4>Trial ends in 3 days</h4>
+ <p>Add a payment method to keep your projects running.</p>
+ <button type="button" class="button button-sm">Add payment method</button>
+ </div>
+ <div class="alert alert-danger" role="alert">
+ <h4>Deployment failed</h4>
+ <p>The build step exited with code 1.</p>
+ </div>
+ <div class="alert"><p>A neutral note without a variant.</p></div>
+ </div>
+ </div>
+ <div class="sc-code"><pre><code><div class="alert alert-success" role="status">
+ <p>Settings saved.</p>
+</div>
+<div class="alert alert-danger" role="alert">
+ <h2>Deployment failed</h2>
+ <p>The build step exited with code 1.</p>
+ <button type="button" class="button button-sm">View logs</button>
+</div></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="component-panel">
+ <h3 id="component-panel"><code>.panel</code></h3>
+ <p>A flat, bordered group for secondary content. Lighter than a card: no shadow.</p>
+ <div class="sc-demo">
+ <section class="panel" aria-labelledby="demo-panel-title">
+ <header class="panel-header split-sm">
+ <h4 id="demo-panel-title">Usage this month</h4>
+ <span class="badge badge-info">Pro plan</span>
+ </header>
+ <div class="panel-body">
+ <p>1,204 of 5,000 build minutes used.</p>
+ </div>
+ </section>
+ </div>
+ <div class="sc-code"><pre><code><section class="panel">
+ <header class="panel-header">
+ <h2>Usage this month</h2>
+ </header>
+ <div class="panel-body">
+ <p>1,204 of 5,000 build minutes used.</p>
+ </div>
+</section></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="component-table">
+ <h3 id="component-table"><code>.table</code></h3>
+ <p>A native table with header and body rows, borders, optional row hover and right-aligned <code>.numeric</code> cells. <code>.table-wrap</code> scrolls it sideways when narrow.</p>
+ <div class="sc-demo">
+ <div class="table-wrap" role="region" aria-label="Invoices" tabindex="0">
+ <table class="table table-hover">
+ <thead>
+ <tr><th scope="col">Invoice</th><th scope="col">Customer</th><th scope="col">Status</th><th scope="col" class="numeric">Amount</th></tr>
+ </thead>
+ <tbody>
+ <tr><td>INV-1042</td><td>Northwind Traders</td><td><span class="badge badge-success">Paid</span></td><td class="numeric">1,250.00</td></tr>
+ <tr><td>INV-1043</td><td>Contoso Ltd</td><td><span class="badge badge-warning">Due</span></td><td class="numeric">89.90</td></tr>
+ <tr><td>INV-1044</td><td>Fabrikam Inc</td><td><span class="badge badge-danger">Overdue</span></td><td class="numeric">12,400.00</td></tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ <div class="sc-code"><pre><code><div class="table-wrap" role="region" aria-label="Invoices" tabindex="0">
+ <table class="table table-hover">
+ <thead>
+ <tr><th scope="col">Invoice</th><th scope="col" class="numeric">Amount</th></tr>
+ </thead>
+ <tbody>
+ <tr><td>INV-1042</td><td class="numeric">1,250.00</td></tr>
+ <tr><td>INV-1043</td><td class="numeric">89.90</td></tr>
+ </tbody>
+ </table>
+</div></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="component-empty-state">
+ <h3 id="component-empty-state"><code>.empty-state</code></h3>
+ <p>A centered message for a list or page with nothing in it yet, with an optional icon and action.</p>
+ <div class="sc-demo">
+ <div class="empty-state">
+ <svg viewBox="0 0 48 48" aria-hidden="true" focusable="false"><path d="M4 12h14l4 4h22v24H4z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
+ <h4>No projects yet</h4>
+ <p>Create a project to start tracking deployments.</p>
+ <button type="button" class="button button-primary"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" fill="none"/></svg> New project</button>
+ </div>
+ </div>
+ <div class="sc-code"><pre><code><div class="empty-state">
+ <svg aria-hidden="true">…</svg>
+ <h2>No projects yet</h2>
+ <p>Create a project to start tracking deployments.</p>
+ <a class="button button-primary" href="/projects/new">New project</a>
+</div></code></pre></div>
+ </article>
+
+ </div>
+ </div>
+ </section>
+
+ <section id="composed" class="sc-band" aria-labelledby="composed-title">
+ <div class="container stack-lg">
+ <div class="stack-sm">
+ <h2 id="composed-title">Composed interface</h2>
+ <p class="sc-muted">
+ A project settings screen built only from SynthCSS layout primitives and
+ components, with no custom CSS. Drag the frame's corner or use the slider to
+ see it adapt.
+ </p>
+ </div>
+ <label class="sc-range">Frame width <input type="range" min="20" max="100" value="100" data-frame="frame-composed"></label>
+ <div class="sc-frame" id="frame-composed">
+ <div class="sidebar" data-composed>
+ <nav class="panel" aria-label="Project settings">
+ <ul class="stack-sm" role="list">
+ <li><a href="#composed">General</a></li>
+ <li><a href="#composed">Members</a></li>
+ <li><a href="#composed">Billing</a></li>
+ </ul>
+ </nav>
+ <div class="stack-lg">
+ <header class="split">
+ <div class="cluster-sm">
+ <h3>Atlas</h3>
+ <span class="badge badge-success">Active</span>
+ </div>
+ <div class="cluster-sm">
+ <button type="button" class="button button-secondary">Preview</button>
+ <button type="button" class="button button-primary"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" fill="none"/></svg> Deploy</button>
+ </div>
+ </header>
+
+ <div class="alert alert-warning" role="status">
+ <h4>Your trial ends in 3 days</h4>
+ <p>Add a payment method to keep Atlas online.</p>
+ </div>
+
+ <form class="card" action="#" aria-labelledby="composed-general">
+ <header class="card-header">
+ <h4 id="composed-general">General</h4>
+ <p>Basic project details.</p>
+ </header>
+ <div class="card-body grid" style="--grid-min: 14rem">
+ <div class="field">
+ <label class="field-label" for="c-name">Project name</label>
+ <input id="c-name" type="text" value="Atlas">
+ </div>
+ <div class="field">
+ <label class="field-label" for="c-email">Alert email</label>
+ <input id="c-email" type="email" value="ops@" aria-invalid="true" aria-describedby="c-email-error">
+ <p class="field-error" id="c-email-error">Enter a full email address.</p>
+ </div>
+ <div class="field">
+ <label class="field-label" for="c-region">Region</label>
+ <select id="c-region"><option>Europe</option><option>North America</option></select>
+ <p class="field-help">Changing the region redeploys the project.</p>
+ </div>
+ <div class="field">
+ <label class="field-label" for="c-retention">Log retention (days)</label>
+ <input id="c-retention" type="number" value="30" min="1">
+ </div>
+ </div>
+ <footer class="card-footer split-sm">
+ <span>Last saved 2 hours ago</span>
+ <div class="card-actions">
+ <button type="button" class="button">Discard</button>
+ <button type="button" class="button button-primary">Save changes</button>
+ </div>
+ </footer>
+ </form>
+
+ <section class="stack" aria-labelledby="composed-envs">
+ <h4 id="composed-envs">Environments</h4>
+ <ul class="grid" role="list" style="--grid-min: 12rem">
+ <li class="card">
+ <div class="card-header split-sm"><h5>Production</h5><span class="badge badge-success">Healthy</span></div>
+ <p class="card-body">Deployed 2 hours ago</p>
+ </li>
+ <li class="card">
+ <div class="card-header split-sm"><h5>Staging</h5><span class="badge badge-warning">Degraded</span></div>
+ <p class="card-body">Deployed yesterday</p>
+ </li>
+ <li class="card">
+ <div class="card-header split-sm"><h5>Preview</h5><span class="badge badge-danger">Failed</span></div>
+ <p class="card-body">Build failed 5 minutes ago</p>
+ </li>
+ </ul>
+ </section>
+
+ <section class="panel" aria-labelledby="composed-members">
+ <header class="panel-header split-sm">
+ <h4 id="composed-members">Members</h4>
+ <button type="button" class="button button-sm">Invite</button>
+ </header>
+ <div class="table-wrap" role="region" aria-labelledby="composed-members" tabindex="0">
+ <table class="table table-hover">
+ <thead>
+ <tr><th scope="col">Name</th><th scope="col">Role</th><th scope="col" class="numeric">Deploys</th><th scope="col">Actions</th></tr>
+ </thead>
+ <tbody>
+ <tr><th scope="row">Ada Lovelace</th><td><span class="badge badge-info">Owner</span></td><td class="numeric">128</td><td><button type="button" class="button button-sm" disabled>Remove</button></td></tr>
+ <tr><th scope="row">Alan Turing</th><td><span class="badge">Developer</span></td><td class="numeric">42</td><td><button type="button" class="button button-danger button-sm">Remove</button></td></tr>
+ </tbody>
+ </table>
+ </div>
+ </section>
+
+ <section class="panel" aria-labelledby="composed-hooks">
+ <header class="panel-header">
+ <h4 id="composed-hooks">Webhooks</h4>
+ </header>
+ <div class="empty-state">
+ <svg viewBox="0 0 48 48" aria-hidden="true" focusable="false"><path d="M4 12h14l4 4h22v24H4z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
+ <h5>No webhooks yet</h5>
+ <p>Send deployment events to your own services.</p>
+ <button type="button" class="button button-secondary">Add webhook</button>
+ </div>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
<section id="ai-examples" class="sc-band" aria-labelledby="ai-title">
<div class="container stack-lg">
<div class="stack-sm">
@@ -495,6 +890,26 @@ <h2 id="ai-title">AI-friendly examples</h2>
<div class="sc-code"><pre><code><main class="cover">
<form class="cover-main center stack">…</form>
</main></code></pre></div>
+ </li>
+ <li class="sc-card stack">
+ <p class="sc-label">Intent</p>
+ <p>"Add a delete button and a status label to the project header."</p>
+ <p class="sc-label">SynthCSS</p>
+ <div class="sc-code"><pre><code><header class="split">
+ <h1>Atlas <span class="badge badge-success">Active</span></h1>
+ <button type="button" class="button button-danger">Delete</button>
+</header></code></pre></div>
+ </li>
+ <li class="sc-card stack">
+ <p class="sc-label">Intent</p>
+ <p>"Show an error under the email input."</p>
+ <p class="sc-label">SynthCSS</p>
+ <div class="sc-code"><pre><code><div class="field">
+ <label class="field-label" for="email">Email</label>
+ <input id="email" type="email" aria-invalid="true"
+ aria-describedby="email-error">
+ <p class="field-error" id="email-error">Enter a full email.</p>
+</div></code></pre></div>
</li>
<li class="sc-card stack">
<p class="sc-label">Intent</p>
@@ -518,6 +933,7 @@ <h2 id="ai-title">AI-friendly examples</h2>
<li><a href="https://nabled.ai/p/synthcss">nabled.ai project</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>
+ <li><a href="https://github.com/nabledhq/synthcss/blob/main/docs/components.md">Component reference</a></li>
</ul>
</div>
</footer>
diff --git a/showcase/showcase.css b/showcase/showcase.css
index 98b066b..c70223a 100644
--- a/showcase/showcase.css
+++ b/showcase/showcase.css
@@ -18,15 +18,17 @@
line-height: var(--leading-normal);
}
-.sc-page :where(h1, h2, h3, h4) {
+/* Page-level element rules use :where() or element-only specificity, so the
+ heading and link styles of SynthCSS components (card, alert, button…) win. */
+:where(.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); }
+:where(.sc-page) h2 { font-size: var(--text-3xl); }
+:where(.sc-page) h3 { font-size: var(--text-xl); }
+:where(.sc-page) h4 { font-size: var(--text-base); color: var(--color-text-secondary); }
.sc-page :where(h1, h2, h3, h4) code { font-size: inherit; }
@@ -37,8 +39,8 @@
font-size: var(--text-sm);
}
-.sc-page :where(a) { color: var(--color-primary); }
-.sc-page :where(a:hover) { color: var(--color-primary-hover); }
+:where(.sc-page) :where(a) { color: var(--color-primary); }
+:where(.sc-page) :where(a:hover) { color: var(--color-primary-hover); }
.sc-page :focus-visible,
.sc-focus-preview {
diff --git a/src/components.css b/src/components.css
new file mode 100644
index 0000000..636984c
--- /dev/null
+++ b/src/components.css
@@ -0,0 +1,711 @@
+/*
+ * SynthCSS components
+ *
+ * Eight semantic components on top of the design tokens and layout
+ * primitives: button, field, card, badge, alert, panel, table, empty-state.
+ *
+ * Rules:
+ * - Every color, space, radius, font size, weight and shadow is a token from
+ * tokens.css. No color literals; tints are color-mix() of two tokens.
+ * - Naming is .component, .component-variant, .component-part.
+ * - State comes from native attributes, not classes: :disabled,
+ * [aria-disabled="true"], [aria-busy="true"], [aria-invalid="true"].
+ * - Components never set `display` on parts meant to be combined with a
+ * layout primitive (for example `class="card-footer split"`).
+ * - Child margin resets use :where() so any author rule wins.
+ * - No JavaScript, no media queries.
+ *
+ * Reference: docs/components.md
+ */
+
+/* -------------------------------------------------------------------------
+ Shared: content regions trim the outer margins of their first and last
+ child (for example the default margins of <p> and <h3>), so arbitrary
+ content sits flush with the padding.
+ ------------------------------------------------------------------------- */
+:where(.card, .card-header, .card-body, .card-footer, .panel, .panel-header, .panel-body, .alert, .empty-state)
+ > :where(:first-child) {
+ margin-block-start: 0;
+}
+
+:where(.card, .card-header, .card-body, .card-footer, .panel, .panel-header, .panel-body, .alert, .empty-state)
+ > :where(:last-child) {
+ margin-block-end: 0;
+}
+
+/* Direct children of the flex-column components get their spacing from gap. */
+:where(.card, .panel, .alert, .empty-state, .field) > * {
+ margin-block: 0;
+}
+
+/* Buttons and links placed directly in a column component keep their own
+ width instead of stretching across it. */
+:where(.card, .panel, .alert) > :where(button, a, .button) {
+ align-self: flex-start;
+}
+
+/* -------------------------------------------------------------------------
+ Button
+ ------------------------------------------------------------------------- */
+.button {
+ -webkit-appearance: none;
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ box-sizing: border-box;
+ max-inline-size: 100%;
+ min-block-size: var(--control-height);
+ margin: 0;
+ padding-block: var(--space-1);
+ padding-inline: var(--space-4);
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--color-surface-elevated);
+ color: var(--color-text);
+ font-family: inherit;
+ font-size: var(--text-base);
+ font-weight: var(--weight-medium);
+ line-height: var(--leading-tight);
+ text-align: center;
+ text-decoration: none;
+ vertical-align: middle;
+ cursor: pointer;
+ transition:
+ background-color var(--duration-fast) var(--ease-standard),
+ border-color var(--duration-fast) var(--ease-standard),
+ color var(--duration-fast) var(--ease-standard);
+}
+
+.button:where(:not(:disabled, [aria-disabled="true"])):hover {
+ background: var(--color-surface);
+ border-color: var(--color-text-muted);
+}
+
+.button:focus-visible {
+ outline: var(--focus-width) solid var(--focus-color);
+ outline-offset: var(--focus-offset);
+}
+
+/* Icons (inline svg, img or any element) sit next to the label with the gap. */
+.button > :where(svg, img) {
+ flex-shrink: 0;
+ inline-size: 1em;
+ block-size: 1em;
+}
+
+.button-primary {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+ color: var(--color-on-primary);
+}
+
+.button-primary:where(:not(:disabled, [aria-disabled="true"])):hover {
+ background: var(--color-primary-hover);
+ border-color: var(--color-primary-hover);
+}
+
+.button-secondary {
+ background: var(--color-surface-elevated);
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+}
+
+.button-secondary:where(:not(:disabled, [aria-disabled="true"])):hover {
+ background: var(--color-surface);
+ border-color: var(--color-primary-hover);
+ color: var(--color-primary-hover);
+}
+
+.button-danger {
+ background: var(--color-danger);
+ border-color: var(--color-danger);
+ color: var(--color-on-primary);
+}
+
+.button-danger:where(:not(:disabled, [aria-disabled="true"])):hover {
+ background: color-mix(in srgb, var(--color-danger) 85%, var(--color-text));
+ border-color: color-mix(in srgb, var(--color-danger) 85%, var(--color-text));
+}
+
+.button-sm {
+ min-block-size: calc(var(--control-height) * 0.8);
+ padding-inline: var(--space-3);
+ font-size: var(--text-sm);
+}
+
+.button-lg {
+ min-block-size: calc(var(--control-height) * 1.2);
+ padding-inline: var(--space-5);
+ font-size: var(--text-lg);
+}
+
+/* Icon-only: a square button. Always give it an aria-label. */
+.button-icon {
+ inline-size: var(--control-height);
+ block-size: var(--control-height);
+ padding: 0;
+}
+
+.button-icon.button-sm {
+ inline-size: calc(var(--control-height) * 0.8);
+ block-size: calc(var(--control-height) * 0.8);
+}
+
+.button-icon.button-lg {
+ inline-size: calc(var(--control-height) * 1.2);
+ block-size: calc(var(--control-height) * 1.2);
+}
+
+/* Disabled: faded, not-allowed cursor, no hover change. The variant color
+ stays recognizable. aria-disabled keeps the button focusable. */
+.button:disabled,
+.button[aria-disabled="true"] {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+/* Loading: aria-busy="true" ignores pointer input and shows a spinner after
+ the label. With reduced motion the duration tokens are 0ms and the ring
+ stays still. */
+.button[aria-busy="true"] {
+ cursor: progress;
+ pointer-events: none;
+ opacity: 0.8;
+}
+
+.button[aria-busy="true"]::after {
+ content: "";
+ flex-shrink: 0;
+ box-sizing: border-box;
+ inline-size: 1em;
+ block-size: 1em;
+ border: calc(var(--border-width) * 2) solid currentColor;
+ border-inline-end-color: transparent;
+ border-radius: var(--radius-full);
+ animation: synthcss-spin calc(var(--duration-slow) * 2.5) linear infinite;
+}
+
+/* An icon-only button swaps its icon for the spinner. */
+.button-icon[aria-busy="true"] > * {
+ display: none;
+}
+
+@keyframes synthcss-spin {
+ to { transform: rotate(1turn); }
+}
+
+/* -------------------------------------------------------------------------
+ Field: label + control + help + error. Native controls are only styled
+ inside .field, so SynthCSS never restyles form elements globally.
+ ------------------------------------------------------------------------- */
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ min-inline-size: 0;
+ color: var(--color-text);
+}
+
+/* A <fieldset class="field"> groups checkboxes or radios. */
+.field:where(fieldset) {
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.field > :where(legend) {
+ margin-block-end: var(--space-2);
+ padding: 0;
+}
+
+.field-label {
+ color: var(--color-text);
+ font-size: var(--text-base);
+ font-weight: var(--weight-medium);
+ line-height: var(--leading-tight);
+}
+
+.field-help {
+ color: var(--color-text-muted);
+ font-size: var(--text-sm);
+ line-height: var(--leading-normal);
+}
+
+/* The error text carries an "!" marker, so the error is not shown by color
+ alone. The marker is hidden from screen readers where supported. */
+.field-error {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-2);
+ color: var(--color-danger);
+ font-size: var(--text-sm);
+ font-weight: var(--weight-medium);
+ line-height: var(--leading-normal);
+}
+
+.field-error::before {
+ content: "!";
+ content: "!" / "";
+ display: inline-grid;
+ place-items: center;
+ flex-shrink: 0;
+ inline-size: 1.25em;
+ block-size: 1.25em;
+ border-radius: var(--radius-full);
+ background: var(--color-danger);
+ color: var(--color-on-primary);
+ font-weight: var(--weight-bold);
+ line-height: var(--leading-tight);
+}
+
+.field
+ :where(
+ input:not([type]),
+ input[type="text"],
+ input[type="email"],
+ input[type="password"],
+ input[type="number"],
+ textarea,
+ select
+ ) {
+ box-sizing: border-box;
+ inline-size: 100%;
+ max-inline-size: 100%;
+ min-block-size: var(--input-height);
+ margin: 0;
+ padding-block: var(--space-2);
+ padding-inline: var(--space-3);
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--color-background);
+ color: var(--color-text);
+ font-family: inherit;
+ font-size: var(--text-base);
+ line-height: var(--leading-normal);
+ transition: border-color var(--duration-fast) var(--ease-standard);
+}
+
+.field :where(textarea) {
+ min-block-size: calc(var(--input-height) * 2.5);
+ resize: vertical;
+}
+
+.field
+ :where(
+ input:not([type]),
+ input[type="text"],
+ input[type="email"],
+ input[type="password"],
+ input[type="number"],
+ textarea,
+ select
+ ):hover {
+ border-color: var(--color-text-muted);
+}
+
+.field :where(input[type="checkbox"], input[type="radio"]) {
+ inline-size: 1.125em;
+ block-size: 1.125em;
+ margin-block: 0;
+ margin-inline: 0 var(--space-2);
+ vertical-align: middle;
+ accent-color: var(--color-primary);
+ cursor: pointer;
+}
+
+/* A label wrapping a checkbox or radio is clickable as a whole. */
+.field :where(label) {
+ cursor: pointer;
+}
+
+.field :where(input, textarea, select):focus-visible {
+ outline: var(--focus-width) solid var(--focus-color);
+ outline-offset: var(--focus-offset);
+ border-color: var(--focus-color);
+}
+
+/* Error: a thicker danger border with a heavy start edge, plus the
+ .field-error message and its marker. */
+.field
+ :where(
+ input:not([type]),
+ input[type="text"],
+ input[type="email"],
+ input[type="password"],
+ input[type="number"],
+ textarea,
+ select
+ )[aria-invalid="true"] {
+ border-color: var(--color-danger);
+ border-width: calc(var(--border-width) * 2);
+ border-inline-start-width: calc(var(--border-width) * 5);
+}
+
+.field :where(input[type="checkbox"], input[type="radio"])[aria-invalid="true"] {
+ outline: calc(var(--border-width) * 2) solid var(--color-danger);
+ outline-offset: var(--border-width);
+}
+
+.field :where(input[type="checkbox"], input[type="radio"])[aria-invalid="true"]:focus-visible {
+ outline: var(--focus-width) solid var(--focus-color);
+ outline-offset: var(--focus-offset);
+}
+
+/* Disabled: faded on the surface color with a not-allowed cursor, the same
+ treatment as disabled buttons. */
+.field :where(input, textarea, select):disabled {
+ opacity: 0.55;
+ background: var(--color-surface);
+ cursor: not-allowed;
+}
+
+.field :where(input, textarea, select):disabled:hover {
+ border-color: var(--border-color);
+}
+
+.field:has(> :where(input, textarea, select):disabled) .field-label,
+.field :where(label):has(> :where(input):disabled) {
+ color: var(--color-text-muted);
+ cursor: not-allowed;
+}
+
+/* -------------------------------------------------------------------------
+ Card: a raised, self-contained block. Optional parts: .card-media,
+ .card-header, .card-body, .card-footer, .card-actions.
+ ------------------------------------------------------------------------- */
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ box-sizing: border-box;
+ min-inline-size: 0;
+ padding: var(--space-5);
+ overflow: hidden;
+ background: var(--color-surface-elevated);
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-md);
+ color: var(--color-text);
+}
+
+.card-header :where(h1, h2, h3, h4, h5, h6) {
+ color: var(--color-text);
+ font-size: var(--text-lg);
+ font-weight: var(--weight-semibold);
+ line-height: var(--leading-tight);
+}
+
+.card-header :where(p) {
+ color: var(--color-text-muted);
+ font-size: var(--text-sm);
+}
+
+/* The body grows, so footers line up across cards in a grid row. */
+.card-body {
+ flex-grow: 1;
+ color: var(--color-text-secondary);
+}
+
+/* Media spans the full card width; at the top or bottom it also bleeds over
+ the card padding. */
+.card-media {
+ display: block;
+ margin-inline: calc(var(--space-5) * -1);
+}
+
+.card-media:first-child {
+ margin-block-start: calc(var(--space-5) * -1);
+}
+
+.card-media:last-child {
+ margin-block-end: calc(var(--space-5) * -1);
+}
+
+.card-media > :where(img, video, picture, svg) {
+ display: block;
+ inline-size: 100%;
+ block-size: auto;
+ object-fit: cover;
+}
+
+/* Directly on <img> or <video>: widen by the padding the margins bleed over. */
+.card-media:where(img, video) {
+ box-sizing: border-box;
+ inline-size: calc(100% + var(--space-5) * 2);
+ max-inline-size: none;
+ block-size: auto;
+ object-fit: cover;
+}
+
+.card-footer {
+ margin-inline: calc(var(--space-5) * -1);
+ padding-block: var(--space-4);
+ padding-inline: var(--space-5);
+ border-block-start: var(--border-width) solid var(--border-color);
+ background: var(--color-surface);
+ color: var(--color-text-muted);
+ font-size: var(--text-sm);
+}
+
+.card-footer:last-child {
+ margin-block-end: calc(var(--space-5) * -1);
+}
+
+/* A wrapping row of buttons or links. */
+.card-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+/* -------------------------------------------------------------------------
+ Badge: a short status label.
+ ------------------------------------------------------------------------- */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ box-sizing: border-box;
+ max-inline-size: 100%;
+ padding-block: calc(var(--space-1) / 2);
+ padding-inline: var(--space-2);
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius-full);
+ background: var(--color-surface);
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+ font-weight: var(--weight-medium);
+ line-height: var(--leading-tight);
+ vertical-align: middle;
+ white-space: nowrap;
+}
+
+.badge-success {
+ border-color: var(--color-success);
+ background: var(--color-background);
+ background: color-mix(in srgb, var(--color-success) 10%, var(--color-background));
+ color: var(--color-success);
+}
+
+.badge-warning {
+ border-color: var(--color-warning);
+ background: var(--color-background);
+ background: color-mix(in srgb, var(--color-warning) 10%, var(--color-background));
+ color: var(--color-warning);
+}
+
+.badge-danger {
+ border-color: var(--color-danger);
+ background: var(--color-background);
+ background: color-mix(in srgb, var(--color-danger) 10%, var(--color-background));
+ color: var(--color-danger);
+}
+
+.badge-info {
+ border-color: var(--color-info);
+ background: var(--color-background);
+ background: color-mix(in srgb, var(--color-info) 10%, var(--color-background));
+ color: var(--color-info);
+}
+
+/* -------------------------------------------------------------------------
+ Alert: a message box. Any headings, paragraphs, lists and buttons inside
+ are spaced by gap; no special structure is needed.
+ ------------------------------------------------------------------------- */
+.alert {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ box-sizing: border-box;
+ min-inline-size: 0;
+ padding: var(--space-4);
+ border: var(--border-width) solid var(--border-color);
+ border-inline-start-width: calc(var(--border-width) * 4);
+ border-radius: var(--radius-md);
+ background: var(--color-surface);
+ color: var(--color-text);
+ line-height: var(--leading-normal);
+}
+
+.alert :where(h1, h2, h3, h4, h5, h6) {
+ font-size: var(--text-base);
+ font-weight: var(--weight-semibold);
+ line-height: var(--leading-tight);
+}
+
+.alert :where(ul, ol) {
+ padding-inline-start: var(--space-5);
+}
+
+.alert-info {
+ border-color: var(--color-info);
+ background: color-mix(in srgb, var(--color-info) 8%, var(--color-background));
+}
+
+.alert-success {
+ border-color: var(--color-success);
+ background: color-mix(in srgb, var(--color-success) 8%, var(--color-background));
+}
+
+.alert-warning {
+ border-color: var(--color-warning);
+ background: color-mix(in srgb, var(--color-warning) 8%, var(--color-background));
+}
+
+.alert-danger {
+ border-color: var(--color-danger);
+ background: color-mix(in srgb, var(--color-danger) 8%, var(--color-background));
+}
+
+/* -------------------------------------------------------------------------
+ Panel: a flat, bordered grouping. Lighter than .card: no shadow, smaller
+ radius and padding, surface background.
+ ------------------------------------------------------------------------- */
+.panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ box-sizing: border-box;
+ min-inline-size: 0;
+ padding: var(--space-4);
+ background: var(--color-surface);
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius-md);
+ color: var(--color-text);
+}
+
+.panel-header {
+ padding-block-end: var(--space-3);
+ border-block-end: var(--border-width) solid var(--border-color);
+}
+
+.panel-header :where(h1, h2, h3, h4, h5, h6) {
+ font-size: var(--text-base);
+ font-weight: var(--weight-semibold);
+ line-height: var(--leading-tight);
+}
+
+.panel-body {
+ color: var(--color-text-secondary);
+}
+
+/* -------------------------------------------------------------------------
+ Table: apply .table to a native <table>. Wrap it in .table-wrap to let a
+ wide table scroll horizontally on narrow screens.
+ ------------------------------------------------------------------------- */
+.table-wrap {
+ box-sizing: border-box;
+ max-inline-size: 100%;
+ overflow-x: auto;
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--color-background);
+}
+
+/* Give a scrollable wrapper tabindex="0" so keyboard users can scroll it. */
+.table-wrap:focus-visible {
+ outline: var(--focus-width) solid var(--focus-color);
+ outline-offset: var(--focus-offset);
+}
+
+.table {
+ inline-size: 100%;
+ border-collapse: collapse;
+ border-spacing: 0;
+ background: var(--color-background);
+ color: var(--color-text);
+ font-size: var(--text-sm);
+ line-height: var(--leading-normal);
+}
+
+.table-wrap > .table {
+ margin: 0;
+}
+
+.table :where(caption) {
+ padding-block-end: var(--space-2);
+ color: var(--color-text-secondary);
+ font-weight: var(--weight-semibold);
+ text-align: start;
+}
+
+.table-wrap > .table :where(caption) {
+ padding: var(--space-3);
+}
+
+.table :where(th, td) {
+ padding-block: var(--space-2);
+ padding-inline: var(--space-3);
+ border-block-end: var(--border-width) solid var(--border-color);
+ text-align: start;
+ vertical-align: middle;
+}
+
+.table :where(thead th, thead td) {
+ border-block-end-width: calc(var(--border-width) * 2);
+ background: var(--color-surface);
+ color: var(--color-text-secondary);
+ font-weight: var(--weight-semibold);
+ vertical-align: bottom;
+}
+
+.table :where(tbody th) {
+ font-weight: var(--weight-medium);
+}
+
+.table :where(tfoot th, tfoot td) {
+ border-block-end: 0;
+ border-block-start: calc(var(--border-width) * 2) solid var(--border-color);
+ font-weight: var(--weight-semibold);
+}
+
+/* Inside the bordered wrapper the last row needs no bottom border. */
+.table-wrap > .table :where(tbody:last-child tr:last-child) > * {
+ border-block-end: 0;
+}
+
+.table-hover :where(tbody tr:hover) > * {
+ background: var(--color-surface);
+}
+
+.table .numeric {
+ text-align: end;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+/* -------------------------------------------------------------------------
+ Empty state: a centered stack of icon, heading, description and action.
+ ------------------------------------------------------------------------- */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-3);
+ box-sizing: border-box;
+ padding-block: var(--space-6);
+ padding-inline: var(--space-4);
+ color: var(--color-text-secondary);
+ text-align: center;
+}
+
+.empty-state > * {
+ max-inline-size: var(--content-width);
+}
+
+.empty-state :where(h1, h2, h3, h4, h5, h6) {
+ color: var(--color-text);
+ font-size: var(--text-lg);
+ font-weight: var(--weight-semibold);
+ line-height: var(--leading-tight);
+}
+
+.empty-state > :where(svg, img) {
+ inline-size: calc(var(--space-6) * 2);
+ block-size: auto;
+ color: var(--color-text-muted);
+}
diff --git a/src/synthcss.css b/src/synthcss.css
index 20e1cc0..36474cc 100644
--- a/src/synthcss.css
+++ b/src/synthcss.css
@@ -5,7 +5,10 @@
* <link rel="stylesheet" href="synthcss/src/synthcss.css">
*
* Tokens must load first: every other file reads them through var(--token).
+ * Components load last so they can be combined with layout primitives on the
+ * same element (for example class="card-footer split").
*/
@import url("tokens.css");
@import url("layout.css");
+@import url("components.css");
This is a thorough, well-scoped implementation of all eight components, built purely on tokens with state taken from native attributes. It comes with detailed per-component docs, a 14-row AI reference table, a showcase article for each component and a composed settings screen. The static checks cover every verification item in the spec, with negative tests. The main gap is that no CI ran, so the passing test suite and build are claimed rather than demonstrated; a few minor design choices (`.card` overflow clipping, showcase CSS specificity changes) deserve a glance.
Acceptance criteria · 9 of 10 met
- YESAll classes listed above exist in the built CSS output.`src/components.css` defines every class in the `COMPONENTS` map, `synthcss.css` imports it after `layout.css`, and `checkComponents` verifies each class against the inlined bundle and rejects extra classes.
- YESComponent color declarations use only SynthCSS tokens (verified by test).`checkComponentsCss` rejects hex/rgb/hsl/named literals and custom properties, requires color props to be a `var()` or a `color-mix()` of two tokens, and checks that each `var()` is defined; tests cover hex, rgb, hsl, named and custom-property cases.
- YESButtons and form controls have :focus-visible and disabled styles (verified by test).`.button:focus-visible`, `.field :where(input, textarea, select):focus-visible`, `.button:disabled`/`[aria-disabled="true"]` and `.field … :disabled` exist, are asserted in `focusTargets` and the disabled checks, and have negative tests.
- YESField error state does not rely on color alone.The `[aria-invalid="true"]` rule doubles the border width with a 5× start edge, `.field-error::before` adds a "!" marker, and the test fails if the border-width change or the marker is removed.
- YESTables scroll horizontally inside .table-wrap on narrow viewports.`.table-wrap` sets `overflow-x: auto; max-inline-size: 100%`, the static test asserts it, and the optional Playwright script checks scrolling at 375px.
- YESDocs cover every component and include the compact AI reference table.`docs/components.md` has a section per component with Purpose, Example, Variants, Composition, Accessibility, Recommended use and Misuse, plus a 14-row AI Component Reference; alerts use `role="alert"`/`role="status"`.
- YESDoc and showcase class usage validated against the CSS (test).`checkClassUsage` validates html blocks in README and `docs/*.md`, showcase snippets and live showcase markup, with negative tests for `.btn`, `.badge-ok` and `.pill`.
- YESShowcase demonstrates all eight components plus at least one composed interface.`showcase/index.html` adds a `#components` article per component demoing every class and a `#composed` settings screen using all eight components, enforced by new `check-showcase.mjs` checks.
- UNCLEARThe existing test suite and build pass.`package.json` test script is extended and the builder reports passing, but no CI ran to confirm this.
- YESNo third-party CSS framework is added.No dependencies are added; Playwright is only an optional, uninstalled dependency of the browser check script, and the existing `THIRD_PARTY` guard remains.
- No CI checks ran on this commit, so the builder's claim that `npm test` passes (40 tests) and that the build is intact is unverified. Run it before merging.
- `.card` sets `overflow: hidden` so media and footer bleed to the rounded corners. This also clips anything that overflows the card, such as a wide table placed without `.table-wrap`, or focus outlines on full-bleed children. It may surprise authors composing inside cards.
- The doc class check in `checkClassUsage` only inspects class attributes inside fenced ```html blocks. Classes mentioned inline in prose or tables are not validated. This is acceptable against the spec, but worth knowing.
- `showcase/showcase.css` heading and link rules were lowered to `:where(.sc-page)` specificity so component styles win. The change is justified, but it touches existing showcase styling, so the earlier sections should get a quick visual check.
- `check-showcase.mjs` now ends sections at `<footer class="sc-footer"` instead of any `<footer`. This makes the section parsing depend on that exact attribute string.
CI details
No CI checks ran on this commit.
Accepted by the backers and merged by the maintainer.
Ballots · 1
Automated review cost $0.29, counted as builder cost.
No comments yet.