Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .agents/references/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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?
2 changes: 1 addition & 1 deletion decisions/semantic-color-roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".

Expand Down
6 changes: 3 additions & 3 deletions docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1382,9 +1382,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
"outline": "4081996c98e029b0",
"outline": "bc3d87eddc9e8ec9",
"snapshot": null,
"outputMd": "1ed3e6d92e582e26",
"outputMd": "2372f987efc82b03",
"examples": "4d19f4f688e0f0ac"
}
},
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions docs-src/concepts/styling-theming.docs.outline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 1 addition & 2 deletions docs/concepts/styling-theming.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 additions & 2 deletions packages/core/tests/browser/semantic-contrast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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"],
Expand Down
38 changes: 38 additions & 0 deletions packages/core/tests/deprecated-colour-utilities.test.ts
Original file line number Diff line number Diff line change
@@ -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.
/(?<![\w-])(?:bg|text|border(?:-[xytrblse])?|divide|outline|ring|ring-offset|fill|stroke|from|via|to|decoration|placeholder|caret|accent|shadow)-(?:destructive(?:-foreground)?|status-[a-z]+|alert-[a-z]+-[a-z-]+)(?![\w-])/g,
// CSS variables.
/--(?:destructive(?:-foreground)?|status-[a-z]+|alert-[a-z]+-[a-z-]+)(?![\w-])/g,
];

function sourceFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
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([]);
});
});
Loading