diff --git a/.agents/references/accessibility.md b/.agents/references/accessibility.md index c9882897..679f0889 100644 --- a/.agents/references/accessibility.md +++ b/.agents/references/accessibility.md @@ -22,6 +22,13 @@ Accessibility is a default review surface for any interactive change. - Icon-only controls need `aria-label` or an equivalent labeling path. - Labels, descriptions, and error messages should stay connected when wrapping or composing controls. +### Colour contrast + +- Text needs 4.5:1 against its background. Non-text UI that people need to see, such as input borders, focus rings and meaningful icons, needs 3:1 (WCAG 1.4.3 and 1.4.11). +- Use the semantic colour roles: `{intent}-text` on `{intent}-surface`, and `{intent}-contrast` on `{intent}-solid`. `packages/core/tests/browser/semantic-contrast.test.ts` checks these pairs. A new pair outside them is not checked. +- Do not show a state by colour alone. Add text or an icon. +- The styling guide (`docs/concepts/styling-theming.md`) lists the known exceptions next to the thresholds. + ### Announced state Review whether state remains understandable through attributes such as: @@ -46,4 +53,5 @@ For table/grid-like UI, check: - Does the interactive element have the correct semantics? - Can the interaction be completed without pointer-only access? - Does an icon-only control still have an accessible name? +- Does new text or a new colour pair meet 4.5:1 for text and 3:1 for non-text UI? - Do wrapper or layout changes silently break focus, state, or announced meaning? diff --git a/decisions/semantic-color-roles.md b/decisions/semantic-color-roles.md index d940d078..fb31745b 100644 --- a/decisions/semantic-color-roles.md +++ b/decisions/semantic-color-roles.md @@ -155,7 +155,7 @@ AppShell's thresholds are 4.5:1 for text (WCAG 1.4.3) and 3:1 for non-text (WCAG - `text` on `surface-hover` stays at the 4.2:1 floor. Choosing another step is a visible change for a later PR. - In `cream` and `bloom`, the main content area is transparent over the shell gradient. At the top of the bloom light gradient (55% of `--shell-gradient-base`), `text` on `surface` is 4.49:1 for success and 4.48:1 for danger. cream light is 4.68:1 or more. The figure under "Not guaranteed" (4.16:1 over `#efe8ff`) used `--shell-gradient-base` itself, which the gradient covers. - `warning` `solid` and `indicator` are 2.15:1 against a light card. They are used with a label. -- The neutral intent is not checked. +- The neutral intent is not checked. Update 2026-10-30: the test now checks it in every palette and mode. `text` on `surface` and `surface-hover`, and `contrast` on `solid` and `solid-hover`, are at least 4.5:1, so it is no longer an exception. This supersedes "The `cream` and `bloom` palettes and their tinted shell backgrounds" under "Not guaranteed". diff --git a/docs-manifest.json b/docs-manifest.json index 6b025c99..8fafe7d3 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -1382,9 +1382,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "4081996c98e029b0", + "outline": "bc3d87eddc9e8ec9", "snapshot": null, - "outputMd": "1ed3e6d92e582e26", + "outputMd": "2372f987efc82b03", "examples": "4d19f4f688e0f0ac" } }, @@ -1771,7 +1771,7 @@ "packages/core/skills/app-shell-patterns/references/concepts/modules-and-resources.md": "bf15b5460f22d165", "packages/core/skills/app-shell-patterns/references/concepts/routing-navigation.md": "8034c7f885994b29", "packages/core/skills/app-shell-patterns/references/concepts/sidebar-navigation.md": "5a3c8bac248061f9", - "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "1ed3e6d92e582e26", + "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "2372f987efc82b03", "packages/core/skills/app-shell-patterns/references/components/action-panel.md": "dc861082aa9610af", "packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0", "packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "82313d5b6fe140d2", diff --git a/docs-src/concepts/styling-theming.docs.outline.md b/docs-src/concepts/styling-theming.docs.outline.md index db5b22b6..242c96e6 100644 --- a/docs-src/concepts/styling-theming.docs.outline.md +++ b/docs-src/concepts/styling-theming.docs.outline.md @@ -202,12 +202,11 @@ Each intent has eight roles. The intents are `info`, `success`, `warning`, `dang The hue roles have the same values in every shipped palette. `cream` and `bloom` change brand and surface colors, not status colors. The `neutral` roles follow the palette, because they read the system tokens. -**Contrast.** AppShell's thresholds are 4.5:1 for text (WCAG 1.4.3) and 3:1 for non-text (WCAG 1.4.11). In every shipped palette (`default`, `cream`, `bloom`), in light and dark mode, on `--card` and `--background`, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for the four hue intents, and `danger` `solid` (the invalid-input border) is at least 3:1. A browser test checks this in Chromium against the built stylesheet. Known exceptions: +**Contrast.** AppShell's thresholds are 4.5:1 for text (WCAG 1.4.3) and 3:1 for non-text (WCAG 1.4.11). In every shipped palette (`default`, `cream`, `bloom`), in light and dark mode, on `--card` and `--background`, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for all five intents, `text` on `surface-hover` is at least 4.5:1 for `neutral`, and `danger` `solid` (the invalid-input border) is at least 3:1. A browser test checks this in Chromium against the built stylesheet. Known exceptions: - `text` on `surface-hover` is below 4.5:1 for `danger` and `success` in light mode. The test holds it at 4.2:1 or more. - In `cream` and `bloom`, the main content area is transparent over the shell gradient. At the top of the `bloom` light gradient, `text` on `surface` is 4.49:1 for `success` and 4.48:1 for `danger`. Put status content on a card when it needs to pass. - `warning` `solid` and `indicator` are 2.15:1 against a light card. Use them with a label, not as the only sign of a state. -- The `neutral` intent reads the system tokens and is not checked. 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. diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index cab965f8..8ed96240 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -219,12 +219,11 @@ Each intent has eight roles. The intents are `info`, `success`, `warning`, `dang The hue roles have the same values in every shipped palette. `cream` and `bloom` change brand and surface colors, not status colors. The `neutral` roles follow the palette, because they read the system tokens. -**Contrast.** AppShell's thresholds are 4.5:1 for text (WCAG 1.4.3) and 3:1 for non-text (WCAG 1.4.11). In every shipped palette (`default`, `cream`, `bloom`), in light and dark mode, on `--card` and `--background`, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for the four hue intents, and `danger` `solid` (the invalid-input border) is at least 3:1. A browser test checks this in Chromium against the built stylesheet. Known exceptions: +**Contrast.** AppShell's thresholds are 4.5:1 for text (WCAG 1.4.3) and 3:1 for non-text (WCAG 1.4.11). In every shipped palette (`default`, `cream`, `bloom`), in light and dark mode, on `--card` and `--background`, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for all five intents, `text` on `surface-hover` is at least 4.5:1 for `neutral`, and `danger` `solid` (the invalid-input border) is at least 3:1. A browser test checks this in Chromium against the built stylesheet. Known exceptions: - `text` on `surface-hover` is below 4.5:1 for `danger` and `success` in light mode. The test holds it at 4.2:1 or more. - In `cream` and `bloom`, the main content area is transparent over the shell gradient. At the top of the `bloom` light gradient, `text` on `surface` is 4.49:1 for `success` and 4.48:1 for `danger`. Put status content on a card when it needs to pass. - `warning` `solid` and `indicator` are 2.15:1 against a light card. Use them with a label, not as the only sign of a state. -- The `neutral` intent reads the system tokens and is not checked. 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. diff --git a/packages/core/tests/browser/semantic-contrast.test.ts b/packages/core/tests/browser/semantic-contrast.test.ts index 05f98d39..f756b7f4 100644 --- a/packages/core/tests/browser/semantic-contrast.test.ts +++ b/packages/core/tests/browser/semantic-contrast.test.ts @@ -3,14 +3,15 @@ import { applyPalette, colour, contrastOn, sample } from "./theme-utils"; /** * Shipped CSS is resolved by Chromium, including aliases, palette overrides and alpha. - * Scope: card/background in all palettes and modes. Shell gradients, neutral intent - * contrast and warning solid/card contrast remain excluded. Subtle hover's 4.2 floor + * Scope: card/background in all palettes and modes. Shell gradients and warning + * solid/card contrast remain excluded. Subtle hover's 4.2 floor * is a documented exception, not an AA pass; see decisions/semantic-color-roles.md. */ const palettes = ["default", "cream", "bloom"] as const; const modes = ["light", "dark"] as const; const pages = ["--card", "--background"] as const; const intents = ["info", "success", "warning", "danger"] as const; +const neutral = (name: string) => `--neutral-${name}`; const alertVariantFor = { info: "info", success: "success", warning: "warning", danger: "error" }; it("measures opaque, transparent and translucent contrast without a CSS parser", () => { @@ -68,6 +69,20 @@ describe.each(palettes.flatMap((palette) => modes.map((mode) => [palette, mode] }); }); + // The neutral roles follow the palette's own surfaces, so they are checked separately + // from the hue intents. Their text is the palette foreground, so hover has no floor. + describe("neutral", () => { + it.each(["solid", "solid-hover"])("contrast meets 4.5:1 on %s", (surface) => { + expect(pair(neutral("contrast"), neutral(surface), "--card")).toBeGreaterThanOrEqual(4.5); + }); + + it.each( + pages.flatMap((page) => (["surface", "surface-hover"] as const).map((s) => [s, page])), + )("text meets 4.5:1 on %s over %s", (surface, page) => { + expect(pair(neutral("text"), neutral(surface), page)).toBeGreaterThanOrEqual(4.5); + }); + }); + it.each([ ["--destructive", "--danger-solid"], ["--destructive-foreground", "--danger-contrast"], diff --git a/packages/core/tests/deprecated-colour-utilities.test.ts b/packages/core/tests/deprecated-colour-utilities.test.ts new file mode 100644 index 00000000..53d43cd1 --- /dev/null +++ b/packages/core/tests/deprecated-colour-utilities.test.ts @@ -0,0 +1,38 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Components read the semantic colour roles. The old status, alert and destructive + * tokens are deprecated aliases for consumers (see decisions/semantic-color-roles.md), + * so core code must not use them again. + */ +const root = join(import.meta.dirname, "../src"); + +const DEPRECATED = [ + // Tailwind utilities, with or without the astw: prefix and variants. + /(? { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + if (!/\.(ts|tsx)$/.test(entry.name) || /\.test\.(ts|tsx)$/.test(entry.name)) return []; + return [path]; + }); +} + +describe("deprecated colour tokens", () => { + it("are not used in core source files", () => { + const found = sourceFiles(root).flatMap((file) => { + const text = readFileSync(file, "utf8"); + return DEPRECATED.flatMap((pattern) => + [...text.matchAll(pattern)].map((match) => `${relative(root, file)}: ${match[0]}`), + ); + }); + expect(found).toEqual([]); + }); +});