diff --git a/.changeset/calm-hues-agree.md b/.changeset/calm-hues-agree.md
new file mode 100644
index 000000000..8a90361a1
--- /dev/null
+++ b/.changeset/calm-hues-agree.md
@@ -0,0 +1,20 @@
+---
+"@tailor-platform/app-shell": minor
+---
+
+Add semantic colour roles (`--{info|success|warning|danger|neutral}-{surface|surface-hover|border|solid|solid-hover|text|contrast|indicator}`), available as Tailwind colours. `--status-*` and `--alert-*` now alias these roles, and Badge reads them, so `text` on `surface` and `contrast` on `solid` are at least 4.5:1 in the default palette, in light and dark mode.
+
+```tsx
+Paid
+```
+
+A gray scale (`--primitive-gray-{50..950}`, Tailwind neutral) is also added; the default palette's text and line tokens read it, with no value change.
+
+Visible changes in the default palette:
+
+- `--status-*` and `--alert-*` are deprecated and will be removed in the next major. `bg-status-*` and `bg-alert-*` keep working; new code uses the roles.
+- `bg-status-*` and `bg-alert-*` change values. `--status-default` now follows `--muted-foreground` (it was a fixed `#737373`), so it is theme-dependent and translucent in the `cream` and `bloom` palettes.
+- `--status-*` are fill colours; do not use them as text (below 4.5:1 in dark mode). Use `text-{intent}-text` instead (for example `text-status-completed` becomes `text-success-text`).
+- Badge, Alert, CsvImporter and MetricCard read the semantic roles. To recolour them, override `--{intent}-*`; overriding `--status-*` or `--alert-*` no longer changes these components.
+- Badge `error` and `subtle-error`, and Alert `error`, follow `--danger-*` instead of `--destructive`. Button and `text-destructive` still follow `--destructive`.
+- The warning Badge text is dark instead of white. Alert description text now has the same colour as the Alert title.
diff --git a/decisions/semantic-color-roles.md b/decisions/semantic-color-roles.md
new file mode 100644
index 000000000..23927ead4
--- /dev/null
+++ b/decisions/semantic-color-roles.md
@@ -0,0 +1,110 @@
+# Decision: hue scales and semantic colour roles under `--status-*` and `--alert-*`
+
+> Status: **Decided for the first step. Hue scales and semantic roles are added; `--status-*` and hue `--alert-*` alias the roles; Badge, Alert, CsvImporter and MetricCard read the roles.**
+> Scope: `packages/core/src/assets/themes/default.css` (hue scales, gray scale, roles, aliases), the Tailwind bridge, and the components that read the roles: Badge, Alert, CsvImporter, MetricCard. `--destructive` and the theme accent scales are not part of this step.
+
+## Context
+
+`--status-*` and `--alert-*` define the same intents (success, warning, error, info) twice, with different values. Neither has a contrast threshold, and some shipped pairs fail WCAG 1.4.3 (white text on the amber warning Badge, for example). Soft backgrounds and borders were built with `color-mix(... 10%, transparent)` per component, with no shared alpha scale.
+
+Tracking issues (tailor-inc/platform-planning):
+
+- #1394 alpha scale for soft backgrounds and borders (primary)
+- #1891 `--status-*` and `--alert-*` double-define the same intents
+- #1892 no contrast thresholds; shipping pairs fail WCAG 1.4.3
+- #1429 status colours are hard to tell apart under colour vision deficiency. This step does not solve it: labels still carry the meaning.
+
+## Decision
+
+### Hue scales (internal)
+
+One gray scale and four hues. Each hue has 12 steps, 12 alpha steps and a contrast colour, for light (`:root`) and dark (`.dark`):
+
+`--primitive-{blue|green|amber|red}-{1..12}`, `--primitive-{hue}-a{1..12}`, `--primitive-{hue}-contrast`
+
+Gray is the Tailwind `neutral` family, `--primitive-gray-{50..950}`, declared once because it is the same in both modes. It is not generated with Radix: the default palette already used these exact values (`#0a0a0a`, `#737373`, `#e5e5e5`, `#fafafa`, `#a3a3a3`), and the Radix generator cannot produce them. In the default palette `--foreground`, `--muted-foreground`, `--border` and `--input` read gray steps (light 950 / 500 / 200 / 200, dark 50 / 400 for the two text tokens). Dark `--border` and `--input` stay translucent white, and all surfaces stay literal: they are theme inputs, not scale steps. No default value changes. cream and bloom keep their own values; a theme can later pick another family (slate for their navy ink). Gray has no alpha steps.
+
+They are not bridged to Tailwind. Consumers use the semantic roles, not steps. The `--primitive-` prefix keeps the two layers readable as primitive then semantic, and avoids collisions with a consumer's own copy of Radix Colors. It is not `--palette-` because "palette" already names the theme files (default, cream, bloom). The role tokens have no prefix. The existing `--semantic-shadow-*` tokens are a different kind of token and are not part of this set.
+
+### Semantic roles (public, bridged)
+
+`--{info|success|warning|danger|neutral}-{role}`, bridged as `--color-{intent}-{role}` (5 intents x 8 roles = 40 utilities such as `bg-info-solid`).
+
+| role | hue source | neutral source |
+| ------------- | ------------------------------- | ----------------------------- |
+| surface | a3 | `var(--muted)` |
+| surface-hover | a4 | `var(--accent)` |
+| border | a6 | `var(--border)` |
+| solid | 9 | `var(--secondary)` |
+| solid-hover | 10 | `var(--accent)` |
+| text | 11 (warning: 12 light, 11 dark) | `var(--foreground)` |
+| contrast | contrast | `var(--secondary-foreground)` |
+| indicator | 9 | `var(--muted-foreground)` |
+
+The full set is bridged, including roles no component uses yet (neutral solid roles, hue `border`), so the set is one coherent vocabulary and `--alert-*` can alias every role.
+
+Adding a role or an intent touches four places in one commit: `default.css` (both the `:root, .dark` block and, if the role has a per-mode exception, the `.dark` rule), `theme.bridge.css`, the pinned counts in `theme-bridge.test.ts`, and the roles table in `docs-src/concepts/styling-theming.docs.outline.md`. The docs table is hand-written; only the bridge count is checked by a test.
+
+The roles and the aliases are declared for `:root, .dark`, so a nested `.dark` wrapper resolves them against its own scales. The only per-mode override is `--warning-text` (step 12 in light, step 11 in dark).
+
+### Aliases
+
+- `--status-default | neutral | completed | attention | danger` alias the neutral indicator and the info, success, warning and danger `solid` roles. `--status-default` therefore follows `--muted-foreground` instead of the fixed `#737373` it had: `#a3a3a3` in dark default, and translucent ink in cream and bloom.
+- `--alert-{success,warning,error,info}-{background,border,foreground}` alias `surface`, `border` and `text` (`error` reads the `danger` intent).
+- `--alert-{h}-foreground-muted` aliases `--alert-{h}-foreground` at full strength. The first plan used 85% of the text colour; over the surface that is 4.26:1 (info), 3.78:1 (success) and 4.23:1 (danger) in light mode, and no opacity below about 97% passes. Keeping the alias on `--alert-{h}-foreground` also keeps the override chain: a consumer who overrides the foreground gets a matching description.
+- `--alert-neutral-*` keeps its values, expressed through the neutral roles.
+
+### Badge
+
+Filled, subtle and outline-dot variants read the semantic roles with `astw:` utilities. `default` and `neutral` are unchanged.
+
+### Contrast contract
+
+A unit test (`semantic-contrast.test.ts`) computes contrast from `default.css` for light and dark and asserts at least 4.5:1 for: `contrast` on `solid` and on `solid-hover`; `text` on `surface`; the alert foreground and description on the alert background. The last two are checked over `--card` and `--background`.
+
+Not guaranteed:
+
+- Hover surfaces. Light-mode text on `surface-hover` is 4.22:1 (danger, over both pages), 4.34:1 (success over `--background`) and 4.51:1 (success over `--card`). Subtle Badge hover can fall below 4.5:1 for danger and success. The test holds `text` on `surface-hover` at a floor of 4.2:1 so it cannot get worse unnoticed. This is a floor, not a pass. Changing the step is a design decision for a later change.
+- The `cream` and `bloom` palettes and their tinted shell backgrounds (for example `text` on `surface` over the bloom light shell `#efe8ff` is 4.16:1 for success and 4.15:1 for danger).
+- The neutral intent, which keeps the pre-existing system tokens.
+- Warning `solid` against the card as a fill: 2.15:1 in light mode. `--status-attention` is unchanged (`#f59e0b`), so `text-status-attention` is still low contrast on light surfaces. CsvImporter now uses the warning text role instead.
+
+## Provenance
+
+Seeds: blue `#2563eb` (Tailwind blue-600), green `#15803d` (green-700), amber `#f59e0b` (amber-500), red `#dc2626` (red-600). Each is the lightest Tailwind step with white text at 4.5:1 or more; amber is the documented exception, and its `contrast` colour is pinned to the light step 12 `#4f3515` in both modes (dark text on a bright solid). Scales come from Radix `generateRadixColors` with gray `#737373` and backgrounds `#fafafa` (light) and `#0a0a0a` (dark), generated by `primitive-lab-gen/gen.ts`. The values in `default.css` are copied from its output without edits.
+
+The "alpha" steps are translucent only where the generator could express them. In light mode, warning a3, a4 and a6, info a6 and danger a4 are opaque 6-digit hex. So the warning surface, surface-hover and border, the info border and the danger surface-hover do not blend with a tinted parent. Dark mode has no opaque values among a3, a4 and a6. Fixing the generator is a later step.
+
+## Target shape
+
+This step is the status part of a two-axis palette. The rest is planned as follows, so the roles added here do not need to change later:
+
+| Axis | Scale | Roles | Step |
+| ------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ |
+| Status (this step) | `--primitive-{blue,green,amber,red}-*` | `--{info,success,warning,danger}-{role}` | done |
+| Ink and lines | `--primitive-gray-*` | base tokens read it (`--foreground`, `--muted-foreground`, `--border`, `--input`) | done for default; themes later |
+| Danger action | red | `--destructive` becomes an alias of `--danger-solid` once its text and border uses move to `--danger-text` / `--danger-border` | next |
+| Brand | an accent scale per theme (bloom and cream read blue, theme-tailor reads cyan) | `--primary`, `--ring` and brand tints read accent steps | with the Theme Generator |
+
+`--status-*` and `--alert-*` stay as aliases for consumer code and are deprecated: the docs and changeset say so, and they are removed in the next major. Shipped components read the roles. An ESLint rule in `@tailor-platform/eslint-plugin-app-shell` that flags the old utilities and offers the role as a fix is a separate change.
+
+The component variant names (`Badge` and `Alert` `error`, Badge `neutral`) are public API and are not renamed here. Aligning them with the intent names (`error` → `danger`) is a breaking change; it is a candidate for the next major, together with removing `--status-*` and `--alert-*`.
+
+## Out of scope
+
+- `--destructive` and `--destructive-foreground` aliasing. Themes override `--destructive`, and it is used as solid, text and border at once, so it needs its own change. Button is untouched.
+- Remapping surfaces or other themes onto the gray scale; gray alpha steps.
+- Theme accent scales (the brand axis) and the Theme Generator.
+- Bridging hue scales to Tailwind.
+- New Badge or Alert variants, icons in Badge.
+- Semantic-role swatches on the `/showcase/colors` page.
+
+## Consequences
+
+- Names do not change: `bg-status-*`, `bg-alert-*` and `text-destructive` keep working. Values do.
+- `--status-*` are now fill and indicator colours. In dark mode they are step 9 values (`#15803d`, `#dc2626`, `#2563eb`), which are below 4.5:1 as text on the dark card (success 3.57:1, danger 3.71:1, info 3.47:1). CsvImporter text and the custom-components example moved to `text-{intent}-text`. Other consumers that use `text-status-*` in dark mode will see lower contrast.
+- Badge `error` and `subtle-error`, and Alert `error`, no longer follow `--destructive`. They use the danger role in both modes. Button and `text-destructive` still follow `--destructive`, so in dark mode a Badge error (`#dc2626`) and a destructive Button (`#f87171`) differ. Consumers who override `--destructive` for Alert or Badge must override `--danger-*` or `--alert-error-*` instead.
+- The warning Badge text is dark (`#4f3515`) instead of white.
+- Alert description text is the same colour as the Alert title in the default palette.
+- Consumers who override `--status-*` or `--alert-*` keep working for their own `bg-status-*` and `bg-alert-*` utilities, which read those tokens. The shipped components (Badge, Alert, CsvImporter, MetricCard — its trend colours were literal `green-600` / `red-600` before) read the roles, not `--status-*` or `--alert-*`. To recolour them, override the roles (`--{intent}-*`); overriding `--status-*` or `--alert-*` no longer changes the components. `--status-*` and `--alert-*` remain for existing consumer code and are not used by components any more.
+- cream, bloom and `_template.css` values are not edited. The header comments now list `--{intent}-{role}` among the inherited tokens.
diff --git a/docs-manifest.json b/docs-manifest.json
index 63477342d..800f11bec 100644
--- a/docs-manifest.json
+++ b/docs-manifest.json
@@ -82,7 +82,7 @@
"hashes": {
"typeSurface": "f28bf34ae1466912",
"outline": "6138eac23571e177",
- "snapshot": "e83317f5eef937e5",
+ "snapshot": "202f4fa7806204a0",
"outputMd": "db2c4388ca783a75",
"examples": "65d5fc73550e395a"
}
@@ -372,9 +372,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
- "outline": "33796a2dd22b5c0d",
+ "outline": "8668ad90672475c7",
"snapshot": null,
- "outputMd": "551e04b1abbfa101",
+ "outputMd": "18b8fc747d52455a",
"examples": null
}
},
@@ -605,7 +605,7 @@
"hashes": {
"typeSurface": "b1614970f950ceb6",
"outline": "bda40376d6e3e008",
- "snapshot": "d4fa33f2da19d5ee",
+ "snapshot": "b845d4c54453b0f0",
"outputMd": "ec6a0ac098d1d87a",
"examples": "934c52d4e8761404"
}
@@ -1013,7 +1013,7 @@
"hashes": {
"typeSurface": "943616def28cafb0",
"outline": "8f06df28129676c8",
- "snapshot": "02dfd02003105939",
+ "snapshot": "6233cd9de511210f",
"outputMd": "ceb4a05822538d20",
"examples": "cff0508bb5520ae6"
}
@@ -1046,9 +1046,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
- "outline": "ef857eb4425c36cf",
+ "outline": "45dcfe4383a87ea9",
"snapshot": null,
- "outputMd": "87e819351e53b6bd",
+ "outputMd": "2fa0b6a249d8bd4f",
"examples": null
}
},
@@ -1166,7 +1166,7 @@
"hashes": {
"typeSurface": "429710973dfa912b",
"outline": "721e8995f53f0e8f",
- "snapshot": "f6fcbd73e127f2f4",
+ "snapshot": "0ceac9786fb4ce3c",
"outputMd": "ae287e2bb5abc1c6",
"examples": "1ce89cec30045466"
}
@@ -1183,7 +1183,7 @@
"hashes": {
"typeSurface": "e760949efcbe4c38",
"outline": "464d91acc48f7c9d",
- "snapshot": "12c20fc6f44d1b4c",
+ "snapshot": "40d15c3f1003fdb0",
"outputMd": "1f81ebd562c50371",
"examples": "b0eebb8c5e2219dc"
}
@@ -1284,9 +1284,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
- "outline": "96d4bfc1c2681154",
+ "outline": "c8a79ca086341d8c",
"snapshot": null,
- "outputMd": "0133fdae405f467c",
+ "outputMd": "1277e06bb82432e6",
"examples": "d3cca347b055a503"
}
},
@@ -1650,13 +1650,13 @@
},
"skill": {
"packages/core/skills/app-shell-patterns/references/concepts/authentication.md": "71f5f42c0fa0698e",
- "packages/core/skills/app-shell-patterns/references/concepts/custom-components.md": "551e04b1abbfa101",
+ "packages/core/skills/app-shell-patterns/references/concepts/custom-components.md": "18b8fc747d52455a",
"packages/core/skills/app-shell-patterns/references/concepts/file-based-routing.md": "caa1ea6264d3b543",
"packages/core/skills/app-shell-patterns/references/concepts/graphql.md": "ba5107604e4f42e8",
"packages/core/skills/app-shell-patterns/references/concepts/modules-and-resources.md": "bf15b5460f22d165",
"packages/core/skills/app-shell-patterns/references/concepts/routing-navigation.md": "2f3ff3f095c2b1cc",
"packages/core/skills/app-shell-patterns/references/concepts/sidebar-navigation.md": "dc6b3afc049b4148",
- "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "0133fdae405f467c",
+ "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "1277e06bb82432e6",
"packages/core/skills/app-shell-patterns/references/components/action-panel.md": "71794d4b561e4d4b",
"packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0",
"packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "824d1aa36bb7f194",
@@ -1735,7 +1735,7 @@
"packages/core/skills/app-shell-patterns/references/patterns/interaction-toast.md": "f68bdab855730f48",
"packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "8062ea0df9403fbb",
"packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "c3fb2588a0699c26",
- "packages/core/skills/app-shell-patterns/references/migrations.md": "20fb5d88b76dd933",
+ "packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096",
"packages/core/skills/app-shell-patterns/SKILL.md": "814325f4ea06ffae"
}
}
diff --git a/docs-src/concepts/custom-components.docs.outline.md b/docs-src/concepts/custom-components.docs.outline.md
index 87eb9ef55..c01340a4a 100644
--- a/docs-src/concepts/custom-components.docs.outline.md
+++ b/docs-src/concepts/custom-components.docs.outline.md
@@ -44,7 +44,7 @@ export function StepIndicator({ steps, current }: Props) {
flex items-center gap-2 rounded-md px-3 py-2
bg-muted text-sm text-muted-foreground
data-[active]:bg-primary data-[active]:text-primary-foreground
- data-[completed]:text-status-completed
+ data-[completed]:text-success-text
"
>
{i < current && }
diff --git a/docs-src/concepts/styling-theming.docs.outline.md b/docs-src/concepts/styling-theming.docs.outline.md
index ffe7b0f85..dff987ad7 100644
--- a/docs-src/concepts/styling-theming.docs.outline.md
+++ b/docs-src/concepts/styling-theming.docs.outline.md
@@ -11,7 +11,7 @@ Styling is done with **Tailwind CSS v4** against `@tailor-platform/app-shell`'s
**The tokens are the rails.** Consistency across customers, apps, and AI runs comes from the token system, not from rules written in prose. A hand-typed `#fff` or `padding: 13px` is not a "small deviation" — it is the mechanism by which consistency dies.
-**Every token in this document is verified against the shipped CSS.** If a token is not listed here, assume it does not exist. Inventing a plausible-sounding token (`bg-surface-1`, `text-fg-muted`, `--space-4`) is the worst failure mode available: Tailwind emits **no CSS at all** for an unknown utility, so the class is silently dropped and the element renders unstyled — no error, no warning, nothing in the console. When unsure, read `node_modules/@tailor-platform/app-shell/dist/themes/default.css`; it is the ground truth.
+**Every token in this document is verified against the shipped CSS.** If a token is not listed here, assume it does not exist. The colour scales (`--primitive-*`) are internal: they are not bridged to Tailwind and are not listed. Inventing a plausible-sounding token (`bg-surface-1`, `text-fg-muted`, `--space-4`) is the worst failure mode available: Tailwind emits **no CSS at all** for an unknown utility, so the class is silently dropped and the element renders unstyled — no error, no warning, nothing in the console. When unsure, read `node_modules/@tailor-platform/app-shell/dist/themes/default.css`; it is the ground truth.
## Setup
@@ -86,14 +86,14 @@ Only import one palette at a time.
Theme tokens live in `packages/core/src/assets/themes/`. Copy `_template.css` to start a new palette — it lists exactly which sections to fill in for light and dark mode.
-| Section | Required? | What to set |
-| --------------------- | --------------------- | ------------------------------------------------------------------------------ |
-| **1. Brand** | Yes | `primary`, `secondary`, `accent` (+ foregrounds) — both modes |
-| **2. Shell gradient** | Branded palettes only | `--shell-gradient-base`, `--shell-gradient-tint` |
-| **3. System** | Tune or copy default | Surfaces: background, card, popover, muted, borders |
-| **4. Palette** | Optional | Radius, chart colors, shadows |
-| **5. Semantic** | Do not duplicate | Status and alert tokens inherit from `default.css` |
-| **6. Structural** | Branded palettes | Copy the structural override block from `bloom.css` or `cream.css` when needed |
+| Section | Required? | What to set |
+| --------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **1. Brand** | Yes | `primary`, `secondary`, `accent` (+ foregrounds) — both modes |
+| **2. Shell gradient** | Branded palettes only | `--shell-gradient-base`, `--shell-gradient-tint` |
+| **3. System** | Tune or copy default | Surfaces: background, card, popover, muted, borders |
+| **4. Palette** | Optional | Radius, chart colors, shadows |
+| **5. Semantic** | Inherit | Colour scales, status and alert tokens inherit from `default.css`. Override a colour role (`--{intent}-{role}`) only to recolour the status components |
+| **6. Structural** | Branded palettes | Copy the structural override block from `bloom.css` or `cream.css` when needed |
A palette is selected by CSS import, not by an AppShell prop. Import exactly one theme file after `@tailor-platform/app-shell/styles`; if you import none, the default palette from `styles` is used.
@@ -158,17 +158,44 @@ There are no `-hover` or `-active` brand tokens. Express interaction states with
#### Status
-Five status colors, used for badge fills and status dots:
+**Deprecated.** These five tokens and their utilities remain for existing code and will be removed in the next major. New code uses the [semantic color roles](#semantic-color-roles) below. In the default palette they are aliases of those roles, so status colors and Alert colors agree:
-| Token | Use | Tailwind |
-| -------------------- | ------------------------ | --------------------- |
-| `--status-default` | none / not applicable | `bg-status-default` |
-| `--status-neutral` | informational | `bg-status-neutral` |
-| `--status-completed` | success, completed | `bg-status-completed` |
-| `--status-attention` | warning, needs attention | `bg-status-attention` |
-| `--status-danger` | error, blocked | `bg-status-danger` |
+| Token | Use | Tailwind | Use instead |
+| -------------------- | ---------------------------------------------------- | --------------------- | ---------------------- |
+| `--status-default` | none / not applicable (follows `--muted-foreground`) | `bg-status-default` | `bg-neutral-indicator` |
+| `--status-neutral` | informational | `bg-status-neutral` | `bg-info-solid` |
+| `--status-completed` | success, completed | `bg-status-completed` | `bg-success-solid` |
+| `--status-attention` | warning, needs attention | `bg-status-attention` | `bg-warning-solid` |
+| `--status-danger` | error, blocked | `bg-status-danger` | `bg-danger-solid` |
-Prefer `Badge` with a semantic variant (`success`, `warning`, `error`, `info`, `neutral`) over applying these directly — the variants already pair fill and foreground correctly. Reach for the raw token only on custom surfaces.
+Prefer `Badge` with a semantic variant (`success`, `warning`, `error`, `info`, `neutral`) over applying a fill directly — the variants already pair fill and foreground correctly.
+
+These are fill and indicator colors. `--status-default` follows `--muted-foreground`, so it is theme-dependent and translucent in `cream` and `bloom`; the four hue tokens are opaque in every palette. Do not use them as text color: `--status-completed`, `--status-danger` and `--status-neutral` are below 4.5:1 on a dark card, and `--status-attention` is below 3:1 on a light one. For text, use `text-{intent}-text`.
+
+#### Semantic color roles
+
+Each intent has eight roles. The intents are `info`, `success`, `warning`, `danger` and `neutral`. The hue intents read internal color scales; `neutral` reads the system tokens. Every role is a token named `--{intent}-{role}` and a Tailwind color named `{intent}-{role}`:
+
+| Role | Tailwind | Use for |
+| --------------- | ----------------------- | ----------------------------------- |
+| `surface` | `bg-info-surface` | Soft background |
+| `surface-hover` | `bg-info-surface-hover` | Hover state of a soft background |
+| `border` | `border-info-border` | Border on a soft background |
+| `solid` | `bg-info-solid` | Filled background |
+| `solid-hover` | `bg-info-solid-hover` | Hover state of a filled background |
+| `text` | `text-info-text` | Text and icons on a soft background |
+| `contrast` | `text-info-contrast` | Text on a filled background |
+| `indicator` | `bg-info-indicator` | Dots and small marks |
+
+```tsx
+Paid
+```
+
+In the default palette, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for the four hue intents, in light and dark mode, on `--card` and `--background`. A unit test checks this from the shipped CSS. `text` on `surface-hover` is held at 4.2:1 by the same test; it is below 4.5:1 for `danger` and `success` in light mode. The test does not cover the `cream` and `bloom` palettes or the `neutral` intent.
+
+Most `surface` and `border` values are translucent tints, so an opacity modifier compounds rather than replaces — `bg-info-surface/50` halves the tint's alpha instead of setting it to 50%. Some light-mode values are opaque instead of translucent: the `warning` surface, surface-hover and border, the `info` border and the `danger` surface-hover. They do not blend with a tinted parent. All dark-mode values are translucent.
+
+`Badge`, `Alert`, `CsvImporter` and `MetricCard` read these roles directly, not `--status-*` or `--alert-*`. To recolour them, override the roles. `Badge` `error` and `subtle-error`, and `Alert` `error`, follow `--danger-*`. They no longer follow `--destructive`, which still drives `Button` and `text-destructive`.
#### Sidebar & charts
@@ -178,24 +205,17 @@ Prefer `Badge` with a semantic variant (`success`, `warning`, `error`, `info`, `
#### Alerts
-`--alert-{neutral,success,warning,error,info}-{background,foreground,foreground-muted,border}` — five variants × four slots, each bridged into Tailwind:
-
-| Slot | Tailwind | Use for |
-| ------------------ | ---------------------------------- | -------------------------- |
-| `background` | `bg-alert-info-background` | Callout fill |
-| `foreground` | `text-alert-info-foreground` | Primary text and icons |
-| `foreground-muted` | `text-alert-info-foreground-muted` | Secondary/description text |
-| `border` | `border-alert-info-border` | Callout border |
+**Deprecated.** `--alert-{neutral,success,warning,error,info}-{background,foreground,foreground-muted,border}` and the matching utilities (`bg-alert-info-background`, `text-alert-info-foreground`, ...) remain for existing code and will be removed in the next major. In the default palette they are aliases of the semantic color roles: `background` is `surface`, `border` is `border`, `foreground` is `text`, and `foreground-muted` follows `foreground`; `error` reads the `danger` intent. The `Alert` component reads the roles, so overriding `--alert-*` changes these utilities but not `Alert`.
-Prefer the `Alert` component — it already pairs the four slots per variant and supplies the icon. Reach for the utilities directly only on custom surfaces `Alert` doesn't cover, such as a status-highlighted table row or an inline note:
+For new custom surfaces, use the roles instead of the alert slots:
```tsx
-
+
…
```
-The `background` and `border` slots are already semi-transparent tints (~10% and ~20%), so an opacity modifier compounds rather than replaces — `bg-alert-info-background/50` lands near 5% alpha, not 50%.
+Across all colour tokens:
```tsx
// Good — semantic token pairs, and a variant where one exists
@@ -486,7 +506,7 @@ AppShell's UI components support data-attribute-based styling, following the [Ba
```css
/* Style a component based on its state */
.SwitchThumb[data-checked] {
- background-color: var(--status-completed);
+ background-color: var(--success-solid);
}
.MenuItem[data-highlighted] {
@@ -557,9 +577,10 @@ These are visual-composition rules every screen must follow, regardless of patte
| Intent | Pick |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Destructive action (delete, void) | `Button variant="destructive"`; `bg-destructive` on custom surfaces; confirm in a dialog at `shadow-lg` |
-| Non-blocking caution | `Badge variant="warning"`, or `bg-status-attention` |
-| Confirmation / completed state | `Badge variant="success"`, or `bg-status-completed` |
-| Neutral callout | `Badge variant="info"`, or the `Alert` component |
+| Non-blocking caution | `Badge variant="warning"`, or `bg-warning-surface text-warning-text` |
+| Confirmation / completed state | `Badge variant="success"`, or `bg-success-surface text-success-text` |
+| Informational callout | `Badge variant="info"`, or `Alert variant="info"` |
+| Neutral callout | `Badge variant="neutral"`, or `Alert variant="neutral"` |
| Persistent panel (sidebar, header) | `shadow-sm` |
| Hovered / sticky surface | `shadow-md` |
| Popover / menu / tooltip | `bg-popover`, `shadow-md`, `duration-150` |
diff --git a/docs-src/guides/migrations.docs.outline.md b/docs-src/guides/migrations.docs.outline.md
index 6fc02f02f..cca666b24 100644
--- a/docs-src/guides/migrations.docs.outline.md
+++ b/docs-src/guides/migrations.docs.outline.md
@@ -15,6 +15,7 @@ Each entry states which versions are affected, what breaks, how to detect it, an
| Version | Change |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| 1.17.0 | [Status and alert tokens now resolve to the colour roles](#1170-status-and-alert-tokens-now-resolve-to-the-colour-roles) |
| 1.15.0 | [Base UI 1.8.0 breaks closed-`Select` and dialog-click tests](#1150-base-ui-180-breaks-closed-select-and-dialog-click-tests) |
| 1.12.0 | [`DateField` / `DatePicker` field chrome moved to `Field.Root`](#1120-datefield--datepicker-field-chrome-moved-to-fieldroot) |
| 1.11.0 | [React 19.2.7 and React Router v8 required](#1110-react-1927-and-react-router-v8-are-now-required) |
@@ -26,6 +27,26 @@ Each entry states which versions are affected, what breaks, how to detect it, an
| 1.0.2 | [`Toaster` no longer accepts `richColors`](#102-toaster-no-longer-accepts-richcolors) |
| before 1.0 | [Pre-1.0 breaking changes](#before-10) |
+## 1.17.0: status and alert tokens now resolve to the colour roles
+
+**Applies to:** apps that use `bg-status-*`, `text-status-*` or `bg-alert-*` utilities, and apps whose theme overrides `--status-*`, `--alert-*` or `--destructive` to recolour `Badge`, `Alert`, `CsvImporter` or `MetricCard`.
+
+1.17.0 adds semantic colour roles, `--{info|success|warning|danger|neutral}-{surface|surface-hover|border|solid|solid-hover|text|contrast|indicator}`, bridged as Tailwind colours such as `bg-success-surface` and `text-danger-text`. The old `--status-*` and `--alert-*` tokens still exist and still work, but they no longer hold their own colours: each one now resolves to a role (for example `--status-completed` is `var(--success-solid)`). They are deprecated and will be removed in the next major. Nothing errors. Three things change silently:
+
+- **Colours change.** `bg-status-*` and `bg-alert-*` resolve to the role values. For example `bg-status-completed` is `#15803d` instead of `#22c55e`. `--status-default` follows `--muted-foreground` instead of a fixed `#737373`.
+- **Component recolouring moves to the roles.** `Badge`, `Alert`, `CsvImporter` and `MetricCard` read the roles directly. A theme that overrides `--status-*` or `--alert-*` no longer changes these components; override `--{intent}-*` instead. `Badge` `error` / `subtle-error` and `Alert` `error` read `--danger-*`, so overriding `--destructive` no longer reaches them either. `Button variant="destructive"` and `text-destructive` still follow `--destructive`.
+- **`text-status-*` loses contrast in dark mode.** The `--status-*` values are now the step-9 fills, which are below 4.5:1 as text on the dark card.
+
+How to detect it: search the app for `status-`, `alert-` and theme overrides of `--status-`, `--alert-` and `--destructive`. Compare `Badge` and `Alert` in light and dark mode after upgrading.
+
+What to change:
+
+- Replace `text-status-completed` with `text-success-text`, `text-status-attention` with `text-warning-text`, `text-status-danger` with `text-danger-text`, and `text-status-neutral` with `text-info-text`.
+- Replace `bg-status-completed/10 border-status-completed/30` style tints with `bg-success-surface border-success-border`. The same applies to the other intents and to `bg-alert-*` / `border-alert-*`.
+- In a theme that recolours the status components, set the roles (`--success-solid`, `--danger-text`, ...) instead of `--status-*`, `--alert-*` or `--destructive`.
+
+The roles are documented in [Styling & theming](./concepts/styling-theming.md#semantic-color-roles). The decision record is `decisions/semantic-color-roles.md`.
+
## 1.15.0: Base UI 1.8.0 breaks closed-`Select` and dialog-click tests
**Applies to:** apps with unit tests that assert `Select` options without opening the `Select`, and apps with Playwright tests that click buttons inside a `Dialog` or `AlertDialog`.
@@ -146,7 +167,7 @@ grep -rnE "@theme inline|@custom-variant|app-shell/theme\.css|--(card|popover|mu
Excluding `node_modules` matters: AppShell's own palette files declare these tokens too, and they must not be touched. In your own CSS, hits are either the workaround, which goes, or deliberate overrides, which should take the `:root` / `:root.dark` form described in [Overriding tokens](./concepts/styling-theming.md#overriding-tokens).
-What remains is short — [`examples/vite-app/src/index.css`](https://github.com/tailor-platform/app-shell/blob/main/examples/vite-app/src/index.css) is a working reference for the shape (it also imports a branded palette, which is optional):
+What remains is short — [`examples/vite-app/src/index.css`](../examples/vite-app/src/index.css) is a working reference for the shape (it also imports a branded palette, which is optional):
```css
@import "tailwindcss";
diff --git a/docs/concepts/custom-components.md b/docs/concepts/custom-components.md
index 873b33e3c..9ac012935 100644
--- a/docs/concepts/custom-components.md
+++ b/docs/concepts/custom-components.md
@@ -44,7 +44,7 @@ export function StepIndicator({ steps, current }: Props) {
flex items-center gap-2 rounded-md px-3 py-2
bg-muted text-sm text-muted-foreground
data-[active]:bg-primary data-[active]:text-primary-foreground
- data-[completed]:text-status-completed
+ data-[completed]:text-success-text
"
>
{i < current && }
diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md
index 8cf735cf9..b553fc46d 100644
--- a/docs/concepts/styling-theming.md
+++ b/docs/concepts/styling-theming.md
@@ -11,7 +11,7 @@ Styling is done with **Tailwind CSS v4** against `@tailor-platform/app-shell`'s
**The tokens are the rails.** Consistency across customers, apps, and AI runs comes from the token system, not from rules written in prose. A hand-typed `#fff` or `padding: 13px` is not a "small deviation" — it is the mechanism by which consistency dies.
-**Every token in this document is verified against the shipped CSS.** If a token is not listed here, assume it does not exist. Inventing a plausible-sounding token (`bg-surface-1`, `text-fg-muted`, `--space-4`) is the worst failure mode available: Tailwind emits **no CSS at all** for an unknown utility, so the class is silently dropped and the element renders unstyled — no error, no warning, nothing in the console. When unsure, read `node_modules/@tailor-platform/app-shell/dist/themes/default.css`; it is the ground truth.
+**Every token in this document is verified against the shipped CSS.** If a token is not listed here, assume it does not exist. The colour scales (`--primitive-*`) are internal: they are not bridged to Tailwind and are not listed. Inventing a plausible-sounding token (`bg-surface-1`, `text-fg-muted`, `--space-4`) is the worst failure mode available: Tailwind emits **no CSS at all** for an unknown utility, so the class is silently dropped and the element renders unstyled — no error, no warning, nothing in the console. When unsure, read `node_modules/@tailor-platform/app-shell/dist/themes/default.css`; it is the ground truth.
## Setup
@@ -86,14 +86,14 @@ Only import one palette at a time.
Theme tokens live in `packages/core/src/assets/themes/`. Copy `_template.css` to start a new palette — it lists exactly which sections to fill in for light and dark mode.
-| Section | Required? | What to set |
-| --------------------- | --------------------- | ------------------------------------------------------------------------------ |
-| **1. Brand** | Yes | `primary`, `secondary`, `accent` (+ foregrounds) — both modes |
-| **2. Shell gradient** | Branded palettes only | `--shell-gradient-base`, `--shell-gradient-tint` |
-| **3. System** | Tune or copy default | Surfaces: background, card, popover, muted, borders |
-| **4. Palette** | Optional | Radius, chart colors, shadows |
-| **5. Semantic** | Do not duplicate | Status and alert tokens inherit from `default.css` |
-| **6. Structural** | Branded palettes | Copy the structural override block from `bloom.css` or `cream.css` when needed |
+| Section | Required? | What to set |
+| --------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **1. Brand** | Yes | `primary`, `secondary`, `accent` (+ foregrounds) — both modes |
+| **2. Shell gradient** | Branded palettes only | `--shell-gradient-base`, `--shell-gradient-tint` |
+| **3. System** | Tune or copy default | Surfaces: background, card, popover, muted, borders |
+| **4. Palette** | Optional | Radius, chart colors, shadows |
+| **5. Semantic** | Inherit | Colour scales, status and alert tokens inherit from `default.css`. Override a colour role (`--{intent}-{role}`) only to recolour the status components |
+| **6. Structural** | Branded palettes | Copy the structural override block from `bloom.css` or `cream.css` when needed |
A palette is selected by CSS import, not by an AppShell prop. Import exactly one theme file after `@tailor-platform/app-shell/styles`; if you import none, the default palette from `styles` is used.
@@ -175,17 +175,44 @@ There are no `-hover` or `-active` brand tokens. Express interaction states with
#### Status
-Five status colors, used for badge fills and status dots:
+**Deprecated.** These five tokens and their utilities remain for existing code and will be removed in the next major. New code uses the [semantic color roles](#semantic-color-roles) below. In the default palette they are aliases of those roles, so status colors and Alert colors agree:
-| Token | Use | Tailwind |
-| -------------------- | ------------------------ | --------------------- |
-| `--status-default` | none / not applicable | `bg-status-default` |
-| `--status-neutral` | informational | `bg-status-neutral` |
-| `--status-completed` | success, completed | `bg-status-completed` |
-| `--status-attention` | warning, needs attention | `bg-status-attention` |
-| `--status-danger` | error, blocked | `bg-status-danger` |
+| Token | Use | Tailwind | Use instead |
+| -------------------- | ---------------------------------------------------- | --------------------- | ---------------------- |
+| `--status-default` | none / not applicable (follows `--muted-foreground`) | `bg-status-default` | `bg-neutral-indicator` |
+| `--status-neutral` | informational | `bg-status-neutral` | `bg-info-solid` |
+| `--status-completed` | success, completed | `bg-status-completed` | `bg-success-solid` |
+| `--status-attention` | warning, needs attention | `bg-status-attention` | `bg-warning-solid` |
+| `--status-danger` | error, blocked | `bg-status-danger` | `bg-danger-solid` |
-Prefer `Badge` with a semantic variant (`success`, `warning`, `error`, `info`, `neutral`) over applying these directly — the variants already pair fill and foreground correctly. Reach for the raw token only on custom surfaces.
+Prefer `Badge` with a semantic variant (`success`, `warning`, `error`, `info`, `neutral`) over applying a fill directly — the variants already pair fill and foreground correctly.
+
+These are fill and indicator colors. `--status-default` follows `--muted-foreground`, so it is theme-dependent and translucent in `cream` and `bloom`; the four hue tokens are opaque in every palette. Do not use them as text color: `--status-completed`, `--status-danger` and `--status-neutral` are below 4.5:1 on a dark card, and `--status-attention` is below 3:1 on a light one. For text, use `text-{intent}-text`.
+
+#### Semantic color roles
+
+Each intent has eight roles. The intents are `info`, `success`, `warning`, `danger` and `neutral`. The hue intents read internal color scales; `neutral` reads the system tokens. Every role is a token named `--{intent}-{role}` and a Tailwind color named `{intent}-{role}`:
+
+| Role | Tailwind | Use for |
+| --------------- | ----------------------- | ----------------------------------- |
+| `surface` | `bg-info-surface` | Soft background |
+| `surface-hover` | `bg-info-surface-hover` | Hover state of a soft background |
+| `border` | `border-info-border` | Border on a soft background |
+| `solid` | `bg-info-solid` | Filled background |
+| `solid-hover` | `bg-info-solid-hover` | Hover state of a filled background |
+| `text` | `text-info-text` | Text and icons on a soft background |
+| `contrast` | `text-info-contrast` | Text on a filled background |
+| `indicator` | `bg-info-indicator` | Dots and small marks |
+
+```tsx
+Paid
+```
+
+In the default palette, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for the four hue intents, in light and dark mode, on `--card` and `--background`. A unit test checks this from the shipped CSS. `text` on `surface-hover` is held at 4.2:1 by the same test; it is below 4.5:1 for `danger` and `success` in light mode. The test does not cover the `cream` and `bloom` palettes or the `neutral` intent.
+
+Most `surface` and `border` values are translucent tints, so an opacity modifier compounds rather than replaces — `bg-info-surface/50` halves the tint's alpha instead of setting it to 50%. Some light-mode values are opaque instead of translucent: the `warning` surface, surface-hover and border, the `info` border and the `danger` surface-hover. They do not blend with a tinted parent. All dark-mode values are translucent.
+
+`Badge`, `Alert`, `CsvImporter` and `MetricCard` read these roles directly, not `--status-*` or `--alert-*`. To recolour them, override the roles. `Badge` `error` and `subtle-error`, and `Alert` `error`, follow `--danger-*`. They no longer follow `--destructive`, which still drives `Button` and `text-destructive`.
#### Sidebar & charts
@@ -195,24 +222,17 @@ Prefer `Badge` with a semantic variant (`success`, `warning`, `error`, `info`, `
#### Alerts
-`--alert-{neutral,success,warning,error,info}-{background,foreground,foreground-muted,border}` — five variants × four slots, each bridged into Tailwind:
-
-| Slot | Tailwind | Use for |
-| ------------------ | ---------------------------------- | -------------------------- |
-| `background` | `bg-alert-info-background` | Callout fill |
-| `foreground` | `text-alert-info-foreground` | Primary text and icons |
-| `foreground-muted` | `text-alert-info-foreground-muted` | Secondary/description text |
-| `border` | `border-alert-info-border` | Callout border |
+**Deprecated.** `--alert-{neutral,success,warning,error,info}-{background,foreground,foreground-muted,border}` and the matching utilities (`bg-alert-info-background`, `text-alert-info-foreground`, ...) remain for existing code and will be removed in the next major. In the default palette they are aliases of the semantic color roles: `background` is `surface`, `border` is `border`, `foreground` is `text`, and `foreground-muted` follows `foreground`; `error` reads the `danger` intent. The `Alert` component reads the roles, so overriding `--alert-*` changes these utilities but not `Alert`.
-Prefer the `Alert` component — it already pairs the four slots per variant and supplies the icon. Reach for the utilities directly only on custom surfaces `Alert` doesn't cover, such as a status-highlighted table row or an inline note:
+For new custom surfaces, use the roles instead of the alert slots:
```tsx
-
+
…
```
-The `background` and `border` slots are already semi-transparent tints (~10% and ~20%), so an opacity modifier compounds rather than replaces — `bg-alert-info-background/50` lands near 5% alpha, not 50%.
+Across all colour tokens:
```tsx
// Good — semantic token pairs, and a variant where one exists
@@ -503,7 +523,7 @@ AppShell's UI components support data-attribute-based styling, following the [Ba
```css
/* Style a component based on its state */
.SwitchThumb[data-checked] {
- background-color: var(--status-completed);
+ background-color: var(--success-solid);
}
.MenuItem[data-highlighted] {
@@ -574,9 +594,10 @@ These are visual-composition rules every screen must follow, regardless of patte
| Intent | Pick |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Destructive action (delete, void) | `Button variant="destructive"`; `bg-destructive` on custom surfaces; confirm in a dialog at `shadow-lg` |
-| Non-blocking caution | `Badge variant="warning"`, or `bg-status-attention` |
-| Confirmation / completed state | `Badge variant="success"`, or `bg-status-completed` |
-| Neutral callout | `Badge variant="info"`, or the `Alert` component |
+| Non-blocking caution | `Badge variant="warning"`, or `bg-warning-surface text-warning-text` |
+| Confirmation / completed state | `Badge variant="success"`, or `bg-success-surface text-success-text` |
+| Informational callout | `Badge variant="info"`, or `Alert variant="info"` |
+| Neutral callout | `Badge variant="neutral"`, or `Alert variant="neutral"` |
| Persistent panel (sidebar, header) | `shadow-sm` |
| Hovered / sticky surface | `shadow-md` |
| Popover / menu / tooltip | `bg-popover`, `shadow-md`, `duration-150` |
diff --git a/docs/migrations.md b/docs/migrations.md
index 3e59451ae..4ffc91542 100644
--- a/docs/migrations.md
+++ b/docs/migrations.md
@@ -15,6 +15,7 @@ Each entry states which versions are affected, what breaks, how to detect it, an
| Version | Change |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| 1.17.0 | [Status and alert tokens now resolve to the colour roles](#1170-status-and-alert-tokens-now-resolve-to-the-colour-roles) |
| 1.15.0 | [Base UI 1.8.0 breaks closed-`Select` and dialog-click tests](#1150-base-ui-180-breaks-closed-select-and-dialog-click-tests) |
| 1.12.0 | [`DateField` / `DatePicker` field chrome moved to `Field.Root`](#1120-datefield--datepicker-field-chrome-moved-to-fieldroot) |
| 1.11.0 | [React 19.2.7 and React Router v8 required](#1110-react-1927-and-react-router-v8-are-now-required) |
@@ -26,6 +27,26 @@ Each entry states which versions are affected, what breaks, how to detect it, an
| 1.0.2 | [`Toaster` no longer accepts `richColors`](#102-toaster-no-longer-accepts-richcolors) |
| before 1.0 | [Pre-1.0 breaking changes](#before-10) |
+## 1.17.0: status and alert tokens now resolve to the colour roles
+
+**Applies to:** apps that use `bg-status-*`, `text-status-*` or `bg-alert-*` utilities, and apps whose theme overrides `--status-*`, `--alert-*` or `--destructive` to recolour `Badge`, `Alert`, `CsvImporter` or `MetricCard`.
+
+1.17.0 adds semantic colour roles, `--{info|success|warning|danger|neutral}-{surface|surface-hover|border|solid|solid-hover|text|contrast|indicator}`, bridged as Tailwind colours such as `bg-success-surface` and `text-danger-text`. The old `--status-*` and `--alert-*` tokens still exist and still work, but they no longer hold their own colours: each one now resolves to a role (for example `--status-completed` is `var(--success-solid)`). They are deprecated and will be removed in the next major. Nothing errors. Three things change silently:
+
+- **Colours change.** `bg-status-*` and `bg-alert-*` resolve to the role values. For example `bg-status-completed` is `#15803d` instead of `#22c55e`. `--status-default` follows `--muted-foreground` instead of a fixed `#737373`.
+- **Component recolouring moves to the roles.** `Badge`, `Alert`, `CsvImporter` and `MetricCard` read the roles directly. A theme that overrides `--status-*` or `--alert-*` no longer changes these components; override `--{intent}-*` instead. `Badge` `error` / `subtle-error` and `Alert` `error` read `--danger-*`, so overriding `--destructive` no longer reaches them either. `Button variant="destructive"` and `text-destructive` still follow `--destructive`.
+- **`text-status-*` loses contrast in dark mode.** The `--status-*` values are now the step-9 fills, which are below 4.5:1 as text on the dark card.
+
+How to detect it: search the app for `status-`, `alert-` and theme overrides of `--status-`, `--alert-` and `--destructive`. Compare `Badge` and `Alert` in light and dark mode after upgrading.
+
+What to change:
+
+- Replace `text-status-completed` with `text-success-text`, `text-status-attention` with `text-warning-text`, `text-status-danger` with `text-danger-text`, and `text-status-neutral` with `text-info-text`.
+- Replace `bg-status-completed/10 border-status-completed/30` style tints with `bg-success-surface border-success-border`. The same applies to the other intents and to `bg-alert-*` / `border-alert-*`.
+- In a theme that recolours the status components, set the roles (`--success-solid`, `--danger-text`, ...) instead of `--status-*`, `--alert-*` or `--destructive`.
+
+The roles are documented in [Styling & theming](./concepts/styling-theming.md#semantic-color-roles). The decision record is `decisions/semantic-color-roles.md`.
+
## 1.15.0: Base UI 1.8.0 breaks closed-`Select` and dialog-click tests
**Applies to:** apps with unit tests that assert `Select` options without opening the `Select`, and apps with Playwright tests that click buttons inside a `Dialog` or `AlertDialog`.
@@ -146,7 +167,7 @@ grep -rnE "@theme inline|@custom-variant|app-shell/theme\.css|--(card|popover|mu
Excluding `node_modules` matters: AppShell's own palette files declare these tokens too, and they must not be touched. In your own CSS, hits are either the workaround, which goes, or deliberate overrides, which should take the `:root` / `:root.dark` form described in [Overriding tokens](./concepts/styling-theming.md#overriding-tokens).
-What remains is short — [`examples/vite-app/src/index.css`](https://github.com/tailor-platform/app-shell/blob/main/examples/vite-app/src/index.css) is a working reference for the shape (it also imports a branded palette, which is optional):
+What remains is short — [`examples/vite-app/src/index.css`](../examples/vite-app/src/index.css) is a working reference for the shape (it also imports a branded palette, which is optional):
```css
@import "tailwindcss";
diff --git a/examples/vite-app/src/pages/showcase/spinner/page.tsx b/examples/vite-app/src/pages/showcase/spinner/page.tsx
index 34d366bac..cd5b0d831 100644
--- a/examples/vite-app/src/pages/showcase/spinner/page.tsx
+++ b/examples/vite-app/src/pages/showcase/spinner/page.tsx
@@ -51,7 +51,7 @@ const SpinnerPage = () => {