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 = () => {
-
+
diff --git a/packages/core/__snapshots__/src__components__alert__alert.test.tsx.snap b/packages/core/__snapshots__/src__components__alert__alert.test.tsx.snap index aeb870c8c..65b6bb8cc 100644 --- a/packages/core/__snapshots__/src__components__alert__alert.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__alert__alert.test.tsx.snap @@ -1,13 +1,13 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Alert > snapshots > error variant 1`] = `""`; +exports[`Alert > snapshots > error variant 1`] = `""`; -exports[`Alert > snapshots > info variant 1`] = `""`; +exports[`Alert > snapshots > info variant 1`] = `""`; -exports[`Alert > snapshots > neutral variant (default) 1`] = `""`; +exports[`Alert > snapshots > neutral variant (default) 1`] = `""`; -exports[`Alert > snapshots > success variant 1`] = `""`; +exports[`Alert > snapshots > success variant 1`] = `""`; -exports[`Alert > snapshots > warning variant 1`] = `""`; +exports[`Alert > snapshots > warning variant 1`] = `""`; -exports[`Alert > snapshots > with dismissible 1`] = `""`; +exports[`Alert > snapshots > with dismissible 1`] = `""`; diff --git a/packages/core/src/assets/semantic-contrast.test.ts b/packages/core/src/assets/semantic-contrast.test.ts new file mode 100644 index 000000000..61974dd16 --- /dev/null +++ b/packages/core/src/assets/semantic-contrast.test.ts @@ -0,0 +1,154 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/** + * WCAG 1.4.3 contrast checks for the semantic colour roles, computed from + * `themes/default.css` itself. Nothing is copied into the test, so a change to + * a hue step or an alias that breaks a pair fails here. + * + * Scope of the guarantee: the default state of each pair, on the default + * palette's `--card` and `--background`. Not covered on purpose: + * - hover states (`surface-hover`) against 4.5:1: subtle-badge hover text is + * below 4.5:1 for some roles (danger and success in light mode). They are held + * at a documented floor (MIN_HOVER_TEXT) so they cannot get worse unnoticed, + * see decisions/semantic-color-roles.md; + * - branded palettes (cream, bloom) and their tinted shell backgrounds; + * - the neutral intent, whose values are the pre-existing system tokens; + * - the warning `solid` fill against the card (a fill, not a text pair). + */ +const here = dirname(fileURLToPath(import.meta.url)); +const css = readFileSync(join(here, "themes/default.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + +type Rgba = { r: number; g: number; b: number; a: number }; +type Mode = "light" | "dark"; + +/** Custom properties in effect on for a mode, applying rules in source order. */ +function variablesFor(mode: Mode): Map { + const vars = new Map(); + for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + const selectors = rule[1].split(",").map((s) => s.trim()); + // matches both `:root` and `.dark`; light matches `:root` only. + const applies = selectors.some((s) => s === ":root" || (mode === "dark" && s === ".dark")); + if (!applies) continue; + for (const decl of rule[2].matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) { + vars.set(decl[1], decl[2].replace(/\s+/g, " ").trim()); + } + } + return vars; +} + +function parseColor(value: string, vars: Map): Rgba { + const v = value.trim(); + const ref = /^var\((--[a-z0-9-]+)\)$/.exec(v); + if (ref) { + const target = vars.get(ref[1]); + if (target === undefined) throw new Error(`Undefined variable ${ref[1]}`); + return parseColor(target, vars); + } + const hex = /^#([0-9a-f]{3,8})$/i.exec(v); + if (hex) { + let h = hex[1]; + if (h.length === 3 || h.length === 4) h = [...h].map((c) => c + c).join(""); + if (h.length !== 6 && h.length !== 8) throw new Error(`Bad hex colour ${v}`); + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1, + }; + } + const fn = /^rgba?\(([^)]+)\)$/.exec(v); + if (fn) { + const [r, g, b, a = "1"] = fn[1].split(",").map((p) => p.trim()); + return { r: Number(r), g: Number(g), b: Number(b), a: Number(a) }; + } + throw new Error(`Unsupported colour syntax: ${v}`); +} + +function over(top: Rgba, base: Rgba): Rgba { + const a = top.a + base.a * (1 - top.a); + const mix = (t: number, b: number) => (t * top.a + b * base.a * (1 - top.a)) / a; + return { r: mix(top.r, base.r), g: mix(top.g, base.g), b: mix(top.b, base.b), a }; +} + +function linearize(channel: number): number { + const s = channel / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; +} + +function luminance({ r, g, b }: Rgba): number { + return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); +} + +function contrast(a: Rgba, b: Rgba): number { + const [hi, lo] = [luminance(a), luminance(b)].toSorted((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const MIN_TEXT = 4.5; +/** Known and accepted: subtle hover text is below AA for some roles. A floor, not a pass. */ +const MIN_HOVER_TEXT = 4.2; +const modes: Mode[] = ["light", "dark"]; +const intents = ["info", "success", "warning", "danger"] as const; +const alertVariantFor = { info: "info", success: "success", warning: "warning", danger: "error" }; +const pages = ["--card", "--background"] as const; + +describe.each(modes)("semantic colour contrast (%s)", (mode) => { + const vars = variablesFor(mode); + const color = (name: string) => parseColor(`var(${name})`, vars); + /** Foreground drawn on a translucent surface that sits on a page colour. */ + const pair = (fg: string, surface: string, page: string) => { + const base = color(page); + const bg = over(color(surface), base); + return contrast(over(color(fg), bg), bg); + }; + + it("resolves the page colours to opaque values", () => { + for (const page of pages) expect(color(page).a).toBe(1); + }); + + describe.each(intents)("%s", (intent) => { + const role = (name: string) => `--${intent}-${name}`; + + it("the contrast role meets 4.5:1 on solid", () => { + expect(contrast(color(role("contrast")), color(role("solid")))).toBeGreaterThanOrEqual( + MIN_TEXT, + ); + }); + + it("the contrast role meets 4.5:1 on solid-hover", () => { + expect(contrast(color(role("contrast")), color(role("solid-hover")))).toBeGreaterThanOrEqual( + MIN_TEXT, + ); + }); + + it.each(pages)("text meets 4.5:1 on surface over %s", (page) => { + expect(pair(role("text"), role("surface"), page)).toBeGreaterThanOrEqual(MIN_TEXT); + }); + + it.each(pages)( + "text stays above the documented hover floor on surface-hover over %s", + (page) => { + expect(pair(role("text"), role("surface-hover"), page)).toBeGreaterThanOrEqual( + MIN_HOVER_TEXT, + ); + }, + ); + + const variant = alertVariantFor[intent]; + + it.each(pages)("alert foreground meets 4.5:1 on alert background over %s", (page) => { + expect( + pair(`--alert-${variant}-foreground`, `--alert-${variant}-background`, page), + ).toBeGreaterThanOrEqual(MIN_TEXT); + }); + + it.each(pages)("alert description meets 4.5:1 on alert background over %s", (page) => { + expect( + pair(`--alert-${variant}-foreground-muted`, `--alert-${variant}-background`, page), + ).toBeGreaterThanOrEqual(MIN_TEXT); + }); + }); +}); diff --git a/packages/core/src/assets/theme-bridge.test.ts b/packages/core/src/assets/theme-bridge.test.ts index a4a47ff30..084b4e356 100644 --- a/packages/core/src/assets/theme-bridge.test.ts +++ b/packages/core/src/assets/theme-bridge.test.ts @@ -14,6 +14,9 @@ const here = dirname(fileURLToPath(import.meta.url)); const bridge = readFileSync(join(here, "theme.bridge.css"), "utf8"); const defaultTheme = readFileSync(join(here, "themes/default.css"), "utf8"); +/** Semantic colour roles: 5 intents x 8 roles, named `--{intent}-{role}`. */ +const SEMANTIC_PREFIX = "(?:info|success|warning|danger|neutral)"; + /** Token names defined in the default palette for a given prefix, e.g. `alert`. */ function definedTokens(prefix: string): string[] { const matches = defaultTheme.matchAll(new RegExp(`^\\s*--(${prefix}-[a-z-]+):`, "gm")); @@ -29,7 +32,11 @@ function bridgedTokens(prefix: string): Map { } describe("theme bridge", () => { - describe.each(["alert", "status"])("--%s-* tokens", (prefix) => { + describe.each([ + ["alert", "alert"], + ["status", "status"], + ["semantic colour role", SEMANTIC_PREFIX], + ])("--%s-* tokens", (_label, prefix) => { const defined = definedTokens(prefix); const bridged = bridgedTokens(prefix); @@ -56,4 +63,9 @@ describe("theme bridge", () => { // both files — fails loudly. expect(bridgedTokens("alert").size).toBe(20); }); + + it("bridges all 40 semantic colour roles", () => { + // 5 intents x 8 roles, pinned for the same reason as the alert count. + expect(bridgedTokens(SEMANTIC_PREFIX).size).toBe(40); + }); }); diff --git a/packages/core/src/assets/theme.bridge.css b/packages/core/src/assets/theme.bridge.css index c0a2ebf65..5500ba076 100644 --- a/packages/core/src/assets/theme.bridge.css +++ b/packages/core/src/assets/theme.bridge.css @@ -100,6 +100,47 @@ --color-alert-info-foreground-muted: var(--alert-info-foreground-muted); --color-alert-info-border: var(--alert-info-border); + --color-neutral-surface: var(--neutral-surface); + --color-neutral-surface-hover: var(--neutral-surface-hover); + --color-neutral-border: var(--neutral-border); + --color-neutral-solid: var(--neutral-solid); + --color-neutral-solid-hover: var(--neutral-solid-hover); + --color-neutral-text: var(--neutral-text); + --color-neutral-contrast: var(--neutral-contrast); + --color-neutral-indicator: var(--neutral-indicator); + --color-info-surface: var(--info-surface); + --color-info-surface-hover: var(--info-surface-hover); + --color-info-border: var(--info-border); + --color-info-solid: var(--info-solid); + --color-info-solid-hover: var(--info-solid-hover); + --color-info-text: var(--info-text); + --color-info-contrast: var(--info-contrast); + --color-info-indicator: var(--info-indicator); + --color-success-surface: var(--success-surface); + --color-success-surface-hover: var(--success-surface-hover); + --color-success-border: var(--success-border); + --color-success-solid: var(--success-solid); + --color-success-solid-hover: var(--success-solid-hover); + --color-success-text: var(--success-text); + --color-success-contrast: var(--success-contrast); + --color-success-indicator: var(--success-indicator); + --color-warning-surface: var(--warning-surface); + --color-warning-surface-hover: var(--warning-surface-hover); + --color-warning-border: var(--warning-border); + --color-warning-solid: var(--warning-solid); + --color-warning-solid-hover: var(--warning-solid-hover); + --color-warning-text: var(--warning-text); + --color-warning-contrast: var(--warning-contrast); + --color-warning-indicator: var(--warning-indicator); + --color-danger-surface: var(--danger-surface); + --color-danger-surface-hover: var(--danger-surface-hover); + --color-danger-border: var(--danger-border); + --color-danger-solid: var(--danger-solid); + --color-danger-solid-hover: var(--danger-solid-hover); + --color-danger-text: var(--danger-text); + --color-danger-contrast: var(--danger-contrast); + --color-danger-indicator: var(--danger-indicator); + --shadow-xs: var(--semantic-shadow-xs); --shadow-sm: var(--semantic-shadow-sm); --shadow-md: var(--semantic-shadow-md); diff --git a/packages/core/src/assets/theme.css b/packages/core/src/assets/theme.css index e9c3b37c2..97899dee8 100644 --- a/packages/core/src/assets/theme.css +++ b/packages/core/src/assets/theme.css @@ -16,7 +16,9 @@ * 4. PALETTE — optional: radius, charts, shadows, destructive. * * Inherited from default.css — do not duplicate in new palettes: - * --ring, --sidebar-*, --status-*, --alert-*. + * --ring, --sidebar-*, --primitive-* (colour scales), --status-*, --alert-*. + * The colour roles --{intent}-{role} inherit too; a palette overrides them + * only to recolour the status components. * * Light/dark mode is controlled separately via `.dark` on . * Palette selection is by CSS import; there is no runtime palette prop. diff --git a/packages/core/src/assets/themes/_template.css b/packages/core/src/assets/themes/_template.css index 4ea979ef0..6783295fa 100644 --- a/packages/core/src/assets/themes/_template.css +++ b/packages/core/src/assets/themes/_template.css @@ -17,7 +17,10 @@ * `@tailor-platform/app-shell/styles` in the consuming app. * * DO NOT SET (inherited from `:root` in default.css): - * --ring, --sidebar-*, --status-*, --alert-* + * --ring, --sidebar-*, --primitive-* (colour scales), --status-*, --alert-* + * The colour roles --{info|success|warning|danger|neutral}-{role} also inherit. + * Override a role only to recolour the status components (Badge, Alert, ...) + * for this palette; never set --status-* or --alert-*, they alias the roles. * * PREVIEW: `/showcase/colors` in the Vite example app. * ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/core/src/assets/themes/default.css b/packages/core/src/assets/themes/default.css index 96929f051..849f012b5 100644 --- a/packages/core/src/assets/themes/default.css +++ b/packages/core/src/assets/themes/default.css @@ -27,15 +27,15 @@ /* ── 4. SYSTEM ────────────────────────────────────────────────────────── */ --background: rgba(250, 250, 250, 1); - --foreground: rgba(10, 10, 10, 1); + --foreground: var(--primitive-gray-950); --card: rgba(255, 255, 255, 1); --card-foreground: rgba(10, 10, 10, 1); --popover: rgba(255, 255, 255, 1); --popover-foreground: rgba(10, 10, 10, 1); --muted: rgba(245, 245, 245, 1); - --muted-foreground: rgba(115, 115, 115, 1); - --border: rgba(229, 229, 229, 1); - --input: rgba(229, 229, 229, 1); + --muted-foreground: var(--primitive-gray-500); + --border: var(--primitive-gray-200); + --input: var(--primitive-gray-200); --sidebar: rgba(250, 250, 250, 1); --sidebar-foreground: rgba(10, 10, 10, 1); --sidebar-border: rgba(229, 229, 229, 1); @@ -53,51 +53,12 @@ --semantic-shadow-sm: 0 1px 3px 0 rgb(15 23 42 / 0.08), 0 1px 2px -1px rgb(15 23 42 / 0.08); --semantic-shadow-md: 0 4px 6px -1px rgb(15 23 42 / 0.09), 0 2px 4px -2px rgb(15 23 42 / 0.06); --semantic-shadow-lg: 0 10px 15px -3px rgb(15 23 42 / 0.12), 0 4px 6px -4px rgb(15 23 42 / 0.09); - - /* ── 6. SEMANTIC — product-wide; inherited by all palettes, do not override */ - --status-default: #737373; - --status-neutral: #0ea5e9; - --status-completed: #22c55e; - --status-attention: #f59e0b; - --status-danger: #ef4444; - --alert-neutral-background: var(--muted); - --alert-neutral-foreground: var(--foreground); - --alert-neutral-foreground-muted: var(--muted-foreground); - --alert-neutral-border: var(--border); - --alert-success-foreground: #15803d; - --alert-success-foreground-muted: color-mix( - in srgb, - var(--alert-success-foreground) 80%, - transparent - ); - --alert-success-background: color-mix(in srgb, #22c55e 10%, transparent); - --alert-success-border: color-mix(in srgb, #22c55e 20%, transparent); - --alert-warning-foreground: #a16207; - --alert-warning-foreground-muted: color-mix( - in srgb, - var(--alert-warning-foreground) 80%, - transparent - ); - --alert-warning-background: color-mix(in srgb, #eab308 10%, transparent); - --alert-warning-border: color-mix(in srgb, #eab308 20%, transparent); - --alert-error-foreground: var(--destructive); - --alert-error-foreground-muted: color-mix( - in srgb, - var(--alert-error-foreground) 80%, - transparent - ); - --alert-error-background: color-mix(in srgb, var(--destructive) 10%, transparent); - --alert-error-border: color-mix(in srgb, var(--destructive) 20%, transparent); - --alert-info-foreground: #1d4ed8; - --alert-info-foreground-muted: color-mix(in srgb, var(--alert-info-foreground) 80%, transparent); - --alert-info-background: color-mix(in srgb, #3b82f6 10%, transparent); - --alert-info-border: color-mix(in srgb, #3b82f6 20%, transparent); } /* - * Dark variant. Overrides BRAND + SYSTEM (+ semantic foregrounds that differ). - * Shared aliases/ring re-resolve when BRAND changes; `--radius` and `--status-*` - * inherit from the light block. + * Dark variant. Overrides BRAND + SYSTEM + PALETTE. The primitive scales (6) + * and the semantic roles (7) follow below. Shared aliases/ring re-resolve + * when BRAND changes; `--radius` inherits from the light block. */ .dark { color-scheme: dark; @@ -112,13 +73,13 @@ /* ── 4. SYSTEM (dark) ─────────────────────────────────────────────────── */ --background: rgba(10, 10, 10, 1); - --foreground: rgba(250, 250, 250, 1); + --foreground: var(--primitive-gray-50); --card: rgba(23, 23, 23, 1); --card-foreground: rgba(250, 250, 250, 1); --popover: rgba(38, 38, 38, 1); --popover-foreground: rgba(250, 250, 250, 1); --muted: rgba(38, 38, 38, 1); - --muted-foreground: rgba(163, 163, 163, 1); + --muted-foreground: var(--primitive-gray-400); --border: rgba(255, 255, 255, 0.1); --input: rgba(255, 255, 255, 0.15); --sidebar: rgba(10, 10, 10, 1); @@ -137,26 +98,333 @@ --semantic-shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.38), 0 1px 2px -1px rgb(0 0 0 / 0.32); --semantic-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.28); --semantic-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.42), 0 4px 6px -4px rgb(0 0 0 / 0.35); +} + +/* + * 6. PRIMITIVE SCALES — internal; consumers use the semantic roles (7) below. + * + * One gray scale and four Radix-style 12-step + alpha hue scales, light then + * dark. Names are prefixed to avoid colliding with a consumer's own copy of + * Radix Colors. Not bridged to Tailwind. See decisions/semantic-color-roles.md + * for how they were made. The hue "alpha" steps are translucent only where the + * generator could express them; some light steps (for example amber a2-a6) + * resolve to opaque hex. + * + * Gray is the Tailwind `neutral` family (the values this palette already used) + * and is the same in both modes, so it is declared once; the SYSTEM text and + * line tokens (4) read it. Surfaces stay literal: they are theme inputs. + */ +:root { + /* gray — both modes */ + --primitive-gray-50: #fafafa; + --primitive-gray-100: #f5f5f5; + --primitive-gray-200: #e5e5e5; + --primitive-gray-300: #d4d4d4; + --primitive-gray-400: #a3a3a3; + --primitive-gray-500: #737373; + --primitive-gray-600: #525252; + --primitive-gray-700: #404040; + --primitive-gray-800: #262626; + --primitive-gray-900: #171717; + --primitive-gray-950: #0a0a0a; + + /* hues — light */ + --primitive-blue-1: #f7f8f9; + --primitive-blue-2: #f0f4fa; + --primitive-blue-3: #e5ecf9; + --primitive-blue-4: #d6e4fd; + --primitive-blue-5: #c5d9ff; + --primitive-blue-6: #b0cbff; + --primitive-blue-7: #98b9f7; + --primitive-blue-8: #769ff0; + --primitive-blue-9: #2563eb; + --primitive-blue-10: #2057d1; + --primitive-blue-11: #1e55cf; + --primitive-blue-12: #152e63; + --primitive-blue-a1: #3b7bbb04; + --primitive-blue-a2: #136ffa0b; + --primitive-blue-a3: #0758ef16; + --primitive-blue-a4: #bed6ff99; + --primitive-blue-a5: #c5d9ff; + --primitive-blue-a6: #b0cbff; + --primitive-blue-a7: #0155f364; + --primitive-blue-a8: #004ee787; + --primitive-blue-a9: #014ae9da; + --primitive-blue-a10: #0140ccdf; + --primitive-blue-a11: #013fcae1; + --primitive-blue-a12: #001b55ea; + --primitive-blue-contrast: #ffffff; + + --primitive-green-1: #f5f9f6; + --primitive-green-2: #eff5f0; + --primitive-green-3: #e1f1e3; + --primitive-green-4: #d1ead5; + --primitive-green-5: #bfe2c5; + --primitive-green-6: #a7d7b0; + --primitive-green-7: #88c895; + --primitive-green-8: #57b36f; + --primitive-green-9: #15803d; + --primitive-green-10: #00722f; + --primitive-green-11: #047936; + --primitive-green-12: #1e3c25; + --primitive-green-a1: #26d05006; + --primitive-green-a2: #1190260c; + --primitive-green-a3: #04a1181a; + --primitive-green-a4: #02991a2a; + --primitive-green-a5: #04961d3d; + --primitive-green-a6: #01911c55; + --primitive-green-a7: #028e1e75; + --primitive-green-a8: #028e26a7; + --primitive-green-a9: #00752cea; + --primitive-green-a10: #00722f; + --primitive-green-a11: #007733fb; + --primitive-green-a12: #012309e1; + --primitive-green-contrast: #ffffff; + + --primitive-amber-1: #f9f7f5; + --primitive-amber-2: #fff2e1; + --primitive-amber-3: #ffe7bd; + --primitive-amber-4: #ffdca5; + --primitive-amber-5: #ffd089; + --primitive-amber-6: #ffbe5d; + --primitive-amber-7: #f8ad4a; + --primitive-amber-8: #e99300; + --primitive-amber-9: #f59e0b; + --primitive-amber-10: #e99300; + --primitive-amber-11: #a35f00; + --primitive-amber-12: #4f3515; + --primitive-amber-a1: #d07b2606; + --primitive-amber-a2: #fff2e1; + --primitive-amber-a3: #ffe7bd; + --primitive-amber-a4: #ffdca5; + --primitive-amber-a5: #ffd089; + --primitive-amber-a6: #ffbe5d; + --primitive-amber-a7: #f78c00b4; + --primitive-amber-a8: #e99300; + --primitive-amber-a9: #f59a00f4; + --primitive-amber-a10: #e99300; + --primitive-amber-a11: #a35f00; + --primitive-amber-a12: #3f2300ea; + --primitive-amber-contrast: #4f3515; + + --primitive-red-1: #faf7f6; + --primitive-red-2: #f9f2f1; + --primitive-red-3: #f9e5e2; + --primitive-red-4: #ffd4ce; + --primitive-red-5: #ffc5bd; + --primitive-red-6: #f9b4ab; + --primitive-red-7: #f19f95; + --primitive-red-8: #e88479; + --primitive-red-9: #dc2626; + --primitive-red-10: #cc0615; + --primitive-red-11: #ce0c17; + --primitive-red-12: #641c18; + --primitive-red-a1: #fb622f05; + --primitive-red-a2: #e12e150a; + --primitive-red-a3: #f0240619; + --primitive-red-a4: #ffd4ce; + --primitive-red-a5: #ffc5bd; + --primitive-red-a6: #f71d0151; + --primitive-red-a7: #e41b0368; + --primitive-red-a8: #d7160084; + --primitive-red-a9: #d70101d9; + --primitive-red-a10: #cb000ff9; + --primitive-red-a11: #cc000cf3; + --primitive-red-a12: #540400e7; + --primitive-red-contrast: #ffffff; +} + +.dark { + /* hues — dark */ + --primitive-blue-1: #050a16; + --primitive-blue-2: #0e1626; + --primitive-blue-3: #11244c; + --primitive-blue-4: #132e66; + --primitive-blue-5: #1a397a; + --primitive-blue-6: #22448b; + --primitive-blue-7: #2b509f; + --primitive-blue-8: #325ebb; + --primitive-blue-9: #2563eb; + --primitive-blue-10: #315dba; + --primitive-blue-11: #88b5ff; + --primitive-blue-12: #d1e2ff; + --primitive-blue-a1: #000af60d; + --primitive-blue-a2: #2c70f81e; + --primitive-blue-a3: #246bfe45; + --primitive-blue-a4: #226aff60; + --primitive-blue-a5: #2d71ff75; + --primitive-blue-a6: #3777fe87; + --primitive-blue-a7: #407dfe9c; + --primitive-blue-a8: #417efeb9; + --primitive-blue-a9: #276afeeb; + --primitive-blue-a10: #407dfeb8; + --primitive-blue-a11: #88b5ff; + --primitive-blue-a12: #d1e2ff; + --primitive-blue-contrast: #ffffff; + + --primitive-green-1: #050c06; + --primitive-green-2: #101a12; + --primitive-green-3: #152b1a; + --primitive-green-4: #0f3c1d; + --primitive-green-5: #124b24; + --primitive-green-6: #155b2c; + --primitive-green-7: #166c34; + --primitive-green-8: #137f3c; + --primitive-green-9: #15803d; + --primitive-green-10: #1b6f37; + --primitive-green-11: #71d088; + --primitive-green-12: #acf7bb; + --primitive-green-a1: #00b40003; + --primitive-green-a2: #64fa8211; + --primitive-green-a3: #5bfb7f23; + --primitive-green-a4: #23fb6635; + --primitive-green-a5: #28fe6c44; + --primitive-green-a6: #2bfd7055; + --primitive-green-a7: #28ff7366; + --primitive-green-a8: #1dff737a; + --primitive-green-a9: #21ff747b; + --primitive-green-a10: #33fd776a; + --primitive-green-a11: #89fea5cf; + --primitive-green-a12: #b2ffc1f7; + --primitive-green-contrast: #ffffff; + + --primitive-amber-1: #0d0906; + --primitive-amber-2: #1c150e; + --primitive-amber-3: #2f1f0c; + --primitive-amber-4: #412500; + --primitive-amber-5: #4e2e00; + --primitive-amber-6: #5d3c0f; + --primitive-amber-7: #724d1e; + --primitive-amber-8: #926426; + --primitive-amber-9: #f59e0b; + --primitive-amber-10: #e99300; + --primitive-amber-11: #fbb050; + --primitive-amber-12: #fde2c4; + --primitive-amber-a1: #ca000004; + --primitive-amber-a2: #fc9e4013; + --primitive-amber-a3: #fc941827; + --primitive-amber-a4: #fc81003a; + --primitive-amber-a5: #ff8c0047; + --primitive-amber-a6: #fd9c1857; + --primitive-amber-a7: #fda7396d; + --primitive-amber-a8: #ffac3d8e; + --primitive-amber-a9: #ffa40bf5; + --primitive-amber-a10: #fea000e9; + --primitive-amber-a11: #ffb351fb; + --primitive-amber-a12: #ffe4c6fd; + --primitive-amber-contrast: #4f3515; + + --primitive-red-1: #100807; + --primitive-red-2: #1f1210; + --primitive-red-3: #3c110e; + --primitive-red-4: #530b0a; + --primitive-red-5: #641210; + --primitive-red-6: #76201c; + --primitive-red-7: #8f312a; + --primitive-red-8: #b94038; + --primitive-red-9: #dc2626; + --primitive-red-10: #b63d36; + --primitive-red-11: #ff9083; + --primitive-red-12: #ffd1ca; + --primitive-red-a1: #e5000007; + --primitive-red-a2: #fe675016; + --primitive-red-a3: #fb2c1e35; + --primitive-red-a4: #ff0e0a4c; + --primitive-red-a5: #ff201b5e; + --primitive-red-a6: #fd3b3271; + --primitive-red-a7: #fe51448b; + --primitive-red-a8: #fe554ab7; + --primitive-red-a9: #ff2b2bdb; + --primitive-red-a10: #fe5248b4; + --primitive-red-a11: #ff9083; + --primitive-red-a12: #ffd1ca; + --primitive-red-contrast: #ffffff; +} - /* ── 6. SEMANTIC (dark foreground adjustments) ──────────────────────── */ - --alert-success-foreground: #86efac; - --alert-success-foreground-muted: color-mix( - in srgb, - var(--alert-success-foreground) 85%, - transparent - ); - --alert-warning-foreground: #fde047; - --alert-warning-foreground-muted: color-mix( - in srgb, - var(--alert-warning-foreground) 85%, - transparent - ); - --alert-error-foreground: #fca5a5; - --alert-error-foreground-muted: color-mix( - in srgb, - var(--alert-error-foreground) 85%, - transparent - ); - --alert-info-foreground: #93c5fd; - --alert-info-foreground-muted: color-mix(in srgb, var(--alert-info-foreground) 85%, transparent); +/* + * 7. SEMANTIC — product-wide; inherited by all palettes, do not override. + * + * Declared for both `:root` and `.dark` so a nested `.dark` wrapper re-resolves + * the roles against its own hue scales (6). The values are identical in both + * selectors; only the hue scales differ per mode. The one per-mode exception, + * `--warning-text`, is declared in the `.dark` rule after this block. + * + * Roles: `--{info|success|warning|danger|neutral}-{surface|surface-hover| + * border|solid|solid-hover|text|contrast|indicator}`. Hue intents read the hue + * scales; `neutral` reads the system tokens. `--status-*` and `--alert-*` below + * are aliases of these roles, so both families agree by construction. + */ +:root, +.dark { + --neutral-surface: var(--muted); + --neutral-surface-hover: var(--accent); + --neutral-border: var(--border); + --neutral-solid: var(--secondary); + --neutral-solid-hover: var(--accent); + --neutral-text: var(--foreground); + --neutral-contrast: var(--secondary-foreground); + --neutral-indicator: var(--muted-foreground); + --info-surface: var(--primitive-blue-a3); + --info-surface-hover: var(--primitive-blue-a4); + --info-border: var(--primitive-blue-a6); + --info-solid: var(--primitive-blue-9); + --info-solid-hover: var(--primitive-blue-10); + --info-text: var(--primitive-blue-11); + --info-contrast: var(--primitive-blue-contrast); + --info-indicator: var(--primitive-blue-9); + --success-surface: var(--primitive-green-a3); + --success-surface-hover: var(--primitive-green-a4); + --success-border: var(--primitive-green-a6); + --success-solid: var(--primitive-green-9); + --success-solid-hover: var(--primitive-green-10); + --success-text: var(--primitive-green-11); + --success-contrast: var(--primitive-green-contrast); + --success-indicator: var(--primitive-green-9); + --warning-surface: var(--primitive-amber-a3); + --warning-surface-hover: var(--primitive-amber-a4); + --warning-border: var(--primitive-amber-a6); + --warning-solid: var(--primitive-amber-9); + --warning-solid-hover: var(--primitive-amber-10); + --warning-text: var(--primitive-amber-12); + --warning-contrast: var(--primitive-amber-contrast); + --warning-indicator: var(--primitive-amber-9); + --danger-surface: var(--primitive-red-a3); + --danger-surface-hover: var(--primitive-red-a4); + --danger-border: var(--primitive-red-a6); + --danger-solid: var(--primitive-red-9); + --danger-solid-hover: var(--primitive-red-10); + --danger-text: var(--primitive-red-11); + --danger-contrast: var(--primitive-red-contrast); + --danger-indicator: var(--primitive-red-9); + + --status-default: var(--neutral-indicator); + --status-neutral: var(--info-solid); + --status-completed: var(--success-solid); + --status-attention: var(--warning-solid); + --status-danger: var(--danger-solid); + --alert-neutral-background: var(--neutral-surface); + --alert-neutral-foreground: var(--neutral-text); + --alert-neutral-foreground-muted: var(--muted-foreground); + --alert-neutral-border: var(--neutral-border); + --alert-success-background: var(--success-surface); + --alert-success-foreground: var(--success-text); + --alert-success-foreground-muted: var(--alert-success-foreground); + --alert-success-border: var(--success-border); + --alert-warning-background: var(--warning-surface); + --alert-warning-foreground: var(--warning-text); + --alert-warning-foreground-muted: var(--alert-warning-foreground); + --alert-warning-border: var(--warning-border); + --alert-error-background: var(--danger-surface); + --alert-error-foreground: var(--danger-text); + --alert-error-foreground-muted: var(--alert-error-foreground); + --alert-error-border: var(--danger-border); + --alert-info-background: var(--info-surface); + --alert-info-foreground: var(--info-text); + --alert-info-foreground-muted: var(--alert-info-foreground); + --alert-info-border: var(--info-border); +} + +.dark { + /* Warning text: step 12 on the light surface, step 11 on the dark one. */ + --warning-text: var(--primitive-amber-11); } diff --git a/packages/core/src/components/alert/alert.tsx b/packages/core/src/components/alert/alert.tsx index 8afb601d4..f39860656 100644 --- a/packages/core/src/components/alert/alert.tsx +++ b/packages/core/src/components/alert/alert.tsx @@ -16,15 +16,14 @@ const alertVariants = cva( variants: { /** Visual style and automatic icon */ variant: { + // Each variant reads the semantic colour roles of its intent. The + // description inherits the title colour; only neutral mutes it. neutral: - "astw:bg-alert-neutral-background astw:text-alert-neutral-foreground astw:border-alert-neutral-border astw:*:data-[slot=alert-description]:text-alert-neutral-foreground-muted", - success: - "astw:bg-alert-success-background astw:text-alert-success-foreground astw:border-alert-success-border astw:*:data-[slot=alert-description]:text-alert-success-foreground-muted", - warning: - "astw:bg-alert-warning-background astw:text-alert-warning-foreground astw:border-alert-warning-border astw:*:data-[slot=alert-description]:text-alert-warning-foreground-muted", - error: - "astw:bg-alert-error-background astw:text-alert-error-foreground astw:border-alert-error-border astw:*:data-[slot=alert-description]:text-alert-error-foreground-muted", - info: "astw:bg-alert-info-background astw:text-alert-info-foreground astw:border-alert-info-border astw:*:data-[slot=alert-description]:text-alert-info-foreground-muted", + "astw:bg-neutral-surface astw:text-neutral-text astw:border-neutral-border astw:*:data-[slot=alert-description]:text-muted-foreground", + success: "astw:bg-success-surface astw:text-success-text astw:border-success-border", + warning: "astw:bg-warning-surface astw:text-warning-text astw:border-warning-border", + error: "astw:bg-danger-surface astw:text-danger-text astw:border-danger-border", + info: "astw:bg-info-surface astw:text-info-text astw:border-info-border", }, }, defaultVariants: { diff --git a/packages/core/src/components/badge/badge.test.tsx b/packages/core/src/components/badge/badge.test.tsx new file mode 100644 index 000000000..73de97159 --- /dev/null +++ b/packages/core/src/components/badge/badge.test.tsx @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { badgeVariants } from "./badge"; + +/** + * Badge is precompiled with `astw:` utilities. A `bg-{intent}-{role}` class whose + * token is missing from the Tailwind bridge emits no CSS and fails silently, + * so check every semantic utility a variant uses against `theme.bridge.css`. + */ +const bridge = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "../../assets/theme.bridge.css"), + "utf8", +); + +const variants = [ + "success", + "warning", + "error", + "info", + "subtle-success", + "subtle-warning", + "subtle-error", + "subtle-info", +] as const; + +describe("badgeVariants semantic colour classes", () => { + it.each(variants)("%s uses only bridged semantic tokens", (variant) => { + const classes = badgeVariants({ variant }).split(/\s+/); + const tokens = classes + .map( + (c) => + /^astw:(?:hover:)?(?:bg|text)-((?:info|success|warning|danger|neutral)-[a-z-]+)$/.exec( + c, + )?.[1], + ) + .filter((t): t is string => t !== undefined); + + expect(tokens.length).toBeGreaterThanOrEqual(3); + for (const token of tokens) { + expect(bridge).toContain(`--color-${token}:`); + } + }); +}); diff --git a/packages/core/src/components/badge/badge.tsx b/packages/core/src/components/badge/badge.tsx index 5054ad5b0..5aefe2b94 100644 --- a/packages/core/src/components/badge/badge.tsx +++ b/packages/core/src/components/badge/badge.tsx @@ -11,22 +11,22 @@ const badgeVariants = cva( default: "astw:border-transparent astw:bg-primary astw:text-primary-foreground astw:hover:bg-primary/80", success: - "astw:border-transparent astw:bg-status-completed astw:text-white astw:hover:bg-status-completed/90", + "astw:border-transparent astw:bg-success-solid astw:text-success-contrast astw:hover:bg-success-solid-hover", warning: - "astw:border-transparent astw:bg-status-attention astw:text-white astw:hover:bg-status-attention/90", + "astw:border-transparent astw:bg-warning-solid astw:text-warning-contrast astw:hover:bg-warning-solid-hover", error: - "astw:border-transparent astw:bg-destructive astw:text-destructive-foreground astw:hover:bg-destructive/80", + "astw:border-transparent astw:bg-danger-solid astw:text-danger-contrast astw:hover:bg-danger-solid-hover", neutral: "astw:border-transparent astw:bg-secondary astw:text-secondary-foreground astw:hover:bg-secondary/80", - info: "astw:border-transparent astw:bg-status-neutral astw:text-white astw:hover:bg-status-neutral/90", + info: "astw:border-transparent astw:bg-info-solid astw:text-info-contrast astw:hover:bg-info-solid-hover", "subtle-success": - "astw:border-transparent astw:bg-status-completed/10 astw:text-status-completed astw:hover:bg-status-completed/20", + "astw:border-transparent astw:bg-success-surface astw:text-success-text astw:hover:bg-success-surface-hover", "subtle-warning": - "astw:border-transparent astw:bg-status-attention/10 astw:text-status-attention astw:hover:bg-status-attention/20", + "astw:border-transparent astw:bg-warning-surface astw:text-warning-text astw:hover:bg-warning-surface-hover", "subtle-error": - "astw:border-transparent astw:bg-destructive/10 astw:text-destructive astw:hover:bg-destructive/20", + "astw:border-transparent astw:bg-danger-surface astw:text-danger-text astw:hover:bg-danger-surface-hover", "subtle-info": - "astw:border-transparent astw:bg-status-neutral/10 astw:text-status-neutral astw:hover:bg-status-neutral/20", + "astw:border-transparent astw:bg-info-surface astw:text-info-text astw:hover:bg-info-surface-hover", // Outline variants with status dots - matches Figma design "outline-success": "astw:gap-0.5 astw:pl-1.5 astw:pr-2 astw:border-border astw:bg-card astw:text-foreground", @@ -48,11 +48,11 @@ const badgeVariants = cva( // Status dot colors for outline variants const statusDotColors: Record = { - "outline-success": "astw:bg-status-completed", - "outline-warning": "astw:bg-status-attention", - "outline-error": "astw:bg-status-danger", - "outline-info": "astw:bg-status-neutral", - "outline-neutral": "astw:bg-status-default", + "outline-success": "astw:bg-success-indicator", + "outline-warning": "astw:bg-warning-indicator", + "outline-error": "astw:bg-danger-indicator", + "outline-info": "astw:bg-info-indicator", + "outline-neutral": "astw:bg-neutral-indicator", }; function StatusDot({ variant }: { variant: string }) { diff --git a/packages/core/src/components/csv-importer/CsvImporter.tsx b/packages/core/src/components/csv-importer/CsvImporter.tsx index ffe757ed8..3cc9b9253 100644 --- a/packages/core/src/components/csv-importer/CsvImporter.tsx +++ b/packages/core/src/components/csv-importer/CsvImporter.tsx @@ -364,10 +364,10 @@ function ReviewStep({ Total: {rawRows.length} rows {errorCount > 0 && Errors: {errorCount}} {warningCount > 0 && ( - Warnings: {warningCount} + Warnings: {warningCount} )} {validated && errorCount === 0 && !validating && ( - + {t("reviewNoErrors")} @@ -417,7 +417,7 @@ function ReviewStep({ className={cn( "astw:px-1 astw:py-1", issue?.level === "error" && "astw:bg-destructive/10", - issue?.level === "warning" && "astw:bg-status-attention/10", + issue?.level === "warning" && "astw:bg-warning-surface", )} >
@@ -426,7 +426,7 @@ function ReviewStep({ className={cn( "astw:w-full astw:rounded astw:border astw:px-2 astw:py-1 astw:text-sm astw:bg-transparent", issue?.level === "error" && "astw:border-destructive", - issue?.level === "warning" && "astw:border-status-attention", + issue?.level === "warning" && "astw:border-warning-solid", !issue && "astw:border-transparent", )} value={displayValue} @@ -438,7 +438,7 @@ function ReviewStep({ "astw:text-xs astw:px-2", issue.level === "error" ? "astw:text-destructive" - : "astw:text-status-attention", + : "astw:text-warning-text", )} > {issue.message} diff --git a/packages/core/src/components/metric-card/MetricCard.tsx b/packages/core/src/components/metric-card/MetricCard.tsx index ea46dd389..8040671fb 100644 --- a/packages/core/src/components/metric-card/MetricCard.tsx +++ b/packages/core/src/components/metric-card/MetricCard.tsx @@ -10,8 +10,8 @@ const iconSlotClasses = "astw:flex astw:size-4 astw:items-center astw:justify-center astw:shrink-0 astw:text-muted-foreground"; const trendDirectionClasses: Record = { - up: "astw:text-green-600 astw:dark:text-green-400", - down: "astw:text-red-600 astw:dark:text-red-400", + up: "astw:text-success-text", + down: "astw:text-danger-text", neutral: "astw:text-muted-foreground", };