Add optional SynthJS runtime (dist/synth.js IIFE): data-synth-* open, dismiss, toggle, tabs, dropdown with ARIA, tests and AI contract
Motivation
AI-generated interfaces built with SynthCSS currently need hand-written DOM event code for interactivity. SynthJS is an optional, dependency-free runtime in the same repo. Markup declares interaction intent with data-synth-* attributes and SynthJS implements it accessibly. CSS-only usage stays fully supported.
Scope
1. Source and build
- Source lives under the existing source layout (e.g.
src/js/synth.js). Do not restructure existing CSS paths. - The existing lightweight build scripts emit
dist/synth.js: a plain, browser-compatible IIFE exposingwindow.Synth, with noimport/requireand no runtime dependencies. - No bundler (esbuild, rollup, etc.) is added. The script copies the file and optionally minifies it if the repo already has a minify step.
dist/synth.cssis unchanged. Non-interactive components need no JS.
2. Behaviors (one attribute per intent, no aliases)
Open (dialog): data-synth-open="<id>" on a button calls showModal() on the target <dialog>. Escape closes it natively. On close, focus returns to the opener.
Dismiss: data-synth-dismiss on a button closes the closest <dialog> if inside one. Otherwise it hides the closest [data-synth-dismissible] ancestor (e.g. an .alert).
- Assumption: hiding means setting the
hiddenattribute, not removing the element.
Toggle: data-synth-toggle="<id>" on a button toggles hidden on the target.
- It sets
aria-controlsif missing and keepsaria-expandedin sync. - Initial
aria-expandedis derived from the target'shiddenstate at init. data-synth-collapseis not added. Collapse is a use case of toggle.
Tabs: data-synth-tabs goes on a container (reusing the existing .tabs / .tabs-item markup and its ARIA-driven CSS state). Its role="tab" buttons reference role="tabpanel" elements via aria-controls.
- Click selects a tab: sets
aria-selected, rovingtabindex(0 on the selected tab, -1 on others), andhiddenon non-selected panels. - ArrowLeft/ArrowRight (wrapping), Home and End move focus and selection.
Dropdown: data-synth-dropdown goes on a wrapper containing a trigger button and a menu element.
- Clicking the trigger toggles the menu's
hiddenand the trigger'saria-expanded. - Outside click and Escape close the menu. Escape returns focus to the trigger.
- Reuse existing nav-related markup where it fits. Otherwise add only the minimal
data-synth-*and ARIA structure. - Assumption: the trigger is identified as
[data-synth-dropdown-trigger]or the firstbutton, and the menu as[data-synth-dropdown-menu]or[role=menu]. - No new visual component API or class names beyond what is strictly required.
3. Lifecycle
- Auto-initializes on
DOMContentLoaded, or immediately ifdocument.readyStateis notloading. Synth.init(root = document)initializes markup inserted later.- Repeated calls must not double-bind. Use a per-element marker or delegated listeners.
- A missing or invalid target logs one
console.warnper element and never throws.
4. AI contract
- Extend
synthcss.ai.jsonwithbehaviorsentries on the relevant components. Each entry has:intent,attribute,targettype,requiredMarkup, andaccessibilityexpectations. - The existing LLM contract generator emits a behavior section from this data. No separate hand-written registry.
- Bump the contract minor version (e.g. 1.1.0 → 1.2.0) and update any changelog the repo keeps.
5. Docs and showcase
- Document each behavior with: intent, minimal HTML, attributes, accessibility behavior, and common misuse.
- Add a compact AI behavior reference.
- The showcase gets live examples of dialog, toggle, tabs, dropdown and dismissible alert, loading the repo's
dist/synth.jsor its source.
6. Tests
- Add happy-dom or jsdom as a devDependency only. Use a real DOM and real events, not a hand-written fake.
- Integrate with the existing test command.
Acceptance criteria
- The build produces
dist/synth.jsas an IIFE definingwindow.Synth, with noimport/requirestatements and no new runtime dependencies inpackage.json. - No bundler is added. Only dev dependencies are added (happy-dom or jsdom).
- A page using only
dist/synth.cssrenders without errors. - Tests cover:
- initialization
- double
Synth.init()with handlers firing exactly once - dialog open, close and focus return
- toggle (
hiddenandaria-expanded, including the initial state) - tab click and Arrow/Home/End keys (
aria-selected,tabindex, panelhidden) - dropdown outside-click and Escape (with focus return)
- dismiss in a dialog and in a
[data-synth-dismissible] - a missing target warns without throwing
-
synthcss.ai.jsoncontainsbehaviorsentries, its minor version is bumped, and the generated LLM contract includes a behavior section. - Docs and showcase cover all five behaviors.
- All existing build and test commands pass.
Out of scope
- State management, routing, data fetching, form frameworks
- Framework adapters
- Drag and drop, rich text
- Animation systems
- Focus-trap polyfills beyond native
<dialog> - Dropdown positioning engines
- Bundler adoption
- A
data-synth-collapsealias
Model claude-opus-5-5 · ceiling $11.25 · started 2 hours ago · finished 2 hours ago
- **`data-synth-dropdown`**: the trigger is `[data-synth-dropdown-trigger]` or the first `button`; the menu is `[data-synth-dropdown-menu]` or `[role=menu]`. Clicking outside or pressing Escape closes it, and Escape returns focus to the trigger. Inside an open dialog, Escape only closes that dialog's own menus.
- **Lifecycle**: runs on `DOMContentLoaded`, or straight away if the page has already loaded. Clicks and keys go through one listener per document, and each element is set up once (tracked with a WeakSet), so calling `Synth.init()` again or loading the script twice never binds anything twice. A missing or invalid target logs one `console.warn` per element and never throws.
### Build and CI
- `scripts/build.mjs` copies the script to `dist/synth.js` with the usual banner. No bundler or minifier was added.
- **happy-dom is the only dependency, and it is a dev dependency.** Because of it, `package-lock.json` is now committed, and both workflows run `npm ci` before `npm test` (until now there was no install step).
- `pages.yml` also deploys `src/js/`. `release.yml` attaches `dist/*.js` to the release and clears jsDelivr's cached `synth(.min).js`.
### AI contract, version 1.2.0 → 1.3.0
- `synthcss.ai.json`: `behaviors` entries `{ intent, attribute, target, requiredMarkup, accessibility[] }` on `button` (open, toggle), `alert` (dismiss), `nav` (dropdown) and `tabs`, plus 5 new intent-map rows.
- The repo had no LLM contract generator; `synthcss.llm.md` was written by hand and checked by the verifier. So the verifier now also generates: `npm run contract:write` writes the "Behaviors (SynthJS)" section from the JSON, and `npm test` fails if that section drifts. The JSON is the only list of behaviors; there is no second registry.
- New verifier checks, each with tests: all five keys present, attribute names are unique and follow `data-synth-<name>`, the markup uses the attribute and only real contract classes, and `src/js/synth.js` actually implements the attribute.
- The repo keeps no changelog file. The version change is described in `docs/ai-contract.md`.
### Docs and showcase
- New `docs/behaviors.md`: for each behavior, its intent, minimal HTML, attributes, accessibility and common misuse, plus a compact "AI Behavior Reference" table.
- Updated: README, `docs/ai-contract.md`, `docs/components.md`, `docs/releasing.md` and `showcase/README.md`.
- The showcase has a new **Behaviors** section with live dialog, toggle, tabs, dropdown and dismissible-alert demos. It loads `../src/js/synth.js`, and `check-showcase.mjs` now checks those demos.
### How it was checked
- `npm test` passes: 115 tests, up from 99.
- `scripts/synth-js.test.mjs` loads the **built** `dist/synth.js` into happy-dom with real clicks and key events, and also loads the real showcase page. It covers every item in the acceptance criteria.
- I made sure the tests can fail by breaking the code on purpose (no focus return, no wrapping, no Escape focus, a throw on a missing target, no outside-click close, removing instead of hiding). Each break was caught.
- A simulated version bump (`bump-version.mjs minor`) also passes, then was reverted.
- **Not tested in a real browser:** the sandbox blocks the Playwright browser download.
### Please look closely at
1. **SynthJS adds a CSS rule to the page.** It inserts one `<style data-synth>` with `[hidden]:not([hidden="until-found"]){display:none!important}`. Without it, `hidden` has no effect on `.alert`, `.nav stack-sm`, `.panel` and others, because those classes set `display`. I did it this way to keep the stylesheet unchanged, as the spec asks. Moving the rule into the stylesheet would be cleaner, but it is an API decision for you.
2. **Node version raised to 20.** `engines` goes from `>=18` to `>=20`, because happy-dom 20 needs Node 20 and versions before 20 have a published security advisory. CI already uses Node 20.
3. **Escape and dialogs.** When SynthJS closes a menu on Escape, it cancels the keydown so the surrounding dialog stays open. This depends on browsers honouring a cancelled Escape keydown for dialogs, which I couldn't confirm without a real browser.
4. **Contract size.** `synthcss.llm.md` grows from about 5,700 to about 7,000 tokens; the warning threshold is 8,000.
5. **Dropdown accessibility.** The recommended dropdown is a plain list of links that opens and closes. `role="menu"` is still detected, but the docs advise against it because SynthJS doesn't add menu arrow-key navigation.
2026-10-04T13:28:15Z [claude] SynthJS is built and pushed to `nabled/feature-17-job-17`, and the submission was accepted. `npm test` passes (115 tests, up from 99), but I couldn't try it in a real browser because the sandbox blocks the browser download.
**What's in the branch:**
- **Runtime:** `src/js/synth.js` is built to `dist/synth.js`, a plain script that defines `window.Synth`. It handles `data-synth-open`, `-dismiss`, `-toggle`, `-tabs` and `-dropdown` with the required ARIA state, starts itself on page load, and `Synth.init(root)` can be called again without binding anything twice. A missing target logs one warning and never throws.
- **Build:** the existing build script just copies the file; no bundler. happy-dom is the only new dependency, and it is a dev dependency.
- **Tests:** they load the built `dist/synth.js` and the real showcase page into happy-dom and use real clicks and key presses. I broke the code on purpose several ways (no focus return, no tab wrapping, a throw on a missing target, and so on) and the tests caught each one.
- **AI contract (1.2.0 → 1.3.0):** `behaviors` entries are on the button, alert, nav and tabs components. The repo had no generator for the LLM contract, so I added one to the existing verifier: `npm run contract:write` writes the Behaviors section from the JSON, and `npm test` fails if that section or the runtime drifts from it. The repo keeps no changelog file.
- **Docs and showcase:** new `docs/behaviors.md` with a compact AI reference table, updates to the other docs, and a live Behaviors section in the showcase covering all five.
**Decisions for you:**
1. **The script adds a CSS rule to the page.** SynthCSS classes like `.alert`, `.nav` and `.panel` set `display`, which overrides the `hidden` attribute, so dismiss, toggle and dropdown wouldn't visibly hide anything. To keep the stylesheet unchanged as the spec asks, SynthJS inserts one rule making `hidden` always hide. Putting that rule in the stylesheet instead would be cleaner.
2. **Node 20 is now required.** happy-dom 20 needs it and older versions have a published security advisory; CI already uses Node 20.
3. **CI now installs dependencies.** `package-lock.json` is committed and both workflows run `npm ci` before `npm test`; until now they had no install step.
4. **Escape inside a dialog isn't confirmed in a real browser.** When a menu inside a dialog is open, Escape closes the menu and should leave the dialog open. That relies on browsers respecting a cancelled Escape key press, which happy-dom can't show.
5. **The LLM contract grows** from about 5,700 to about 7,000 tokens (the warning threshold is 8,000).
During the work I accidentally discarded uncommitted doc edits with a git command. I recovered them in full from git and checked them before committing.
Files are in /mnt/session/outputs:
- synth.js
- behaviors.md
2026-10-04T13:28:17Z Changed files:
modified .github/workflows/pages.yml +7 -1
modified .github/workflows/release.yml +8 -1
modified README.md +17 -3
modified docs/ai-contract.md +28 -6
added docs/behaviors.md +314 -0
modified docs/components.md +5 -2
modified docs/releasing.md +3 -1
added package-lock.json +130 -0
modified package.json +7 -2
modified scripts/build.mjs +11 -6
modified scripts/build.test.mjs +26 -3
modified scripts/check-showcase.mjs +24 -1
modified scripts/check-showcase.test.mjs +9 -0
added scripts/synth-js.test.mjs +369 -0
modified scripts/verify-ai-contract.mjs +101 -4
modified scripts/verify-ai-contract.test.mjs +74 -3
modified showcase/README.md +9 -5
modified showcase/index.html +137 -5
modified showcase/showcase.css +12 -0
modified showcase/showcase.js +7 -0
added src/js/synth.js +300 -0
modified synthcss.ai.json +75 -7
modified synthcss.llm.md +105 -2
2026-10-04T13:28:19Z Opened pull request https://github.com/nabledhq/synthcss/pull/18
2026-10-04T13:28:19Z Finished: success=true turns=68 tokens(in/out)=9718225/94224 list cost=$4.83
Show patch
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 432d416..fa4fb16 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -10,6 +10,7 @@ on:
paths:
- "showcase/**"
- "src/**.css"
+ - "src/**.js"
- "synthcss.llm.md"
- "synthcss.ai.json"
- ".github/workflows/pages.yml"
@@ -37,6 +38,9 @@ jobs:
with:
node-version: 20
+ - name: Install dev dependencies
+ run: npm ci
+
- name: Run checks
run: npm test
@@ -45,9 +49,11 @@ jobs:
- name: Assemble site
run: |
- mkdir -p _site/showcase _site/src
+ mkdir -p _site/showcase _site/src/js
cp showcase/index.html showcase/showcase.css showcase/showcase.js _site/showcase/
cp src/*.css _site/src/
+ # SynthJS, loaded by the showcase's interactive demos.
+ cp src/js/synth.js _site/src/js/
# The AI contract, linked from the showcase as ../synthcss.llm.md and ../synthcss.ai.json.
cp synthcss.llm.md synthcss.ai.json _site/
# Cache busting: Pages lets browsers cache files for 10 minutes, so add the
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 30c9d36..4dab1a5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -48,6 +48,9 @@ jobs:
with:
node-version: 20
+ - name: Install dev dependencies
+ run: npm ci
+
- name: Decide what to release
id: plan
env:
@@ -143,9 +146,10 @@ jobs:
- Base styles only (load tokens first): ${cdn}/dist/base.min.css
- Layout only (load tokens first): ${cdn}/dist/layout.min.css
- Components only (load tokens first): ${cdn}/dist/components.min.css
+ - Optional SynthJS behaviors: ${cdn}/dist/synth.js (or synth.min.js)
- AI contract: ${cdn}/synthcss.llm.md and ${cdn}/synthcss.ai.json
EOF
- gh release create "$TAG" dist/*.css \
+ gh release create "$TAG" dist/*.css dist/*.js \
--verify-tag \
--title "$TAG" \
--notes-file notes.md \
@@ -165,6 +169,9 @@ jobs:
curl -fsS "https://purge.jsdelivr.net/gh/${GITHUB_REPOSITORY}@${alias}/dist/${file}.${ext}" >/dev/null
done
done
+ for ext in js min.js; do
+ curl -fsS "https://purge.jsdelivr.net/gh/${GITHUB_REPOSITORY}@${alias}/dist/synth.${ext}" >/dev/null
+ done
for file in synthcss.llm.md synthcss.ai.json; do
curl -fsS "https://purge.jsdelivr.net/gh/${GITHUB_REPOSITORY}@${alias}/${file}" >/dev/null
done
diff --git a/README.md b/README.md
index 7be664e..234b59b 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ Load the bundle from the jsDelivr CDN, pinned to a [release](https://github.com/
| `dist/base.css` | Base styles only (page font and colors, `h1`–`h4` sizes). Load `tokens.css` first. |
| `dist/layout.css` | Layout primitives only. Load `tokens.css` first. |
| `dist/components.css` | Components only. Load `tokens.css` first. |
+| `dist/synth.js` | Optional [SynthJS](docs/behaviors.md) behaviors script (`<script src="…/dist/synth.js" defer>`). Not needed for any styling. |
Use `.min.css` for the minified file (jsDelivr minifies on request) or `.css` for the readable one. `@0.8` follows the latest 0.8.x patch release; pin an exact version in production. To self-host, download the files from a [GitHub release](https://github.com/nabledhq/synthcss/releases) or run `npm run build` and copy `dist/`. SynthCSS follows [semantic versioning](https://semver.org); while it is 0.x, a minor release may contain breaking changes. See [docs/releasing.md](docs/releasing.md) for how releases are made.
@@ -32,7 +33,7 @@ All visual decisions (color, spacing, typography, radius, borders, shadows, sizi
:root { --color-primary: #YOUR_COLOR; --radius-md: 0.5rem; --space-3: 0.75rem; }
```
-The main bundle also applies the tokens to plain markup: `--font-sans`, `--color-text` and `--color-background` on the page and the `--text-*` scale on `h1`–`h4`, in a zero-specificity `synth.base` cascade layer that any rule of your own overrides ([`src/base.css`](src/base.css)). See [docs/tokens.md](docs/tokens.md) for the full token reference, override examples, guidance for AI agents and reduced-motion behavior. Run `npm test` to check that the tokens, the docs and the contrast requirements are in sync. It needs only Node.js 18 or later.
+The main bundle also applies the tokens to plain markup: `--font-sans`, `--color-text` and `--color-background` on the page and the `--text-*` scale on `h1`–`h4`, in a zero-specificity `synth.base` cascade layer that any rule of your own overrides ([`src/base.css`](src/base.css)). See [docs/tokens.md](docs/tokens.md) for the full token reference, override examples, guidance for AI agents and reduced-motion behavior. Run `npm install` once, then `npm test` to check that the tokens, the docs and the contrast requirements are in sync. It needs Node.js 20 or later; the only dependency is the dev dependency happy-dom, for the SynthJS tests.
## Layout primitives
@@ -61,11 +62,24 @@ Thirteen semantic components in [`src/components.css`](src/components.css), incl
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.
+## Behaviors (SynthJS)
+
+[`dist/synth.js`](docs/behaviors.md) is an optional, dependency-free script that makes the markup interactive. Declare the intent with one `data-synth-*` attribute and SynthJS handles the events and keeps `hidden` and the ARIA state in sync: `data-synth-open` (modal `<dialog>`, focus returns to the opener), `data-synth-dismiss` (close a dialog or hide a `[data-synth-dismissible]` alert), `data-synth-toggle` (show or hide a section, with `aria-expanded`), `data-synth-tabs` (tab panels with arrow, Home and End keys) and `data-synth-dropdown` (a list of links that closes on an outside click or Escape). CSS-only pages keep working without it.
+
+```html
+<script src="https://cdn.jsdelivr.net/gh/nabledhq/synthcss@0.8.0/dist/synth.js" defer></script>
+
+<button type="button" class="button" data-synth-toggle="filters">Filters</button>
+<div id="filters" class="panel" hidden>…</div>
+```
+
+It initializes itself on load; call `Synth.init(element)` after inserting markup (repeated calls are safe). See [docs/behaviors.md](docs/behaviors.md) for each behavior's markup, accessibility and common misuse, plus a compact "AI Behavior Reference" table.
+
## AI contract
-[`synthcss.llm.md`](synthcss.llm.md) is the whole public vocabulary in one prompt-ready file of about 5,700 tokens: every token, layout primitive, component, part and variant, an intent table, composition rules, ten generation rules, the one legal fallback when the vocabulary lacks a pattern (`data-ui="<name>"` styled inside `@layer synth.ext` with tokens only) with a "Not covered yet" list, and valid and invalid examples, including a full app-shell page. Paste it into a model's context. [`synthcss.ai.json`](synthcss.ai.json) is the same contract as structured data and the canonical source. Both state the SynthCSS version they describe; the schema is in [docs/ai-contract.md](docs/ai-contract.md).
+[`synthcss.llm.md`](synthcss.llm.md) is the whole public vocabulary in one prompt-ready file of about 7,000 tokens: every token, layout primitive, component, part and variant, an intent table, the SynthJS behaviors (`data-synth-*`), composition rules, ten generation rules, the one legal fallback when the vocabulary lacks a pattern (`data-ui="<name>"` styled inside `@layer synth.ext` with tokens only) with a "Not covered yet" list, and valid and invalid examples, including a full app-shell page. Paste it into a model's context. [`synthcss.ai.json`](synthcss.ai.json) is the same contract as structured data and the canonical source. Both state the SynthCSS version they describe; the schema is in [docs/ai-contract.md](docs/ai-contract.md).
-**Any change to the public API (a class or token added, renamed or removed, or a new version) must update both contract files in the same pull request.** `npm test` runs `scripts/verify-ai-contract.mjs`, which fails when the contract and the CSS or `package.json` disagree.
+**Any change to the public API (a class, token or `data-synth-*` behavior added, renamed or removed, or a new version) must update both contract files in the same pull request.** The Behaviors (SynthJS) section of `synthcss.llm.md` is generated from the JSON with `npm run contract:write`. `npm test` runs `scripts/verify-ai-contract.mjs`, which fails when the contract and the CSS or `package.json` disagree.
## Showcase
diff --git a/docs/ai-contract.md b/docs/ai-contract.md
index 1744a93..12ab98f 100644
--- a/docs/ai-contract.md
+++ b/docs/ai-contract.md
@@ -7,7 +7,7 @@ window, with no need to crawl these docs or read `src/`.
| File | For | Notes |
| --- | --- | --- |
| [`synthcss.ai.json`](../synthcss.ai.json) | Tools and agents that read structured data | The canonical, machine-readable contract. |
-| [`synthcss.llm.md`](../synthcss.llm.md) | Pasting into a prompt | A terse hand-written twin of the JSON, one line per item, about 5,700 tokens. |
+| [`synthcss.llm.md`](../synthcss.llm.md) | Pasting into a prompt | A terse twin of the JSON, one line per item, about 7,000 tokens. Hand-written, except the Behaviors (SynthJS) section, which is generated from the JSON. |
Both are published next to the showcase on GitHub Pages
(`<site>/synthcss.llm.md`, `<site>/synthcss.ai.json`) and are in every release tag.
@@ -23,7 +23,7 @@ Class names are written **without** the leading dot. Token names keep their `--`
| `tokens` | object | Token name → short purpose, for every custom property on `:root` in `src/tokens.css`. |
| `baseStyles` | object | `{ note, rules }`: what the base styles in `src/base.css` apply. `note` is a one-sentence summary that the Markdown Design Tokens section repeats; `rules` maps each selector (without `:where()`) to its declarations, exactly as in `src/base.css`. |
| `layouts` | object | Layout class → intent: the eight primitives, their `-sm` / `-lg` gap variants, `cover-main` and `sidebar-end`. |
-| `components` | object | Component base class → `{ intent, parts, variants }`. `parts` and `variants` map class → purpose (empty `{}` when there are none). |
+| `components` | object | Component base class → `{ intent, parts, variants, behaviors? }`. `parts` and `variants` map class → purpose (empty `{}` when there are none). `behaviors` (optional) lists the [SynthJS](behaviors.md) behaviors that belong to the component, each `{ intent, attribute, target, requiredMarkup, accessibility }`: `attribute` is the one `data-synth-*` attribute for the intent, `target` says what it points at, `requiredMarkup` is minimal valid HTML and `accessibility` a list of what SynthJS guarantees or the author must add. |
| `intentMap` | array | `{ intent, use }` pairs: a plain-language need and the markup to use for it. |
| `compositionRules` | object | `{ recommended: [], avoid: [] }`: how to combine primitives and components. |
| `generationRules` | array | Exactly 10 rules an agent must follow when generating SynthCSS markup. |
@@ -38,7 +38,15 @@ Class names are written **without** the leading dot. Token names keep their `--`
"baseStyles": { "note": "Base styles apply --font-sans, … Override the tokens to restyle.", "rules": { "h1": { "font-size": "var(--text-3xl)" } } },
"layouts": { "stack": "vertical flow with tokenized spacing" },
"components": {
- "badge": { "intent": "short status label", "parts": {}, "variants": { "badge-success": "positive status" } }
+ "badge": { "intent": "short status label", "parts": {}, "variants": { "badge-success": "positive status" } },
+ "button": {
+ "intent": "…", "parts": {}, "variants": { "…": "…" },
+ "behaviors": [{
+ "intent": "show or hide a section", "attribute": "data-synth-toggle", "target": "the id of any element; …",
+ "requiredMarkup": "<button type=\"button\" class=\"button\" data-synth-toggle=\"filters\">Filters</button>\n<div id=\"filters\" hidden>…</div>",
+ "accessibility": ["aria-expanded on the button follows the target's hidden state", "…"]
+ }]
+ }
},
"intentMap": [{ "intent": "Vertical list of blocks", "use": ".stack" }],
"compositionRules": { "recommended": ["…"], "avoid": ["…"] },
@@ -67,7 +75,8 @@ class if its note names that class as the alternative (for example
## `synthcss.llm.md` layout
A version header (`SynthCSS <version> · contract <version>`), then these `##` sections:
-Design Tokens, Layout Vocabulary, Component Vocabulary, Intent Mapping, Composition
+Design Tokens, Layout Vocabulary, Component Vocabulary, Intent Mapping, Behaviors
+(SynthJS) (generated, see below), Composition
Rules (with `### Recommended` and `### Avoid`), AI Generation Rules (numbered 1–10),
When the vocabulary is missing a pattern (the extension rule as numbered steps, then an
`Allowed properties:` and an `Allowed keywords:` line listing the JSON arrays in order),
@@ -77,6 +86,12 @@ code block for a fallback), Valid Examples (one `html` code block each) and Inva
Discouraged Examples (one line each: `` - `<html>` — note ``). It is written by hand;
keep it in step with the JSON.
+The Behaviors (SynthJS) section is the exception: `npm run contract:write`
+(`node scripts/verify-ai-contract.mjs --write`) renders it from the components'
+`behaviors` entries, in component order, and the verifier fails when it differs from
+that rendering. Edit the JSON, then run the command; there is no separate registry of
+behaviors.
+
## The extension rule
When nothing in the vocabulary fits, there is one legal fallback: compose from the
@@ -88,8 +103,10 @@ layout and component rules are unlayered, so they win over anything in `synth.ex
## Changing the contract
-Any change to the public API (a class or token added, renamed or removed) must update
-**both** files in the same pull request. Version bumps are automatic: the release
+Any change to the public API (a class or token added, renamed or removed, or a
+`data-synth-*` behavior) must update **both** files in the same pull request. Bump
+`contractVersion`'s minor for additions to the format (1.3.0 added `behaviors`) and its
+major for breaking changes. Version bumps are automatic: the release
workflow updates `synthcssVersion` and the Markdown header with
[`scripts/bump-version.mjs`](../scripts/bump-version.mjs) (see [releasing.md](releasing.md)).
@@ -118,6 +135,11 @@ workflow updates `synthcssVersion` and the Markdown header with
`notCovered` snippet uses a class outside the contract or an inline style other than a
token override; or the Markdown extension section and Not covered yet list differ
from the JSON;
+- a `behaviors` entry lacks one of its five keys or has another, two entries share an
+ attribute, an attribute is not `data-synth-<name>`, its `requiredMarkup` does not use
+ the attribute and the component's class or breaks the markup rules above,
+ `src/js/synth.js` does not implement the attribute, or the Markdown Behaviors
+ (SynthJS) section is not the rendering of the JSON;
- the showcase AI Contract section, the Pages workflow or the README no longer publish
and describe the contract.
diff --git a/docs/behaviors.md b/docs/behaviors.md
new file mode 100644
index 0000000..b6d64f4
--- /dev/null
+++ b/docs/behaviors.md
@@ -0,0 +1,314 @@
+# Behaviors (SynthJS)
+
+SynthJS is an optional, dependency-free script that makes SynthCSS markup interactive.
+The markup declares what should happen with one `data-synth-*` attribute per intent;
+SynthJS adds the event handling and keeps `hidden` and the ARIA state in sync. A page
+generated by a model needs no hand-written event code for these five intents:
+
+| Attribute | Intent |
+| --- | --- |
+| [`data-synth-open`](#data-synth-open) | Open a modal `<dialog>` |
+| [`data-synth-dismiss`](#data-synth-dismiss) | Close the dialog it is in, or hide a dismissible message |
+| [`data-synth-toggle`](#data-synth-toggle) | Show or hide a section (collapse, disclosure, "show more") |
+| [`data-synth-tabs`](#data-synth-tabs) | Switch between tab panels, with keyboard support |
+| [`data-synth-dropdown`](#data-synth-dropdown) | Open a list of links from a button |
+
+There is one attribute per intent and no aliases: there is no `data-synth-collapse`
+(collapsing is `data-synth-toggle`), and no `data-synth-close` (closing is
+`data-synth-dismiss`).
+
+SynthCSS itself needs no JavaScript. A page that loads only the stylesheet renders the
+same; these behaviors are simply inactive.
+
+## Loading
+
+Load the script after the stylesheet, from the same [release](releasing.md) (replace
+`X.Y.Z` with the version). `defer` keeps it from blocking rendering:
+
+```html
+<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/nabledhq/synthcss@X.Y.Z/dist/synthcss.min.css">
+<script src="https://cdn.jsdelivr.net/gh/nabledhq/synthcss@X.Y.Z/dist/synth.js" defer></script>
+```
+
+`dist/synth.js` is built from [`src/js/synth.js`](../src/js/synth.js) by `npm run build`,
+which copies it and adds the version banner. There is no bundler and no minifier;
+jsDelivr serves `dist/synth.min.js` on request. It is a plain script (an IIFE), not a
+module: it has no `import` or `require` and defines one global, `window.Synth`.
+
+### Lifecycle
+
+- SynthJS initializes itself on `DOMContentLoaded`, or straight away if the document has
+ already been parsed.
+- After inserting markup later (a template, a fetched fragment), call
+ `Synth.init(element)` to set its initial ARIA state. `Synth.init()` with no argument
+ covers the whole document.
+- Calling `Synth.init()` again is safe. Clicks and keys are handled by one delegated
+ listener per document, and each element is initialized once, so nothing is bound
+ twice and every click acts once. Loading the script twice keeps the first copy.
+- A missing or invalid target (an id that does not exist, `data-synth-open` naming an
+ element that is not a `<dialog>`, a dismiss button with nothing to dismiss) logs one
+ `console.warn` per element, prefixed `SynthJS:`, and never throws.
+
+### `hidden` always hides
+
+Toggle targets, dismissible alerts, tab panels and dropdown menus are shown and hidden
+with the `hidden` attribute. Several SynthCSS classes set `display` (`.alert`, `.panel`,
+`.stack`, `.nav` with a layout class…), which would otherwise win over the browser's own
+`[hidden]` rule. So SynthJS adds one rule to the page, in a `<style data-synth>`
+element:
+
+```css
+[hidden]:not([hidden="until-found"]) { display: none !important; }
+```
+
+The stylesheet is unchanged. Without SynthJS, nothing is hidden or shown dynamically, so
+the rule is not needed.
+
+## AI Behavior Reference
+
+Paste this table into a model's context together with the
+[AI Component Reference](components.md#ai-component-reference). The contract files carry
+the same data: the `behaviors` entries of `synthcss.ai.json` and the generated
+"Behaviors (SynthJS)" section of `synthcss.llm.md`.
+
+| Intent | Markup | SynthJS keeps in sync |
+| --- | --- | --- |
+| Modal dialog | `<button class="button" data-synth-open="ID">` + `<dialog id="ID" aria-labelledby="…">` | `showModal()`; focus back to the button on close |
+| Close a dialog | `<button class="button" data-synth-dismiss>` inside the `<dialog>` | `close()` |
+| Dismissible message | `<div class="alert" role="status" data-synth-dismissible>` > `<button class="button button-sm" data-synth-dismiss>` | `hidden` on the alert |
+| Show or hide a section | `<button class="button" data-synth-toggle="ID">` + `<div id="ID" hidden>` | `hidden` on the target; `aria-expanded`, `aria-controls` on the button |
+| Tabs that switch panels | `<div data-synth-tabs>` > `.tabs` with `role="tab"` + `aria-controls`, and `role="tabpanel"` panels | `aria-selected`, roving `tabindex`, `hidden` on panels; arrows, Home, End |
+| Dropdown of links | `<div data-synth-dropdown>` > `<button data-synth-dropdown-trigger>` + `<ul class="nav stack-sm" data-synth-dropdown-menu hidden>` | `hidden` on the menu, `aria-expanded` on the trigger; outside click and Escape close |
+
+Rules: put every trigger on a `<button type="button">`; give every target the `id` its
+trigger names; write no `onclick` or event listeners for these intents; never use
+`data-synth-collapse`.
+
+## `data-synth-open`
+
+### Intent
+
+Open a modal dialog: a confirmation, a short form, details that need focus.
+
+### Minimal HTML
+
+```html
+<button type="button" class="button button-danger" data-synth-open="confirm-delete">Delete project</button>
+<dialog id="confirm-delete" aria-labelledby="confirm-delete-title">
+ <div class="stack">
+ <h2 id="confirm-delete-title">Delete project?</h2>
+ <div class="cluster-sm">
+ <button type="button" class="button button-danger">Delete</button>
+ <button type="button" class="button" data-synth-dismiss>Cancel</button>
+ </div>
+ </div>
+</dialog>
+```
+
+### Attributes
+
+| Attribute | On | Value |
+| --- | --- | --- |
+| `data-synth-open` | the `<button>` that opens the dialog | the `id` of a `<dialog>` |
+| `aria-labelledby` | the `<dialog>` | the `id` of its heading (yours to add) |
+
+### Accessibility
+
+- SynthJS calls the native `showModal()`: the rest of the page becomes inert, focus
+ moves into the dialog and the browser draws a `::backdrop`.
+- <kbd>Escape</kbd> closes the dialog natively. However it closes (Escape, a
+ `data-synth-dismiss` button, `dialog.close()`, a `<form method="dialog">`), focus
+ returns to the button that opened it.
+- The rest of the page is inert while the dialog is open, so <kbd>Tab</kbd> cannot reach
+ it. SynthJS adds no focus trap of its own.
+
+### Common misuse
+
+- Pointing `data-synth-open` at a `<div>`: only `<dialog>` works (SynthJS warns).
+- Putting SynthCSS layout or component classes on the `<dialog>` itself. `.card` or
+ `.stack` set `display` and would show a closed dialog; put them on a child.
+- Opening from a link (`<a href>`); use a `<button type="button">`.
+
+## `data-synth-dismiss`
+
+### Intent
+
+Close the dialog the button is in, or dismiss a message such as an `.alert`.
+
+### Minimal HTML
+
+```html
+<div class="alert alert-success" role="status" data-synth-dismissible>
+ <p>Settings saved.</p>
+ <button type="button" class="button button-sm" data-synth-dismiss>Dismiss</button>
+</div>
+```
+
+### Attributes
+
+| Attribute | On | Value |
+| --- | --- | --- |
+| `data-synth-dismiss` | a `<button>` | none |
+| `data-synth-dismissible` | the element to hide (an `.alert`, a `.panel`…) | none |
+
+The button closes its closest `<dialog>` if it is in one. Otherwise it sets `hidden` on
+its closest `[data-synth-dismissible]` ancestor.
+
+### Accessibility
+
+- A dismissed element gets the `hidden` attribute: it stays in the DOM, out of view and
+ out of the accessibility tree. Remove `hidden` to show it again.
+- Give the button a visible label ("Dismiss", "Cancel"). An icon-only
+ `.button button-icon` needs `aria-label="Dismiss"`.
+- In a dialog, focus returns to the opener as described under `data-synth-open`.
+
+### Common misuse
+
+- A dismiss button outside any `<dialog>` and without a `data-synth-dismissible`
+ ancestor (SynthJS warns).
+- Dismissing a `role="alert"` message the user must act on; keep errors visible until
+ they are fixed.
+- Expecting the element to be removed: it is hidden, not deleted.
+
+## `data-synth-toggle`
+
+### Intent
+
+Show or hide one section: a collapsible panel, filters, "show more", a disclosure.
+
+### Minimal HTML
+
+```html
+<button type="button" class="button" data-synth-toggle="filters">Filters</button>
+<div id="filters" class="panel" hidden>…</div>
+```
+
+### Attributes
+
+| Attribute | On | Value |
+| --- | --- | --- |
+| `data-synth-toggle` | the `<button>` | the `id` of the element to show and hide |
+| `hidden` | the target | present when it starts hidden |
+| `aria-expanded` | the button | set by SynthJS |
+| `aria-controls` | the button | set by SynthJS to the target id when missing; an existing value is kept |
+
+### Accessibility
+
+- `aria-expanded` is derived from the target's `hidden` state when SynthJS initializes,
+ then updated on every click. Every button that names the same target is kept in sync.
+- The target stays in the DOM; while it is hidden it is out of the accessibility tree.
+
+### Common misuse
+
+- `data-synth-collapse`: there is no such attribute; collapsing is a toggle.
+- Hiding with a class or `style="display: none"` instead of `hidden`: SynthJS reads and
+ writes `hidden` only.
+- Toggling from a link or a `<div>`; use a `<button type="button">`.
+- Using a toggle for a single on/off setting that is saved; use a `.switch`.
+
+## `data-synth-tabs`
+
+### Intent
+
+Switch between panels of content with the existing `.tabs` / `.tabs-item` markup.
+
+### Minimal HTML
+
+```html
+<div class="stack" data-synth-tabs>
+ <div class="tabs" role="tablist" aria-label="Range">
+ <button type="button" class="tabs-item" role="tab" id="tab-week" aria-controls="panel-week" aria-selected="true">Week</button>
+ <button type="button" class="tabs-item" role="tab" id="tab-month" aria-controls="panel-month" aria-selected="false">Month</button>
+ </div>
+ <div role="tabpanel" id="panel-week" aria-labelledby="tab-week">…</div>
+ <div role="tabpanel" id="panel-month" aria-labelledby="tab-month" hidden>…</div>
+</div>
+```
+
+### Attributes
+
+| Attribute | On | Value |
+| --- | --- | --- |
+| `data-synth-tabs` | a container of the tablist and its panels (or the `.tabs` element itself) | none |
+| `role="tab"`, `aria-controls` | each `.tabs-item` | the `id` of its panel |
+| `aria-selected` | each `.tabs-item` | `"true"` on the selected tab in the markup; SynthJS maintains it |
+| `role="tabpanel"`, `aria-labelledby` | each panel | the `id` of its tab |
+
+### Accessibility
+
+- On initialization the tab marked `aria-selected="true"` (or the first tab) is
+ selected: it gets `tabindex="0"`, the others `tabindex="-1"` (roving tabindex, so
+ <kbd>Tab</kbd> enters and leaves the tab list in one step), and every other panel gets
+ `hidden`.
+- A click selects a tab. <kbd>ArrowRight</kbd> and <kbd>ArrowLeft</kbd> move to the next
+ and previous tab, wrapping at the ends; <kbd>Home</kbd> and <kbd>End</kbd> move to the
+ first and last. Focus and selection move together; disabled tabs are skipped.
+- The selected tab's look comes from the existing `aria-selected="true"` style of
+ `.tabs-item`.
+
+### Common misuse
+
+- Tabs that navigate to other pages; use `.nav` with `aria-current="page"`.
+- `aria-pressed` or a selected class instead of `aria-selected`.
+- Leaving out `aria-controls`: the tab still selects, but no panel switches (SynthJS
+ warns once per tab).
+- Hiding panels with a class; SynthJS sets `hidden`.
+
+## `data-synth-dropdown`
+
+### Intent
+
+A button that reveals a short list of links or actions: an account menu, overflow
+actions. It is a disclosure: the list is in the page flow below the button, with no
+positioning engine.
+
+### Minimal HTML
+
+```html
+<div data-synth-dropdown>
+ <button type="button" class="button" data-synth-dropdown-trigger>Account</button>
+ <ul class="nav stack-sm" role="list" data-synth-dropdown-menu hidden>
+ <li><a class="nav-link" href="/profile">Profile</a></li>
+ <li><a class="nav-link" href="/logout">Sign out</a></li>
+ </ul>
+</div>
+```
+
+### Attributes
+
+| Attribute | On | Value |
+| --- | --- | --- |
+| `data-synth-dropdown` | a wrapper around the trigger and the menu | none |
+| `data-synth-dropdown-trigger` | the trigger `<button>` | none; without it, the wrapper's first `<button>` is the trigger |
+| `data-synth-dropdown-menu` | the menu element, `hidden` to start closed | none; without it, the wrapper's `[role="menu"]` is the menu |
+| `aria-expanded` | the trigger | set by SynthJS |
+| `aria-controls` | the trigger | set by SynthJS when the menu has an `id` |
+
+### Accessibility
+
+- Clicking the trigger toggles the menu's `hidden` and the trigger's `aria-expanded`.
+- A click anywhere outside the wrapper closes the menu without moving focus.
+- <kbd>Escape</kbd> closes an open menu and returns focus to its trigger. It does not
+ also close a dialog the menu sits in; with no menu open in the dialog, Escape closes
+ the dialog as usual.
+- Reuse `.nav` + `.nav-link` for the list: it is a list of links reached with
+ <kbd>Tab</kbd>.
+
+### Common misuse
+
+- `role="menu"` on a list of links: it promises arrow-key navigation between
+ `menuitem`s, which SynthJS does not add. Use a plain list with
+ `data-synth-dropdown-menu`.
+- Expecting an absolutely positioned popover; the menu takes space in the flow.
+- Several triggers in one wrapper: mark the right one with `data-synth-dropdown-trigger`.
+
+## Tests
+
+`npm test` runs [`scripts/synth-js.test.mjs`](../scripts/synth-js.test.mjs), which loads
+the built `dist/synth.js` into a real DOM ([happy-dom](https://github.com/capricorn86/happy-dom),
+the repository's only dependency, a dev dependency) and drives it with real clicks and
+key events: initialization, repeated `Synth.init()`, dialogs and focus return, toggles,
+tab clicks and keys, dropdown outside clicks and Escape, dismiss, and warnings for
+missing targets. Run `npm install` once before `npm test`. The AI contract check
+([docs/ai-contract.md](ai-contract.md)) also fails when a `behaviors` attribute is not
+implemented in `src/js/synth.js`.
diff --git a/docs/components.md b/docs/components.md
index c77989c..dcd1289 100644
--- a/docs/components.md
+++ b/docs/components.md
@@ -43,7 +43,8 @@ How the components behave:
`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.**
+- **No JavaScript.** Interactivity (dialogs, toggles, tab panels, dropdowns, dismissible
+ alerts) is optional: load [SynthJS](behaviors.md) and add `data-synth-*` attributes.
## AI Component Reference
@@ -937,7 +938,9 @@ Put a filter next to a heading with `.split`, or above content in a `.stack`. In
- When the tabs switch panels, link each tab to its panel with `aria-controls` and give
the panel `role="tabpanel"` and `aria-labelledby`. Panels are not styled by SynthCSS.
- Keyboard: the recommended pattern is a roving `tabindex` (only the selected tab is
- in the tab order) with arrow keys moving between tabs. That script is the author's.
+ in the tab order) with arrow keys moving between tabs. The CSS does not do this; wrap
+ the tabs and panels in `data-synth-tabs` and [SynthJS](behaviors.md#data-synth-tabs)
+ does it, or write your own script.
- Items show a `:focus-visible` ring from the `--focus-*` tokens.
### Recommended use
diff --git a/docs/releasing.md b/docs/releasing.md
index bbfbf3f..553e838 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -10,7 +10,9 @@ npm account.
- **`dist/`** holds the distributable stylesheets. `npm run build` writes them from
`src/`: `dist/synthcss.css` is the bundle with every `@import` inlined, and
`dist/tokens.css`, `dist/base.css`, `dist/layout.css` and `dist/components.css` are the parts on
- their own. A new file in `src/` must be added to `OUTPUTS` in `scripts/build.mjs`
+ their own. `dist/synth.js` is the optional [SynthJS](behaviors.md) script, copied
+ from `src/js/synth.js` (no bundler; jsDelivr serves `synth.min.js` on request).
+ A new file in `src/` must be added to `OUTPUTS` (or `SCRIPTS`) in `scripts/build.mjs`
to be published on its own; anything imported by `src/synthcss.css` is always in
the bundle. Each file starts
with a `/*! SynthCSS vX.Y.Z … */` banner. `dist/` is ignored on `main`, so changes to
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..3286e40
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,130 @@
+{
+ "name": "synthcss",
+ "version": "0.8.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "synthcss",
+ "version": "0.8.0",
+ "license": "MIT",
+ "devDependencies": {
+ "happy-dom": "^20.14.5"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
+ "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
+ "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/buffer-image-size": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
+ "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/happy-dom": {
+ "version": "20.14.5",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz",
+ "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "buffer-image-size": "^0.6.4",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 38a8712..33d7c68 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=18"
+ "node": ">=20"
},
"scripts": {
"build": "node scripts/build.mjs",
@@ -20,6 +20,11 @@
"check:showcase": "node scripts/check-showcase.mjs",
"check:showcase:browser": "node scripts/check-showcase-browser.mjs",
"check:ai-contract": "node scripts/verify-ai-contract.mjs",
- "test": "node scripts/check-tokens.mjs && node scripts/check-base.mjs && node scripts/check-layout.mjs && node scripts/check-components.mjs && node scripts/check-showcase.mjs && node scripts/verify-ai-contract.mjs && node --test scripts/build.test.mjs scripts/bump-version.test.mjs scripts/check-tokens.test.mjs scripts/check-base.test.mjs scripts/check-layout.test.mjs scripts/check-components.test.mjs scripts/check-showcase.test.mjs scripts/verify-ai-contract.test.mjs"
+ "contract:write": "node scripts/verify-ai-contract.mjs --write",
+ "test:js": "node --test scripts/synth-js.test.mjs",
+ "test": "node scripts/check-tokens.mjs && node scripts/check-base.mjs && node scripts/check-layout.mjs && node scripts/check-components.mjs && node scripts/check-showcase.mjs && node scripts/verify-ai-contract.mjs && node --test scripts/build.test.mjs scripts/bump-version.test.mjs scripts/check-tokens.test.mjs scripts/check-base.test.mjs scripts/check-layout.test.mjs scripts/check-components.test.mjs scripts/check-showcase.test.mjs scripts/verify-ai-contract.test.mjs scripts/synth-js.test.mjs"
+ },
+ "devDependencies": {
+ "happy-dom": "^20.14.5"
}
}
diff --git a/scripts/build.mjs b/scripts/build.mjs
index 8c075c7..509ce9d 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -1,5 +1,6 @@
#!/usr/bin/env node
-// Dependency-free build of the distributable stylesheets in dist/.
+// Dependency-free build of the distributable stylesheets and the optional SynthJS
+// script in dist/.
// dist/ is not committed on main: the release workflow builds it and tags a
// release commit that contains it, which is what jsDelivr serves.
// Usage: node scripts/build.mjs
@@ -11,6 +12,9 @@ import { dirname, resolve } from "node:path";
export const REPO_URL = "https://github.com/nabledhq/synthcss";
// Standalone files. synthcss.css is the bundle with every @import inlined.
export const OUTPUTS = ["synthcss.css", "tokens.css", "base.css", "layout.css", "components.css"];
+// Scripts: dist file → source under src/. Copied as they are, with the banner; no
+// bundler and no minifier (jsDelivr serves .min.js on request, like .min.css).
+export const SCRIPTS = { "synth.js": "js/synth.js" };
const IMPORT = /^@import\s+url\(\s*["']?([^"')]+)["']?\s*\)\s*;[ \t]*$/gm;
const toLf = (text) => text.replace(/\r\n/g, "\n");
@@ -28,9 +32,10 @@ export function inlineImports(css, readFile, seen = new Set()) {
}
export function build(version, readSrc) {
- return Object.fromEntries(
- OUTPUTS.map((file) => [file, banner(version, file) + inlineImports(readSrc(file), readSrc).trimEnd() + "\n"]),
- );
+ return Object.fromEntries([
+ ...OUTPUTS.map((file) => [file, banner(version, file) + inlineImports(readSrc(file), readSrc).trimEnd() + "\n"]),
+ ...Object.entries(SCRIPTS).map(([file, src]) => [file, banner(version, file) + toLf(readSrc(src)).trimEnd() + "\n"]),
+ ]);
}
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
@@ -40,6 +45,6 @@ if (isMain) {
const { version } = JSON.parse(readFileSync(resolve(repo, "package.json"), "utf8"));
const outputs = build(version, (file) => readFileSync(resolve(repo, "src", file), "utf8"));
mkdirSync(resolve(repo, "dist"), { recursive: true });
- for (const file of OUTPUTS) writeFileSync(resolve(repo, "dist", file), outputs[file]);
- console.log(`build: wrote ${OUTPUTS.map((f) => `dist/${f}`).join(", ")} for v${version}.`);
+ for (const [file, text] of Object.entries(outputs)) writeFileSync(resolve(repo, "dist", file), text);
+ console.log(`build: wrote ${Object.keys(outputs).map((f) => `dist/${f}`).join(", ")} for v${version}.`);
}
diff --git a/scripts/build.test.mjs b/scripts/build.test.mjs
index a26a753..9940440 100644
--- a/scripts/build.test.mjs
+++ b/scripts/build.test.mjs
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
-import { build, inlineImports, OUTPUTS } from "./build.mjs";
+import { build, inlineImports, OUTPUTS, SCRIPTS } from "./build.mjs";
const fakeSrc = {
"synthcss.css": '/* bundle */\r\n@import url("tokens.css");\r\n@import url(\'layout.css\');\r\n',
@@ -9,6 +9,7 @@ const fakeSrc = {
"base.css": "@layer synth.base { :where(html) { color: red; } }\n",
"layout.css": ".stack { gap: var(--space-1); }\n",
"components.css": ".card { padding: var(--space-1); }\n",
+ "js/synth.js": '(function () {\r\n window.Synth = {};\r\n})();\r\n',
};
const read = (file) => fakeSrc[file];
@@ -19,8 +20,8 @@ test("inlines every @import of the bundle in order", () => {
test("stamps every output with the version and file name", () => {
const outputs = build("1.2.3", read);
- assert.deepEqual(Object.keys(outputs), OUTPUTS);
- for (const file of OUTPUTS) assert.ok(outputs[file].startsWith(`/*! SynthCSS v1.2.3 | ${file} |`), outputs[file]);
+ assert.deepEqual(Object.keys(outputs), [...OUTPUTS, "synth.js"]);
+ for (const file of Object.keys(outputs)) assert.ok(outputs[file].startsWith(`/*! SynthCSS v1.2.3 | ${file} |`), outputs[file]);
assert.ok(!outputs["synthcss.css"].includes("@import"));
});
@@ -43,3 +44,25 @@ test("the built bundle contains the synth.base layer; the modular files do not",
assert.ok(!outputs[file].includes(":where(html)"), `${file} styles html`);
}
});
+
+test("copies src/js/synth.js to synth.js as it is, after the banner", () => {
+ assert.deepEqual(SCRIPTS, { "synth.js": "js/synth.js" });
+ const outputs = build("1.2.3", read);
+ assert.equal(outputs["synth.js"], "/*! SynthCSS v1.2.3 | synth.js | MIT License | https://github.com/nabledhq/synthcss */\n(function () {\n window.Synth = {};\n})();\n");
+});
+
+test("the built synth.js is a plain IIFE defining window.Synth, with no import or require", () => {
+ const src = new URL("../src/", import.meta.url);
+ const js = build("0.0.0", (file) => readFileSync(new URL(file, src), "utf8"))["synth.js"];
+ const code = js.replace(/\/\*[\s\S]*?\*\//g, "").trim();
+ assert.ok(code.startsWith("(function () {"), "synth.js must start with an IIFE");
+ assert.ok(code.endsWith("})();"), "synth.js must end by calling the IIFE");
+ assert.ok(code.includes("window.Synth ="), "synth.js must define window.Synth");
+ assert.doesNotMatch(code, /^\s*(import|export)\b|\bimport\s*\(|\brequire\s*\(/m);
+});
+
+test("package.json has no runtime dependencies", () => {
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
+ assert.equal(pkg.dependencies, undefined);
+ assert.deepEqual(Object.keys(pkg.devDependencies ?? {}), ["happy-dom"]);
+});
diff --git a/scripts/check-showcase.mjs b/scripts/check-showcase.mjs
index 4fd649b..0898f16 100644
--- a/scripts/check-showcase.mjs
+++ b/scripts/check-showcase.mjs
@@ -10,7 +10,10 @@ import { parseBlocks, parseDeclarations, parseTokens } from "./check-tokens.mjs"
import { PRIMITIVES, VARIANTS, HELPER_CLASSES } from "./check-layout.mjs";
import { COMPONENTS, COMPONENT_CLASSES } from "./check-components.mjs";
-export const SECTIONS = ["hero", "why", "tokens", "layouts", "responsive", "components", "composed", "ai-examples", "ai-contract"];
+export const SECTIONS = ["hero", "why", "tokens", "layouts", "responsive", "components", "behaviors", "composed", "ai-examples", "ai-contract"];
+// SynthJS behaviors with a live demo: data-synth-<name>.
+export const BEHAVIORS = ["open", "toggle", "tabs", "dropdown", "dismiss"];
+export const SYNTH_JS = "../src/js/synth.js";
// The composed interface must use at least this many different components.
export const MIN_COMPOSED_COMPONENTS = 6;
export const RESPONSIVE = ["grid", "sidebar", "cluster", "split"];
@@ -68,6 +71,9 @@ export function checkHtml(html, tokens, showcaseClasses) {
if (!["../src/synthcss.css", "showcase.css"].includes(m[1])) errors.push(`index.html loads an extra stylesheet: ${m[1]}`);
}
if (/<style\b/i.test(html)) errors.push("index.html must not contain <style> blocks; put showcase styles in showcase.css");
+ if (!new RegExp(`<script src="${SYNTH_JS.replace(/\./g, "\\.")}" defer></script>`).test(html)) {
+ errors.push(`index.html must load SynthJS with <script src="${SYNTH_JS}" defer></script>`);
+ }
if (/<script\b[^>]*\bsrc="(?:https?:)?\/\//i.test(html)) errors.push("index.html must not load third-party scripts");
if (THIRD_PARTY.test(html)) errors.push("index.html references a third-party CSS framework");
@@ -152,6 +158,23 @@ export function checkHtml(html, tokens, showcaseClasses) {
}
}
+ // One article per SynthJS behavior, each with a live demo and a snippet using its attribute.
+ const behaviors = sectionHtml(html, "behaviors") ?? "";
+ const behaviorArticles = behaviors.split(/<article\b/).slice(1);
+ for (const name of BEHAVIORS) {
+ const attribute = `data-synth-${name}`;
+ const article = behaviorArticles.find((a) => a.includes(`id="behavior-${name}"`));
+ if (!article) {
+ errors.push(`behaviors: missing example for ${attribute}`);
+ continue;
+ }
+ const demo = article.split('class="sc-code"')[0].split('class="sc-demo"')[1] ?? "";
+ if (!new RegExp(`\\s${attribute}[\\s=>]`).test(demo)) errors.push(`behaviors: ${attribute} example has no live demo using it`);
+ if (!snippetsIn(article).some((s) => new RegExp(`\\s${attribute}[\\s=>]`).test(s))) {
+ errors.push(`behaviors: ${attribute} example needs an HTML snippet that uses it`);
+ }
+ }
+
// A composed interface built only from framework classes.
const composed = sectionHtml(html, "composed") ?? "";
const app = composed.split("data-composed")[1];
diff --git a/scripts/check-showcase.test.mjs b/scripts/check-showcase.test.mjs
index 89ef1fc..a62d798 100644
--- a/scripts/check-showcase.test.mjs
+++ b/scripts/check-showcase.test.mjs
@@ -82,3 +82,12 @@ test("fails when the composed interface is missing or uses non-SynthCSS classes"
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");
});
+
+test("fails when SynthJS or a behavior demo is missing", () => {
+ const noScript = files.html.replace('<script src="../src/js/synth.js" defer></script>', "");
+ assertError(errorsWith({ html: noScript }), "must load SynthJS");
+ assertError(errorsWith({ html: files.html.replace('id="behaviors"', 'id="other"') }), 'missing <section id="behaviors">');
+ assertError(errorsWith({ html: files.html.replace('id="behavior-dropdown"', 'id="behavior-x"') }), "missing example for data-synth-dropdown");
+ const noDemo = files.html.replace('data-synth-toggle="demo-filters"', "");
+ assertError(errorsWith({ html: noDemo }), "data-synth-toggle example has no live demo");
+});
diff --git a/scripts/synth-js.test.mjs b/scripts/synth-js.test.mjs
new file mode 100644
index 0000000..f7b1707
--- /dev/null
+++ b/scripts/synth-js.test.mjs
@@ -0,0 +1,369 @@
+// SynthJS (src/js/synth.js) in a real DOM: happy-dom, real elements, real events.
+// The script under test is the built dist/synth.js, evaluated in a fresh context
+// that only has window, document and console, as a classic <script> would.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import vm from "node:vm";
+import { Window } from "happy-dom";
+import { build } from "./build.mjs";
+
+const src = new URL("../src/", import.meta.url);
+const SCRIPT = build("0.0.0", (file) => readFileSync(new URL(file, src), "utf8"))["synth.js"];
+
+// Loads html into a new page, then runs synth.js. Returns the page and the
+// console.warn calls the script made. An exception in a SynthJS listener (which
+// happy-dom reports as a window error event, as browsers do) fails the test.
+// Assertions compare element ids: a failing assert on a happy-dom element would
+// try to print its whole object graph.
+function page(t, html, { readyState } = {}) {
+ const window = new Window({ url: "https://example.test/" });
+ const { document } = window;
+ document.body.innerHTML = html;
+ if (readyState) Object.defineProperty(document, "readyState", { value: readyState, configurable: true });
+ const warnings = [];
+ const errors = [];
+ window.addEventListener("error", (event) => errors.push(String(event.error ?? event.message)));
+ const run = () => vm.runInNewContext(SCRIPT, { window, document, console: { warn: (...args) => warnings.push(args) } });
+ run();
+ t.after(async () => {
+ await window.happyDOM.close();
+ assert.deepEqual(errors, [], "SynthJS threw in a listener");
+ });
+ const $ = (selector) => document.querySelector(selector);
+ const focused = () => document.activeElement?.id;
+ const key = (el, k) => el.dispatchEvent(new window.KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }));
+ return { window, document, $, key, focused, warnings: { list: warnings, messages: () => warnings.map(([m]) => m) }, run };
+}
+
+const TOGGLE = `
+ <button type="button" id="open-filters" data-synth-toggle="filters">Filters</button>
+ <div id="filters" hidden>Filter form</div>
+ <button type="button" id="hide-help" data-synth-toggle="help" aria-controls="help other">Help</button>
+ <p id="help">Help text</p>`;
+
+const TABS = `
+ <div data-synth-tabs>
+ <div class="tabs" role="tablist" aria-label="Range">
+ <button type="button" class="tabs-item" role="tab" id="t-day" aria-controls="p-day" aria-selected="false">Day</button>
+ <button type="button" class="tabs-item" role="tab" id="t-week" aria-controls="p-week" aria-selected="true">Week</button>
+ <button type="button" class="tabs-item" role="tab" id="t-month" aria-controls="p-month" aria-selected="false">Month</button>
+ </div>
+ <div role="tabpanel" id="p-day" aria-labelledby="t-day">Day panel</div>
+ <div role="tabpanel" id="p-week" aria-labelledby="t-week" hidden>Week panel</div>
+ <div role="tabpanel" id="p-month" aria-labelledby="t-month">Month panel</div>
+ </div>`;
+
+const DROPDOWN = `
+ <div data-synth-dropdown id="account">
+ <button type="button" class="button" data-synth-dropdown-trigger>Account</button>
+ <ul class="nav stack-sm" role="list" id="account-menu" data-synth-dropdown-menu hidden>
+ <li><a class="nav-link" href="#profile">Profile</a></li>
+ <li><button type="button" id="inside">Sign out</button></li>
+ </ul>
+ </div>
+ <button type="button" id="outside">Elsewhere</button>`;
+
+const DIALOG = `
+ <button type="button" id="opener" data-synth-open="confirm">Delete…</button>
+ <button type="button" id="other">Other</button>
+ <dialog id="confirm" aria-labelledby="confirm-title">
+ <h2 id="confirm-title">Delete project?</h2>
+ <button type="button" id="cancel" data-synth-dismiss>Cancel</button>
+ </dialog>`;
+
+const state = (tabs) => tabs.map((t) => [t.id, t.getAttribute("aria-selected"), t.getAttribute("tabindex")]);
+
+test("defines window.Synth and initializes the page on load", (t) => {
+ const { window, $, document, warnings } = page(t, TOGGLE + TABS + DROPDOWN);
+ assert.equal(typeof window.Synth.init, "function");
+ assert.equal($("#open-filters").getAttribute("aria-expanded"), "false");
+ assert.equal($("#t-week").getAttribute("tabindex"), "0");
+ assert.equal($("[data-synth-dropdown-trigger]").getAttribute("aria-expanded"), "false");
+ // One rule so hidden beats the display of SynthCSS classes (.nav, .stack, .alert).
+ const rule = document.querySelectorAll("style[data-synth]");
+ assert.equal(rule.length, 1);
+ assert.match(rule[0].textContent, /\[hidden\][^{]*\{ display: none !important; \}/);
+ assert.deepEqual(warnings.messages(), []);
+});
+
+test("waits for DOMContentLoaded while the document is loading", (t) => {
+ const { window, document, $ } = page(t, TOGGLE, { readyState: "loading" });
+ assert.equal(typeof window.Synth.init, "function");
+ assert.equal($("#open-filters").hasAttribute("aria-expanded"), false);
+ document.dispatchEvent(new window.Event("DOMContentLoaded"));
+ assert.equal($("#open-filters").getAttribute("aria-expanded"), "false");
+});
+
+test("Synth.init(root) initializes markup inserted later", (t) => {
+ const { window, document, $ } = page(t, "");
+ const box = document.createElement("section");
+ box.innerHTML = TOGGLE + TABS;
+ document.body.append(box);
+ assert.equal($("#open-filters").hasAttribute("aria-expanded"), false);
+ window.Synth.init(box);
+ assert.equal($("#open-filters").getAttribute("aria-expanded"), "false");
+ assert.equal($("#open-filters").getAttribute("aria-controls"), "filters");
+ assert.equal($("#t-day").getAttribute("tabindex"), "-1");
+ // An element can be the root itself.
+ const solo = document.createElement("div");
+ solo.innerHTML = '<button type="button" id="solo" data-synth-toggle="filters">More</button>';
+ document.body.append(solo);
+ window.Synth.init($("#solo"));
+ assert.equal($("#solo").getAttribute("aria-expanded"), "false");
+});
+
+test("repeated Synth.init() and a second copy of the script never double-bind", (t) => {
+ const { window, document, $, run } = page(t, TOGGLE + TABS + DROPDOWN + DIALOG);
+ window.Synth.init();
+ window.Synth.init(document);
+ window.Synth.init(document.body);
+ const first = window.Synth;
+ run();
+ assert.ok(window.Synth === first, "a second copy keeps the first Synth");
+ assert.equal(document.querySelectorAll("style[data-synth]").length, 1);
+
+ // A doubled handler would toggle twice and leave everything as it was.
+ $("#open-filters").click();
+ assert.equal($("#filters").hidden, false);
+ assert.equal($("#open-filters").getAttribute("aria-expanded"), "true");
+ $("[data-synth-dropdown-trigger]").click();
+ assert.equal($("#account-menu").hidden, false);
+
+ let opened = 0;
+ const dialog = $("#confirm");
+ const showModal = dialog.showModal.bind(dialog);
+ dialog.showModal = () => (opened++, showModal());
+ $("#opener").click();
+ assert.equal(opened, 1);
+ assert.equal(dialog.open, true);
+
+ let closes = 0;
+ $("#opener").focus = () => closes++;
+ dialog.close();
+ assert.equal(closes, 1, "focus returns once");
+});
+
+test("data-synth-open shows the dialog modally and returns focus to the opener on close", (t) => {
+ const { $, focused } = page(t, DIALOG);
+ const dialog = $("#confirm");
+ $("#opener").focus();
+ $("#opener").click();
+ assert.equal(dialog.open, true);
+ $("#cancel").focus();
+ // Escape closes a modal <dialog> natively, which fires close like this does.
+ dialog.close();
+ assert.equal(dialog.open, false);
+ assert.equal(focused(), "opener");
+
+ // Opening an open dialog again is a no-op.
+ $("#opener").click();
+ $("#opener").click();
+ assert.equal(dialog.open, true);
+});
+
+test("data-synth-dismiss closes its dialog, and focus returns to the opener", (t) => {
+ const { $, focused } = page(t, DIALOG);
+ $("#opener").click();
+ $("#cancel").focus();
+ $("#cancel").click();
+ assert.equal($("#confirm").open, false);
+ assert.equal(focused(), "opener");
+});
+
+test("data-synth-dismiss hides its [data-synth-dismissible] ancestor without removing it", (t) => {
+ const { $, warnings } = page(t, `
+ <div class="alert alert-success" role="status" data-synth-dismissible id="saved">
+ <p>Settings saved.</p>
+ <button type="button" class="button button-sm" data-synth-dismiss aria-label="Dismiss"><span>×</span></button>
+ </div>`);
+ $("[data-synth-dismiss] span").click();
+ assert.equal($("#saved").hidden, true);
+ assert.ok($("#saved").isConnected);
+ assert.deepEqual(warnings.messages(), []);
+});
+
+test("data-synth-toggle flips hidden and keeps aria-expanded in sync from the initial state", (t) => {
+ const { $ } = page(t, TOGGLE + '<button type="button" id="second" data-synth-toggle="filters">Also filters</button>');
+ const [button, target] = [$("#open-filters"), $("#filters")];
+ // Initial state comes from the target's hidden attribute.
+ assert.equal(button.getAttribute("aria-expanded"), "false");
+ assert.equal($("#hide-help").getAttribute("aria-expanded"), "true");
+ // aria-controls is added when missing and kept when present.
+ assert.equal(button.getAttribute("aria-controls"), "filters");
+ assert.equal($("#hide-help").getAttribute("aria-controls"), "help other");
+
+ button.click();
+ assert.equal(target.hidden, false);
+ assert.equal(button.getAttribute("aria-expanded"), "true");
+ assert.equal($("#second").getAttribute("aria-expanded"), "true", "every trigger of the target is synced");
+ button.click();
+ assert.equal(target.hidden, true);
+ assert.equal(button.getAttribute("aria-expanded"), "false");
+
+ $("#hide-help").click();
+ assert.equal($("#help").hidden, true);
+ assert.equal($("#hide-help").getAttribute("aria-expanded"), "false");
+});
+
+test("data-synth-tabs selects on click: aria-selected, roving tabindex and panel hidden", (t) => {
+ const { document, $ } = page(t, TABS);
+ const tabs = [...document.querySelectorAll('[role="tab"]')];
+ const panels = [...document.querySelectorAll('[role="tabpanel"]')];
+ // Init applies the markup's selected tab to tabindex and panels.
+ assert.deepEqual(state(tabs), [["t-day", "false", "-1"], ["t-week", "true", "0"], ["t-month", "false", "-1"]]);
+ assert.deepEqual(panels.map((p) => p.hidden), [true, false, true]);
+
+ $("#t-month").click();
+ assert.deepEqual(state(tabs), [["t-day", "false", "-1"], ["t-week", "false", "-1"], ["t-month", "true", "0"]]);
+ assert.deepEqual(panels.map((p) => p.hidden), [true, true, false]);
+});
+
+test("data-synth-tabs: ArrowRight/ArrowLeft wrap, Home and End jump, focus follows", (t) => {
+ const { document, $, key, focused } = page(t, TABS);
+ const tabs = [...document.querySelectorAll('[role="tab"]')];
+ const panels = [...document.querySelectorAll('[role="tabpanel"]')];
+ const selected = () => tabs.find((tab) => tab.getAttribute("aria-selected") === "true").id;
+ const press = (k) => {
+ const event = key(document.activeElement, k);
+ assert.equal(event, false, `${k} is handled (default prevented)`);
+ assert.equal(focused(), selected(), "focus moves with the selection");
+ assert.deepEqual(tabs.map((tab) => tab.getAttribute("tabindex")), tabs.map((tab) => (tab.id === selected() ? "0" : "-1")));
+ assert.deepEqual(panels.map((p) => p.hidden), tabs.map((tab) => tab.id !== selected()));
+ return selected();
+ };
+ $("#t-week").focus();
+ assert.equal(press("ArrowRight"), "t-month");
+ assert.equal(press("ArrowRight"), "t-day", "ArrowRight wraps to the first tab");
+ assert.equal(press("ArrowLeft"), "t-month", "ArrowLeft wraps to the last tab");
+ assert.equal(press("ArrowLeft"), "t-week");
+ assert.equal(press("Home"), "t-day");
+ assert.equal(press("End"), "t-month");
+ // Other keys are left alone.
+ assert.equal(key(document.activeElement, "ArrowDown"), true);
+ assert.equal(selected(), "t-month");
+});
+
+test("data-synth-dropdown toggles on the trigger and closes on an outside click", (t) => {
+ const { $, focused } = page(t, DROPDOWN);
+ const [trigger, menu] = [$("[data-synth-dropdown-trigger]"), $("#account-menu")];
+ assert.equal(trigger.getAttribute("aria-controls"), "account-menu");
+ trigger.click();
+ assert.equal(menu.hidden, false);
+ assert.equal(trigger.getAttribute("aria-expanded"), "true");
+ $("#inside").click();
+ assert.equal(menu.hidden, false, "a click inside the menu keeps it open");
+ $("#outside").focus();
+ $("#outside").click();
+ assert.equal(menu.hidden, true);
+ assert.equal(trigger.getAttribute("aria-expanded"), "false");
+ assert.equal(focused(), "outside", "an outside click does not move focus");
+ trigger.click();
+ trigger.click();
+ assert.equal(menu.hidden, true, "the trigger closes it again");
+});
+
+test("data-synth-dropdown closes on Escape and returns focus to the trigger", (t) => {
+ const { $, key, focused } = page(t, DROPDOWN);
+ const [trigger, menu] = [$("[data-synth-dropdown-trigger]"), $("#account-menu")];
+ trigger.id = "trigger";
+ trigger.click();
+ $("#inside").focus();
+ assert.equal(key($("#inside"), "Escape"), false);
+ assert.equal(menu.hidden, true);
+ assert.equal(trigger.getAttribute("aria-expanded"), "false");
+ assert.equal(focused(), "trigger");
+ // With no open menu, Escape is left to the browser (e.g. to close a dialog).
+ assert.equal(key(trigger, "Escape"), true);
+});
+
+test("Escape in a dialog leaves menus outside it alone, and closes menus inside it first", (t) => {
+ const { $, key, focused } = page(t, `
+ <div data-synth-dropdown>
+ <button type="button" id="more">More</button>
+ <ul data-synth-dropdown-menu id="more-menu" hidden><li><button type="button" id="rename" data-synth-open="dlg">Rename…</button></li></ul>
+ </div>
+ <dialog id="dlg">
+ <div data-synth-dropdown>
+ <button type="button" id="inner">Options</button>
+ <ul data-synth-dropdown-menu id="inner-menu" hidden><li><button type="button" id="opt">Option</button></li></ul>
+ </div>
+ </dialog>`);
+ $("#more").click();
+ $("#rename").click();
+ assert.equal($("#dlg").open, true);
+ $("#inner").focus();
+ assert.equal(key($("#inner"), "Escape"), true, "Escape is left to the dialog");
+ assert.equal($("#more-menu").hidden, false, "the menu behind the dialog is untouched");
+
+ $("#inner").click();
+ assert.equal($("#more-menu").hidden, true, "a click in the dialog is outside the other menu");
+ $("#opt").focus();
+ assert.equal(key($("#opt"), "Escape"), false, "the dialog's own menu closes first");
+ assert.equal($("#inner-menu").hidden, true);
+ assert.equal(focused(), "inner");
+ assert.equal(key($("#inner"), "Escape"), true, "then Escape is left to the dialog");
+});
+
+test("data-synth-dropdown falls back to the first button and a role=menu element", (t) => {
+ const { $ } = page(t, `
+ <div data-synth-dropdown>
+ <button type="button" id="more">More</button>
+ <div role="menu" id="more-menu" hidden><button type="button" role="menuitem">Rename</button></div>
+ </div>`);
+ assert.equal($("#more").getAttribute("aria-expanded"), "false");
+ $("#more").click();
+ assert.equal($("#more-menu").hidden, false);
+ assert.equal($("#more").getAttribute("aria-expanded"), "true");
+});
+
+test("a missing or invalid target warns once per element and never throws", (t) => {
+ const { window, $, warnings } = page(t, `
+ <button type="button" id="b1" data-synth-open="nope">Open</button>
+ <button type="button" id="b2" data-synth-open="not-a-dialog">Open</button><div id="not-a-dialog"></div>
+ <button type="button" id="b3" data-synth-toggle="missing">Toggle</button>
+ <button type="button" id="b4" data-synth-toggle>Toggle</button>
+ <button type="button" id="b5" data-synth-dismiss>Dismiss</button>
+ <div data-synth-tabs id="w6"><button type="button" role="tab" id="b6" aria-selected="true">Lonely</button></div>
+ <div data-synth-tabs id="w7"></div>
+ <div data-synth-dropdown id="w8"><button type="button" id="b8">No menu</button></div>`);
+ const warnedAbout = () => warnings.list.map(([message, el]) => {
+ assert.match(message, /^SynthJS: /);
+ return el.id;
+ });
+ assert.deepEqual(warnedAbout().sort(), ["b1", "b2", "b3", "b4", "b5", "b6", "w7", "w8"]);
+ for (const id of ["b1", "b2", "b3", "b4", "b5", "b6", "b8"]) assert.doesNotThrow(() => $(`#${id}`).click());
+ assert.doesNotThrow(() => window.Synth.init());
+ assert.equal(warnings.list.length, 8, "no repeated warnings after clicks or another init");
+ assert.equal($("#b6").getAttribute("aria-selected"), "true", "tabs without panels still select");
+});
+
+test("the showcase demos initialize without warnings and respond", (t) => {
+ const html = readFileSync(new URL("../showcase/index.html", import.meta.url), "utf8");
+ const body = html.split(/<body[^>]*>/)[1].split("</body>")[0];
+ const { $, key, focused, warnings } = page(t, body);
+ assert.deepEqual(warnings.messages(), []);
+
+ $('[data-synth-open="demo-confirm"]').click();
+ assert.equal($("#demo-confirm").open, true);
+ $("#demo-confirm [data-synth-dismiss]").click();
+ assert.equal($("#demo-confirm").open, false);
+
+ $('[data-synth-toggle="demo-filters"]').click();
+ assert.equal($("#demo-filters").hidden, false);
+
+ $("#demo-tab-week").focus();
+ key($("#demo-tab-week"), "End");
+ assert.equal(focused(), "demo-tab-month");
+ assert.equal($("#demo-panel-month").hidden, false);
+ assert.equal($("#demo-panel-week").hidden, true);
+
+ const menu = $("#behavior-dropdown ~ .sc-demo [data-synth-dropdown-menu]");
+ $("#behavior-dropdown ~ .sc-demo [data-synth-dropdown-trigger]").click();
+ assert.equal(menu.hidden, false);
+ $("#behaviors-title").click();
+ assert.equal(menu.hidden, true);
+
+ $("#demo-saved [data-synth-dismiss]").click();
+ assert.equal($("#demo-saved").hidden, true);
+});
diff --git a/scripts/verify-ai-contract.mjs b/scripts/verify-ai-contract.mjs
index 5705c71..2e59420 100644
--- a/scripts/verify-ai-contract.mjs
+++ b/scripts/verify-ai-contract.mjs
@@ -2,10 +2,13 @@
// Dependency-free verification of the AI contract: synthcss.ai.json (canonical,
// machine-readable) and synthcss.llm.md (prompt-ready) against the framework CSS,
// package.json, each other, the showcase and the Pages workflow.
-// Usage: node scripts/verify-ai-contract.mjs [--max-tokens=8000]
+// It is also the generator of the one generated part of synthcss.llm.md: the
+// Behaviors (SynthJS) section, rendered from the components' "behaviors" in
+// synthcss.ai.json. --write rewrites that section, then verifies.
+// Usage: node scripts/verify-ai-contract.mjs [--write] [--max-tokens=8000]
// The size threshold can also be set with SYNTHCSS_AI_CONTRACT_MAX_TOKENS.
-import { readFileSync } from "node:fs";
+import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { parseTokens } from "./check-tokens.mjs";
@@ -35,6 +38,7 @@ export const MD_SECTIONS = [
"Layout Vocabulary",
"Component Vocabulary",
"Intent Mapping",
+ "Behaviors (SynthJS)",
"Composition Rules",
"AI Generation Rules",
"When the vocabulary is missing a pattern",
@@ -59,6 +63,13 @@ export const VALID_EXAMPLES = [2, 5];
export const SHOWCASE_SIZE_TOLERANCE = 0.1;
export const LLM_FILE = "synthcss.llm.md";
export const JSON_FILE = "synthcss.ai.json";
+// SynthJS behaviors: components.<name>.behaviors entries, each with exactly these
+// keys, rendered into this Markdown section. Each attribute is one intent with no
+// aliases, and must be implemented by the runtime.
+export const BEHAVIOR_SECTION = "Behaviors (SynthJS)";
+export const BEHAVIOR_KEYS = ["intent", "attribute", "target", "requiredMarkup", "accessibility"];
+export const SYNTH_JS_FILE = "src/js/synth.js";
+const BEHAVIOR_ATTRIBUTE = /^data-synth-[a-z]+(-[a-z]+)*$/;
// `.name` not preceded by a word character, dot, slash or dash, so file names
// (synthcss.ai.json), URLs and "e.g." are not read as classes.
@@ -163,6 +174,12 @@ function jsonStrings(contract) {
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
const isExample = (e) => isObject(e) && typeof e.html === "string" && typeof e.note === "string";
+const isStrings = (a) => Array.isArray(a) && a.length > 0 && a.every((v) => typeof v === "string" && v.trim());
+const isBehavior = (b) =>
+ isObject(b) &&
+ Object.keys(b).join() === BEHAVIOR_KEYS.join() &&
+ BEHAVIOR_KEYS.slice(0, 4).every((k) => typeof b[k] === "string" && b[k].trim()) &&
+ isStrings(b.accessibility);
function checkJsonShape(c) {
const errors = [];
@@ -190,7 +207,9 @@ function checkJsonShape(c) {
else {
for (const [name, comp] of Object.entries(c.components)) {
if (!isObject(comp) || typeof comp.intent !== "string" || !isObject(comp.parts) || !isObject(comp.variants)) {
- errors.push(`${JSON_FILE}: components.${name} must be { intent, parts: {class: purpose}, variants: {class: purpose} }`);
+ errors.push(`${JSON_FILE}: components.${name} must be { intent, parts: {class: purpose}, variants: {class: purpose}, behaviors? }`);
+ } else if (comp.behaviors !== undefined && !(Array.isArray(comp.behaviors) && comp.behaviors.length && comp.behaviors.every(isBehavior))) {
+ errors.push(`${JSON_FILE}: components.${name}.behaviors must be a list of { ${BEHAVIOR_KEYS.join(", ")} } with accessibility a list of strings`);
}
}
}
@@ -243,6 +262,55 @@ export function jsonVocabulary(c) {
return classes;
}
+// Every SynthJS behavior, in contract order, with the component it belongs to.
+export function behaviorsOf(contract) {
+ return Object.entries(contract.components).flatMap(([component, comp]) =>
+ (comp.behaviors ?? []).map((behavior) => ({ component, ...behavior })),
+ );
+}
+
+// Backticks markup in free text for the Markdown: <tags>, [selectors],
+// attr="value" pairs, data-synth-* attributes and .class mentions.
+const CODE_IN_TEXT = /<[^>]+>|\[[^\]]+\]|\b[a-z][a-z-]*="[^"]*"|\bdata-synth-[a-z-]*[a-z]|(?<![\w./-])\.[a-z][a-z0-9-]*/g;
+const codify = (text) => text.replace(CODE_IN_TEXT, (code) => `\`${code}\``);
+
+// The body of the Markdown Behaviors (SynthJS) section, generated from the JSON.
+export function renderBehaviors(contract) {
+ const script = `https://cdn.jsdelivr.net/gh/nabledhq/synthcss@${contract.synthcssVersion}/dist/synth.js`;
+ const lines = [
+ "",
+ `<!-- Generated from the components' "behaviors" in ${JSON_FILE} by \`npm run contract:write\`. Edit the JSON, not this section. -->`,
+ "",
+ `Optional SynthJS script, loaded after the stylesheet: \`<script src="${script}" defer></script>\`.`,
+ "Declare interactivity with exactly one `data-synth-*` attribute per intent, as below: no aliases, no `data-synth-collapse`, no event handlers of your own. Triggers are `<button type=\"button\">`; state lives in `hidden` and ARIA attributes, which SynthJS keeps in sync. Without the script the page still renders; only these behaviors are inactive. After inserting markup later, call `Synth.init(element)`.",
+ ];
+ for (const b of behaviorsOf(contract)) {
+ lines.push(
+ "",
+ `### \`${b.attribute}\` — ${b.intent}`,
+ "",
+ `- Component: \`.${b.component}\``,
+ `- Target: ${codify(b.target)}`,
+ "- Accessibility:",
+ ...b.accessibility.map((line) => ` - ${codify(line)}`),
+ "",
+ "```html",
+ b.requiredMarkup.trim(),
+ "```",
+ );
+ }
+ return lines.join("\n") + "\n";
+}
+
+// Replaces the body of the "## title" section of a Markdown file.
+export function replaceSection(md, title, body) {
+ const start = md.search(new RegExp(`^## ${title.replace(/[()]/g, "\\$&")}\\s*$`, "m"));
+ if (start === -1) throw new Error(`${LLM_FILE} has no "## ${title}" section`);
+ const head = md.indexOf("\n", start) + 1;
+ const next = md.slice(head).search(/^## /m);
+ return md.slice(0, head) + body + (next === -1 ? "" : "\n" + md.slice(head + next));
+}
+
const DATA_UI = /\bdata-ui="([^"]*)"/g;
const STYLE_BLOCK = /<style\b[^>]*>([\s\S]*?)<\/style>/g;
const TOKEN_VALUE = /var\((--[a-z][a-z0-9-]*)\)/g;
@@ -428,6 +496,24 @@ export function verifyContract(files, { maxTokens = DEFAULT_MAX_TOKENS } = {}) {
errors.push(...checkInvalidExample(`${JSON_FILE}: invalid example ${i + 1}`, e.html, e.note, cssClasses, cssTokens));
});
+ // SynthJS behaviors.
+ const behaviors = behaviorsOf(contract);
+ const seenAttributes = new Set();
+ for (const b of behaviors) {
+ const where = `${JSON_FILE}: components.${b.component}.behaviors ${b.attribute}`;
+ if (!BEHAVIOR_ATTRIBUTE.test(b.attribute)) errors.push(`${where}: attribute must be data-synth-<name>`);
+ if (seenAttributes.has(b.attribute)) errors.push(`${where}: attribute is listed more than once (one attribute per intent)`);
+ seenAttributes.add(b.attribute);
+ if (!new RegExp(`\\s${b.attribute}[\\s=>]`).test(b.requiredMarkup)) errors.push(`${where}: requiredMarkup must use ${b.attribute}`);
+ if (!new RegExp(`class="([^"]*\\s)?${b.component}(\\s[^"]*)?"`).test(b.requiredMarkup)) {
+ errors.push(`${where}: requiredMarkup must use .${b.component}`);
+ }
+ if (files.synthJs !== undefined && !files.synthJs.includes(`"${b.attribute}`) && !files.synthJs.includes(`[${b.attribute}`)) {
+ errors.push(`${where}: ${SYNTH_JS_FILE} does not implement ${b.attribute}`);
+ }
+ errors.push(...checkMarkup(`${where}: requiredMarkup`, b.requiredMarkup, undefined, markupContext));
+ }
+
// Markdown.
const { preamble, sections } = parseMarkdown(md);
const header = /SynthCSS v?(\d+\.\d+\.\d+\S*)\s*·\s*contract v?(\d+\.\d+\.\d+)/.exec(preamble);
@@ -472,6 +558,10 @@ export function verifyContract(files, { maxTokens = DEFAULT_MAX_TOKENS } = {}) {
errors.push(`${LLM_FILE}: the Component Vocabulary must say never to use \`aria-pressed\``);
}
+ if (sections.has(BEHAVIOR_SECTION) && sections.get(BEHAVIOR_SECTION).trim() !== renderBehaviors(contract).trim()) {
+ errors.push(`${LLM_FILE}: the ${BEHAVIOR_SECTION} section must be generated from ${JSON_FILE}; run npm run contract:write`);
+ }
+
const rows = section("Intent Mapping").split("\n").filter((l) => /^\s*\|/.test(l)).slice(2);
if (rows.length !== contract.intentMap.length) {
errors.push(`${LLM_FILE}: the Intent Mapping table has ${rows.length} rows, ${JSON_FILE} has ${contract.intentMap.length}`);
@@ -628,6 +718,7 @@ export function readRepoFiles(repo) {
workflow: read(".github/workflows/pages.yml"),
readme: read("README.md"),
schemaDoc: read("docs/ai-contract.md"),
+ synthJs: read(SYNTH_JS_FILE),
};
}
@@ -645,7 +736,13 @@ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(imp
if (isMain) {
const repo = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const maxTokens = maxTokensFrom(process.argv.slice(2), process.env);
- const { errors, warnings, size } = verifyContract(readRepoFiles(repo), { maxTokens });
+ const files = readRepoFiles(repo);
+ if (process.argv.includes("--write")) {
+ files.md = replaceSection(files.md, BEHAVIOR_SECTION, renderBehaviors(JSON.parse(files.json)));
+ writeFileSync(resolve(repo, LLM_FILE), files.md);
+ console.log(`verify-ai-contract: wrote the ${BEHAVIOR_SECTION} section of ${LLM_FILE}.`);
+ }
+ const { errors, warnings, size } = verifyContract(files, { maxTokens });
console.log(`verify-ai-contract: ${LLM_FILE} is ${size.chars} characters, ~${size.tokens} tokens (chars ÷ 4; threshold ${maxTokens}).`);
for (const w of warnings) console.warn(` WARN ${w}`);
if (errors.length) {
diff --git a/scripts/verify-ai-contract.test.mjs b/scripts/verify-ai-contract.test.mjs
index 31f2243..9909578 100644
--- a/scripts/verify-ai-contract.test.mjs
+++ b/scripts/verify-ai-contract.test.mjs
@@ -1,12 +1,17 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
+ BEHAVIOR_KEYS,
+ BEHAVIOR_SECTION,
DEFAULT_MAX_TOKENS,
+ behaviorsOf,
checkExtensionCss,
estimateTokens,
maxTokensFrom,
parseNotCovered,
readRepoFiles,
+ renderBehaviors,
+ replaceSection,
verifyContract,
} from "./verify-ai-contract.mjs";
@@ -209,7 +214,7 @@ const editAppShell = (find, replace) => {
};
};
-test("states the one extension rule in both files, at contract 1.2.0", () => {
+test("states the one extension rule in both files, at contract 1.3.0", () => {
const ext = contract.extension;
assert.equal(ext.attribute, "data-ui");
assert.equal(ext.layer, "synth.ext");
@@ -218,8 +223,8 @@ test("states the one extension rule in both files, at contract 1.2.0", () => {
for (const banned of ["display", "flex", "grid-template-columns", "gap", "margin", "position", "order"]) {
assert.ok(!ext.properties.includes(banned), `${banned} is not an extension property`);
}
- assert.equal(contract.contractVersion, "1.2.0");
- assert.match(files.md, /· contract 1\.2\.0 ·/);
+ assert.equal(contract.contractVersion, "1.3.0");
+ assert.match(files.md, /· contract 1\.3\.0 ·/);
assert.match(files.md, /^## When the vocabulary is missing a pattern$/m);
for (const phrase of ['`data-ui="<name>"`', "`@layer synth.ext", "`var(--…)`"]) assert.ok(files.md.includes(phrase), phrase);
assertError(errorsWith(withJson((c) => delete c.extension)), 'missing top-level key "extension"');
@@ -302,3 +307,69 @@ test("lists every not-covered pattern with a composition or a data-ui fallback",
const dropped = files.md.replace(/^- Icon tile — .*\n/m, "");
assertError(errorsWith({ md: dropped }), "Not covered yet has 7 items");
});
+
+// Edits the JSON and regenerates the Markdown behavior section from it.
+const withBehaviors = (edit) => {
+ const c = structuredClone(contract);
+ edit(c);
+ return { json: JSON.stringify(c, null, 2), md: replaceSection(files.md, BEHAVIOR_SECTION, renderBehaviors(c)) };
+};
+
+test("lists the five SynthJS behaviors on their components, one attribute each", () => {
+ const behaviors = behaviorsOf(contract);
+ assert.deepEqual(
+ behaviors.map((b) => [b.component, b.attribute]),
+ [
+ ["button", "data-synth-open"],
+ ["button", "data-synth-toggle"],
+ ["alert", "data-synth-dismiss"],
+ ["nav", "data-synth-dropdown"],
+ ["tabs", "data-synth-tabs"],
+ ],
+ );
+ for (const b of behaviors) {
+ assert.deepEqual(Object.keys(b), ["component", ...BEHAVIOR_KEYS]);
+ assert.ok(b.accessibility.length >= 2, `${b.attribute} has accessibility expectations`);
+ }
+ assert.ok(!files.md.includes("data-synth-collapse=") && !files.synthJs.includes("data-synth-collapse"), "no collapse alias");
+});
+
+test("generates the Markdown behavior section from the JSON", () => {
+ const body = files.md.split(`## ${BEHAVIOR_SECTION}\n`)[1].split("\n## ")[0];
+ assert.equal(body.trim(), renderBehaviors(contract).trim());
+ assert.match(body, /Generated from the components' "behaviors" in synthcss\.ai\.json/);
+ assert.match(body, /synthcss@\d+\.\d+\.\d+\/dist\/synth\.js/);
+ for (const b of behaviorsOf(contract)) {
+ assert.ok(body.includes(`### \`${b.attribute}\` — ${b.intent}`), b.attribute);
+ assert.ok(body.includes("```html\n" + b.requiredMarkup + "\n```"), `${b.attribute} markup`);
+ }
+ // Editing the JSON without regenerating fails; regenerating passes.
+ const stale = withJson((c) => (c.components.tabs.behaviors[0].intent = "pick a panel"));
+ assertError(errorsWith(stale), "the Behaviors (SynthJS) section must be generated from synthcss.ai.json");
+ assert.deepEqual(errorsWith(withBehaviors((c) => (c.components.tabs.behaviors[0].intent = "pick a panel"))), []);
+ const hand = files.md.replace("- Component: `.tabs`", "- Component: `.tabs` (edited)");
+ assertError(errorsWith({ md: hand }), "must be generated");
+ assertError(errorsWith({ md: files.md.replace(`## ${BEHAVIOR_SECTION}`, "## Behaviours") }), 'missing section "## Behaviors (SynthJS)"');
+});
+
+test("fails on a malformed, duplicated, unimplemented or invalid behavior", () => {
+ assertError(errorsWith(withJson((c) => delete c.components.button.behaviors[0].target)), "components.button.behaviors must be a list of");
+ assertError(errorsWith(withJson((c) => (c.components.button.behaviors[0].accessibility = "focus returns"))), "behaviors must be a list of");
+ assertError(errorsWith(withJson((c) => (c.components.alert.behaviors[0].aliases = ["data-synth-close"]))), "behaviors must be a list of");
+ const dup = withBehaviors((c) => (c.components.button.behaviors[1].attribute = "data-synth-open"));
+ assertError(errorsWith(dup), "attribute is listed more than once");
+ const badName = withBehaviors((c) => (c.components.button.behaviors[1].attribute = "data-toggle"));
+ assertError(errorsWith(badName), "attribute must be data-synth-<name>");
+ const unused = withBehaviors((c) => (c.components.button.behaviors[1].requiredMarkup = '<button type="button" class="button">Filters</button>'));
+ assertError(errorsWith(unused), "requiredMarkup must use data-synth-toggle");
+ const invented = withBehaviors((c) => (c.components.alert.behaviors[0].requiredMarkup = c.components.alert.behaviors[0].requiredMarkup.replace('class="alert', 'class="alert-dismissible alert')));
+ assertError(errorsWith(invented), "uses .alert-dismissible, which is not a SynthCSS class");
+ const synthJs = files.synthJs.replaceAll("data-synth-tabs", "data-synth-tablist");
+ assertError(errorsWith({ synthJs }), "src/js/synth.js does not implement data-synth-tabs");
+});
+
+test("--write replaces only the behavior section", () => {
+ const md = replaceSection(files.md, BEHAVIOR_SECTION, "\nnew body\n");
+ assert.ok(md.includes(`## ${BEHAVIOR_SECTION}\n\nnew body\n\n## Composition Rules\n`));
+ assert.equal(replaceSection(md, BEHAVIOR_SECTION, renderBehaviors(contract)), files.md);
+});
diff --git a/showcase/README.md b/showcase/README.md
index a62026f..7a50bd2 100644
--- a/showcase/README.md
+++ b/showcase/README.md
@@ -9,7 +9,10 @@ published to GitHub Pages.
| --- | --- |
| `index.html` | The page. Loads the real framework from `../src/synthcss.css`. |
| `showcase.css` | Showcase-only styles (base typography, demo boxes, frames, copy buttons). Every class starts with `sc-`, and every value is a `var(--token)` from `src/tokens.css`. |
-| `showcase.js` | Optional vanilla JS: copy buttons, frame-width sliders, the motion demo and live token values. The page works without it. |
+| `showcase.js` | Optional vanilla JS: copy buttons, frame-width sliders, the motion demo, live token values and the "show the alert again" button. The page works without it. |
+
+The page also loads the real SynthJS runtime from `../src/js/synth.js` for the live
+Behaviors section (dialog, toggle, tabs, dropdown and dismissible alert).
There is no build step and no dependency. Token values are never copied into the
showcase: swatches and specimens use `var(--…)`, and the values printed next to them
@@ -36,11 +39,11 @@ python3 -m http.server 8000
[`.github/workflows/pages.yml`](../.github/workflows/pages.yml) deploys the page with the
official `actions/configure-pages`, `actions/upload-pages-artifact` and
`actions/deploy-pages` actions. It runs on every push to `main` that changes
-`showcase/**`, a framework stylesheet (`src/**.css`), an AI contract file
+`showcase/**`, a framework stylesheet (`src/**.css`), SynthJS (`src/**.js`), an AI contract file
(`synthcss.llm.md`, `synthcss.ai.json`) or the workflow itself, and can be
started by hand from the Actions tab (`workflow_dispatch`).
-The build job runs `npm test`, then assembles a temporary `_site/` folder that mirrors
+The build job runs `npm ci` and `npm test`, then assembles a temporary `_site/` folder that mirrors
the repository layout:
```text
@@ -49,7 +52,7 @@ _site/
synthcss.llm.md the AI contract, linked from the AI Contract section
synthcss.ai.json
showcase/ index.html, showcase.css, showcase.js
- src/ tokens.css, layout.css, components.css, synthcss.css
+ src/ tokens.css, layout.css, components.css, synthcss.css, js/synth.js
```
Because `showcase/` sits next to `src/`, the same relative link works locally and on
@@ -100,7 +103,8 @@ a new primitive or component, append a section:
`npm test` runs [`scripts/check-showcase.mjs`](../scripts/check-showcase.mjs) (Node.js
only). It checks that the page loads the framework bundle and no other stylesheet, that
every section and hero item exists, that every token is rendered with `var(--…)`, that
-every layout primitive has a demo, description and snippet, that grid, sidebar, cluster
+every layout primitive has a demo, description and snippet, that SynthJS is loaded and
+each `data-synth-*` behavior has a live demo and a snippet, that grid, sidebar, cluster
and split have width-adjustable frames, that every component has an article with a live
demo of all its classes and a snippet, that the composed interface (`data-composed`)
uses only SynthCSS classes and at least six components, that there are at least three intent examples
diff --git a/showcase/index.html b/showcase/index.html
index 4bb9747..da917e8 100644
--- a/showcase/index.html
+++ b/showcase/index.html
@@ -10,6 +10,8 @@
<link rel="stylesheet" href="../src/synthcss.css">
<link rel="stylesheet" href="showcase.css">
<script src="showcase.js" defer></script>
+ <!-- SynthJS, the optional behaviors runtime (dist/synth.js once built). -->
+ <script src="../src/js/synth.js" defer></script>
</head>
<body class="sc-page">
<!--
@@ -29,6 +31,7 @@
<li><a href="#layouts">Layouts</a></li>
<li><a href="#responsive">Responsive</a></li>
<li><a href="#components">Components</a></li>
+ <li><a href="#behaviors">Behaviors</a></li>
<li><a href="#composed">Composed</a></li>
<li><a href="#ai-examples">AI examples</a></li>
<li><a href="#ai-contract">AI contract</a></li>
@@ -797,7 +800,7 @@ <h3 id="component-nav"><code>.nav</code></h3>
<article class="sc-card stack" aria-labelledby="component-tabs">
<h3 id="component-tabs"><code>.tabs</code></h3>
- <p>A tab list or segmented filter. The selected item is <code>aria-selected="true"</code> with <code>role="tab"</code>; switching panels is up to your script.</p>
+ <p>A tab list or segmented filter. The selected item is <code>aria-selected="true"</code> with <code>role="tab"</code>; for keyboard support and panel switching, see <a href="#behavior-tabs"><code>data-synth-tabs</code></a>.</p>
<div class="sc-demo">
<div class="stack">
<div class="tabs" role="tablist" aria-label="Demo member filter">
@@ -855,6 +858,135 @@ <h3 id="component-avatar"><code>.avatar</code></h3>
</div>
</section>
+ <section id="behaviors" class="sc-band" aria-labelledby="behaviors-title">
+ <div class="container stack-lg">
+ <div class="stack-sm">
+ <h2 id="behaviors-title">Behaviors (SynthJS)</h2>
+ <p class="sc-muted">
+ Optional and dependency-free: load <code>dist/synth.js</code> after the stylesheet and
+ declare interactivity with one <code>data-synth-*</code> attribute per intent. SynthJS
+ adds the events and keeps <code>hidden</code>, <code>aria-expanded</code>,
+ <code>aria-selected</code> and <code>tabindex</code> in sync. Without it every component
+ still renders. This page loads <code>../src/js/synth.js</code>.
+ </p>
+ </div>
+ <div class="grid-lg" style="--grid-min: 22rem">
+
+ <article class="sc-card stack" aria-labelledby="behavior-open">
+ <h3 id="behavior-open"><code>data-synth-open</code></h3>
+ <p>Opens a native <code><dialog></code> as a modal. <kbd>Escape</kbd> or a <code>data-synth-dismiss</code> button closes it, and focus returns to the button that opened it.</p>
+ <div class="sc-demo">
+ <button type="button" class="button button-danger" data-synth-open="demo-confirm">Delete project</button>
+ <dialog id="demo-confirm" class="sc-dialog" aria-labelledby="demo-confirm-title">
+ <div class="stack">
+ <h3 id="demo-confirm-title">Delete project?</h3>
+ <p>Atlas and its 12 environments will be removed.</p>
+ <div class="cluster-sm">
+ <button type="button" class="button button-danger" data-synth-dismiss>Delete</button>
+ <button type="button" class="button" data-synth-dismiss>Cancel</button>
+ </div>
+ </div>
+ </dialog>
+ </div>
+ <div class="sc-code"><pre><code><button type="button" class="button button-danger" data-synth-open="confirm">Delete project</button>
+<dialog id="confirm" aria-labelledby="confirm-title">
+ <div class="stack">
+ <h2 id="confirm-title">Delete project?</h2>
+ <div class="cluster-sm">
+ <button type="button" class="button button-danger">Delete</button>
+ <button type="button" class="button" data-synth-dismiss>Cancel</button>
+ </div>
+ </div>
+</dialog></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="behavior-toggle">
+ <h3 id="behavior-toggle"><code>data-synth-toggle</code></h3>
+ <p>Shows and hides the element with that id through its <code>hidden</code> attribute. The button's <code>aria-expanded</code> follows, from the initial markup on. Use it for any collapse or "show more".</p>
+ <div class="sc-demo">
+ <div class="stack-sm">
+ <div class="cluster-sm"><button type="button" class="button" data-synth-toggle="demo-filters">Filters</button></div>
+ <div id="demo-filters" class="panel" hidden>
+ <div class="panel-body stack-sm">
+ <label class="switch"><input type="checkbox" role="switch" checked> Active projects only</label>
+ <label class="switch"><input type="checkbox" role="switch"> Include archived</label>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="sc-code"><pre><code><button type="button" class="button" data-synth-toggle="filters">Filters</button>
+<div id="filters" class="panel" hidden>…</div></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="behavior-tabs">
+ <h3 id="behavior-tabs"><code>data-synth-tabs</code></h3>
+ <p>Turns <code>.tabs</code> into working tabs: click, <kbd>←</kbd> <kbd>→</kbd> (wrapping), <kbd>Home</kbd> and <kbd>End</kbd> set <code>aria-selected</code>, a roving <code>tabindex</code> and <code>hidden</code> on the other panels.</p>
+ <div class="sc-demo">
+ <div class="stack" data-synth-tabs>
+ <div class="tabs" role="tablist" aria-label="Demo report range">
+ <button type="button" class="tabs-item" role="tab" id="demo-tab-day" aria-controls="demo-panel-day" aria-selected="false">Day</button>
+ <button type="button" class="tabs-item" role="tab" id="demo-tab-week" aria-controls="demo-panel-week" aria-selected="true">Week</button>
+ <button type="button" class="tabs-item" role="tab" id="demo-tab-month" aria-controls="demo-panel-month" aria-selected="false">Month</button>
+ </div>
+ <div role="tabpanel" id="demo-panel-day" aria-labelledby="demo-tab-day" hidden><p>Today: 3 deploys, 0 incidents.</p></div>
+ <div role="tabpanel" id="demo-panel-week" aria-labelledby="demo-tab-week"><p>This week: 18 deploys, 1 incident.</p></div>
+ <div role="tabpanel" id="demo-panel-month" aria-labelledby="demo-tab-month" hidden><p>This month: 74 deploys, 2 incidents.</p></div>
+ </div>
+ </div>
+ <div class="sc-code"><pre><code><div class="stack" data-synth-tabs>
+ <div class="tabs" role="tablist" aria-label="Range">
+ <button type="button" class="tabs-item" role="tab" id="tab-week" aria-controls="panel-week" aria-selected="true">Week</button>
+ <button type="button" class="tabs-item" role="tab" id="tab-month" aria-controls="panel-month" aria-selected="false">Month</button>
+ </div>
+ <div role="tabpanel" id="panel-week" aria-labelledby="tab-week">…</div>
+ <div role="tabpanel" id="panel-month" aria-labelledby="tab-month" hidden>…</div>
+</div></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="behavior-dropdown">
+ <h3 id="behavior-dropdown"><code>data-synth-dropdown</code></h3>
+ <p>A trigger button and a <code>.nav</code> list of links. The trigger toggles the list and its <code>aria-expanded</code>; a click outside or <kbd>Escape</kbd> closes it, and <kbd>Escape</kbd> returns focus to the trigger.</p>
+ <div class="sc-demo">
+ <div class="stack-sm" data-synth-dropdown>
+ <div class="cluster-sm"><button type="button" class="button" data-synth-dropdown-trigger>Account</button></div>
+ <ul class="nav stack-sm" role="list" data-synth-dropdown-menu hidden>
+ <li><a class="nav-link" href="#behavior-dropdown">Profile</a></li>
+ <li><a class="nav-link" href="#behavior-dropdown">Billing</a></li>
+ <li><a class="nav-link" href="#behavior-dropdown">Sign out</a></li>
+ </ul>
+ </div>
+ </div>
+ <div class="sc-code"><pre><code><div data-synth-dropdown>
+ <button type="button" class="button" data-synth-dropdown-trigger>Account</button>
+ <ul class="nav stack-sm" role="list" data-synth-dropdown-menu hidden>
+ <li><a class="nav-link" href="/profile">Profile</a></li>
+ <li><a class="nav-link" href="/logout">Sign out</a></li>
+ </ul>
+</div></code></pre></div>
+ </article>
+
+ <article class="sc-card stack" aria-labelledby="behavior-dismiss">
+ <h3 id="behavior-dismiss"><code>data-synth-dismiss</code></h3>
+ <p>Closes the <code><dialog></code> it is in, otherwise hides its closest <code>data-synth-dismissible</code> ancestor, such as an <code>.alert</code>, with <code>hidden</code>. The element stays in the page.</p>
+ <div class="sc-demo">
+ <div class="stack-sm">
+ <div id="demo-saved" class="alert alert-success" role="status" data-synth-dismissible>
+ <p>Settings saved.</p>
+ <button type="button" class="button button-sm" data-synth-dismiss>Dismiss</button>
+ </div>
+ <div class="cluster-sm"><button type="button" class="button button-sm" data-sc-restore="demo-saved">Show the alert again</button></div>
+ </div>
+ </div>
+ <div class="sc-code"><pre><code><div class="alert alert-success" role="status" data-synth-dismissible>
+ <p>Settings saved.</p>
+ <button type="button" class="button button-sm" data-synth-dismiss>Dismiss</button>
+</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">
@@ -1077,8 +1209,8 @@ <h2 id="ai-contract-title">AI Contract</h2>
<p class="sc-muted">
One compact file teaches a model the whole public vocabulary of SynthCSS: every
token, layout primitive, component, part and variant, an intent table, composition
- rules, ten generation rules, one extension rule for patterns the vocabulary lacks
- and valid and invalid examples. Paste
+ rules, ten generation rules, the SynthJS behaviors, one extension rule for patterns
+ the vocabulary lacks and valid and invalid examples. Paste
<code>synthcss.llm.md</code> into a prompt, or read the same contract as structured
data from <code>synthcss.ai.json</code>. Both are versioned with the framework and
checked against the CSS on every change.
@@ -1088,10 +1220,10 @@ <h2 id="ai-contract-title">AI Contract</h2>
<a class="sc-button" href="../synthcss.llm.md">View synthcss.llm.md</a>
<a class="sc-button sc-button-secondary" href="../synthcss.ai.json" download>Download synthcss.ai.json</a>
<a href="https://github.com/nabledhq/synthcss/blob/main/synthcss.llm.md">Read it on GitHub</a>
- <span class="badge badge-info">≈ 5,700 tokens</span>
+ <span class="badge badge-info">≈ 7,000 tokens</span>
</div>
<p class="sc-muted">
- Size estimated as characters ÷ 4 (about 22,700 characters), small enough for one
+ Size estimated as characters ÷ 4 (about 27,900 characters), small enough for one
context window with room to spare.
</p>
<article class="sc-card sidebar-lg">
diff --git a/showcase/showcase.css b/showcase/showcase.css
index c70223a..3920488 100644
--- a/showcase/showcase.css
+++ b/showcase/showcase.css
@@ -316,3 +316,15 @@
/* The copy button sits on the dark code block, so its ring uses the light
background color to stay visible. */
.sc-copy:focus-visible { outline-color: var(--color-background); }
+
+/* The SynthJS dialog demo: SynthCSS ships no dialog styles, the native
+ <dialog> element and its ::backdrop do the work. */
+.sc-dialog {
+ max-inline-size: var(--content-width);
+ padding: var(--space-5);
+ background: var(--color-surface-elevated);
+ color: var(--color-text);
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+}
diff --git a/showcase/showcase.js b/showcase/showcase.js
index 71d9cc6..f25274d 100644
--- a/showcase/showcase.js
+++ b/showcase/showcase.js
@@ -68,3 +68,10 @@ for (const toggle of document.querySelectorAll("[data-motion-toggle]")) {
toggle.setAttribute("aria-pressed", String(playing));
});
}
+
+// Behaviors demo: bring a dismissed alert back.
+for (const button of document.querySelectorAll("[data-sc-restore]")) {
+ button.addEventListener("click", () => {
+ document.getElementById(button.dataset.scRestore).hidden = false;
+ });
+}
diff --git a/src/js/synth.js b/src/js/synth.js
new file mode 100644
index 0000000..cbd5756
--- /dev/null
+++ b/src/js/synth.js
@@ -0,0 +1,300 @@
+/*
+ * SynthJS: optional behaviors for SynthCSS markup.
+ *
+ * Markup declares intent with one data-synth-* attribute; this script adds the
+ * events and keeps the ARIA state in sync. SynthCSS needs no JavaScript: pages
+ * that do not load this file keep working, they just lose these behaviors.
+ *
+ * data-synth-open="<id>" button: showModal() on <dialog id>; focus returns on close
+ * data-synth-dismiss button: closes its <dialog>, or hides its [data-synth-dismissible]
+ * data-synth-toggle="<id>" button: toggles hidden on #id; aria-expanded / aria-controls
+ * data-synth-tabs container of role="tab" / role="tabpanel"; click, arrows, Home, End
+ * data-synth-dropdown wrapper of a trigger button and a menu; outside click and Escape close
+ *
+ * Plain browser script with no dependencies: it defines window.Synth and
+ * initializes itself on DOMContentLoaded. Call Synth.init(element) after
+ * inserting markup; calling it again on the same markup does nothing.
+ *
+ * Reference: docs/behaviors.md
+ */
+(function () {
+ "use strict";
+
+ // Loaded twice: keep the first copy, so listeners are never bound twice.
+ if (window.Synth) return;
+
+ var warned = new WeakSet();
+ var ready = new WeakSet();
+ var listening = new WeakSet();
+ var openers = new WeakMap();
+ var watched = new WeakSet();
+
+ // One warning per element, never an exception.
+ function warn(el, message) {
+ if (warned.has(el)) return;
+ warned.add(el);
+ console.warn("SynthJS: " + message, el);
+ }
+
+ // Elements under root matching selector, root included.
+ function all(root, selector) {
+ var found = Array.prototype.slice.call(root.querySelectorAll(selector));
+ if (root.matches && root.matches(selector)) found.unshift(root);
+ return found;
+ }
+
+ function targetOf(el, attribute) {
+ var id = el.getAttribute(attribute);
+ var target = id ? el.ownerDocument.getElementById(id) : null;
+ if (!target) warn(el, attribute + '="' + (id || "") + '" names no element id in the document');
+ return target;
+ }
+
+ // Open: data-synth-open="<dialog id>".
+ function dialogOf(button) {
+ var dialog = targetOf(button, "data-synth-open");
+ if (dialog && (dialog.tagName !== "DIALOG" || typeof dialog.showModal !== "function")) {
+ warn(button, 'data-synth-open must name a <dialog>, found <' + dialog.tagName.toLowerCase() + ">");
+ return null;
+ }
+ return dialog;
+ }
+
+ function open(button) {
+ var dialog = dialogOf(button);
+ if (!dialog || dialog.open) return;
+ openers.set(dialog, button);
+ if (!watched.has(dialog)) {
+ watched.add(dialog);
+ // Escape (native), dismiss buttons and dialog.close() all end here.
+ dialog.addEventListener("close", function () {
+ var opener = openers.get(dialog);
+ openers.delete(dialog);
+ if (opener && opener.isConnected) opener.focus();
+ });
+ }
+ dialog.showModal();
+ }
+
+ // Dismiss: closest <dialog>, otherwise closest [data-synth-dismissible].
+ function dismiss(button) {
+ var dialog = button.closest("dialog");
+ if (dialog) {
+ if (dialog.open) dialog.close();
+ return;
+ }
+ var box = button.closest("[data-synth-dismissible]");
+ if (box) box.hidden = true;
+ else warn(button, "data-synth-dismiss is not inside a <dialog> or a [data-synth-dismissible] element");
+ }
+
+ // Toggle: data-synth-toggle="<id>" flips hidden on the target.
+ function syncToggle(button, target) {
+ if (!button.hasAttribute("aria-controls")) button.setAttribute("aria-controls", target.id);
+ button.setAttribute("aria-expanded", String(!target.hidden));
+ }
+
+ function toggle(button) {
+ var target = targetOf(button, "data-synth-toggle");
+ if (!target) return;
+ target.hidden = !target.hidden;
+ // Every button that controls the same target reports the same state.
+ all(button.ownerDocument, "[data-synth-toggle]").forEach(function (other) {
+ if (other.getAttribute("data-synth-toggle") === target.id) syncToggle(other, target);
+ });
+ }
+
+ // Tabs: role="tab" elements of a [data-synth-tabs] container (not of a nested one).
+ function tabsOf(container) {
+ return all(container, '[role="tab"]').filter(function (tab) {
+ return tab.closest("[data-synth-tabs]") === container;
+ });
+ }
+
+ function panelOf(tab) {
+ var id = tab.getAttribute("aria-controls");
+ var panel = id ? tab.ownerDocument.getElementById(id) : null;
+ if (!panel) warn(tab, 'role="tab" needs aria-controls naming the id of its role="tabpanel" element');
+ return panel;
+ }
+
+ function select(container, selected, moveFocus) {
+ tabsOf(container).forEach(function (tab) {
+ var on = tab === selected;
+ tab.setAttribute("aria-selected", String(on));
+ tab.setAttribute("tabindex", on ? "0" : "-1");
+ var panel = panelOf(tab);
+ if (panel) panel.hidden = !on;
+ });
+ if (moveFocus) selected.focus();
+ }
+
+ function initTabs(container) {
+ var tabs = tabsOf(container);
+ if (!tabs.length) {
+ warn(container, 'data-synth-tabs contains no role="tab" elements');
+ return;
+ }
+ var selected = tabs.filter(function (tab) {
+ return tab.getAttribute("aria-selected") === "true";
+ })[0];
+ select(container, selected || tabs[0], false);
+ }
+
+ var TAB_KEYS = { ArrowRight: 1, ArrowLeft: -1, Home: 0, End: 0 };
+
+ function tabKey(event, tab, container) {
+ var tabs = tabsOf(container).filter(function (t) {
+ return !t.disabled;
+ });
+ var i = tabs.indexOf(tab);
+ if (i === -1) return;
+ var next =
+ event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (i + TAB_KEYS[event.key] + tabs.length) % tabs.length;
+ event.preventDefault();
+ select(container, tabs[next], true);
+ }
+
+ // Dropdown: a trigger button and a menu inside [data-synth-dropdown].
+ function dropdownOf(wrapper) {
+ var trigger = wrapper.querySelector("[data-synth-dropdown-trigger]") || wrapper.querySelector("button");
+ var menu = wrapper.querySelector("[data-synth-dropdown-menu]") || wrapper.querySelector('[role="menu"]');
+ if (!trigger || !menu) {
+ warn(wrapper, "data-synth-dropdown needs a trigger <button> and a [data-synth-dropdown-menu] element");
+ return null;
+ }
+ return { trigger: trigger, menu: menu };
+ }
+
+ function setDropdown(parts, expanded) {
+ parts.menu.hidden = !expanded;
+ parts.trigger.setAttribute("aria-expanded", String(expanded));
+ }
+
+ function initDropdown(wrapper) {
+ var parts = dropdownOf(wrapper);
+ if (!parts) return;
+ if (parts.menu.id && !parts.trigger.hasAttribute("aria-controls")) parts.trigger.setAttribute("aria-controls", parts.menu.id);
+ parts.trigger.setAttribute("aria-expanded", String(!parts.menu.hidden));
+ }
+
+ // Open dropdowns, each with its wrapper.
+ function openDropdowns(doc) {
+ return all(doc, "[data-synth-dropdown]")
+ .map(function (wrapper) {
+ var parts = dropdownOf(wrapper);
+ if (parts) parts.wrapper = wrapper;
+ return parts;
+ })
+ .filter(function (parts) {
+ return parts && !parts.menu.hidden;
+ });
+ }
+
+ // Delegated listeners: one pair per document, whatever the number of init() calls.
+ function onClick(event) {
+ var target = event.target;
+ if (!target || typeof target.closest !== "function") return;
+
+ openDropdowns(target.ownerDocument).forEach(function (parts) {
+ if (!parts.wrapper.contains(target)) setDropdown(parts, false);
+ });
+
+ var el = target.closest("[data-synth-open]");
+ if (el) open(el);
+ el = target.closest("[data-synth-dismiss]");
+ if (el) dismiss(el);
+ el = target.closest("[data-synth-toggle]");
+ if (el) toggle(el);
+ el = target.closest('[role="tab"]');
+ var container = el && el.closest("[data-synth-tabs]");
+ if (container) select(container, el, false);
+ el = target.closest("[data-synth-dropdown]");
+ var parts = el && dropdownOf(el);
+ if (parts && parts.trigger.contains(target)) setDropdown(parts, parts.menu.hidden);
+ }
+
+ function onKeydown(event) {
+ var target = event.target;
+ if (!target || typeof target.closest !== "function") return;
+
+ if (event.key === "Escape") {
+ // Inside an open dialog, only its own menus: Escape then closes the dialog.
+ var dialog = target.closest("dialog[open]");
+ var expanded = openDropdowns(target.ownerDocument).filter(function (parts) {
+ return !dialog || dialog.contains(parts.wrapper);
+ });
+ if (!expanded.length) return;
+ // Close the menus, not the dialog they may sit in.
+ event.preventDefault();
+ expanded.forEach(function (parts) {
+ setDropdown(parts, false);
+ });
+ var inner = expanded.filter(function (parts) {
+ return parts.wrapper.contains(target);
+ })[0];
+ (inner || expanded[expanded.length - 1]).trigger.focus();
+ return;
+ }
+
+ if (event.key in TAB_KEYS && !event.altKey && !event.ctrlKey && !event.metaKey) {
+ var tab = target.closest('[role="tab"]');
+ var container = tab && tab.closest("[data-synth-tabs]");
+ if (container) tabKey(event, tab, container);
+ }
+ }
+
+ // SynthCSS classes such as .alert, .stack or .nav set display, which beats the
+ // browser's [hidden] rule. This one rule lets hidden hide them.
+ function addHiddenRule(doc) {
+ if (doc.querySelector("style[data-synth]")) return;
+ var style = doc.createElement("style");
+ style.setAttribute("data-synth", "");
+ style.textContent = '[hidden]:not([hidden="until-found"]) { display: none !important; }';
+ (doc.head || doc.documentElement).appendChild(style);
+ }
+
+ // Sets the initial ARIA state of the markup under root (default: the whole
+ // document) and checks its targets. Safe to call any number of times.
+ function init(root) {
+ root = root || document;
+ var doc = root.ownerDocument || root;
+ if (!listening.has(doc)) {
+ listening.add(doc);
+ doc.addEventListener("click", onClick);
+ doc.addEventListener("keydown", onKeydown);
+ addHiddenRule(doc);
+ }
+ var steps = [
+ ["[data-synth-open]", dialogOf],
+ ["[data-synth-toggle]", function (button) {
+ var target = targetOf(button, "data-synth-toggle");
+ if (target) syncToggle(button, target);
+ }],
+ ["[data-synth-dismiss]", function (button) {
+ if (!button.closest("dialog, [data-synth-dismissible]")) {
+ warn(button, "data-synth-dismiss is not inside a <dialog> or a [data-synth-dismissible] element");
+ }
+ }],
+ ["[data-synth-tabs]", initTabs],
+ ["[data-synth-dropdown]", initDropdown],
+ ];
+ steps.forEach(function (step) {
+ all(root, step[0]).forEach(function (el) {
+ if (ready.has(el)) return;
+ ready.add(el);
+ step[1](el);
+ });
+ });
+ }
+
+ window.Synth = Object.freeze({ init: init });
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", function () {
+ init(document);
+ });
+ } else {
+ init(document);
+ }
+})();
diff --git a/synthcss.ai.json b/synthcss.ai.json
index 041a0a8..94d5dba 100644
--- a/synthcss.ai.json
+++ b/synthcss.ai.json
@@ -1,6 +1,6 @@
{
"synthcssVersion": "0.8.0",
- "contractVersion": "1.2.0",
+ "contractVersion": "1.3.0",
"tokens": {
"--color-background": "page background",
"--color-surface": "subtle background for panels, table heads, footers",
@@ -112,7 +112,31 @@
"button-sm": "smaller button",
"button-lg": "larger button",
"button-icon": "square icon-only button; needs aria-label"
- }
+ },
+ "behaviors": [
+ {
+ "intent": "open a modal dialog",
+ "attribute": "data-synth-open",
+ "target": "the id of a <dialog> element",
+ "requiredMarkup": "<button type=\"button\" class=\"button button-danger\" data-synth-open=\"confirm-delete\">Delete project</button>\n<dialog id=\"confirm-delete\" aria-labelledby=\"confirm-delete-title\">\n <div class=\"stack\">\n <h2 id=\"confirm-delete-title\">Delete project?</h2>\n <div class=\"cluster-sm\">\n <button type=\"button\" class=\"button button-danger\">Delete</button>\n <button type=\"button\" class=\"button\" data-synth-dismiss>Cancel</button>\n </div>\n </div>\n</dialog>",
+ "accessibility": [
+ "showModal(): the page behind is inert and Escape closes the dialog natively",
+ "focus returns to the opening button when the dialog closes",
+ "label the <dialog> with aria-labelledby pointing at its heading"
+ ]
+ },
+ {
+ "intent": "show or hide a section (collapse, disclosure, show more)",
+ "attribute": "data-synth-toggle",
+ "target": "the id of any element; its hidden attribute is the state",
+ "requiredMarkup": "<button type=\"button\" class=\"button\" data-synth-toggle=\"filters\">Filters</button>\n<div id=\"filters\" class=\"panel\" hidden>…</div>",
+ "accessibility": [
+ "aria-expanded on the button follows the target's hidden state, starting from the markup",
+ "aria-controls is added to the button when missing",
+ "the target stays in the DOM; hidden removes it from the accessibility tree"
+ ]
+ }
+ ]
},
"field": {
"intent": "one labeled form control with help or error text",
@@ -164,7 +188,20 @@
"alert-success": "action succeeded",
"alert-warning": "needs attention",
"alert-danger": "error"
- }
+ },
+ "behaviors": [
+ {
+ "intent": "dismiss a message, or close the dialog it is in",
+ "attribute": "data-synth-dismiss",
+ "target": "none: the closest <dialog>, otherwise the closest [data-synth-dismissible] ancestor",
+ "requiredMarkup": "<div class=\"alert alert-success\" role=\"status\" data-synth-dismissible>\n <p>Settings saved.</p>\n <button type=\"button\" class=\"button button-sm\" data-synth-dismiss>Dismiss</button>\n</div>",
+ "accessibility": [
+ "a dismissible element gets the hidden attribute and stays in the DOM",
+ "the button needs a visible text label, or aria-label when it shows only an icon",
+ "inside a <dialog> it closes the dialog and focus returns to the opener"
+ ]
+ }
+ ]
},
"panel": {
"intent": "flat bordered group of secondary content",
@@ -194,14 +231,40 @@
"parts": {
"nav-link": "an <a href> in .nav, optional leading <svg>; current page: aria-current=\"page\""
},
- "variants": {}
+ "variants": {},
+ "behaviors": [
+ {
+ "intent": "dropdown of links behind a button (account menu, overflow actions)",
+ "attribute": "data-synth-dropdown",
+ "target": "a wrapper holding the trigger ([data-synth-dropdown-trigger], else the first <button>) and the menu ([data-synth-dropdown-menu], else [role=\"menu\"])",
+ "requiredMarkup": "<div data-synth-dropdown>\n <button type=\"button\" class=\"button\" data-synth-dropdown-trigger>Account</button>\n <ul class=\"nav stack-sm\" role=\"list\" data-synth-dropdown-menu hidden>\n <li><a class=\"nav-link\" href=\"/profile\">Profile</a></li>\n <li><a class=\"nav-link\" href=\"/logout\">Sign out</a></li>\n </ul>\n</div>",
+ "accessibility": [
+ "aria-expanded on the trigger follows the menu's hidden state",
+ "a click outside or Escape closes the menu; Escape returns focus to the trigger",
+ "a disclosure list of links: no role=\"menu\", which promises arrow-key navigation SynthJS does not add"
+ ]
+ }
+ ]
},
"tabs": {
- "intent": "tab list or segmented filter; role=\"tablist\" + aria-label; wraps when narrow. CSS only: arrow keys and panel switching are your script",
+ "intent": "tab list or segmented filter; role=\"tablist\" + aria-label; wraps when narrow. Arrow keys and panel switching: data-synth-tabs (SynthJS, see behaviors)",
"parts": {
"tabs-item": "<button role=\"tab\">; selected: aria-selected=\"true\", others aria-selected=\"false\""
},
- "variants": {}
+ "variants": {},
+ "behaviors": [
+ {
+ "intent": "switch between panels of content",
+ "attribute": "data-synth-tabs",
+ "target": "a container of the .tabs tablist and its role=\"tabpanel\" elements; each tab names its panel with aria-controls",
+ "requiredMarkup": "<div class=\"stack\" data-synth-tabs>\n <div class=\"tabs\" role=\"tablist\" aria-label=\"Range\">\n <button type=\"button\" class=\"tabs-item\" role=\"tab\" id=\"tab-week\" aria-controls=\"panel-week\" aria-selected=\"true\">Week</button>\n <button type=\"button\" class=\"tabs-item\" role=\"tab\" id=\"tab-month\" aria-controls=\"panel-month\" aria-selected=\"false\">Month</button>\n </div>\n <div role=\"tabpanel\" id=\"panel-week\" aria-labelledby=\"tab-week\">…</div>\n <div role=\"tabpanel\" id=\"panel-month\" aria-labelledby=\"tab-month\" hidden>…</div>\n</div>",
+ "accessibility": [
+ "click, ArrowLeft / ArrowRight (wrapping), Home and End select a tab and move focus to it",
+ "the selected tab gets aria-selected=\"true\" and tabindex=\"0\", the others aria-selected=\"false\" and tabindex=\"-1\"",
+ "panels of unselected tabs get hidden; label each panel with aria-labelledby"
+ ]
+ }
+ ]
},
"avatar": {
"intent": "fixed square for initials, a photo or an icon, on <span>, <div> or <img>",
@@ -244,7 +307,12 @@
{ "intent": "Navigation links", "use": ".nav + .nav-link" },
{ "intent": "Tabs or segmented filter", "use": ".tabs + .tabs-item" },
{ "intent": "Person initials or photo", "use": ".avatar / .avatar-round" },
- { "intent": "Icon on a tinted tile", "use": ".avatar + .avatar-primary / -success / -warning / -danger / -info / -accent" }
+ { "intent": "Icon on a tinted tile", "use": ".avatar + .avatar-primary / -success / -warning / -danger / -info / -accent" },
+ { "intent": "Tabs that switch panels", "use": "data-synth-tabs around .tabs + role=\"tabpanel\" panels (SynthJS)" },
+ { "intent": "Modal dialog", "use": "<dialog> opened by a .button with data-synth-open (SynthJS)" },
+ { "intent": "Show or hide a section", "use": ".button with data-synth-toggle (SynthJS)" },
+ { "intent": "Dropdown of links", "use": "data-synth-dropdown around a .button and a .nav list (SynthJS)" },
+ { "intent": "Dismissible message", "use": ".alert with data-synth-dismissible and a data-synth-dismiss .button (SynthJS)" }
],
"compositionRules": {
"recommended": [
diff --git a/synthcss.llm.md b/synthcss.llm.md
index b733a3e..0ad383d 100644
--- a/synthcss.llm.md
+++ b/synthcss.llm.md
@@ -1,6 +1,6 @@
# SynthCSS AI Contract
-Version: SynthCSS 0.8.0 · contract 1.2.0 · machine-readable twin: synthcss.ai.json
+Version: SynthCSS 0.8.0 · contract 1.3.0 · machine-readable twin: synthcss.ai.json
The complete public vocabulary of SynthCSS. Use only the classes and tokens listed here; anything else does not exist.
Load: `<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/nabledhq/synthcss@0.8.0/dist/synthcss.min.css">`
@@ -140,7 +140,7 @@ One state attribute each: current nav link `aria-current="page"`; selected tab `
- `.empty-state` — nothing to show yet: icon, heading, text, action
- `.nav` — list reset for navigation links on `<ul>`; add `.stack-sm` (vertical) or `.cluster-sm` (horizontal)
- part `.nav-link` — an `<a href>` in `.nav`, optional leading `<svg>`; current page: `aria-current="page"`
-- `.tabs` — tab list or segmented filter; `role="tablist"` + `aria-label`; wraps when narrow. CSS only: arrow keys and panel switching are your script
+- `.tabs` — tab list or segmented filter; `role="tablist"` + `aria-label`; wraps when narrow. Arrow keys and panel switching: `data-synth-tabs` (SynthJS, see Behaviors)
- part `.tabs-item` — `<button role="tab">`; selected: `aria-selected="true"`, others `aria-selected="false"`
- `.avatar` — fixed square for initials, a photo or an icon, on `<span>`, `<div>` or `<img>`
- variant `.avatar-round` — circle; usual for people
@@ -182,6 +182,109 @@ One state attribute each: current nav link `aria-current="page"`; selected tab `
| Tabs or segmented filter | `.tabs` + `.tabs-item` |
| Person initials or photo | `.avatar` / `.avatar-round` |
| Icon on a tinted tile | `.avatar` + `.avatar-primary` / -success / -warning / -danger / -info / -accent |
+| Tabs that switch panels | `data-synth-tabs` around `.tabs` + `role="tabpanel"` panels (SynthJS) |
+| Modal dialog | `<dialog>` opened by a `.button` with `data-synth-open` (SynthJS) |
+| Show or hide a section | `.button` with `data-synth-toggle` (SynthJS) |
+| Dropdown of links | `data-synth-dropdown` around a `.button` and a `.nav` list (SynthJS) |
+| Dismissible message | `.alert` with `data-synth-dismissible` and a `data-synth-dismiss` `.button` (SynthJS) |
+
+## Behaviors (SynthJS)
+
+<!-- Generated from the components' "behaviors" in synthcss.ai.json by `npm run contract:write`. Edit the JSON, not this section. -->
+
+Optional SynthJS script, loaded after the stylesheet: `<script src="https://cdn.jsdelivr.net/gh/nabledhq/synthcss@0.8.0/dist/synth.js" defer></script>`.
+Declare interactivity with exactly one `data-synth-*` attribute per intent, as below: no aliases, no `data-synth-collapse`, no event handlers of your own. Triggers are `<button type="button">`; state lives in `hidden` and ARIA attributes, which SynthJS keeps in sync. Without the script the page still renders; only these behaviors are inactive. After inserting markup later, call `Synth.init(element)`.
+
+### `data-synth-open` — open a modal dialog
+
+- Component: `.button`
+- Target: the id of a `<dialog>` element
+- Accessibility:
+ - showModal(): the page behind is inert and Escape closes the dialog natively
+ - focus returns to the opening button when the dialog closes
+ - label the `<dialog>` with aria-labelledby pointing at its heading
+
+```html
+<button type="button" class="button button-danger" data-synth-open="confirm-delete">Delete project</button>
+<dialog id="confirm-delete" aria-labelledby="confirm-delete-title">
+ <div class="stack">
+ <h2 id="confirm-delete-title">Delete project?</h2>
+ <div class="cluster-sm">
+ <button type="button" class="button button-danger">Delete</button>
+ <button type="button" class="button" data-synth-dismiss>Cancel</button>
+ </div>
+ </div>
+</dialog>
+```
+
+### `data-synth-toggle` — show or hide a section (collapse, disclosure, show more)
+
+- Component: `.button`
+- Target: the id of any element; its hidden attribute is the state
+- Accessibility:
+ - aria-expanded on the button follows the target's hidden state, starting from the markup
+ - aria-controls is added to the button when missing
+ - the target stays in the DOM; hidden removes it from the accessibility tree
+
+```html
+<button type="button" class="button" data-synth-toggle="filters">Filters</button>
+<div id="filters" class="panel" hidden>…</div>
+```
+
+### `data-synth-dismiss` — dismiss a message, or close the dialog it is in
+
+- Component: `.alert`
+- Target: none: the closest `<dialog>`, otherwise the closest `[data-synth-dismissible]` ancestor
+- Accessibility:
+ - a dismissible element gets the hidden attribute and stays in the DOM
+ - the button needs a visible text label, or aria-label when it shows only an icon
+ - inside a `<dialog>` it closes the dialog and focus returns to the opener
+
+```html
+<div class="alert alert-success" role="status" data-synth-dismissible>
+ <p>Settings saved.</p>
+ <button type="button" class="button button-sm" data-synth-dismiss>Dismiss</button>
+</div>
+```
+
+### `data-synth-dropdown` — dropdown of links behind a button (account menu, overflow actions)
+
+- Component: `.nav`
+- Target: a wrapper holding the trigger (`[data-synth-dropdown-trigger]`, else the first `<button>`) and the menu (`[data-synth-dropdown-menu]`, else `[role="menu"]`)
+- Accessibility:
+ - aria-expanded on the trigger follows the menu's hidden state
+ - a click outside or Escape closes the menu; Escape returns focus to the trigger
+ - a disclosure list of links: no `role="menu"`, which promises arrow-key navigation SynthJS does not add
+
+```html
+<div data-synth-dropdown>
+ <button type="button" class="button" data-synth-dropdown-trigger>Account</button>
+ <ul class="nav stack-sm" role="list" data-synth-dropdown-menu hidden>
+ <li><a class="nav-link" href="/profile">Profile</a></li>
+ <li><a class="nav-link" href="/logout">Sign out</a></li>
+ </ul>
+</div>
+```
+
+### `data-synth-tabs` — switch between panels of content
+
+- Component: `.tabs`
+- Target: a container of the `.tabs` tablist and its `role="tabpanel"` elements; each tab names its panel with aria-controls
+- Accessibility:
+ - click, ArrowLeft / ArrowRight (wrapping), Home and End select a tab and move focus to it
+ - the selected tab gets `aria-selected="true"` and `tabindex="0"`, the others `aria-selected="false"` and `tabindex="-1"`
+ - panels of unselected tabs get hidden; label each panel with aria-labelledby
+
+```html
+<div class="stack" data-synth-tabs>
+ <div class="tabs" role="tablist" aria-label="Range">
+ <button type="button" class="tabs-item" role="tab" id="tab-week" aria-controls="panel-week" aria-selected="true">Week</button>
+ <button type="button" class="tabs-item" role="tab" id="tab-month" aria-controls="panel-month" aria-selected="false">Month</button>
+ </div>
+ <div role="tabpanel" id="panel-week" aria-labelledby="tab-week">…</div>
+ <div role="tabpanel" id="panel-month" aria-labelledby="tab-month" hidden>…</div>
+</div>
+```
## Composition Rules
This is a thorough, well-structured delivery. The runtime is a dependency-free IIFE that implements all five behaviors with delegated listeners and per-element warnings. It ships with real-DOM happy-dom tests for every listed scenario, a contract generator built into the verifier, and full docs and showcase coverage. The main reservations are that no CI ran to confirm the tests pass, and that it adds an unrequested Node >=20 engine bump and a runtime-injected global `[hidden]` style rule.
Acceptance criteria · 6 of 7 met
- YESBuild produces dist/synth.js as an IIFE defining window.Synth, no import/require, no new runtime deps`scripts/build.mjs` adds `SCRIPTS = { "synth.js": "js/synth.js" }`, and `build.test.mjs` asserts the IIFE shape, `window.Synth =`, the absence of import/require, and that `pkg.dependencies` is undefined.
- YESNo bundler is added; only dev dependencies (happy-dom or jsdom)`package.json` adds only `devDependencies: { happy-dom }`, the build is a plain copy plus banner, and a test asserts happy-dom is the only dev dependency.
- YESA page using only the CSS renders without errorsNo `src/*.css` files are changed, and the docs and components.md state that CSS-only pages keep working.
- YESTests cover init, double init, dialog open/close/focus return, toggle incl. initial state, tabs click and keys, dropdown outside-click and Escape with focus return, dismiss in dialog and dismissible, missing target warns without throwing`scripts/synth-js.test.mjs` runs the built script in happy-dom via vm with real click and keydown events, and has a dedicated test for each listed case plus the Escape-in-dialog and showcase smoke tests.
- YESsynthcss.ai.json has behaviors entries, minor version bumped, generated LLM contract includes a behavior sectionThere are behaviors on button, alert, nav and tabs and `contractVersion` goes 1.2.0→1.3.0. `verify-ai-contract.mjs` gains `renderBehaviors` and `--write`, `synthcss.llm.md` contains the generated "Behaviors (SynthJS)" section, and drift is tested.
- YESDocs and showcase cover all five behaviorsThe new `docs/behaviors.md` covers intent, HTML, attributes, accessibility and misuse for each behavior plus an AI Behavior Reference table. `showcase/index.html` has a Behaviors section with five live demos that `check-showcase.mjs` enforces.
- UNCLEARAll existing build and test commands passNo CI ran; the only evidence is the builder's statement that `npm test` passes with 115 tests.
- No CI checks ran on this commit, so the criterion "All existing build and test commands pass" rests only on the builder's claim of 115 passing tests. This matters more because the workflows now run `npm ci` for the first time.
- `package.json` raises `engines.node` from >=18 to >=20 because happy-dom 20 needs it, and the README is updated to match. This drops Node 18 support for contributors, which the spec did not ask for. Separately, the committed `package-lock.json` root entry still says `"node": ">=18"`, so the lockfile was not regenerated after the engines change.
- At init, `src/js/synth.js` injects a global `<style data-synth>` containing `[hidden]:not([hidden="until-found"]) { display: none !important; }`. It is documented and pragmatic, since `.alert`, `.nav` and `.stack` set `display`. But it is page-wide CSS added by the runtime and will override any author rule that intentionally styles `[hidden]` elements.
- Elements are marked ready once and never re-checked. If tabs are added to an existing `[data-synth-tabs]` container, or a dropdown menu is added later, a further `Synth.init()` call will not set their initial roving tabindex or `aria-expanded`. Click and keyboard handling still works because it is delegated.
- The dialog focus-return test closes the dialog with `dialog.close()` rather than a real Escape keydown, so native Escape behavior is assumed, not exercised. The double-init test likewise counts calls by monkeypatching `showModal` and `focus`, which is acceptable but indirect.
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.