diff --git a/packages/ui-patterns/src/LanguageDialog.tsx b/packages/ui-patterns/src/LanguageDialog.tsx index f414e11..a0db69b 100644 --- a/packages/ui-patterns/src/LanguageDialog.tsx +++ b/packages/ui-patterns/src/LanguageDialog.tsx @@ -182,7 +182,7 @@ export const LanguageDialog = ({ {translationLinkHref && ( {enName} {/* Visual pointer to the footnote; the accessible link is the @@ -332,7 +332,7 @@ const LanguageCard = ({ diff --git a/packages/ui/README.md b/packages/ui/README.md index 70e274c..b92fc44 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -205,7 +205,7 @@ Panda emits every token as a CSS custom property with its default naming — `{category}-{path}` with dots become dashes, camelCase becomes kebab-case: - `colors.brand.500` → `var(--colors-brand-500)` -- `colors.statusBarBg` → `var(--colors-status-bar-bg)` +- `colors.surface.statusBar` → `var(--colors-surface-status-bar)` - `fonts.display` → `var(--fonts-display)` These names are **API** for styling that lives outside React/Panda — e.g. @@ -215,19 +215,109 @@ keep them stable: - Never set `hashing` or `prefix` in `panda.config.ts`. - Brand/app presets may change token _values_, never token _names_. -Semantic tokens (`languageText`, `statusBarBg`, `danger.*`, `toast*Bg`, -`button.*`, `controlCheckedBg`, `focusBorder`, …) are the extension points -brand presets override; they resolve through var indirection, so overrides -apply wherever the token is consumed. +### What a preset overrides -`focusRing` and `focusBorder` need more care: their values are condition -objects and a merge replaces a value wholesale, so the flat form drops the -on-dark flip below. Keep the shape: +Colour comes in two layers, overridden for different reasons. + +**Ramps** — `gray`, `brand`, `brand2`, `red` and the rest of the base +scales. Rebrand by changing their _values_. `gray` additionally carries a +per-stop contrast contract; re-tint it only luminance-matched, and read the +comment on the ramp in `base-preset.ts` before you do. + +**Roles** — what the recipes actually reference, so a value set here +applies wherever the role is consumed. Four groups, namespaced by the CSS +property they belong to: + +| group | what it colours | +| ----------- | ------------------------------------------------------------ | +| `fg.*` | text and icons | +| `surface.*` | container backgrounds, and the state fills that go over them | +| `fill.*` | backgrounds of controls _sitting on_ a surface | +| `border.*` | borders and control outlines | + +A foreground role is not usable as a background, and vice versa — the +surface/fill split is the one that does the real work, so a row's hover +(`surface.*`) and a grey button's hover (`fill.*`) climb different ladders +on purpose. + +Plus four component groups, for idioms that are a component fact rather +than a role: `button.*` (the family ships two button idioms — +brand-coloured and black-on-white — and this is the seam between them), +`buttonToolbar.*`, `closeButton.*`, `languageDialog.*`. `focusRing`, +`focusBorder` and the whole-ramp alias `danger` keep their own names. + +**`base-preset.ts` is authoritative for the inventory**, and carries the +reasoning for each role in comments. + +### Panda does not check these names + +**An unknown `semanticTokens` key is accepted silently.** It typechecks, it +generates, and the override never applies — the token keeps the base +preset's value, with no error at build or at runtime. + +So assert your preset's keys in a test: ```ts -focusBorder: { value: { base: "{colors.brand.700}", _onDark: "{colors.white}" } }; +import { unknownSemanticTokens } from "@microbit/ui/preset-lint"; + +it("overrides only semantic tokens @microbit/ui defines", () => { + // `introduces` declares the tokens this preset adds rather than overrides. + expect(unknownSemanticTokens(appPreset, { introduces })).toEqual([]); +}); ``` +Check a paired private brand preset from the app too, when it is linked: +the brand repo stays free of a dependency on this package, and the check +runs where the presets actually merge. + +That covers names. For values, a green typecheck verifies nothing: resolve +the generated CSS and read them back. + +```sh +rm -rf styled-system && npx panda cssgen --outfile after.css +``` + +The same applies to `colorPalette`: Panda derives its key space from the +union of every stop name across all colour tokens, so a miss emits a var +that resolves to nothing and the declaration is dropped, again silently. +See `docs/hints.md`. + +### Tokens whose value is a condition object + +Seven tokens hold `{ base, _onDark }` rather than a flat value. A merge +replaces the value _wholesale_, so writing the flat form silently drops the +on-dark flip: + +`fg.strong`, `fill.transparentHover`, `fill.transparentActive`, +`closeButton.bgHover`, `closeButton.bgActive`, `focusBorder`, `focusRing`. + +Keep the shape: + +```ts +focusBorder: { value: { base: "{colors.brand.600}", _onDark: "{colors.white}" } }; +``` + +This is the same silent failure as an unknown key, and it has the same +guard — assert it alongside `unknownSemanticTokens`: + +```ts +import { droppedConditionTokens } from "@microbit/ui/preset-lint"; + +it("keeps every condition the base preset's tokens carry", () => { + expect(droppedConditionTokens(appPreset)).toEqual([]); +}); +``` + +A preset that genuinely wants no flip states it, with both keys equal: +`{ base: X, _onDark: X }`. + +Those seven are the whole of the dark-surface mechanism — one +`[data-surface="dark"]` block redefining seven custom properties, and every +component inside it recolours with no per-component rules. The corollary is +that a component painting its own contrasting fill must _not_ consume them: +it sits inside the tag while being light itself. That is what +`buttonToolbar.*` exists for — static values that do not flip. + ## Dark surfaces Focus indicators are surface-aware through one tag. The default focus ring @@ -247,7 +337,10 @@ on whatever is behind it. (The Toast does this — the card is dark and focusable, so the tag sits on its close button.) Portalled overlays (a modal opened from a dark toolbar) escape the tag with the DOM, which is correct. Under the hood it is `data-surface="dark"`, which the preset's -`onDark` condition scopes the `focusRing`/`focusBorder` token flips to. +`onDark` condition scopes the seven token flips to (listed under +[the CSS-variable contract](#tokens-whose-value-is-a-condition-object)). +The tag does more than focus: inside it, `ghost` buttons and both close +buttons recolour too. Two rules: @@ -273,8 +366,8 @@ extraction can't see), import the runtime lookup: ```ts import { token } from "@microbit/ui"; // re-exports styled-system/tokens -token("colors.brand.500"); // "#007dbc" — raw value, safe for colour math -token("colors.statusBarBg"); // "var(--colors-brand2-500)" — CSS contexts only +token("colors.brand.500"); // "#3182ce" — raw value, safe for colour math +token("colors.surface.statusBar"); // "var(--colors-brand2-500)" — CSS contexts only ``` Base tokens resolve to raw values; **semantic tokens resolve to `var()` diff --git a/packages/ui/package.json b/packages/ui/package.json index ee10dae..c9aaad4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,6 +9,7 @@ "./base-preset": "./src/base-preset.ts", "./base-tokens": "./src/base-tokens.ts", "./dense-preset": "./src/dense-preset.ts", + "./preset-lint": "./src/preset-lint.ts", "./messages": "./src/messages.ts", "./postcss-legacy-safari": "./postcss-legacy-safari.cjs", "./reset.css": "./reset.css", diff --git a/packages/ui/src/Avatar.recipe.ts b/packages/ui/src/Avatar.recipe.ts index 1f971ce..bb7679f 100644 --- a/packages/ui/src/Avatar.recipe.ts +++ b/packages/ui/src/Avatar.recipe.ts @@ -42,9 +42,9 @@ export const avatar = defineSlotRecipe({ // The no-name defaults; the name-derived pair arrives inline. // gray.350 is the decorative-fill stop — 400+ are reserved for // accessible outlines and text (see the ramp in base-preset.ts). - background: "var(--avatar-bg, token(colors.gray.350))", + background: "var(--avatar-bg, token(colors.fill.decorative))", color: "var(--avatar-color, token(colors.white))", - borderColor: "white", + borderColor: "border.onEmphasis", }, label: { lineHeight: "1", @@ -64,7 +64,7 @@ export const avatar = defineSlotRecipe({ // em-relative, so a badge keeps its proportions at every avatar size. borderWidth: "0.2em", borderStyle: "solid", - borderColor: "white", + borderColor: "border.onEmphasis", // The corner variants pin the badge with logical insets but nudge it // out over the edge with a transform, which is physical — so the // outward direction is a variable the RTL rule negates. Without it a diff --git a/packages/ui/src/Avatar.tsx b/packages/ui/src/Avatar.tsx index b7dcf8c..46a67f5 100644 --- a/packages/ui/src/Avatar.tsx +++ b/packages/ui/src/Avatar.tsx @@ -211,8 +211,8 @@ export const Avatar = ({ // selector so a call site's `css` colour still wins (see the // recipe). "--avatar-color": isLight(bg) - ? token("colors.gray.800") - : token("colors.white"), + ? token("colors.fg.default") + : token("colors.fg.onEmphasis"), } as CSSProperties) : style } diff --git a/packages/ui/src/Button.recipe.ts b/packages/ui/src/Button.recipe.ts index ad3cd50..10c5be1 100644 --- a/packages/ui/src/Button.recipe.ts +++ b/packages/ui/src/Button.recipe.ts @@ -83,35 +83,35 @@ export const button = defineRecipe({ lineHeight: "normal", verticalAlign: "baseline", borderWidth: "0", - color: "brand.600", + color: "fg.link", fontWeight: "normal", bg: "transparent", _hover: { textDecoration: "underline", }, }, - // Colours come from the `button.*` semantic tokens so the family's two - // button idioms (brand-coloured vs black-on-white) share this recipe — - // see the token block in base-preset.ts. + // Colours come from the `button.*` component tokens so the family's + // two button idioms (brand-coloured vs black-on-white) share this + // recipe — see the token block in base-preset.ts. secondary: { borderWidth: "2px", - borderColor: "button.secondaryBorder", - color: "button.secondaryText", + borderColor: "button.secondary.border", + color: "button.secondary.fg", bg: "transparent", _hover: { - borderColor: "button.secondaryHoverBorder", - bg: "button.secondaryHoverBg", + borderColor: "button.secondary.borderHover", + bg: "button.secondary.bgHover", }, _active: { - bg: "button.secondaryActiveBg", - borderColor: "button.secondaryActiveBorder", + bg: "button.secondary.bgActive", + borderColor: "button.secondary.borderActive", }, }, ghost: { - color: "black", + color: "fg.strong", bg: "transparent", - _hover: { bg: "blackAlpha.50" }, - _active: { bg: "blackAlpha.100" }, + _hover: { bg: "fill.transparentHover" }, + _active: { bg: "fill.transparentActive" }, }, // Base-only styling: a transparent, colour-inheriting button. Used for // the action-bar icon-button menu triggers (settings/help), which supply @@ -121,20 +121,20 @@ export const button = defineRecipe({ color: "inherit", }, primary: { - color: "white", - bg: "button.primaryBg", + color: "fg.onEmphasis", + bg: "button.primary.bg", _hover: { - bg: "button.primaryHoverBg", - _disabled: { bg: "button.primaryBg" }, + bg: "button.primary.bgHover", + _disabled: { bg: "button.primary.bg" }, }, - _active: { bg: "button.primaryActiveBg" }, + _active: { bg: "button.primary.bgActive" }, }, // `solid`/`outline` are coloured by `tone`, and reference only the // stops it guarantees (50/500/600/700). They are not `primary`/ // `secondary` in another colour: those follow the app's button idiom, // which is black-on-white in half the family and so can't be a palette. solid: { - color: "white", + color: "fg.onEmphasis", bg: "colorPalette.500", _hover: { bg: "colorPalette.600", @@ -159,19 +159,22 @@ export const button = defineRecipe({ // Not a palette shape: a light fill under dark text is a different // button from `solid`, and 350 exists in no other ramp. neutral: { - color: "gray.800", - bg: "gray.100", - _hover: { bg: "gray.300", _disabled: { bg: "gray.100" } }, - _active: { bg: "gray.350" }, + color: "fg.default", + bg: "fill.neutral", + _hover: { bg: "fill.neutralHover", _disabled: { bg: "fill.neutral" } }, + _active: { bg: "fill.neutralActive" }, }, // Family-wide variant (every censused app has toolbar-class buttons). // No ring override: the bar decides, and a dark one must spread // `darkSurface` — an app adopting this variant needs the tag with it. toolbar: { - color: "black", - bg: "white", - _hover: { bg: "whiteAlpha.900", _disabled: { bg: "white" } }, - _active: { bg: "whiteAlpha.800" }, + color: "buttonToolbar.fg", + bg: "buttonToolbar.bg", + _hover: { + bg: "buttonToolbar.bgHover", + _disabled: { bg: "buttonToolbar.bg" }, + }, + _active: { bg: "buttonToolbar.bgActive" }, }, }, /** diff --git a/packages/ui/src/Card.recipe.ts b/packages/ui/src/Card.recipe.ts index 1dc5c48..93b04fc 100644 --- a/packages/ui/src/Card.recipe.ts +++ b/packages/ui/src/Card.recipe.ts @@ -21,7 +21,7 @@ export const card = defineSlotRecipe({ position: "relative", minWidth: 0, wordWrap: "break-word", - bg: "white", + bg: "surface.raised", borderRadius: "md", color: "inherit", }, @@ -39,7 +39,7 @@ export const card = defineSlotRecipe({ container: { borderWidth: "1px", borderStyle: "solid", - borderColor: "gray.200", + borderColor: "border.default", }, }, }, diff --git a/packages/ui/src/Checkbox.recipe.ts b/packages/ui/src/Checkbox.recipe.ts index 4adf965..e19d1f9 100644 --- a/packages/ui/src/Checkbox.recipe.ts +++ b/packages/ui/src/Checkbox.recipe.ts @@ -30,7 +30,7 @@ export const checkbox = defineSlotRecipe({ // Stated here rather than on the control — whose `borderColor: // inherit` reads it — so a call site can still tint the whole control // by setting borderColor on the root. - borderColor: "gray.400", + borderColor: "border.controlEmphasis", "&[data-disabled]": { cursor: "not-allowed" }, }, control: { @@ -42,28 +42,31 @@ export const checkbox = defineSlotRecipe({ borderStyle: "solid", borderRadius: "sm", borderColor: "inherit", - color: "white", - bg: "white", + color: "fg.onEmphasis", + bg: "fill.control", "&[data-selected]": { - bg: "controlCheckedBg", - borderColor: "controlCheckedBg", - color: "white", + bg: "fill.accent", + borderColor: "fill.accent", + color: "fg.onEmphasis", _hover: { - bg: "controlCheckedHoverBg", - borderColor: "controlCheckedHoverBg", + bg: "fill.accentHover", + borderColor: "fill.accentHover", }, }, // Disabled greys; the selected block restates _hover so the widened // native-:hover condition can't re-tint a disabled control. "&[data-disabled]": { - bg: "gray.100", - borderColor: "gray.100", + bg: "fill.disabled", + borderColor: "border.disabled", }, "&[data-selected][data-disabled]": { - bg: "gray.200", - borderColor: "gray.200", - color: "gray.500", - _hover: { bg: "gray.200", borderColor: "gray.200" }, + bg: "fill.disabledEmphasis", + borderColor: "border.disabledEmphasis", + color: "fg.disabled", + _hover: { + bg: "fill.disabledEmphasis", + borderColor: "border.disabledEmphasis", + }, }, "&[data-focus-visible]": { focusRing: "outline", diff --git a/packages/ui/src/CloseButton.tsx b/packages/ui/src/CloseButton.tsx index b790bf7..5e7bb80 100644 --- a/packages/ui/src/CloseButton.tsx +++ b/packages/ui/src/CloseButton.tsx @@ -55,8 +55,8 @@ export const CloseButton = forwardRef( borderRadius: "md", transitionProperty: "background-color, box-shadow", transitionDuration: "normal", - _hover: { bg: "blackAlpha.100" }, - _active: { bg: "blackAlpha.200" }, + _hover: { bg: "closeButton.bgHover" }, + _active: { bg: "closeButton.bgActive" }, _focusVisible: { focusRing: "outline" }, }, size === "sm" diff --git a/packages/ui/src/Code.tsx b/packages/ui/src/Code.tsx index a9c41cf..ea0c347 100644 --- a/packages/ui/src/Code.tsx +++ b/packages/ui/src/Code.tsx @@ -14,7 +14,7 @@ export const Code = styled("code", { fontSize: "sm", px: "0.2em", borderRadius: "sm", - bg: "gray.100", - color: "gray.800", + bg: "surface.inset", + color: "fg.default", }, }); diff --git a/packages/ui/src/Divider.tsx b/packages/ui/src/Divider.tsx index 57e3263..1d3c2ec 100644 --- a/packages/ui/src/Divider.tsx +++ b/packages/ui/src/Divider.tsx @@ -9,7 +9,7 @@ import { styled } from "styled-system/jsx"; const StyledDivider = styled("hr", { base: { border: 0, - borderColor: "gray.200", + borderColor: "border.default", opacity: 0.6, }, variants: { diff --git a/packages/ui/src/Drawer.recipe.ts b/packages/ui/src/Drawer.recipe.ts index d8e0624..0fe7c33 100644 --- a/packages/ui/src/Drawer.recipe.ts +++ b/packages/ui/src/Drawer.recipe.ts @@ -25,7 +25,7 @@ export const drawer = defineSlotRecipe({ inset: 0, w: "100%", h: "100%", - bg: "blackAlpha.600", + bg: "surface.overlay", zIndex: "modal", opacity: 1, transition: "opacity 0.2s ease-out", @@ -41,7 +41,7 @@ export const drawer = defineSlotRecipe({ maxWidth: "xs", maxH: "100dvh", color: "inherit", - bg: "white", + bg: "surface.raised", boxShadow: "lg", display: "flex", flexDirection: "column", diff --git a/packages/ui/src/Field.recipe.ts b/packages/ui/src/Field.recipe.ts index d39fbe9..fee51d2 100644 --- a/packages/ui/src/Field.recipe.ts +++ b/packages/ui/src/Field.recipe.ts @@ -52,7 +52,7 @@ export const field = defineSlotRecipe({ }, requiredIndicator: { marginStart: "1", - color: "danger.500", + color: "fg.danger", }, helperText: { // RAC's Text renders a span, and RadioGroup/CheckboxGroup roots are not @@ -62,7 +62,7 @@ export const field = defineSlotRecipe({ mt: "2", fontSize: "sm", lineHeight: "normal", - color: "gray.600", + color: "fg.muted", }, errorMessage: { display: "flex", @@ -70,7 +70,7 @@ export const field = defineSlotRecipe({ mt: "2", fontSize: "sm", lineHeight: "normal", - color: "danger.500", + color: "fg.danger", }, }, variants: { diff --git a/packages/ui/src/GridList.recipe.ts b/packages/ui/src/GridList.recipe.ts index ba5e805..d0a539f 100644 --- a/packages/ui/src/GridList.recipe.ts +++ b/packages/ui/src/GridList.recipe.ts @@ -36,7 +36,7 @@ export const gridList = defineSlotRecipe({ transitionProperty: "background", transitionDuration: "ultra-fast", transitionTimingFunction: "ease-in", - _hover: { bg: "gray.50" }, + _hover: { bg: "surface.hover" }, // A row holding an open menu (or any other popover) keeps the hover // grey, so the row an open menu belongs to stays visible. Hover state // cannot do this on its own: a Popover lays a fixed full-viewport @@ -44,11 +44,11 @@ export const gridList = defineSlotRecipe({ // row — `:hover` and RAC's own `data-hovered` both drop the moment the // menu appears. A trigger carries `aria-expanded` (useOverlayTrigger), // so the row can see its own open overlay. - "&:has([aria-expanded=true])": { bg: "gray.50" }, + "&:has([aria-expanded=true])": { bg: "surface.hover" }, "&[data-selected]": { - bg: "gray.100", - _hover: { bg: "gray.100" }, - "&:has([aria-expanded=true])": { bg: "gray.100" }, + bg: "surface.selected", + _hover: { bg: "surface.selected" }, + "&:has([aria-expanded=true])": { bg: "surface.selected" }, }, "&[data-focus-visible]": { focusRing: "outline" }, "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" }, diff --git a/packages/ui/src/Heading.recipe.ts b/packages/ui/src/Heading.recipe.ts index 781e78a..c55aa5d 100644 --- a/packages/ui/src/Heading.recipe.ts +++ b/packages/ui/src/Heading.recipe.ts @@ -44,7 +44,7 @@ export const heading = defineRecipe({ // GT Walsheim in the private preset. variant: { marketing: { fontFamily: "display" }, - // Page-title chrome in the accent colour (`headingAccent` — see + // Page-title chrome in the accent colour (`fg.accent` — see // base-preset.ts). Converged from classroom and data-microbit-org, // which carried these two byte-identically app-side. // @@ -56,12 +56,12 @@ export const heading = defineRecipe({ // width instead. label: { "&&": { fontSize: "4xl" }, - color: "headingAccent", + color: "fg.accent", }, subtitle: { "&&": { fontSize: "xl" }, fontWeight: "normal", - color: "headingAccent", + color: "fg.accent", }, }, }, diff --git a/packages/ui/src/Input.recipe.ts b/packages/ui/src/Input.recipe.ts index 88d96dd..40e9e23 100644 --- a/packages/ui/src/Input.recipe.ts +++ b/packages/ui/src/Input.recipe.ts @@ -38,16 +38,16 @@ export const input = defineRecipe({ // identifies the field (label, or a ≥3:1 icon/placeholder). // Checkbox/Radio keep gray.400: their box IS the identifier. // Rationale: ui-private docs/a11y-positions.md. - borderColor: "gray.300", + borderColor: "border.control", bg: "inherit", color: "inherit", - _hover: { borderColor: "gray.500" }, + _hover: { borderColor: "border.controlHover" }, // Any focus, pointer included; the keyboard ring composes on top. // zIndex so the border — and the ring with it — paint over // attached-group neighbours. "&&:is(:focus, [data-focused])": { zIndex: 1, borderColor: "focusBorder" }, "&&&:is([data-invalid], :user-invalid)": { - borderColor: "danger.500", + borderColor: "border.danger", }, // Modality-tracked: RAC's attribute, or Input.tsx's. Native // :focus-visible is the fallback for a bare element wearing the recipe, diff --git a/packages/ui/src/Kbd.tsx b/packages/ui/src/Kbd.tsx index 052e532..e964662 100644 --- a/packages/ui/src/Kbd.tsx +++ b/packages/ui/src/Kbd.tsx @@ -10,11 +10,11 @@ import { styled } from "styled-system/jsx"; */ export const Kbd = styled("kbd", { base: { - bg: "gray.100", + bg: "surface.inset", borderRadius: "md", borderWidth: "1px", borderStyle: "solid", - borderColor: "gray.200", + borderColor: "border.default", borderBottomWidth: "3px", fontFamily: "mono", fontSize: "0.8em", diff --git a/packages/ui/src/ListBox.recipe.ts b/packages/ui/src/ListBox.recipe.ts index 57baf61..f2d07bb 100644 --- a/packages/ui/src/ListBox.recipe.ts +++ b/packages/ui/src/ListBox.recipe.ts @@ -34,8 +34,11 @@ export const listBox = defineSlotRecipe({ transitionProperty: "background", transitionDuration: "ultra-fast", transitionTimingFunction: "ease-in", - _hover: { bg: "gray.50" }, - "&[data-selected]": { bg: "gray.100", _hover: { bg: "gray.100" } }, + _hover: { bg: "surface.hover" }, + "&[data-selected]": { + bg: "surface.selected", + _hover: { bg: "surface.selected" }, + }, "&[data-focus-visible]": { focusRing: "outline" }, "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" }, }, diff --git a/packages/ui/src/Menu.recipe.ts b/packages/ui/src/Menu.recipe.ts index 5c38333..cf077e3 100644 --- a/packages/ui/src/Menu.recipe.ts +++ b/packages/ui/src/Menu.recipe.ts @@ -31,7 +31,7 @@ export const menu = defineSlotRecipe({ ], base: { content: { - bg: "white", + bg: "surface.raised", color: "inherit", minWidth: "3xs", py: "2", @@ -42,7 +42,7 @@ export const menu = defineSlotRecipe({ zIndex: "popover", borderRadius: "md", borderWidth: "1px", - borderColor: "gray.200", + borderColor: "border.default", boxShadow: "sm", // Fade/scale enter/exit. RAC toggles data-entering/data-exiting on the // Popover and waits for the transition before unmount. @@ -69,9 +69,9 @@ export const menu = defineSlotRecipe({ // data-focused is RAC's active item, either modality. The highlight // is focus indication: no transition (it snaps with the ring, which // keyboard nav adds; inset, the rows being full-bleed). - "&[data-focused]": { bg: "gray.100" }, + "&[data-focused]": { bg: "surface.highlight" }, "&[data-focus-visible]": { focusRing: "outlineInset" }, - "&[data-pressed]": { bg: "gray.200" }, + "&[data-pressed]": { bg: "surface.active" }, "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" }, }, label: { @@ -93,7 +93,7 @@ export const menu = defineSlotRecipe({ divider: { border: 0, borderBottom: "1px solid", - borderColor: "gray.200", + borderColor: "border.default", my: "2", opacity: 0.6, }, diff --git a/packages/ui/src/Modal.recipe.ts b/packages/ui/src/Modal.recipe.ts index 0f8d241..9b92cd6 100644 --- a/packages/ui/src/Modal.recipe.ts +++ b/packages/ui/src/Modal.recipe.ts @@ -27,7 +27,7 @@ const dialogBox = { my: "16", mx: "2", borderRadius: "md", - background: "white", + background: "surface.raised", minHeight: "auto", padding: "0", }; @@ -65,7 +65,7 @@ export const dialog = defineSlotRecipe({ // the real viewport edge. w: "100vw", h: "100%", - bg: "blackAlpha.600", + bg: "surface.overlay", zIndex: "modal", display: "flex", justifyContent: "center", @@ -153,9 +153,10 @@ export const dialog = defineSlotRecipe({ paddingBottom: "env(safe-area-inset-bottom)", paddingLeft: "env(safe-area-inset-left)", paddingRight: "env(safe-area-inset-right)", - // brand colour in the status-bar area, white below (matches ActionBar) + // The status-bar surface above the notch, the dialog's own surface + // below, so the gradient can't diverge from the panel it caps. background: - "linear-gradient(to bottom, token(colors.statusBarBg) env(safe-area-inset-top), white env(safe-area-inset-top))", + "linear-gradient(to bottom, token(colors.surface.statusBar) env(safe-area-inset-top), token(colors.surface.raised) env(safe-area-inset-top))", }, header: { pl: "calc(var(--window-controls-left, 0px) + token(spacing.6))", diff --git a/packages/ui/src/Modal.tsx b/packages/ui/src/Modal.tsx index 8733499..a4de459 100644 --- a/packages/ui/src/Modal.tsx +++ b/packages/ui/src/Modal.tsx @@ -366,8 +366,8 @@ export const ModalCloseButton = ({ outline: "none", transitionProperty: "background-color, box-shadow", transitionDuration: "normal", - _hover: { bg: "blackAlpha.100" }, - _active: { bg: "blackAlpha.200" }, + _hover: { bg: "closeButton.bgHover" }, + _active: { bg: "closeButton.bgActive" }, _focusVisible: { focusRing: "outline" }, }), )} diff --git a/packages/ui/src/NumberField.recipe.ts b/packages/ui/src/NumberField.recipe.ts index 88efc63..adb6452 100644 --- a/packages/ui/src/NumberField.recipe.ts +++ b/packages/ui/src/NumberField.recipe.ts @@ -33,7 +33,7 @@ export const numberField = defineSlotRecipe({ // not forgiving, so one list would drop the whole selector on a // browser that doesn't know the pseudo-class (Safari < 16.5). "&:hover input:not(:focus, [data-focused], [data-invalid]):not(:user-invalid)": - { borderColor: "gray.500" }, + { borderColor: "border.controlHover" }, }, // A column overlaying the input's right edge, inset by the input border. stepper: { @@ -58,7 +58,7 @@ export const numberField = defineSlotRecipe({ color: "inherit", bg: "transparent", borderStart: "1px solid", - borderColor: "gray.200", + borderColor: "border.default", transitionProperty: "background", transitionDuration: "ultra-fast", // Follow the input's corners, less the 2px border the stepper is inset @@ -70,12 +70,12 @@ export const numberField = defineSlotRecipe({ }, "&:last-child": { borderTop: "1px solid", - borderTopColor: "gray.200", + borderTopColor: "border.default", marginTop: "-1px", borderEndEndRadius: "calc(token(radii.md) - 2px)", }, - "&[data-hovered]": { bg: "gray.100" }, - "&[data-pressed]": { bg: "gray.200" }, + "&[data-hovered]": { bg: "surface.highlight" }, + "&[data-pressed]": { bg: "surface.active" }, "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" }, }, }, diff --git a/packages/ui/src/PopoverArrow.tsx b/packages/ui/src/PopoverArrow.tsx index 5eb52fb..14eb5f2 100644 --- a/packages/ui/src/PopoverArrow.tsx +++ b/packages/ui/src/PopoverArrow.tsx @@ -41,7 +41,7 @@ export interface PopoverArrowProps { * the gap. Defaults to the proportions of an 8px square rotated 45°. */ size?: number; - /** Styles merged after the base, e.g. `{ "& svg": { fill: "white" } }`. */ + /** Styles merged after the base, e.g. `{ "& svg": { fill: "surface.raised" } }`. */ css?: SystemStyleObject; className?: string; } diff --git a/packages/ui/src/ProgressBar.tsx b/packages/ui/src/ProgressBar.tsx index bcae37f..d892a3e 100644 --- a/packages/ui/src/ProgressBar.tsx +++ b/packages/ui/src/ProgressBar.tsx @@ -36,7 +36,7 @@ export const ProgressBar = ({ { width: "100%", height: 3, - bg: "gray.100", + bg: "surface.inset", overflow: "hidden", borderRadius: "sm", }, @@ -48,7 +48,7 @@ export const ProgressBar = ({ className={css( { height: "100%", - bg: "brand.500", + bg: "fill.accent", }, barCss, )} diff --git a/packages/ui/src/Radio.recipe.ts b/packages/ui/src/Radio.recipe.ts index e418a4d..5eddd39 100644 --- a/packages/ui/src/Radio.recipe.ts +++ b/packages/ui/src/Radio.recipe.ts @@ -30,7 +30,7 @@ export const radio = defineSlotRecipe({ // As the checkbox: the accessible outline stop (WCAG 1.4.11), on the // root so the control's `borderColor: inherit` reads it and call // sites can still tint at the root. - borderColor: "gray.400", + borderColor: "border.controlEmphasis", "&[data-disabled]": { cursor: "not-allowed" }, }, control: { @@ -42,15 +42,15 @@ export const radio = defineSlotRecipe({ borderStyle: "solid", borderRadius: "full", borderColor: "inherit", - color: "white", - bg: "white", + color: "fg.onEmphasis", + bg: "fill.control", "&[data-selected]": { - bg: "controlCheckedBg", - borderColor: "controlCheckedBg", - color: "white", + bg: "fill.accent", + borderColor: "fill.accent", + color: "fg.onEmphasis", _hover: { - bg: "controlCheckedHoverBg", - borderColor: "controlCheckedHoverBg", + bg: "fill.accentHover", + borderColor: "fill.accentHover", }, // The radio dot. _before: { @@ -66,14 +66,17 @@ export const radio = defineSlotRecipe({ // Disabled greys; the selected block restates _hover so the widened // native-:hover condition can't re-tint a disabled control. "&[data-disabled]": { - bg: "gray.100", - borderColor: "gray.100", + bg: "fill.disabled", + borderColor: "border.disabled", }, "&[data-selected][data-disabled]": { - bg: "gray.200", - borderColor: "gray.200", - color: "gray.500", - _hover: { bg: "gray.200", borderColor: "gray.200" }, + bg: "fill.disabledEmphasis", + borderColor: "border.disabledEmphasis", + color: "fg.disabled", + _hover: { + bg: "fill.disabledEmphasis", + borderColor: "border.disabledEmphasis", + }, }, "&[data-focus-visible]": { focusRing: "outline", diff --git a/packages/ui/src/Select.recipe.ts b/packages/ui/src/Select.recipe.ts index e43f88a..1455e28 100644 --- a/packages/ui/src/Select.recipe.ts +++ b/packages/ui/src/Select.recipe.ts @@ -67,11 +67,11 @@ export const select = defineSlotRecipe({ border: "2px solid", // As the input recipe (rationale there). The dropdown card below // keeps gray.200 — a surface edge, not a form-control boundary. - borderColor: "gray.300", - bg: "white", + borderColor: "border.control", + bg: "fill.control", color: "inherit", // As the input recipe, so mixed fields tint together on hover. - _hover: { borderColor: "gray.500" }, + _hover: { borderColor: "border.controlHover" }, // `data-invalid` lands on the root — and, in a ComboBox, on the input — // but never on the trigger: a RAC Button has no validity state, and our // ComboBox control is a plain div. So it comes down from the parent. @@ -83,7 +83,7 @@ export const select = defineSlotRecipe({ }, // Border colour alone, as the input recipe. "[data-invalid] > &&&": { - borderColor: "danger.500", + borderColor: "border.danger", }, // Keyboard-only ring, two cases. Select's button: RAC's attribute. // ComboBox: the input's RAC attributes are stripped by react-aria's @@ -111,8 +111,8 @@ export const select = defineSlotRecipe({ font: "inherit", // RAC sets data-placeholder on SelectValue when nothing is chosen; the // ComboBox input uses the real placeholder attribute. - "&[data-placeholder]": { color: "gray.500" }, - _placeholder: { color: "gray.500" }, + "&[data-placeholder]": { color: "fg.placeholder" }, + _placeholder: { color: "fg.placeholder" }, }, indicator: { display: "inline-flex", @@ -141,13 +141,13 @@ export const select = defineSlotRecipe({ minWidth: "var(--trigger-width)", display: "flex", flexDirection: "column", - bg: "white", + bg: "surface.raised", color: "inherit", py: "2", zIndex: "popover", borderRadius: "md", borderWidth: "1px", - borderColor: "gray.200", + borderColor: "border.default", boxShadow: "sm", // Matches the menu recipe's fade/scale. transformOrigin: "top", @@ -174,9 +174,9 @@ export const select = defineSlotRecipe({ // As the menu recipe: the highlight is focus indication (no // transition — it snaps with the ring, which keyboard nav adds; // inset, the rows being full-bleed). - "&[data-focused]": { bg: "gray.100" }, + "&[data-focused]": { bg: "surface.highlight" }, "&[data-focus-visible]": { focusRing: "outlineInset" }, - "&[data-pressed]": { bg: "gray.200" }, + "&[data-pressed]": { bg: "surface.active" }, "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" }, }, optionIndicator: { @@ -192,7 +192,7 @@ export const select = defineSlotRecipe({ empty: { px: "3", py: "2", - color: "gray.600", + color: "fg.muted", }, }, variants: { diff --git a/packages/ui/src/Skeleton.tsx b/packages/ui/src/Skeleton.tsx index be7403a..a2dab6d 100644 --- a/packages/ui/src/Skeleton.tsx +++ b/packages/ui/src/Skeleton.tsx @@ -15,9 +15,9 @@ import { SystemStyleObject } from "styled-system/types"; * retint one skeleton without knowing how the animation works. */ const skeletonBase: SystemStyleObject = { - "--skeleton-start-color": "token(colors.gray.100)", + "--skeleton-start-color": "token(colors.surface.inset)", // gray.350, the decorative-fill stop, as the Avatar default. - "--skeleton-end-color": "token(colors.gray.350)", + "--skeleton-end-color": "token(colors.fill.decorative)", background: "var(--skeleton-start-color)", borderColor: "var(--skeleton-end-color)", opacity: 0.7, diff --git a/packages/ui/src/Slider.recipe.ts b/packages/ui/src/Slider.recipe.ts index aba9a8d..4d58005 100644 --- a/packages/ui/src/Slider.recipe.ts +++ b/packages/ui/src/Slider.recipe.ts @@ -50,10 +50,10 @@ export const slider = defineSlotRecipe({ height: "1", overflow: "hidden", borderRadius: "sm", - bg: "gray.200", + bg: "fill.track", cursor: "pointer", "[data-disabled] &": { - bg: "gray.300", + bg: "fill.trackEmphasis", cursor: "default", }, }, @@ -65,7 +65,7 @@ export const slider = defineSlotRecipe({ insetStart: 0, height: "100%", // Fill colour is set per call site via `filledTrackCss`. - bg: "brand.500", + bg: "fill.accent", }, thumb: { position: "absolute", @@ -78,7 +78,7 @@ export const slider = defineSlotRecipe({ outline: "none", zIndex: 1, borderRadius: "full", - bg: "white", + bg: "fill.knob", boxShadow: "base", transitionProperty: "transform", transitionDuration: "normal", @@ -90,7 +90,7 @@ export const slider = defineSlotRecipe({ focusRing: "outline", }, "&[data-disabled]": { - bg: "gray.300", + bg: "fill.knobDisabled", }, }, mark: { diff --git a/packages/ui/src/Slider.tsx b/packages/ui/src/Slider.tsx index 1518b58..a39b01b 100644 --- a/packages/ui/src/Slider.tsx +++ b/packages/ui/src/Slider.tsx @@ -129,8 +129,8 @@ export const Slider = ({ position: "absolute", bottom: "calc(50% + token(sizes.3.5))", transform: "translateX(-50%)", - bg: "gray.700", - color: "white", + bg: "surface.inverse", + color: "fg.onInverse", px: "2", py: "1", borderRadius: "md", @@ -153,7 +153,7 @@ export const Slider = ({ // shorthands (they emit verbatim and the browser drops the // invalid declaration). borderColor: "transparent", - borderTopColor: "gray.700", + borderTopColor: "surface.inverse", }, })} style={{ left: `${offset}%` }} diff --git a/packages/ui/src/Switch.recipe.ts b/packages/ui/src/Switch.recipe.ts index 8598c3d..2babdd5 100644 --- a/packages/ui/src/Switch.recipe.ts +++ b/packages/ui/src/Switch.recipe.ts @@ -37,9 +37,9 @@ export const switchRecipe = defineSlotRecipe({ p: "0.5", transitionProperty: "background-color", transitionDuration: "fast", - bg: "gray.300", + bg: "fill.trackEmphasis", "&[data-selected]": { - bg: "controlCheckedBg", + bg: "fill.accent", }, "&[data-focus-visible]": { focusRing: "outline", @@ -55,7 +55,7 @@ export const switchRecipe = defineSlotRecipe({ }, }, thumb: { - bg: "white", + bg: "fill.knob", transitionProperty: "transform", transitionDuration: "normal", borderRadius: "inherit", diff --git a/packages/ui/src/Toast.recipe.ts b/packages/ui/src/Toast.recipe.ts index 54084a9..f719a92 100644 --- a/packages/ui/src/Toast.recipe.ts +++ b/packages/ui/src/Toast.recipe.ts @@ -64,7 +64,7 @@ export const toast = defineSlotRecipe({ paddingEnd: "10", borderRadius: "md", boxShadow: "lg", - color: "white", + color: "fg.onEmphasis", maxW: "sm", pointerEvents: "auto", // The card is focusable (RAC gives it tabindex), so it takes the @@ -102,7 +102,7 @@ export const toast = defineSlotRecipe({ position: "absolute", top: "1", insetEnd: "1", - color: "white", + color: "fg.onEmphasis", display: "inline-flex", alignItems: "center", justifyContent: "center", @@ -118,17 +118,17 @@ export const toast = defineSlotRecipe({ transitionProperty: "background-color, box-shadow", transitionDuration: "normal", // Hover is a subtle dark overlay (blackAlpha), not a bright highlight. - _hover: { bg: "blackAlpha.100" }, - _active: { bg: "blackAlpha.200" }, + _hover: { bg: "closeButton.bgHover" }, + _active: { bg: "closeButton.bgActive" }, _focusVisible: { focusRing: "outline" }, }, }, variants: { status: { - info: { root: { bg: "toastInfoBg" } }, - success: { root: { bg: "toastSuccessBg" } }, - warning: { root: { bg: "toastWarningBg" } }, - error: { root: { bg: "toastErrorBg" } }, + info: { root: { bg: "surface.info" } }, + success: { root: { bg: "surface.success" } }, + warning: { root: { bg: "surface.warning" } }, + error: { root: { bg: "surface.danger" } }, }, }, defaultVariants: { status: "info" }, diff --git a/packages/ui/src/Tooltip.recipe.ts b/packages/ui/src/Tooltip.recipe.ts index 3233441..aca21bf 100644 --- a/packages/ui/src/Tooltip.recipe.ts +++ b/packages/ui/src/Tooltip.recipe.ts @@ -18,8 +18,8 @@ import { defineRecipe } from "@pandacss/dev"; export const tooltip = defineRecipe({ className: "tooltip", base: { - bg: "gray.700", - color: "whiteAlpha.900", + bg: "surface.inverse", + color: "fg.onInverse", px: "2", py: "0.5", borderRadius: "sm", diff --git a/packages/ui/src/Tooltip.tsx b/packages/ui/src/Tooltip.tsx index 3c60f27..6d1c10a 100644 --- a/packages/ui/src/Tooltip.tsx +++ b/packages/ui/src/Tooltip.tsx @@ -124,7 +124,9 @@ export const Tooltip = ({ offset={hasArrow ? 8 : 4} className={cx(tooltip(), cssProp ? css(cssProp) : undefined)} > - {hasArrow && } + {hasArrow && ( + + )} {label} diff --git a/packages/ui/src/base-preset.ts b/packages/ui/src/base-preset.ts index 6f2d65b..66cf7d7 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -108,7 +108,7 @@ const red = { * (pill `radii.button`, the `focusRing` utility/token pair, Helvetica * fonts, the * `toolbar` button variant in Button.recipe.ts, the - * `languageText`/`toast*Bg`/`statusBarBg` semantic tokens), the shared-ui + * `fg`/`surface`/`fill`/`border` role tokens), the shared-ui * component recipes, the react-aria condition widening, the `globalCss` * defaults, and the `staticCss` that keeps runtime-prop recipe * variants generated. Used alone it renders in the OSS default look. @@ -164,7 +164,7 @@ export const basePreset = definePreset({ // OSS default brand ramps (see the brand contract above). `brand` // aliases the blue ramp; `brand2` is a frozen legacy alias of the // slate gray in base-tokens, deliberately decoupled from the neutral - // `gray` above so ml-trainer's OSS look and `statusBarBg`'s default + // `gray` above so ml-trainer's OSS look and `surface.statusBar`'s default // don't move. // Removing the slot is a follow-up needing an ml-trainer lockstep. brand: colors.blue, @@ -206,10 +206,251 @@ export const basePreset = definePreset({ }, semanticTokens: { colors: { - // Checked states of form controls: Checkbox/Switch/Radio checked - // backgrounds. Semantic so a brand can diverge them from its ramp. - controlCheckedBg: { value: "{colors.brand.500}" }, - controlCheckedHoverBg: { value: "{colors.brand.600}" }, + // ── Role tokens ──────────────────────────────────────────────── + // Recipes and apps consume these, not ramp stops. Grouped by the + // property they belong to, so a foreground role can't be used as + // a background: `fg` (text and icons), `surface` (the background + // of a container), `fill` (the background of a control sitting + // ON a surface), `border`. The surface/fill split is why the two + // grey state ladders below are both right — rows on a raised + // surface hover to 50, a grey button fill hovers to 300. + // Rationale and the systems this follows: ui-private + // docs/role-tokens.md. + // + // Several roles share a value today. That is the point: a dark + // mode is a second value per role here, and nothing else. + + // Text and icons. No separate icon group — icons take + // currentColor everywhere in this library. + fg: { + default: { value: "{colors.gray.800}" }, + // Full black, for the buttons that want more weight than body + // text on a light fill (`ghost`, `toolbar`). Flips on a tagged + // dark surface, which is what lets `ghost` work on dark chrome + // without a per-app variant. Condition object — an override must + // keep the `{ base, _onDark }` shape or silently lose the flip. + strong: { + value: { base: "{colors.black}", _onDark: "{colors.white}" }, + }, + muted: { value: "{colors.gray.600}" }, + // Three roles at one value, three jobs: tertiary content, an + // empty field, an unavailable control. A dark mode and any + // future contrast work will want them apart. + subtle: { value: "{colors.gray.500}" }, + placeholder: { value: "{colors.gray.500}" }, + disabled: { value: "{colors.gray.500}" }, + // On a solid `fill.accent`/status fill. + onEmphasis: { value: "{colors.white}" }, + // On `surface.inverse`. + onInverse: { value: "{colors.whiteAlpha.900}" }, + // Decorative brand accent: the `label`/`subtitle` heading + // variants. classroom and data-microbit-org carried + // byte-identical variants with a hardcoded #cd0365 — the brand + // deep pink, which is data's `pink.500`; both override this. + accent: { value: "{colors.brand.500}" }, + link: { value: "{colors.brand.600}" }, + danger: { value: "{colors.danger.500}" }, + }, + + // Backgrounds of containers, and the state fills that go over + // them. The state fills are opaque greys, so they are only valid + // over `canvas`/`raised` — over anything coloured, use the + // translucent `fill.transparent*` pair. + surface: { + canvas: { value: "{colors.white}" }, + raised: { value: "{colors.white}" }, + // Filled panels punched into a surface: Code, Kbd, the + // ProgressBar track, the Skeleton base. + inset: { value: "{colors.gray.100}" }, + inverse: { value: "{colors.gray.700}" }, + overlay: { value: "{colors.blackAlpha.600}" }, + // Two hover weights, not an inconsistency. Large row targets + // (ListBox, GridList) take the light one; small targets whose + // hover has to read at a glance — an option in an overlay, a + // NumberField stepper — take `highlight`, which is also the + // keyboard highlight in a Menu or Select. `selected` shares + // `highlight`'s value and differs in job: it persists. + hover: { value: "{colors.gray.50}" }, + highlight: { value: "{colors.gray.100}" }, + selected: { value: "{colors.gray.100}" }, + active: { value: "{colors.gray.200}" }, + // Toast statuses. Three roles at one value is deliberate: the + // family has decided not to differentiate info/success/warning, + // and that decision belongs where an app can undo it. + info: { value: "{colors.teal.800}" }, + success: { value: "{colors.teal.800}" }, + warning: { value: "{colors.teal.800}" }, + // 500, the text-safe stop, rather than following the teal + // toasts' 800: white on it is 4.55:1 and an error toast should + // read as red. + danger: { value: "{colors.danger.500}" }, + // The native app's status-bar area, shared by the ActionBar and + // the full-size dialog's safe-area gradient. + statusBar: { value: "{colors.brand2.500}" }, + }, + + // Backgrounds of controls sitting on a surface. + fill: { + // Checked Checkbox/Switch/Radio, the Slider range, the + // ProgressBar bar. Brand-coloured in every app including the + // black-on-white ones — this is not the button idiom. + accent: { value: "{colors.brand.500}" }, + accentHover: { value: "{colors.brand.600}" }, + // The grey filled button. Not `accent` in another palette: a + // light fill under dark text is a different button, and 350 + // exists in no other ramp. + neutral: { value: "{colors.gray.100}" }, + neutralHover: { value: "{colors.gray.300}" }, + neutralActive: { value: "{colors.gray.350}" }, + // Washes for controls with no fill of their own (`ghost`). + // Translucent because they must work over an unknown + // background; the only place this library uses translucency + // for state. + // Alphas are not mirrored: a white wash reads weaker than a + // black one at equal alpha, so the on-dark pair is two ramp + // steps up (0.08/0.16 against 0.04/0.06). 0.08 is also + // Material's hover figure. + transparentHover: { + value: { + base: "{colors.blackAlpha.50}", + _onDark: "{colors.whiteAlpha.200}", + }, + }, + transparentActive: { + value: { + base: "{colors.blackAlpha.100}", + _onDark: "{colors.whiteAlpha.300}", + }, + }, + // The resting background of a control that contains something: + // the Checkbox/Radio box, an Input, a Select trigger. White like + // `surface.raised` today and not the same thing — one is a + // container, the other is a control on it. + control: { value: "{colors.white}" }, + disabled: { value: "{colors.gray.100}" }, + // A disabled control that is also checked, which needs to stay + // distinguishable from a disabled unchecked one. + disabledEmphasis: { value: "{colors.gray.200}" }, + track: { value: "{colors.gray.200}" }, + trackEmphasis: { value: "{colors.gray.300}" }, + knob: { value: "{colors.white}" }, + // Darker than `fill.disabled`: a knob has to stay visible + // against the track it sits on. + knobDisabled: { value: "{colors.gray.300}" }, + // gray.350, the ramp's documented decorative/state fill stop + // (~2.1:1): the Avatar disc, the Skeleton pulse. Never text or + // boundaries. + decorative: { value: "{colors.gray.350}" }, + }, + + border: { + // Surface edges: Card, popover, Menu separators, Divider, Kbd. + default: { value: "{colors.gray.200}" }, + // Fields rest lighter than the 3:1 control stop — they are + // identified by their label (a11y-positions.md №1). + control: { value: "{colors.gray.300}" }, + controlHover: { value: "{colors.gray.500}" }, + // The 3:1 floor, for boundaries that ARE the control's + // identifier: the Checkbox/Radio box. + controlEmphasis: { value: "{colors.gray.400}" }, + // A disabled control's border matches its fill, so the box reads + // as one flat shape rather than an outlined empty one. + disabled: { value: "{colors.gray.100}" }, + disabledEmphasis: { value: "{colors.gray.200}" }, + danger: { value: "{colors.danger.500}" }, + // A ring separating a control from a busy background: the + // Avatar. + onEmphasis: { value: "{colors.white}" }, + }, + + // ── Component tokens ─────────────────────────────────────────── + // Not roles: groups whose *shape*, not just values, is an app + // choice. Primer and Carbon both keep button tokens separate from + // their role layer for the same reason. + + // The `primary`/`secondary` button variants. Two brand idioms + // exist in the family: brand-coloured buttons (ml-trainer, + // python-editor — the defaults below) and a black-on-white system + // (classroom, data-microbit-org: black solid, black outline, no + // border colour change on hover but a blackAlpha wash instead). + // The two differ in structure, not just hue — one darkens the + // border and keeps the background clear, the other holds the + // border and washes the background — so no palette expresses + // both, and these stay tokens rather than a `tone`. + // `primary`'s text colour is `fg.onEmphasis`: every app in the + // family puts white on a dark solid. `ghost` needs no tokens + // (black + blackAlpha in all four apps). + button: { + primary: { + bg: { value: "{colors.brand.500}" }, + bgHover: { value: "{colors.brand.600}" }, + bgActive: { value: "{colors.brand.700}" }, + }, + secondary: { + fg: { value: "{colors.brand.700}" }, + border: { value: "{colors.brand.500}" }, + borderHover: { value: "{colors.brand.600}" }, + bgHover: { value: "transparent" }, + borderActive: { value: "{colors.brand.700}" }, + bgActive: { value: "{colors.brand.50}" }, + }, + }, + + // The `toolbar` button: a white pill that lives ON dark chrome, so + // it must NOT follow the dark-surface tag — its own fill is light + // whatever it sits on, and a flipping `fg` role would render it + // white on white. Component tokens rather than `fill.*`/`fg.*` for + // exactly that reason: anything that paints against the surface + // has to opt out of the flips. + buttonToolbar: { + fg: { value: "{colors.black}" }, + bg: { value: "{colors.white}" }, + bgHover: { value: "{colors.whiteAlpha.900}" }, + bgActive: { value: "{colors.whiteAlpha.800}" }, + }, + + // The language-dialog cards' name text (@microbit/ui-patterns' + // LanguageDialog). The brand colour, so the name reads as the + // choice being offered rather than as body copy. + // + // A component token rather than a role, because consumers really + // do diverge and along their own idiom, not this component's: + // classroom, python-editor and ml-trainer take the default; + // CreateAI flattens to brand.600; data-microbit-org uses black, + // which is its emphasis colour throughout (black buttons, black + // outlines) while its actual links stay blue. So this must not + // fold into `fg.link` — data would lose that distinction. + // + // `fgHover` is the one part with no live justification: it dates + // from when this was a text link with no background, and the card + // now hovers its own background (`surface.highlight`), which is + // why both overriding presets set it equal to `fg`. Kept because + // dropping it is a visible change in the three apps still on the + // default. See ui-private docs/role-tokens.md. + languageDialog: { + fg: { value: "{colors.brand.500}" }, + fgHover: { value: "{colors.brand.600}" }, + }, + + // The close buttons on the Modal and the Toast. A step stronger + // than `fill.transparent*` — a close button has to read on a + // white dialog and on a dark toast alike, which is also why this + // is the pair that wants the on-dark flip (docs/role-tokens.md). + closeButton: { + bgHover: { + value: { + base: "{colors.blackAlpha.100}", + _onDark: "{colors.whiteAlpha.300}", + }, + }, + bgActive: { + value: { + base: "{colors.blackAlpha.200}", + _onDark: "{colors.whiteAlpha.400}", + }, + }, + }, + // Focused form-control border, any modality: the dark brand stop // (all-ink read flat next to the ink ring). Flips white under the // dark-surface tag, like `focusRing`. @@ -243,52 +484,6 @@ export const basePreset = definePreset({ 800: { value: "{colors.red.800}" }, 900: { value: "{colors.red.900}" }, }, - // The language-dialog cards' text colour (@microbit/ui-patterns' - // LanguageDialog) follows the primary interactive brand: every - // consumer resolves it to its `brand` ramp (CreateAI privately to - // brand.600 with no hover change, python-editor to brand.500/600 — - // the default; data-microbit-org to black). Semantic tokens so the - // pattern stays shared and a brand preset overrides only values. - languageText: { value: "{colors.brand.500}" }, - languageTextHover: { value: "{colors.brand.600}" }, - // The `label`/`subtitle` heading variants' colour (page-title chrome). - // classroom and data-microbit-org carried byte-identical variants with - // a hardcoded #cd0365 — the brand deep pink, which is data's - // `pink.500`; both override this to it. The OSS default follows the - // languageText precedent: the primary interactive brand. - headingAccent: { value: "{colors.brand.500}" }, - // The `primary`/`secondary` button variants' colours. Two brand - // idioms exist in the family: brand-coloured buttons (ml-trainer, - // python-editor — the defaults below) and a black-on-white system - // (classroom, data-microbit-org: black solid, black outline, no - // border colour change on hover but a blackAlpha wash instead). - // Tokens rather than per-app recipe overrides so both idioms share - // one recipe — a `variant` fork would be duplicated by every app on - // the far side of it. `primary`'s text colour stays a literal - // `white`: every app in the family puts white on a dark solid. - // `ghost` needs no tokens (black + blackAlpha in all four apps). - button: { - primaryBg: { value: "{colors.brand.500}" }, - primaryHoverBg: { value: "{colors.brand.600}" }, - primaryActiveBg: { value: "{colors.brand.700}" }, - secondaryText: { value: "{colors.brand.700}" }, - secondaryBorder: { value: "{colors.brand.500}" }, - secondaryHoverBorder: { value: "{colors.brand.600}" }, - secondaryHoverBg: { value: "transparent" }, - secondaryActiveBorder: { value: "{colors.brand.700}" }, - secondaryActiveBg: { value: "{colors.brand.50}" }, - }, - // Toast status colours (teal for every status except error), shared - // across the app family. - toastInfoBg: { value: "{colors.teal.800}" }, - toastSuccessBg: { value: "{colors.teal.800}" }, - toastWarningBg: { value: "{colors.teal.800}" }, - // 500, the text-safe stop, rather than following the teal toasts' - // 800: white on it is 4.55:1 and an error toast should read as red. - toastErrorBg: { value: "{colors.danger.500}" }, - // The native app's status-bar area colour, shared by the ActionBar - // and the full-size dialog's safe-area gradient. - statusBarBg: { value: "{colors.brand2.500}" }, }, }, recipes: { @@ -337,14 +532,14 @@ export const basePreset = definePreset({ minHeight: "100%", fontFeatureSettings: '"kern"', fontFamily: "body", - color: "gray.800", - bg: "white", + color: "fg.default", + bg: "surface.canvas", transitionProperty: "background-color", transitionDuration: "normal", lineHeight: "base", }, "*::placeholder": { - color: "gray.500", + color: "fg.placeholder", }, // The `* { border-color; word-wrap }` defaults live in ../reset.css, // imported into the `reset` layer by consumers' layers.css — NOT here: diff --git a/packages/ui/src/preset-lint.ts b/packages/ui/src/preset-lint.ts new file mode 100644 index 0000000..70e3413 --- /dev/null +++ b/packages/ui/src/preset-lint.ts @@ -0,0 +1,130 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { basePreset } from "./base-preset"; + +/** + * Semantic-token key checking for app and brand presets. + * + * Panda accepts an unknown `semanticTokens` key without complaint: it + * typechecks, it generates, and the override never applies — the token keeps + * the base preset's value with no error at build or at runtime. So a rename + * in this library silently un-does every downstream override of the old + * name, and nothing fails until someone looks at the pixels. + * + * The guard is to assert that every leaf a preset overrides is one the base + * preset actually defines. It is not a Panda plugin because the check has to + * run per consumer — the library cannot see the presets that merge after it. + */ + +type Unknown = Record; + +const isObject = (v: unknown): v is Unknown => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** + * A leaf is an object carrying `value`, which is Panda's token shape. The + * value itself may be a string or a condition object (`{ base, _onDark }`), + * so we stop at `value` rather than recursing into it. + */ +const leafEntries = ( + node: unknown, + prefix: string[] = [], +): [string, unknown][] => { + if (!isObject(node)) return []; + if ("value" in node) return [[prefix.join("."), node.value]]; + return Object.entries(node).flatMap(([k, v]) => + leafEntries(v, [...prefix, k]), + ); +}; + +/** + * `definePreset` results keep `theme.extend.semanticTokens`; hand-written + * brand presets sometimes use `theme.semanticTokens`. Accept both, and merge + * rather than picking one, so a preset using both is fully checked. + */ +const semanticEntriesOf = (preset: unknown): [string, unknown][] => { + if (!isObject(preset)) return []; + const theme = isObject(preset.theme) ? preset.theme : {}; + const extend = isObject(theme.extend) ? theme.extend : {}; + return [ + ...leafEntries(theme.semanticTokens), + ...leafEntries(extend.semanticTokens), + ]; +}; + +const semanticTokensOf = (preset: unknown): string[] => + semanticEntriesOf(preset).map(([path]) => path); + +export interface SemanticTokenCheckOptions { + /** + * Dotted paths this preset deliberately introduces rather than overrides — + * tokens of its own that the base preset does not define, e.g. + * `"colors.sidebarHeaderBg"`. Listing one is the statement that it is new; + * anything unlisted and unknown is treated as a mistake. + */ + introduces?: string[]; +} + +/** + * The semantic-token paths a preset sets that the base preset does not + * define and that are not declared as new. Empty means the preset's + * overrides all land. + * + * Paths include the token category, e.g. `colors.fg.link`. + */ +export const unknownSemanticTokens = ( + preset: unknown, + { introduces = [] }: SemanticTokenCheckOptions = {}, +): string[] => { + const known = new Set(semanticTokensOf(basePreset)); + const declared = new Set(introduces); + return semanticTokensOf(preset) + .filter((path) => !known.has(path) && !declared.has(path)) + .sort(); +}; + +/** + * The paths where a preset's override loses a condition the base preset's + * value carries. + * + * Some base-preset tokens hold a condition object rather than a flat value — + * `{ base, _onDark }`, the dark-surface flips. A token merge replaces the + * value wholesale, so overriding one of these with a flat value (or a + * condition object missing a key) silently drops the flip: the same failure + * mode as an unknown key, one door over. A preset that genuinely wants no + * flip states it — `{ base: X, _onDark: X }`. + * + * Empty means every conditional token the preset touches keeps all of the + * base preset's condition keys. + */ +export const droppedConditionTokens = (preset: unknown): string[] => { + const conditional = new Map( + semanticEntriesOf(basePreset).filter(([, value]) => isObject(value)), + ); + const dropped = new Set(); + for (const [path, value] of semanticEntriesOf(preset)) { + const base = conditional.get(path); + if (!base) continue; + const keeps = isObject(value) && Object.keys(base).every((k) => k in value); + if (!keeps) dropped.add(path); + } + return [...dropped].sort(); +}; + +/** + * The paths listed as new that the base preset now defines. Not an error in + * itself — but it means the library has grown a token of the same name, so + * the preset is silently overriding it rather than defining its own, and the + * two probably want reconciling. + */ +export const reservedSemanticTokens = ( + preset: unknown, + { introduces = [] }: SemanticTokenCheckOptions = {}, +): string[] => { + const known = new Set(semanticTokensOf(basePreset)); + const set = new Set(semanticTokensOf(preset)); + return introduces.filter((path) => known.has(path) && set.has(path)).sort(); +}; diff --git a/packages/ui/tests/Avatar.test.tsx b/packages/ui/tests/Avatar.test.tsx index 8cdcb53..5bcf462 100644 --- a/packages/ui/tests/Avatar.test.tsx +++ b/packages/ui/tests/Avatar.test.tsx @@ -44,13 +44,13 @@ it("darkens the text over a light derived background", () => { (light.firstElementChild as HTMLElement).style.getPropertyValue( "--avatar-color", ), - ).toBe(token("colors.gray.800")); + ).toBe(token("colors.fg.default")); const { container: dark } = render(); expect( (dark.firstElementChild as HTMLElement).style.getPropertyValue( "--avatar-color", ), - ).toBe(token("colors.white")); + ).toBe(token("colors.fg.onEmphasis")); }); it("labels a supplied icon in place rather than wrapping it", () => { diff --git a/packages/ui/tests/Select.test.tsx b/packages/ui/tests/Select.test.tsx index 7df24f3..e896c03 100644 --- a/packages/ui/tests/Select.test.tsx +++ b/packages/ui/tests/Select.test.tsx @@ -173,7 +173,7 @@ const isTriggerFocusStyled = (el: Element) => /** Fails if the invalid rule goes back to a `data-invalid` RAC never sets. */ const isTriggerInvalidStyled = (el: Element) => - el.matches(ruleFor("danger.500").replaceAll("&", "*")); + el.matches(ruleFor("border.danger").replaceAll("&", "*")); it("ComboBox keeps its focus styling while an option is active", () => { render( @@ -286,8 +286,8 @@ it("a valid control is not painted red", () => { // needing an order. it("orders the trigger's state rules hover before invalid", () => { const keys = Object.keys(triggerRules()); - const hover = keys.indexOf(ruleFor("gray.500")); - const invalid = keys.indexOf(ruleFor("danger.500")); + const hover = keys.indexOf(ruleFor("border.controlHover")); + const invalid = keys.indexOf(ruleFor("border.danger")); expect(hover).toBeLessThan(invalid); }); diff --git a/packages/ui/tests/preset-lint.test.ts b/packages/ui/tests/preset-lint.test.ts new file mode 100644 index 0000000..cef5f0f --- /dev/null +++ b/packages/ui/tests/preset-lint.test.ts @@ -0,0 +1,180 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { describe, expect, it } from "vitest"; +import { basePreset } from "../src/base-preset"; +import { + droppedConditionTokens, + reservedSemanticTokens, + unknownSemanticTokens, +} from "../src/preset-lint"; + +// Shaped like a definePreset result; the checker only reads `theme`. +const preset = (semanticTokens: unknown) => ({ + name: "test", + theme: { extend: { semanticTokens } }, +}); + +describe("unknownSemanticTokens", () => { + it("accepts an override of a role the base preset defines", () => { + expect( + unknownSemanticTokens( + preset({ colors: { fg: { link: { value: "{colors.brand.500}" } } } }), + ), + ).toEqual([]); + }); + + it("accepts a nested component-group override", () => { + expect( + unknownSemanticTokens( + preset({ + colors: { button: { primary: { bg: { value: "{colors.black}" } } } }, + }), + ), + ).toEqual([]); + }); + + it("catches a leaf that no longer exists under a group that does", () => { + // The subtle case: the group name still resolves, so a flat key left + // behind by a nesting change reads as plausible. + expect( + unknownSemanticTokens( + preset({ colors: { button: { primaryBg: { value: "#000" } } } }), + ), + ).toEqual(["colors.button.primaryBg"]); + }); + + it("catches a renamed top-level token", () => { + // A whole token renamed out from under a preset, leaving the override + // pointing at nothing. + expect( + unknownSemanticTokens( + preset({ + colors: { + toastSuccessBg: { value: "{colors.blimpTeal.700}" }, + toastErrorBg: { value: "{colors.code.error}" }, + }, + }), + ), + ).toEqual(["colors.toastErrorBg", "colors.toastSuccessBg"]); + }); + + it("allows tokens a preset declares as its own", () => { + expect( + unknownSemanticTokens( + preset({ + colors: { sidebarHeaderBg: { value: "{colors.brand.500}" } }, + }), + { introduces: ["colors.sidebarHeaderBg"] }, + ), + ).toEqual([]); + }); + + it("reads a condition object as one leaf, not a base/_onDark pair", () => { + expect( + unknownSemanticTokens( + preset({ + colors: { + focusRing: { value: { base: "#000", _onDark: "#fff" } }, + }, + }), + ), + ).toEqual([]); + }); + + it("reports nothing for the base preset against itself", () => { + expect(unknownSemanticTokens(basePreset)).toEqual([]); + }); + + it("tolerates a preset with no semantic tokens", () => { + expect(unknownSemanticTokens({ name: "empty", theme: {} })).toEqual([]); + }); +}); + +describe("droppedConditionTokens", () => { + it("catches a flat override of a condition-object token", () => { + expect( + droppedConditionTokens( + preset({ colors: { focusRing: { value: "#000" } } }), + ), + ).toEqual(["colors.focusRing"]); + }); + + it("catches a condition object missing a key the base value has", () => { + expect( + droppedConditionTokens( + preset({ colors: { focusRing: { value: { base: "#000" } } } }), + ), + ).toEqual(["colors.focusRing"]); + }); + + it("accepts an override keeping the full condition shape", () => { + expect( + droppedConditionTokens( + preset({ + colors: { + focusRing: { value: { base: "#000", _onDark: "#fff" } }, + }, + }), + ), + ).toEqual([]); + }); + + it("accepts a deliberate no-flip stated as equal values", () => { + expect( + droppedConditionTokens( + preset({ + colors: { + fg: { strong: { value: { base: "#000", _onDark: "#000" } } }, + }, + }), + ), + ).toEqual([]); + }); + + it("ignores flat tokens overridden flat", () => { + expect( + droppedConditionTokens( + preset({ colors: { fg: { link: { value: "#000" } } } }), + ), + ).toEqual([]); + }); + + it("allows a preset to add conditions to a flat token", () => { + expect( + droppedConditionTokens( + preset({ + colors: { + fg: { link: { value: { base: "#000", _onDark: "#fff" } } }, + }, + }), + ), + ).toEqual([]); + }); + + it("reports nothing for the base preset against itself", () => { + expect(droppedConditionTokens(basePreset)).toEqual([]); + }); +}); + +describe("reservedSemanticTokens", () => { + it("flags a declared-new token the library has since defined", () => { + expect( + reservedSemanticTokens( + preset({ colors: { fg: { link: { value: "#000" } } } }), + { introduces: ["colors.fg.link"] }, + ), + ).toEqual(["colors.fg.link"]); + }); + + it("stays quiet for a token that is genuinely the preset's own", () => { + expect( + reservedSemanticTokens( + preset({ colors: { sidebarHeaderBg: { value: "#000" } } }), + { introduces: ["colors.sidebarHeaderBg"] }, + ), + ).toEqual([]); + }); +});