Add .avatar component (initials/image/icon, sizes, tone variants), --color-accent token, and .alert-icon part
Motivation
Dashboards need small square or round chips for initials, photos and icons on a tinted background. SynthCSS has no component for these, and no non-status decorative hue. In a benchmark, agents hand-wrote 21–70 lines of custom CSS per page, invented --color-accent, and in one run added unsanctioned classes. Alerts with an icon also render the icon above the text, because .alert is a column flexbox.
Scope
1. Token
Add --color-accent to the default theme alongside the status colours.
- It is a violet hue.
- It must meet WCAG AA (4.5:1) as text on its own tint:
color-mix(in srgb, var(--color-accent) 12%, var(--color-background)). - Add it to any other theme that already exists in the repo.
- Add it to the token reference docs.
2. .avatar component
The component goes in the existing components stylesheet, following the conventions from #4.
Base class .avatar:
- Works on
<span>,<div>or<img>. - Uses inline-flex and centres its content both ways.
- Uses a fixed equal width and height.
- Default size equals
--control-height. - Corners are
--radius-md. - Sets
overflow: hiddenandflex-shrink: 0. - Initials use a font-weight and font-size scaled to the avatar and do not wrap.
- A child
<img>, or.avataron an<img>itself, fills the box withobject-fit: cover. - A child
<svg>is sized proportionally (about 50–60%) and usescurrentColor.
Variants:
.avatar-rounduses--radius-full..avatar-smand.avatar-lggive smaller and larger size steps derived from--control-heightwithcalc(). Assumption: roughly 0.75× and 1.5×.- Tone variants are
.avatar-primary,.avatar-success,.avatar-warning,.avatar-danger,.avatar-infoand.avatar-accent.- Each sets a local
--tonecustom property. - Background is
color-mix(in srgb, var(--tone) 12%, var(--color-background)). - Foreground colour is the tone.
- Each sets a local
- Assumption: an untoned avatar uses a neutral surface or muted background with the normal text colour.
3. .alert-icon
Add an .alert-icon part so that an icon inside .alert sits to the left of the content, aligned to the top. The content keeps its existing stacking.
- Assumption: implement with
.alert:has(> .alert-icon)switching to a two-column grid or row flex, with a fixed icon size. - Alerts without an icon must render exactly as before.
4. AI contract
Update both synthcss.llm.md and synthcss.ai.json with:
- the new token;
- the avatar classes and variants;
.alert-icon.
Add these Intent Mapping entries:
- "Person initials or photo" maps to
.avatar/.avatar-round. - "Icon on a tinted tile" maps to
.avatarwith a tone variant.
Include the HTML examples from the proposal. Bump version metadata following the project's existing convention.
Acceptance criteria
-
--color-accentis defined in the default theme and documented in the token reference. -
.avatarrenders initials, an image and an svg icon centred at a fixed square size. It has.avatar-sm, default and.avatar-lgsizes, all derived from--control-height. -
.avatar-roundproduces a circle, and the default uses--radius-md. - All six tone variants exist and derive their tint via
color-mix(... 12%, var(--color-background)), so token overrides propagate. - Each tone's foreground on its tint meets WCAG AA 4.5:1 with default tokens. This is verified by a test or script, or documented with computed ratios in the PR. If a tone fails, its foreground is adjusted and the PR explains how.
-
.alertcontaining.alert-iconshows the icon beside the content..alertwithout an icon is unchanged. -
synthcss.llm.mdandsynthcss.ai.jsonlist all new classes and the token, plus the two Intent Mapping entries. The existing contract sync verification passes. - Existing tests and build pass. New CSS follows existing naming and file conventions.
Out of scope
- Avatar groups or stacks, status dots, badges on avatars, and image fallback logic.
- Additional decorative hues beyond
--color-accent. - Showcase site changes, unless the maintainer confirms otherwise.
Model claude-opus-5-5 · ceiling $5.25 · started 47 minutes ago · finished 40 minutes ago
- Every other child goes in column 2 and stacks with the existing `--space-2` gap. Buttons keep their own width (`justify-self: start`). - Alerts without an icon still match only the old `.alert` rule, so they render as before. ### AI contract (`synthcss.llm.md`, `synthcss.ai.json`) - Both files now include the token, the `.avatar` component with its nine variants, and the `alert-icon` part. - Two new Intent Mapping rows: "Person initials or photo" maps to `.avatar` / `.avatar-round`, and "Icon on a tinted tile" maps to `.avatar` plus a tone variant. The "Message or notification" row now mentions `.alert-icon`. - **Examples:** I didn't have the proposal's HTML examples, so I wrote my own. The contract allows at most 4 valid examples and already has 4, so instead of adding one I extended the existing **Dashboard** example to show an alert with an icon, an accent icon tile in a card, and an activity list with initials and a photo. - I also added one invalid example (`user-avatar rounded-full`, which should be `.avatar avatar-round`), since the benchmark agents invented class names like these. - **Version:** `synthcssVersion` and `contractVersion` are unchanged. `docs/releasing.md` says pull requests must not change the version (the release workflow bumps it after merge), and `contractVersion` has only changed when the schema changed, which it didn't here. ### Checks and tests - **`scripts/check-components.mjs`**: - Adds `avatar` and `alert-icon` to the component list. - Allows exactly one local custom property, `--tone`, and only as a `var(--color-*)` token. - Resolves `--tone` when checking contrast, so all six tones are contrast-checked. - Checks that avatars are sized from `--control-height`, that round avatars use `--radius-full`, that images use `object-fit: cover`, and that tones use the 12% `color-mix`. - Checks that `.alert` stays a flexbox and only `:has(> .alert-icon)` switches to grid. - **New tests:** in `check-components.test.mjs` and `check-tokens.test.mjs`. - **`scripts/check-components-browser.mjs`**: checks that avatars are 0.75×/1×/1.5× squares with centred content, the round and square shapes, the accent colour, `object-fit: cover` on images, that the icon sits left of the content and top-aligned, and that a plain alert is still a column flexbox. ### Docs and showcase - **Docs:** `docs/components.md` gets a full `.avatar` section and an `.alert-icon` part with an example. Two reference-table rows were merged so the table stays within its 15-row limit. The "ten components" text and contract size in README and `docs/ai-contract.md` are updated. - **Showcase:** the spec puts it out of scope, but the existing `check-showcase` test fails unless every token and component is on the page. So I added only what it needs: an accent swatch, an avatar article, icons in two alert demos, and updated token counts. ## Verification - `npm test`: all checks plus 80 node tests pass. `npm run build` succeeds. - `check-components-browser`, `check-layout-browser` and `check-base-browser` pass (run with Playwright, which isn't a project dependency). - **`check-showcase-browser` fails** in this sandbox: the only errors are the external `https://nabled.ai/api/badges/button.svg` image not loading (certificate error, no network). This isn't related to this change, but I couldn't confirm the check passes. - I looked at screenshots of the alert and avatar demos: the icons line up and the avatars are centred at all three sizes. ## Worth a close look - **Initials font size:** it steps with the size classes instead of being calculated from `--control-height`. This is because `check-components` requires `font-size` to be a `--text-*` token. - **Icon colour:** an svg inside an avatar takes the tone only if it draws with `currentColor`. I didn't set `fill: currentColor` in CSS because that would override `fill="none"` on outline icons; the docs say to draw with `currentColor`. - **Loose text in an alert with an icon** should be wrapped in a `<p>`, so it lands in the content column. The docs say this. 2026-10-03T14:38:25Z [claude] I added `.avatar`, `--color-accent` and `.alert-icon`, updated the AI contract and docs, and pushed the branch `nabled/feature-8-job-9`. `npm test` passes (80 of 80) and the build succeeds. One browser check, `check-showcase-browser`, still fails here because the sandbox can't load an external badge image, so I couldn't confirm it passes. **What's in it** - **Token:** `--color-accent` is a violet, `#6847b0`. It contrasts 6.74:1 with the page background and 5.64:1 as text on its own 12% tint. The repo has only one theme, so there was nowhere else to add it. - **`.avatar`:** a fixed square chip, the same size as a button by default, for initials, a photo or an icon. It has a round variant, smaller and larger sizes, and six colour tones that each tint the background with 12% of their colour. - **`.alert-icon`:** an alert with an icon becomes two columns, with the icon top-left and the content stacked beside it. Alerts without an icon match only the old rule, so they render as before. **Contrast:** every tone meets the 4.5:1 minimum with the default colours, and the tests check this. | Tone | Ratio | | --- | --- | | primary | 6.21:1 | | success | 5.02:1 | | warning | 4.60:1 | | danger | 5.02:1 | | info | 5.14:1 | | accent | 5.64:1 | Success failed at first (4.46:1), so its text on the avatar is darkened with 10% of the normal text colour. The success colour itself is unchanged, so badges and alerts aren't affected. **Where I departed from the spec** - **Version numbers:** I didn't bump them. The project's release docs say pull requests must not change the version; the release workflow bumps it after merge. - **Examples:** I didn't have the proposal's HTML examples, so I wrote my own. The contract allows at most four valid examples and already had four, so I extended the existing Dashboard example to show all three features. I also added one "don't do this" example for invented avatar class names. - **Showcase:** the spec put it out of scope, but an existing test fails unless every token and component appears there. I added only what that test needs: a colour swatch, an avatar demo and icons in two alert demos. - **Initials text size:** it steps with the size classes (small, normal, extra large) rather than being calculated from the avatar's height. The project's checks require text sizes to come from its existing size scale. - **Icon colour:** an icon in an avatar takes the tone only if it's drawn in the current text colour (`currentColor`). Forcing that in CSS would fill in outline icons, so the docs ask authors to do it instead. I also extended the component checker (it now allows the one local `--tone` property and checks contrast for every tone) and added unit tests and browser checks for avatar sizing, shape and colour and for the icon's position. 2026-10-03T14:38:27Z Changed files: modified README.md +2 -2 modified docs/ai-contract.md +1 -1 modified docs/components.md +101 -9 modified docs/tokens.md +5 -0 modified scripts/check-components-browser.mjs +53 -3 modified scripts/check-components.mjs +76 -5 modified scripts/check-components.test.mjs +36 -0 modified scripts/check-tokens.mjs +2 -0 modified scripts/check-tokens.test.mjs +7 -0 modified showcase/index.html +40 -5 modified src/components.css +115 -2 modified src/tokens.css +1 -0 modified synthcss.ai.json +29 -5 modified synthcss.llm.md +32 -4 2026-10-03T14:38:29Z Opened pull request https://github.com/nabledhq/synthcss/pull/14 2026-10-03T14:38:29Z Finished: success=true turns=43 tokens(in/out)=4380847/39738 list cost=$2.36
Show patch
diff --git a/README.md b/README.md
index 14ebe8a..19cea83 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ See [docs/layout.md](docs/layout.md) for each primitive and a compact "AI Layout
## Components
-Ten semantic components in [`src/components.css`](src/components.css), included in the main bundle: `.button`, `.field`, `.card`, `.badge`, `.alert`, `.panel`, `.table`, `.empty-state`, `.nav` and `.tabs`, with a small set of variants (`.button-primary`, `.badge-success`, `.alert-danger`, …) and parts (`.card-header`, `.field-error`, `.nav-link`, `.tabs-item`, …). They use only tokens, take their state from native attributes (`disabled`, `aria-busy="true"`, `aria-invalid="true"`, `aria-current="page"` on the current `.nav-link`, `aria-selected="true"` on the selected `.tabs-item`) and need no JavaScript:
+Eleven semantic components in [`src/components.css`](src/components.css), included in the main bundle: `.button`, `.field`, `.card`, `.badge`, `.alert`, `.panel`, `.table`, `.empty-state`, `.nav`, `.tabs` and `.avatar`, with a small set of variants (`.button-primary`, `.badge-success`, `.alert-danger`, `.avatar-round`, …) and parts (`.card-header`, `.field-error`, `.alert-icon`, `.nav-link`, `.tabs-item`, …). They use only tokens, take their state from native attributes (`disabled`, `aria-busy="true"`, `aria-invalid="true"`, `aria-current="page"` on the current `.nav-link`, `aria-selected="true"` on the selected `.tabs-item`) and need no JavaScript:
```html
<form class="card">
@@ -63,7 +63,7 @@ See [docs/components.md](docs/components.md) for each component's variants, comp
## AI contract
-[`synthcss.llm.md`](synthcss.llm.md) is the whole public vocabulary in one prompt-ready file of about 3,500 tokens: every token, layout primitive, component, part and variant, an intent table, composition rules, ten generation rules and valid and invalid examples. Paste it into a model's context. [`synthcss.ai.json`](synthcss.ai.json) is the same contract as structured data and the canonical source. Both state the SynthCSS version they describe; the schema is in [docs/ai-contract.md](docs/ai-contract.md).
+[`synthcss.llm.md`](synthcss.llm.md) is the whole public vocabulary in one prompt-ready file of about 3,900 tokens: every token, layout primitive, component, part and variant, an intent table, composition rules, ten generation rules and valid and invalid examples. Paste it into a model's context. [`synthcss.ai.json`](synthcss.ai.json) is the same contract as structured data and the canonical source. Both state the SynthCSS version they describe; the schema is in [docs/ai-contract.md](docs/ai-contract.md).
**Any change to the public API (a class or token added, renamed or removed, or a new version) must update both contract files in the same pull request.** `npm test` runs `scripts/verify-ai-contract.mjs`, which fails when the contract and the CSS or `package.json` disagree.
diff --git a/docs/ai-contract.md b/docs/ai-contract.md
index 1c88943..ede3e6e 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 3,500 tokens. |
+| [`synthcss.llm.md`](../synthcss.llm.md) | Pasting into a prompt | A terse hand-written twin of the JSON, one line per item, about 3,900 tokens. |
Both are published next to the showcase on GitHub Pages
(`<site>/synthcss.llm.md`, `<site>/synthcss.ai.json`) and are in every release tag.
diff --git a/docs/components.md b/docs/components.md
index cbd0ac8..1cbc410 100644
--- a/docs/components.md
+++ b/docs/components.md
@@ -1,10 +1,10 @@
# Components
-SynthCSS ships ten semantic components in [`src/components.css`](../src/components.css):
+SynthCSS ships eleven semantic components in [`src/components.css`](../src/components.css):
`.button`, `.field`, `.card`, `.badge`, `.alert`, `.panel`, `.table`, `.empty-state`,
-`.nav` and `.tabs`. Each one names a UI intent, so a model can map a request ("a delete
-button", "an error message", "a table of invoices", "a segmented filter") to one
-predictable class.
+`.nav`, `.tabs` and `.avatar`. Each one names a UI intent, so a model can map a request
+("a delete button", "an error message", "a table of invoices", "a segmented filter",
+"a user's initials") to one predictable class.
They are part of the main bundle, together with the [design tokens](tokens.md) and the
[layout primitives](layout.md):
@@ -54,18 +54,18 @@ Paste this table into a model's context together with the
| Main action of a form or page | `<button class="button button-primary">` |
| Secondary or cancel action | `.button button-secondary`, or plain `.button` |
| Destructive action (delete, remove) | `.button button-danger` |
-| Icon-only action | `.button button-icon` + `aria-label="…"` |
-| Smaller or larger button | add `.button-sm` / `.button-lg` |
+| Icon-only, smaller or larger button | `.button button-icon` + `aria-label="…"`; add `.button-sm` / `.button-lg` |
| Unavailable or in-progress action | `disabled` or `aria-disabled="true"`; `aria-busy="true"` |
| Labeled input with help or error text | `.field` > `.field-label` + native control + `.field-help` / `.field-error`; `aria-invalid="true"` on an invalid control |
| Self-contained item (project, product, user) | `.card` with `.card-header`, `.card-body`, `.card-footer`, `.card-media`, `.card-actions` |
| Short status label | `.badge` + `.badge-success` / `-warning` / `-danger` / `-info` |
-| Message or notification | `.alert` + `.alert-info` / `-success` / `-warning` / `-danger`, `role="status"` or `role="alert"` |
+| Message or notification | `.alert` + `.alert-info` / `-success` / `-warning` / `-danger`, `role="status"` or `role="alert"`; optional leading `<svg class="alert-icon">` |
| Flat group of secondary content | `.panel` with `.panel-header`, `.panel-body` |
| Tabular data | `<table class="table">` inside `.table-wrap`; `.table-hover`, `.numeric` cells |
| Nothing to show yet | `.empty-state` with heading, text and an optional action |
| Navigation links | `<ul class="nav stack-sm">` or `nav cluster-sm` > `<a class="nav-link">`; `aria-current="page"` on the current one |
| Tabs or segmented filter | `.tabs` + `role="tablist"` > `<button class="tabs-item" role="tab">`; `aria-selected="true"` / `"false"` |
+| Person initials, photo, or an icon on a tinted tile | `.avatar` (+ `.avatar-round`, `.avatar-sm` / `.avatar-lg`, `.avatar-primary` / `-success` / `-warning` / `-danger` / `-info` / `-accent`) |
Rules: state comes from attributes, never from classes; put components on native
elements; arrange them with `.stack`, `.cluster`, `.split` and `.grid`.
@@ -402,6 +402,20 @@ and buttons can go inside in any order without special parts: children are space
Every alert has a thick start border, so it reads as a callout even without color; the
variant also tints the background. Body text stays `--color-text`.
+| Part | Use |
+| --- | --- |
+| `.alert-icon` | An `<svg>` (or `<img>`) as a direct child. The alert becomes two columns: the icon, 1.25em wide and one text line tall, sits at the top of the first; every other child stacks in the second with the usual gap. Alerts without it are unchanged. |
+
+```html
+<div class="alert alert-warning" role="status">
+ <svg class="alert-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/></svg>
+ <h2>Trial ends in 3 days</h2>
+ <p>Upgrade to keep your projects running.</p>
+</div>
+```
+
+Wrap loose text in a `<p>` when the alert has an icon, so it lands in the content column.
+
```html
<div class="alert alert-danger" role="alert">
<h2>Payment failed</h2>
@@ -437,6 +451,8 @@ Put an alert at the top of a `.stack` (page or form), and actions inside it in a
the page loads can omit the role.
- Start the message with what happened ("Payment failed"), so it is clear without
color.
+- An `.alert-icon` is decoration: give it `aria-hidden="true"`; the text carries the
+ message.
### Recommended use
@@ -795,6 +811,80 @@ filters, and tabbed sections on one page.
- Toggle buttons with `aria-pressed`; SynthCSS tabs use `aria-selected`.
- A single on/off option; use a checkbox in a `.field`.
+## `.avatar`
+
+### Purpose
+
+A small fixed square for a person's initials or photo, or an icon on a tinted tile. It
+works on a `<span>`, a `<div>` or directly on an `<img>`. Content is centered both ways,
+initials do not wrap, and anything larger is clipped.
+
+### Example
+
+```html
+<span class="avatar avatar-round avatar-primary">AL</span>
+<img class="avatar avatar-round" src="/people/ana.jpg" alt="Ana Lima">
+<span class="avatar avatar-accent"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg></span>
+```
+
+### Variants
+
+| Class | Use |
+| --- | --- |
+| `.avatar` | `--control-height` square with `--radius-md` corners on `--color-surface`, in `--color-text`. Initials in `--text-base`, semibold. |
+| `.avatar-round` | A circle (`--radius-full`), the usual shape for people. |
+| `.avatar-sm`, `.avatar-lg` | 0.75× and 1.5× `--control-height`, with `--text-sm` and `--text-2xl` initials. |
+| `.avatar-primary`, `.avatar-success`, `.avatar-warning`, `.avatar-danger`, `.avatar-info`, `.avatar-accent` | A tone: the variant sets `--tone` to its color token, the background is `color-mix(in srgb, var(--tone) 12%, var(--color-background))` and the content is drawn in the tone. |
+
+A child `<img>`, or `.avatar` on an `<img>`, fills the box with `object-fit: cover`. A
+child `<svg>` takes 55% of the box; draw it with `currentColor` (`fill` or `stroke`) so
+it takes the tone. `.avatar-accent` uses `--color-accent`, a violet with no status
+meaning, for decorative tiles.
+
+With the default tokens every tone meets 4.5:1 on its tint, and `npm test` checks it:
+primary 6.21:1, success 5.02:1, warning 4.60:1, danger 5.02:1, info 5.14:1, accent
+5.64:1. `--color-success` alone is 4.46:1 on its 12% tint, so `.avatar-success` draws
+its content in `color-mix(in srgb, var(--tone) 90%, var(--color-text))`.
+
+### Composition
+
+Put an avatar in front of a name with `.cluster`, or at the start of a table cell or
+list row. Size steps follow `--control-height`, so a compact theme shrinks them too:
+
+```html
+<ul class="stack-sm" role="list">
+ <li class="cluster-sm">
+ <span class="avatar avatar-round avatar-info avatar-sm" aria-hidden="true">BK</span>
+ <span>Ben Kim</span>
+ </li>
+ <li class="cluster-sm">
+ <img class="avatar avatar-round avatar-sm" src="/people/ana.jpg" alt="">
+ <span>Ana Lima</span>
+ </li>
+</ul>
+```
+
+### Accessibility
+
+- Next to the person's name, the avatar repeats it: hide initials with
+ `aria-hidden="true"` and give a photo `alt=""`.
+- On its own, name the person: `alt="Ana Lima"` on an `<img>`, or `role="img"` with
+ `aria-label="Ana Lima"` on initials.
+- Icons in a tile are decoration: `aria-hidden="true"` on the `<svg>`, with a visible
+ label next to the tile.
+- A tone is decoration too; do not use `.avatar-danger` as the only sign of an error.
+
+### Recommended use
+
+Members and authors in lists, tables and cards, account menus, and icon tiles on
+dashboard stats.
+
+### Misuse
+
+- Long text: an avatar holds one to three characters.
+- Buttons or links styled as avatars; wrap the avatar in a `.button` or `<a>` instead.
+- Status dots, stacks or badges on avatars: SynthCSS has none of these.
+
## Verification
- `npm test` runs [`scripts/check-components.mjs`](../scripts/check-components.mjs). It
@@ -802,8 +892,10 @@ filters, and tabbed sections on one page.
(`src/synthcss.css` with its imports inlined) and no other class is added; that every
declaration uses tokens defined in `tokens.css`, with no hex, `rgb()`, `hsl()` or named
color literals and no hard-coded lengths; that text on every button, badge, alert,
- card and panel background, and on the current nav link and default and selected tabs,
- meets 4.5:1 contrast; that buttons, field controls, nav links, tabs and `.table-wrap`
+ card, panel and avatar background (including every avatar tone on its tint), and on the
+ current nav link and default and selected tabs, meets 4.5:1 contrast; that avatars are
+ sized from `--control-height`, tones tint with 12% of `--tone`, and only an alert with
+ an `.alert-icon` switches to a grid; that buttons, field controls, nav links, tabs and `.table-wrap`
have `:focus-visible` rings; that `.nav` resets the list without setting a layout,
the current nav link (`aria-current="page"`) and selected tab
(`aria-selected="true"`) are styled from those attributes, `aria-pressed` is not used
diff --git a/docs/tokens.md b/docs/tokens.md
index d39fd9e..89ab79b 100644
--- a/docs/tokens.md
+++ b/docs/tokens.md
@@ -34,6 +34,7 @@ not tied to any brand. Text colors meet WCAG AA, and `npm test` checks this (see
| `--color-warning` | `#9a5b0c` | Warning state: needs attention, not blocking. |
| `--color-danger` | `#b3362e` | Error and destructive state: errors, delete actions. |
| `--color-info` | `#2f6694` | Neutral informational state: tips, notices. |
+| `--color-accent` | `#6847b0` | Decorative violet with no status meaning: avatars, icon tiles, categories. Meets 4.5:1 as text on its own 12% tint. |
### Spacing
@@ -204,6 +205,9 @@ computed styles of fixture pages in Chromium. It needs Playwright.
- Use semantic color tokens for their stated purpose. For example, use
`--color-danger` for destructive actions, not because you want red.
- If text is placed on `--color-primary`, color it with `--color-on-primary`.
+- Use `--color-accent` for decoration that carries no status, such as an
+ `.avatar-accent` tile. If you change it, keep it at least 4.5:1 as text on
+ `color-mix(in srgb, var(--color-accent) 12%, var(--color-background))`.
Common requests and the tokens to change:
@@ -228,5 +232,6 @@ Keep the spacing scale increasing (`--space-1` < `--space-2` < …) when you cha
- `--color-text-muted` on `--color-background`: at least 4.5:1
- `--color-on-primary` on `--color-primary`: at least 4.5:1
- `--focus-color` on `--color-background`: at least 3:1
+ - `--color-accent` on `--color-background`: at least 4.5:1
When you add or rename a token, add it to the matching table in this file in the same change.
diff --git a/scripts/check-components-browser.mjs b/scripts/check-components-browser.mjs
index 665f0f2..017406b 100644
--- a/scripts/check-components-browser.mjs
+++ b/scripts/check-components-browser.mjs
@@ -1,7 +1,7 @@
#!/usr/bin/env node
// Optional browser check of the components on showcase/index.html at 375px and
-// 1280px viewport width: tokens applied, states, focus rings and table scrolling,
-// plus .tabs at 320px.
+// 1280px viewport width: tokens applied, states, focus rings, table scrolling,
+// avatar sizes and .alert-icon placement, plus .tabs at 320px.
// Needs Playwright, which is not a dependency of this repository:
// npm install --no-save playwright && npx playwright install chromium
// Usage: node scripts/check-components-browser.mjs
@@ -102,6 +102,45 @@ try {
tabTrackBg: getComputedStyle(selected.parentElement).backgroundColor === token("--color-surface"),
};
}
+ // Avatars: fixed squares from --control-height with centered content;
+ // .alert-icon beside the content, and plain alerts still a column flexbox.
+ function avatarsAndAlerts() {
+ const probeSize = document.createElement("div");
+ probeSize.style.inlineSize = "var(--control-height)";
+ document.body.append(probeSize);
+ const control = probeSize.getBoundingClientRect().width;
+ probeSize.remove();
+ const avatars = [...document.querySelectorAll("#component-avatar ~ .sc-demo .avatar")];
+ const box = (el) => el.getBoundingClientRect();
+ const near = (a, b) => Math.abs(a - b) < 1;
+ const expected = (el) => control * (el.classList.contains("avatar-sm") ? 0.75 : el.classList.contains("avatar-lg") ? 1.5 : 1);
+ const centered = (el) => {
+ const child = el.firstElementChild;
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ const inner = child ? box(child) : range.getBoundingClientRect();
+ const outer = box(el);
+ return near(inner.left + inner.width / 2, outer.left + outer.width / 2) && near(inner.top + inner.height / 2, outer.top + outer.height / 2);
+ };
+ const round = avatars.find((a) => a.classList.contains("avatar-round"));
+ const square = avatars.find((a) => !a.classList.contains("avatar-round"));
+ const accent = avatars.find((a) => a.classList.contains("avatar-accent"));
+ const img = avatars.find((a) => a.tagName === "IMG");
+ const iconAlert = document.querySelector("#component-alert ~ .sc-demo .alert:has(> .alert-icon)");
+ const icon = iconAlert.querySelector(".alert-icon");
+ const content = icon.nextElementSibling;
+ const plainAlert = document.querySelector("#component-alert ~ .sc-demo .alert:not(:has(> .alert-icon))");
+ return {
+ avatarSizes: avatars.length > 0 && avatars.every((a) => near(box(a).width, expected(a)) && near(box(a).height, expected(a))),
+ avatarCentered: avatars.filter((a) => a.tagName !== "IMG").every(centered),
+ avatarRound: getComputedStyle(round).borderTopLeftRadius === getComputedStyle(probe).getPropertyValue("--radius-full").trim(),
+ avatarSquareRadius: parseFloat(getComputedStyle(square).borderTopLeftRadius) > 0 && parseFloat(getComputedStyle(square).borderTopLeftRadius) < box(square).width / 2,
+ avatarAccent: getComputedStyle(accent).color === token("--color-accent"),
+ avatarImgCover: getComputedStyle(img).objectFit === "cover",
+ alertIconBeside: box(icon).right <= box(content).left && near(box(icon).top, box(content).top),
+ alertPlainColumn: getComputedStyle(plainAlert).display === "flex" && getComputedStyle(plainAlert).flexDirection === "column",
+ };
+ }
const result = {
overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
primaryBg: css("#components .button-primary", "background-color") === token("--color-primary"),
@@ -119,6 +158,7 @@ try {
emptyAlign: css("#components .empty-state", "text-align"),
wraps,
...navAndTabs(),
+ ...avatarsAndAlerts(),
};
probe.remove();
return result;
@@ -147,6 +187,14 @@ try {
expect(m.tabSelectedBg && m.tabTrackBg, `${width}px: .tabs track or selected .tabs-item does not use its surface tokens`);
expect(m.tabSelectedDistinct && m.tabUnselectedSame, `${width}px: aria-selected="true" is not distinct, or aria-selected="false" items differ`);
+ expect(m.avatarSizes, `${width}px: .avatar sizes are not 0.75×, 1× and 1.5× --control-height squares`);
+ expect(m.avatarCentered, `${width}px: .avatar content is not centered`);
+ expect(m.avatarRound && m.avatarSquareRadius, `${width}px: .avatar-round is not a circle, or .avatar is not a rounded square`);
+ expect(m.avatarAccent, `${width}px: .avatar-accent content is not --color-accent`);
+ expect(m.avatarImgCover, `${width}px: img.avatar does not use object-fit: cover`);
+ expect(m.alertIconBeside, `${width}px: .alert-icon is not to the left of the content, top-aligned`);
+ expect(m.alertPlainColumn, `${width}px: an .alert without an icon is no longer a column flexbox`);
+
for (const [what, sel] of [
["button", "#components .button"],
["text input", "#components .field input[type=text]"],
@@ -199,4 +247,6 @@ if (errors.length) {
console.error(`\ncheck-components-browser: ${errors.length} problem(s) found.`);
process.exit(1);
}
-console.log("check-components-browser: components render with tokens, states, focus rings and scrolling tables at 375px and 1280px; .tabs wraps at 320px.");
+console.log(
+ "check-components-browser: components render with tokens, states, focus rings and scrolling tables at 375px and 1280px; avatars are centered squares and .alert-icon sits beside the content; .tabs wraps at 320px.",
+);
diff --git a/scripts/check-components.mjs b/scripts/check-components.mjs
index 6f84519..b12f0db 100644
--- a/scripts/check-components.mjs
+++ b/scripts/check-components.mjs
@@ -15,13 +15,31 @@ export const COMPONENTS = {
field: ["field", "field-label", "field-help", "field-error"],
card: ["card", "card-header", "card-body", "card-footer", "card-media", "card-actions"],
badge: ["badge", "badge-success", "badge-warning", "badge-danger", "badge-info"],
- alert: ["alert", "alert-info", "alert-success", "alert-warning", "alert-danger"],
+ alert: ["alert", "alert-info", "alert-success", "alert-warning", "alert-danger", "alert-icon"],
panel: ["panel", "panel-header", "panel-body"],
table: ["table", "table-wrap", "table-hover", "numeric"],
"empty-state": ["empty-state"],
nav: ["nav", "nav-link"],
tabs: ["tabs", "tabs-item"],
+ avatar: [
+ "avatar",
+ "avatar-round",
+ "avatar-sm",
+ "avatar-lg",
+ "avatar-primary",
+ "avatar-success",
+ "avatar-warning",
+ "avatar-danger",
+ "avatar-info",
+ "avatar-accent",
+ ],
};
+// Tone variants of .avatar: each sets the local --tone to a color token and is
+// tinted with color-mix(in srgb, var(--tone) 12%, var(--color-background)).
+export const AVATAR_TONES = ["primary", "success", "warning", "danger", "info", "accent"];
+// The only custom properties components.css may set, each to a var(--color-*)
+// token on the component's own variant classes.
+export const LOCAL_PROPERTIES = ["--tone"];
export const COMPONENT_NAMES = Object.keys(COMPONENTS);
export const COMPONENT_CLASSES = Object.values(COMPONENTS).flat();
@@ -44,6 +62,8 @@ const COLOR_VARIANTS = [
["alert-danger", "alert"],
["card", "card"],
["panel", "panel"],
+ ["avatar", "avatar"],
+ ...AVATAR_TONES.map((t) => [`avatar-${t}`, "avatar"]),
];
// Attribute-driven states: [label, selectors merged in order]. The text color
@@ -132,6 +152,11 @@ export function classesInCss(css) {
return out;
}
+// Custom properties set in a list of declarations (such as --tone), resolved
+// on top of the tokens.
+const withLocals = (tokens, decls) =>
+ new Map([...tokens, ...decls.filter((d) => d.prop.startsWith("--")).map((d) => [d.prop, d.value])]);
+
function colorOf(value, tokens) {
const v = value.trim();
let m = /^var\((--[\w-]+)\)$/.exec(v);
@@ -178,7 +203,12 @@ export function checkComponentsCss(componentsCss, tokensCss) {
for (const r of rules) {
for (const d of r.decls) {
const w = where(r, d);
- if (d.prop.startsWith("--")) errors.push(`components.css must not define custom properties: ${w}`);
+ if (d.prop.startsWith("--") && !LOCAL_PROPERTIES.includes(d.prop)) {
+ errors.push(`components.css must not define custom properties other than ${LOCAL_PROPERTIES.join(", ")}: ${w}`);
+ }
+ if (LOCAL_PROPERTIES.includes(d.prop) && !/^var\(--color-[\w-]+\)$/.test(d.value)) {
+ errors.push(`${w} must be a var(--color-*) token`);
+ }
if (COLOR_LITERAL.test(d.value)) errors.push(`hard-coded color literal in ${w}; use a var(--color-*) token`);
if (NAMED_COLOR.test(d.value)) errors.push(`named color in ${w}; use a var(--color-*) token`);
if (COLOR_PROPS.test(d.prop) && !TOKEN_COLOR.test(d.value)) {
@@ -190,7 +220,9 @@ export function checkComponentsCss(componentsCss, tokensCss) {
}
if (TOKEN_PROPS[d.prop] && !TOKEN_PROPS[d.prop].test(d.value)) errors.push(`${w} must use a matching var(--token)`);
for (const m of d.value.matchAll(/var\(\s*(--[\w-]+)/g)) {
- if (!tokens.has(m[1])) errors.push(`${w} references ${m[1]}, which is not defined in tokens.css`);
+ if (!tokens.has(m[1]) && !LOCAL_PROPERTIES.includes(m[1])) {
+ errors.push(`${w} references ${m[1]}, which is not defined in tokens.css`);
+ }
}
}
}
@@ -282,6 +314,44 @@ export function checkComponentsCss(componentsCss, tokensCss) {
errors.push('components.css must not style aria-pressed; tabs use aria-selected="true"');
}
+ // Avatar: a fixed square sized from --control-height, round variant, tones
+ // that tint with 12% of --tone so token overrides propagate.
+ const avatar = declsOf(".avatar");
+ for (const prop of ["inline-size", "block-size"]) {
+ if (!avatar.some((d) => d.prop === prop && d.value === "var(--control-height)")) {
+ errors.push(`.avatar must set ${prop}: var(--control-height)`);
+ }
+ }
+ for (const [prop, value] of [["overflow", "hidden"], ["flex-shrink", "0"], ["border-radius", "var(--radius-md)"], ["white-space", "nowrap"]]) {
+ if (!avatar.some((d) => d.prop === prop && d.value === value)) errors.push(`.avatar must set ${prop}: ${value}`);
+ }
+ if (!declsOf(".avatar-round").some((d) => d.prop === "border-radius" && d.value === "var(--radius-full)")) {
+ errors.push(".avatar-round must set border-radius: var(--radius-full)");
+ }
+ for (const size of ["avatar-sm", "avatar-lg"]) {
+ for (const prop of ["inline-size", "block-size"]) {
+ if (!declsOf(`.${size}`).some((d) => d.prop === prop && /^calc\(var\(--control-height\) \* [\d.]+\)$/.test(d.value))) {
+ errors.push(`.${size} must set ${prop} to calc(var(--control-height) * N)`);
+ }
+ }
+ }
+ if (!has((r) => sel(r, /\.avatar\b.*img/) && decl(r, "object-fit", /^cover$/))) errors.push(".avatar images must use object-fit: cover");
+ for (const tone of AVATAR_TONES) {
+ const decls = declsOf(`.avatar-${tone}`);
+ if (!decls.some((d) => d.prop === "--tone" && d.value === `var(--color-${tone})`)) {
+ errors.push(`.avatar-${tone} must set --tone: var(--color-${tone})`);
+ }
+ if (!decls.some((d) => d.prop === "background" && d.value === "color-mix(in srgb, var(--tone) 12%, var(--color-background))")) {
+ errors.push(`.avatar-${tone} must use background: color-mix(in srgb, var(--tone) 12%, var(--color-background))`);
+ }
+ }
+
+ // Alert icon: only an alert with an .alert-icon child changes layout.
+ if (!has((r) => sel(r, /^\.alert:has\(> \.alert-icon\)$/) && decl(r, "display", /^grid$/))) {
+ errors.push(".alert:has(> .alert-icon) must switch to display: grid");
+ }
+ if (declsOf(".alert").some((d) => d.prop === "display" && d.value !== "flex")) errors.push(".alert must stay display: flex");
+
// Contrast of text on each variant's and state's background.
const plainDecls = (cls) => declsOf(`.${cls}`);
const last = (decls, props) => decls.filter((d) => props.includes(d.prop)).at(-1)?.value;
@@ -301,8 +371,9 @@ export function checkComponentsCss(componentsCss, tokensCss) {
continue;
}
try {
- const a = colorOf(fg, tokens);
- const b = colorOf(bg, tokens);
+ const scope = withLocals(tokens, decls);
+ const a = colorOf(fg, scope);
+ const b = colorOf(bg, scope);
if (!a || !b) throw new Error("could not resolve colors");
const ratio = contrastRatio(a, b);
if (ratio < 4.5) errors.push(`contrast too low in ${label}: ${ratio.toFixed(2)}:1 (min 4.5:1)`);
diff --git a/scripts/check-components.test.mjs b/scripts/check-components.test.mjs
index b709e7f..3827a90 100644
--- a/scripts/check-components.test.mjs
+++ b/scripts/check-components.test.mjs
@@ -161,3 +161,39 @@ test("fails when doc examples or the showcase use classes that are not in the CS
const live = files.showcaseHtml.replace('<span class="badge badge-info">Pro plan</span>', '<span class="pill">Pro plan</span>');
assertError(errorsWith({ showcaseHtml: live }), "uses .pill, which is neither");
});
+
+test("fails when an avatar is not sized from --control-height or loses its shape", () => {
+ const fixed = withCss((css) => css.replace(/(\.avatar \{[^}]*)inline-size: var\(--control-height\);/, "$1inline-size: var(--input-height);"));
+ assertError(errorsWith(fixed), ".avatar must set inline-size: var(--control-height)");
+ const sm = withCss((css) => css.replace(/(\.avatar-sm \{[^}]*)block-size: calc\(var\(--control-height\) \* 0\.75\);/, "$1block-size: var(--control-height);"));
+ assertError(errorsWith(sm), ".avatar-sm must set block-size to calc(var(--control-height) * N)");
+ const round = withCss((css) => css.replace(/(\.avatar-round \{[^}]*)var\(--radius-full\)/, "$1var(--radius-lg)"));
+ assertError(errorsWith(round), ".avatar-round must set border-radius: var(--radius-full)");
+ const cover = withCss((css) => css.replace("object-fit: cover;\n}\n\n.avatar > :where(img)", "object-fit: fill;\n}\n\n.avatar > :where(img)"));
+ assertError(errorsWith(cover), ".avatar images must use object-fit: cover");
+});
+
+test("fails when an avatar tone does not tint through --tone", () => {
+ const token = withCss((css) => css.replace(".avatar-accent { --tone: var(--color-accent); }", ".avatar-accent { --tone: var(--color-info); }"));
+ assertError(errorsWith(token), ".avatar-accent must set --tone: var(--color-accent)");
+ const mix = withCss((css) => css.replace("var(--tone) 12%, var(--color-background)", "var(--tone) 20%, var(--color-background)"));
+ assertError(errorsWith(mix), "must use background: color-mix(in srgb, var(--tone) 12%, var(--color-background))");
+ const literal = withCss((css) => css.replace(".avatar-info { --tone: var(--color-info); }", ".avatar-info { --tone: 1rem; }"));
+ assertError(errorsWith(literal), "must be a var(--color-*) token");
+ const other = withCss((css) => css + "\n.avatar { --size: var(--control-height); }\n");
+ assertError(errorsWith(other), "must not define custom properties other than --tone");
+});
+
+test("fails when an avatar tone's foreground has too little contrast on its tint", () => {
+ const success = withCss((css) => css.replace("color-mix(in srgb, var(--tone) 90%, var(--color-text))", "var(--tone)"));
+ assertError(errorsWith(success), "contrast too low in .avatar-success: 4.46:1");
+ const tokensCss = files.tokensCss.replace(/--color-accent: #[0-9a-f]+;/, "--color-accent: #a78bfa;");
+ assertError(errorsWith({ tokensCss }), "contrast too low in .avatar-accent");
+});
+
+test("fails when .alert-icon does not switch the alert layout or .alert itself changes", () => {
+ const noGrid = withCss((css) => css.replace(/(\.alert:has\(> \.alert-icon\) \{[^}]*)display: grid;/, "$1"));
+ assertError(errorsWith(noGrid), ".alert:has(> .alert-icon) must switch to display: grid");
+ const changed = withCss((css) => css.replace(/(\.alert \{[^}]*)display: flex;/, "$1display: grid;"));
+ assertError(errorsWith(changed), ".alert must stay display: flex");
+});
diff --git a/scripts/check-tokens.mjs b/scripts/check-tokens.mjs
index ee0c6ed..8982724 100644
--- a/scripts/check-tokens.mjs
+++ b/scripts/check-tokens.mjs
@@ -21,6 +21,7 @@ export const REQUIRED_TOKENS = [
"--color-warning",
"--color-danger",
"--color-info",
+ "--color-accent",
"--space-1",
"--space-2",
"--space-3",
@@ -77,6 +78,7 @@ export const CONTRAST_PAIRS = [
["--color-text-muted", "--color-background", 4.5],
["--color-on-primary", "--color-primary", 4.5],
["--focus-color", "--color-background", 3],
+ ["--color-accent", "--color-background", 4.5],
];
const COLOR_NAME_RE =
diff --git a/scripts/check-tokens.test.mjs b/scripts/check-tokens.test.mjs
index 62fa926..455232a 100644
--- a/scripts/check-tokens.test.mjs
+++ b/scripts/check-tokens.test.mjs
@@ -56,3 +56,10 @@ test("contrast math matches WCAG reference values", () => {
assert.equal(ratio("#777777", "#ffffff").toFixed(2), "4.48");
assert.equal(ratio("rgb(255 255 255)", "#ffffff").toFixed(2), "1.00");
});
+
+test("fails when --color-accent is missing or too light", () => {
+ const missing = css.replace(/^\s*--color-accent:.*$/m, "");
+ assert.ok(errorsFor(missing, docs).some((e) => e.includes("missing required token --color-accent")));
+ const light = css.replace(/--color-accent: #[0-9a-f]+;/, "--color-accent: #b9a6e8;");
+ assert.ok(errorsFor(light, docs).some((e) => e.startsWith("contrast too low, --color-accent")));
+});
diff --git a/showcase/index.html b/showcase/index.html
index 7519bf9..f318eb3 100644
--- a/showcase/index.html
+++ b/showcase/index.html
@@ -116,6 +116,7 @@ <h3>Colors</h3>
<li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-warning)"></span><code>--color-warning</code><code class="sc-value" data-token="--color-warning"></code></li>
<li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-danger)"></span><code>--color-danger</code><code class="sc-value" data-token="--color-danger"></code></li>
<li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-info)"></span><code>--color-info</code><code class="sc-value" data-token="--color-info"></code></li>
+ <li class="sc-swatch"><span class="sc-swatch-chip" style="background: var(--color-accent)"></span><code>--color-accent</code><code class="sc-value" data-token="--color-accent"></code></li>
</ul>
</div>
@@ -452,7 +453,7 @@ <h3 id="responsive-split"><code>.split</code>: adapts</h3>
<div class="stack-sm">
<h2 id="components-title">Components</h2>
<p class="sc-muted">
- Ten semantic components built only from tokens. State comes from native
+ Eleven semantic components built only from tokens. State comes from native
attributes such as <code>disabled</code>, <code>aria-busy</code>,
<code>aria-invalid</code>, <code>aria-current</code> and
<code>aria-selected</code>. Press <kbd>Tab</kbd> to see the focus rings.
@@ -612,10 +613,13 @@ <h3 id="component-badge"><code>.badge</code></h3>
<article class="sc-card stack" aria-labelledby="component-alert">
<h3 id="component-alert"><code>.alert</code></h3>
- <p>A message box for status and errors. Headings, paragraphs and buttons inside are spaced automatically.</p>
+ <p>A message box for status and errors. Headings, paragraphs and buttons inside are spaced automatically; an <code>.alert-icon</code> sits to their left.</p>
<div class="sc-demo">
<div class="stack-sm">
- <div class="alert alert-info" role="status"><p>A new version is available.</p></div>
+ <div class="alert alert-info" role="status">
+ <svg class="alert-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></svg>
+ <p>A new version is available.</p>
+ </div>
<div class="alert alert-success" role="status"><p>Settings saved.</p></div>
<div class="alert alert-warning" role="status">
<h4>Trial ends in 3 days</h4>
@@ -623,6 +627,7 @@ <h4>Trial ends in 3 days</h4>
<button type="button" class="button button-sm">Add payment method</button>
</div>
<div class="alert alert-danger" role="alert">
+ <svg class="alert-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v6M12 16h.01"/></svg>
<h4>Deployment failed</h4>
<p>The build step exited with code 1.</p>
</div>
@@ -633,6 +638,7 @@ <h4>Deployment failed</h4>
<p>Settings saved.</p>
</div>
<div class="alert alert-danger" role="alert">
+ <svg class="alert-icon" aria-hidden="true" viewBox="0 0 24 24">…</svg>
<h2>Deployment failed</h2>
<p>The build step exited with code 1.</p>
<button type="button" class="button button-sm">View logs</button>
@@ -768,6 +774,35 @@ <h3 id="component-tabs"><code>.tabs</code></h3>
</div></code></pre></div>
</article>
+ <article class="sc-card stack" aria-labelledby="component-avatar">
+ <h3 id="component-avatar"><code>.avatar</code></h3>
+ <p>A fixed square for initials, a photo or an icon on a 12% tint of its tone. Sizes follow <code>--control-height</code>.</p>
+ <div class="sc-demo">
+ <div class="stack">
+ <div class="cluster-sm">
+ <span class="avatar avatar-sm avatar-round avatar-primary" aria-hidden="true">AL</span>
+ <span class="avatar avatar-round avatar-info" aria-hidden="true">BK</span>
+ <span class="avatar avatar-lg avatar-round" aria-hidden="true">CM</span>
+ <img class="avatar avatar-round" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' fill='%237d93ab'/%3E%3Ccircle cx='20' cy='16' r='7' fill='%23e8edf2'/%3E%3Cpath d='M6 40a14 14 0 0 1 28 0z' fill='%23e8edf2'/%3E%3C/svg%3E" alt="Placeholder photo">
+ </div>
+ <div class="cluster-sm">
+ <span class="avatar avatar-primary"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg></span>
+ <span class="avatar avatar-success"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg></span>
+ <span class="avatar avatar-warning"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg></span>
+ <span class="avatar avatar-danger"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg></span>
+ <span class="avatar avatar-info"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg></span>
+ <span class="avatar avatar-accent"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg></span>
+ </div>
+ </div>
+ </div>
+ <div class="sc-code"><pre><code><div class="cluster-sm">
+ <span class="avatar avatar-round avatar-primary" aria-hidden="true">AL</span>
+ <span>Ana Lima</span>
+</div>
+<img class="avatar avatar-round avatar-lg" src="/people/ben.jpg" alt="Ben Kim">
+<span class="avatar avatar-accent"><svg aria-hidden="true" viewBox="0 0 24 24">…</svg></span></code></pre></div>
+ </article>
+
</div>
</div>
</section>
@@ -1004,10 +1039,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">≈ 3,490 tokens</span>
+ <span class="badge badge-info">≈ 3,930 tokens</span>
</div>
<p class="sc-muted">
- Size estimated as characters ÷ 4 (about 11,300 characters), small enough for one
+ Size estimated as characters ÷ 4 (about 15,700 characters), small enough for one
context window with room to spare.
</p>
<article class="sc-card sidebar-lg">
diff --git a/src/components.css b/src/components.css
index 69c67a2..249eb5b 100644
--- a/src/components.css
+++ b/src/components.css
@@ -1,13 +1,15 @@
/*
* SynthCSS components
*
- * Ten semantic components on top of the design tokens and layout
+ * Eleven semantic components on top of the design tokens and layout
* primitives: button, field, card, badge, alert, panel, table, empty-state,
- * nav and tabs.
+ * nav, tabs and avatar.
*
* Rules:
* - Every color, space, radius, font size, weight and shadow is a token from
* tokens.css. No color literals; tints are color-mix() of two tokens.
+ * The only local custom property is --tone, set by the avatar tone
+ * variants to a color token.
* - Naming is .component, .component-variant, .component-part.
* - State comes from native attributes, not classes: :disabled,
* [aria-disabled="true"], [aria-busy="true"], [aria-invalid="true"],
@@ -562,6 +564,34 @@
background: color-mix(in srgb, var(--color-danger) 8%, var(--color-background));
}
+/* With an .alert-icon child the alert becomes two columns: the icon at the
+ top of the first, everything else stacked in the second with the same gap.
+ Alerts without an icon keep the column flexbox above. */
+.alert:has(> .alert-icon) {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: start;
+ column-gap: var(--space-3);
+}
+
+.alert:has(> .alert-icon) > :where(:not(.alert-icon)) {
+ grid-column: 2;
+}
+
+.alert:has(> .alert-icon) > :where(button, a, .button) {
+ justify-self: start;
+}
+
+/* An <svg> or <img>, 1.25em wide and one body line tall, so it lines up
+ with the first line of text. */
+.alert-icon {
+ grid-column: 1;
+ grid-row: 1;
+ inline-size: 1.25em;
+ block-size: calc(var(--leading-normal) * 1em);
+ object-fit: contain;
+}
+
/* -------------------------------------------------------------------------
Panel: a flat, bordered grouping. Lighter than .card: no shadow, smaller
radius and padding, surface background.
@@ -833,3 +863,86 @@
outline: var(--focus-width) solid var(--focus-color);
outline-offset: var(--focus-offset);
}
+
+/* -------------------------------------------------------------------------
+ Avatar: a fixed square chip for initials, a photo or an icon. Put it on a
+ <span>, <div> or directly on an <img>. Tone variants tint the background
+ with 12% of their color and use the color itself for the content.
+ ------------------------------------------------------------------------- */
+.avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ inline-size: var(--control-height);
+ block-size: var(--control-height);
+ overflow: hidden;
+ border-radius: var(--radius-md);
+ background: var(--color-surface);
+ color: var(--color-text);
+ font-size: var(--text-base);
+ font-weight: var(--weight-semibold);
+ line-height: var(--leading-tight);
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: middle;
+}
+
+/* A photo fills the box, cropped to the square. */
+.avatar:where(img),
+.avatar > :where(img) {
+ object-fit: cover;
+}
+
+.avatar > :where(img) {
+ inline-size: 100%;
+ block-size: 100%;
+}
+
+/* An icon takes about half the box and draws in the avatar's color when it
+ uses currentColor (fill="currentColor" or stroke="currentColor"). */
+.avatar > :where(svg) {
+ flex-shrink: 0;
+ inline-size: 55%;
+ block-size: 55%;
+}
+
+.avatar-round {
+ border-radius: var(--radius-full);
+}
+
+.avatar-sm {
+ inline-size: calc(var(--control-height) * 0.75);
+ block-size: calc(var(--control-height) * 0.75);
+ font-size: var(--text-sm);
+}
+
+.avatar-lg {
+ inline-size: calc(var(--control-height) * 1.5);
+ block-size: calc(var(--control-height) * 1.5);
+ font-size: var(--text-2xl);
+}
+
+.avatar-primary { --tone: var(--color-primary); }
+.avatar-success { --tone: var(--color-success); }
+.avatar-warning { --tone: var(--color-warning); }
+.avatar-danger { --tone: var(--color-danger); }
+.avatar-info { --tone: var(--color-info); }
+.avatar-accent { --tone: var(--color-accent); }
+
+.avatar-primary,
+.avatar-success,
+.avatar-warning,
+.avatar-danger,
+.avatar-info,
+.avatar-accent {
+ background: color-mix(in srgb, var(--tone) 12%, var(--color-background));
+ color: var(--tone);
+}
+
+/* --color-success alone is 4.46:1 on its 12% tint; 10% of --color-text
+ brings the content to 5:1. */
+.avatar-success {
+ color: color-mix(in srgb, var(--tone) 90%, var(--color-text));
+}
diff --git a/src/tokens.css b/src/tokens.css
index c782488..92e95d6 100644
--- a/src/tokens.css
+++ b/src/tokens.css
@@ -24,6 +24,7 @@
--color-warning: #9a5b0c;
--color-danger: #b3362e;
--color-info: #2f6694;
+ --color-accent: #6847b0;
/* Spacing */
--space-1: 0.25rem;
diff --git a/synthcss.ai.json b/synthcss.ai.json
index 6af50fd..cf1a7fa 100644
--- a/synthcss.ai.json
+++ b/synthcss.ai.json
@@ -16,6 +16,7 @@
"--color-warning": "warning status",
"--color-danger": "errors and destructive actions",
"--color-info": "neutral informational status",
+ "--color-accent": "decorative violet with no status meaning (avatar tiles)",
"--space-1": "0.25rem spacing step",
"--space-2": "0.5rem spacing step (-sm gaps)",
"--space-3": "0.75rem spacing step",
@@ -145,7 +146,9 @@
},
"alert": {
"intent": "message box; role=\"status\" or role=\"alert\"",
- "parts": {},
+ "parts": {
+ "alert-icon": "leading <svg aria-hidden=\"true\"> as a direct child; sits left of the content, top-aligned"
+ },
"variants": {
"alert-info": "neutral information",
"alert-success": "action succeeded",
@@ -189,6 +192,21 @@
"tabs-item": "<button role=\"tab\">; selected: aria-selected=\"true\", others aria-selected=\"false\""
},
"variants": {}
+ },
+ "avatar": {
+ "intent": "fixed square for initials, a photo or an icon, on <span>, <div> or <img>",
+ "parts": {},
+ "variants": {
+ "avatar-round": "circle; usual for people",
+ "avatar-sm": "smaller (0.75× --control-height)",
+ "avatar-lg": "larger (1.5× --control-height)",
+ "avatar-primary": "primary tint",
+ "avatar-success": "success tint",
+ "avatar-warning": "warning tint",
+ "avatar-danger": "danger tint",
+ "avatar-info": "info tint",
+ "avatar-accent": "violet tint with no status meaning"
+ }
}
},
"intentMap": [
@@ -207,12 +225,14 @@
{ "intent": "Labeled input with help or error", "use": ".field + .field-label + .field-help / .field-error" },
{ "intent": "Self-contained item", "use": ".card + parts" },
{ "intent": "Status label", "use": ".badge + .badge-success / -warning / -danger / -info" },
- { "intent": "Message or notification", "use": ".alert + .alert-info / -success / -warning / -danger" },
+ { "intent": "Message or notification", "use": ".alert + .alert-info / -success / -warning / -danger; optional .alert-icon" },
{ "intent": "Secondary grouped content", "use": ".panel + .panel-header / .panel-body" },
{ "intent": "Tabular data", "use": ".table-wrap > .table; .table-hover, .numeric" },
{ "intent": "No data yet", "use": ".empty-state" },
{ "intent": "Navigation links", "use": ".nav + .nav-link" },
- { "intent": "Tabs or segmented filter", "use": ".tabs + .tabs-item" }
+ { "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" }
],
"compositionRules": {
"recommended": [
@@ -258,8 +278,8 @@
"note": "Data view: split header with an action, scrollable table with status badges."
},
{
- "html": "<div class=\"sidebar-lg sidebar-end\" style=\"--sidebar-width: 20rem\">\n <main class=\"stack\">\n <h1>Dashboard</h1>\n <div class=\"grid\"><div class=\"card\">…</div><div class=\"card\">…</div></div>\n </main>\n <aside class=\"panel\">\n <div class=\"panel-header\"><h2>Activity</h2></div>\n <div class=\"panel-body\">Recent events.</div>\n </aside>\n</div>",
- "note": "Dashboard: main content first, narrow column on the right with a per-instance width."
+ "html": "<div class=\"sidebar-lg sidebar-end\" style=\"--sidebar-width: 20rem\">\n <main class=\"stack\">\n <h1>Dashboard</h1>\n <div class=\"alert alert-info\" role=\"status\">\n <svg class=\"alert-icon\" aria-hidden=\"true\" viewBox=\"0 0 24 24\">…</svg>\n <p>Billing moves to the 1st of each month.</p>\n </div>\n <div class=\"grid\">\n <div class=\"card\">\n <div class=\"cluster-sm\"><span class=\"avatar avatar-accent\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\">…</svg></span><h2>Revenue</h2></div>\n <p>$12,400 this month.</p>\n </div>\n <div class=\"card\">…</div>\n </div>\n </main>\n <aside class=\"panel\">\n <div class=\"panel-header\"><h2>Activity</h2></div>\n <ul class=\"panel-body stack-sm\" role=\"list\">\n <li class=\"cluster-sm\"><span class=\"avatar avatar-round avatar-sm avatar-primary\" aria-hidden=\"true\">AL</span><span>Ana merged #42</span></li>\n <li class=\"cluster-sm\"><img class=\"avatar avatar-round avatar-sm\" src=\"/ben.jpg\" alt=\"\"><span>Ben joined</span></li>\n </ul>\n </aside>\n</div>",
+ "note": "Dashboard: main content first, narrow column on the right; an alert with an icon, an icon tile in a card, and people as avatars."
}
],
"invalid": [
@@ -290,6 +310,10 @@
{
"html": "<button class=\"tabs-item\" aria-pressed=\"true\">Week</button>",
"note": "aria-pressed for a tab. Use .tabs-item with role=\"tab\" and aria-selected=\"true\"."
+ },
+ {
+ "html": "<span class=\"user-avatar rounded-full\">AL</span>",
+ "note": "Invented classes. Use .avatar and .avatar-round."
}
]
}
diff --git a/synthcss.llm.md b/synthcss.llm.md
index 6df2dfb..6e6a989 100644
--- a/synthcss.llm.md
+++ b/synthcss.llm.md
@@ -25,6 +25,7 @@ Base styles apply `--font-sans`, `--color-text` and `--color-background` to the
- `--color-warning` — warning status
- `--color-danger` — errors and destructive actions
- `--color-info` — neutral informational status
+- `--color-accent` — decorative violet with no status meaning (avatar tiles)
- `--space-1` — 0.25rem spacing step
- `--space-2` — 0.5rem spacing step (-sm gaps)
- `--space-3` — 0.75rem spacing step
@@ -126,6 +127,7 @@ One state attribute each: current nav link `aria-current="page"`; selected tab `
- variant `.alert-success` — action succeeded
- variant `.alert-warning` — needs attention
- variant `.alert-danger` — error
+ - part `.alert-icon` — leading `<svg aria-hidden="true">` as a direct child; sits left of the content, top-aligned
- `.panel` — flat bordered group of secondary content
- part `.panel-header` — title area with a bottom border
- part `.panel-body` — content area
@@ -138,6 +140,16 @@ One state attribute each: current nav link `aria-current="page"`; selected tab `
- 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
- 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
+ - variant `.avatar-sm` — smaller (0.75× `--control-height`)
+ - variant `.avatar-lg` — larger (1.5× `--control-height`)
+ - variant `.avatar-primary` — primary tint
+ - variant `.avatar-success` — success tint
+ - variant `.avatar-warning` — warning tint
+ - variant `.avatar-danger` — danger tint
+ - variant `.avatar-info` — info tint
+ - variant `.avatar-accent` — violet tint with no status meaning
## Intent Mapping
@@ -158,12 +170,14 @@ One state attribute each: current nav link `aria-current="page"`; selected tab `
| Labeled input with help or error | `.field` + `.field-label` + `.field-help` / `.field-error` |
| Self-contained item | `.card` + parts |
| Status label | `.badge` + `.badge-success` / -warning / -danger / -info |
-| Message or notification | `.alert` + `.alert-info` / -success / -warning / -danger |
+| Message or notification | `.alert` + `.alert-info` / -success / -warning / -danger; optional `.alert-icon` |
| Secondary grouped content | `.panel` + `.panel-header` / `.panel-body` |
| Tabular data | `.table-wrap` > `.table`; `.table-hover`, `.numeric` |
| No data yet | `.empty-state` |
| Navigation links | `.nav` + `.nav-link` |
| 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 |
## Composition Rules
@@ -265,17 +279,30 @@ Data view: split header with an action, scrollable table with status badges.
</section>
```
-Dashboard: main content first, narrow column on the right with a per-instance width.
+Dashboard: main content first, narrow column on the right; an alert with an icon, an icon tile in a card, and people as avatars.
```html
<div class="sidebar-lg sidebar-end" style="--sidebar-width: 20rem">
<main class="stack">
<h1>Dashboard</h1>
- <div class="grid"><div class="card">…</div><div class="card">…</div></div>
+ <div class="alert alert-info" role="status">
+ <svg class="alert-icon" aria-hidden="true" viewBox="0 0 24 24">…</svg>
+ <p>Billing moves to the 1st of each month.</p>
+ </div>
+ <div class="grid">
+ <div class="card">
+ <div class="cluster-sm"><span class="avatar avatar-accent"><svg aria-hidden="true" viewBox="0 0 24 24">…</svg></span><h2>Revenue</h2></div>
+ <p>$12,400 this month.</p>
+ </div>
+ <div class="card">…</div>
+ </div>
</main>
<aside class="panel">
<div class="panel-header"><h2>Activity</h2></div>
- <div class="panel-body">Recent events.</div>
+ <ul class="panel-body stack-sm" role="list">
+ <li class="cluster-sm"><span class="avatar avatar-round avatar-sm avatar-primary" aria-hidden="true">AL</span><span>Ana merged #42</span></li>
+ <li class="cluster-sm"><img class="avatar avatar-round avatar-sm" src="/ben.jpg" alt=""><span>Ben joined</span></li>
+ </ul>
</aside>
</div>
```
@@ -291,3 +318,4 @@ Never generate these. Each line: wrong markup — why — what to use instead.
- `<p class="mt-4">Saved.</p>` — Spacing utility. Use .stack on the parent.
- `<a class="nav-link active" href="/team">Team</a>` — State class. Use .nav-link with aria-current="page".
- `<button class="tabs-item" aria-pressed="true">Week</button>` — aria-pressed for a tab. Use .tabs-item with role="tab" and aria-selected="true".
+- `<span class="user-avatar rounded-full">AL</span>` — Invented classes. Use .avatar and .avatar-round.
This is a thorough, well-tested implementation: the `--color-accent` token, an `.avatar` component with sizes, round shape and six tones, an `.alert-icon` part that only affects alerts containing it, and synchronized AI contract files. Contrast is checked by script and documented per tone, and the one failing tone (success) is fixed in a documented way. It cannot be a clean pass: no CI ran to confirm the claimed test and build results, the showcase was edited even though the spec puts that out of scope unless confirmed, and the proposal's HTML examples were replaced with the builder's own.
Acceptance criteria · 6 of 8 met
- YES`--color-accent` is defined in the default theme and documented in the token reference.`src/tokens.css` adds `--color-accent: #6847b0`, and `docs/tokens.md` adds a table row and agent guidance; `check-tokens` now requires the token and checks it is at least 4.5:1 on the background.
- YES`.avatar` renders initials, an image and an svg icon centred at a fixed square size, with `.avatar-sm`, default and `.avatar-lg` sizes all derived from `--control-height`.`src/components.css` `.avatar` uses inline-flex, centres both ways, sets inline/block-size `var(--control-height)`, gives img `object-fit: cover` and svg 55%, and `-sm`/`-lg` use `calc(var(--control-height) * 0.75/1.5)`; static checks and browser assertions were added.
- YES`.avatar-round` produces a circle, and the default uses `--radius-md`.`.avatar` sets `border-radius: var(--radius-md)` and `.avatar-round` sets `var(--radius-full)`, and both are enforced in `check-components.mjs`.
- YESAll six tone variants exist and derive their tint via `color-mix(... 12%, var(--color-background))`, so token overrides propagate.Each `.avatar-<tone>` sets `--tone: var(--color-<tone>)`, and a shared rule applies `background: color-mix(in srgb, var(--tone) 12%, var(--color-background)); color: var(--tone)`; the checker verifies each tone.
- YESEach tone's foreground on its tint meets WCAG AA 4.5:1 with default tokens, verified by a test or script or documented with ratios, and any adjustment explained.`COLOR_VARIANTS` gains the avatar tones resolved through `withLocals`; the ratios are documented (success adjusted from 4.46 to 5.02:1 via color-mix), and tests assert that failure cases are caught.
- YES`.alert` containing `.alert-icon` shows the icon beside the content, and `.alert` without an icon is unchanged.`.alert:has(> .alert-icon)` switches to a two-column grid with the icon in column 1 row 1 and other children in column 2, while the base `.alert` rule is untouched; a check asserts `.alert` stays flex.
- PARTIAL`synthcss.llm.md` and `synthcss.ai.json` list all new classes and the token plus the two Intent Mapping entries, and contract sync verification passes.Both files add the token, the avatar component with nine variants, the `alert-icon` part and the rows "Person initials or photo" and "Icon on a tinted tile"; the sync pass is claimed but not shown by CI, and the proposal's HTML examples were replaced with the builder's own.
- UNCLEARExisting tests and build pass, and new CSS follows existing naming and file conventions.The CSS follows `.component-variant/-part` naming in `components.css`, but no CI ran, so passing tests and build cannot be confirmed.
- No CI checks ran on this commit. The claims that `npm test` passes (80/80) and the build succeeds rest only on the builder's word, and the optional Playwright check (`check-components-browser.mjs`) that exercises real layout was probably not run.
- The spec puts showcase site changes out of scope unless the maintainer confirms. This PR edits `showcase/index.html`: an avatar demo card, alert icons, an accent swatch and a token-count badge. It looks needed because the existing class checks and the new browser assertions read the showcase, but the maintainer should sign off.
- The spec asks to "include the HTML examples from the proposal". The builder did not have them and wrote its own. It extended the existing Dashboard example, citing a 4-example cap, and added one invalid example. Backers should check these match what the proposal intended.
- The version was not bumped. The builder cites `docs/releasing.md` saying PRs must not change the version, which seems consistent with the spec's "follow the existing convention", but that doc is not in the diff to confirm.
- `.avatar-success` draws its foreground as a 90/10 mix with `--color-text`, not pure `var(--tone)`. The spec allows this adjustment and the PR documents it. With `.alert-icon`, loose text nodes in an alert land in auto-placed grid cells; this is documented ("wrap loose text in a <p>") rather than handled.
CI details
No CI checks ran on this commit.
Accepted by the backers and merged by the maintainer.
Ballots · 1
Automated review cost $0.17, counted as builder cost.
No comments yet.