From 946335a449e74ad026cca72132b9823c58ffe3e9 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 16 Aug 2026 21:53:21 +0000 Subject: [PATCH 1/7] ui: role-based colour tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipes and components consume roles, not ramp stops. Phase 3 of the focus-and-colour plan; the design note is ui-private's docs/role-tokens.md. Roles are grouped by the property they belong to — fg, surface, fill, border — so a foreground role cannot be used as a background. The surface/fill split (Polaris's, and Carbon and Primer draw the same line in different words) is what makes the two grey state ladders coherent: rows on a raised surface hover to gray.50, a grey button fill hovers to gray.300. They were never inconsistent, just on different axes. button.* survives as a component group rather than dissolving into the roles, restructured to button.primary.bg / button.secondary.border and so on. Primer and Carbon both keep button tokens out of their functional layer, and the family needs it: half the apps are brand-coloured and half black-on-white, and the two idioms differ in structure, not just hue — one darkens the border and keeps the background clear, the other holds the border and washes the background. No numeric ramp carries both. languageDialog.* and closeButton.* join it for the same reason. The ramps stay public and unrenamed, so raw var(--colors-gray-*) in consumers' plain CSS (python-editor's CodeMirror themes) is unaffected. Value-preserving, measured: Panda's generated CSS resolved through every var() chain shows 0 changed declarations across 916 selectors, and every renamed atomic class resolves to a byte-identical value. Two deliberate exceptions in LanguageDialog, made rather than adding two roles for one call site each — the "help translate" link moves brand.500 to fg.link (brand.600, the link colour everywhere else) and the English-name label moves gray.700 to fg.muted. The two tests updated asserted the old token names, not behaviour. --- packages/ui-patterns/src/LanguageDialog.tsx | 14 +- packages/ui/src/Avatar.recipe.ts | 6 +- packages/ui/src/Avatar.tsx | 4 +- packages/ui/src/Button.recipe.ts | 54 ++--- packages/ui/src/Card.recipe.ts | 4 +- packages/ui/src/Checkbox.recipe.ts | 31 +-- packages/ui/src/CloseButton.tsx | 4 +- packages/ui/src/Code.tsx | 4 +- packages/ui/src/Divider.tsx | 2 +- packages/ui/src/Drawer.recipe.ts | 4 +- packages/ui/src/Field.recipe.ts | 6 +- packages/ui/src/GridList.recipe.ts | 10 +- packages/ui/src/Heading.recipe.ts | 6 +- packages/ui/src/Input.recipe.ts | 6 +- packages/ui/src/Kbd.tsx | 4 +- packages/ui/src/ListBox.recipe.ts | 7 +- packages/ui/src/Menu.recipe.ts | 10 +- packages/ui/src/Modal.recipe.ts | 6 +- packages/ui/src/Modal.tsx | 4 +- packages/ui/src/NumberField.recipe.ts | 10 +- packages/ui/src/PopoverArrow.tsx | 2 +- packages/ui/src/ProgressBar.tsx | 4 +- packages/ui/src/Radio.recipe.ts | 31 +-- packages/ui/src/Select.recipe.ts | 22 +- packages/ui/src/Skeleton.tsx | 4 +- packages/ui/src/Slider.recipe.ts | 10 +- packages/ui/src/Slider.tsx | 6 +- packages/ui/src/Switch.recipe.ts | 6 +- packages/ui/src/Toast.recipe.ts | 16 +- packages/ui/src/Tooltip.recipe.ts | 4 +- packages/ui/src/Tooltip.tsx | 4 +- packages/ui/src/base-preset.ts | 249 ++++++++++++++++---- packages/ui/tests/Avatar.test.tsx | 4 +- packages/ui/tests/Select.test.tsx | 6 +- 34 files changed, 360 insertions(+), 204 deletions(-) diff --git a/packages/ui-patterns/src/LanguageDialog.tsx b/packages/ui-patterns/src/LanguageDialog.tsx index 64fe828..15e5e1e 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/src/Avatar.recipe.ts b/packages/ui/src/Avatar.recipe.ts index 6eff5a0..7f9e461 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", }, }, variants: { 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..40ce8e3 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,19 @@ 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: "fg.strong", + bg: "fill.inverse", + _hover: { bg: "fill.inverseHover", _disabled: { bg: "fill.inverse" } }, + _active: { bg: "fill.inverseActive" }, }, }, /** 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 adfae1e..e73e262 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 648056a..790df3f 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", }; @@ -62,7 +62,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", @@ -152,7 +152,7 @@ export const dialog = defineSlotRecipe({ paddingRight: "env(safe-area-inset-right)", // brand colour in the status-bar area, white below (matches ActionBar) 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), white 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 97bae11..c594b7a 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", }, }, @@ -63,7 +63,7 @@ export const slider = defineSlotRecipe({ left: 0, height: "100%", // Fill colour is set per call site via `filledTrackCss`. - bg: "brand.500", + bg: "fill.accent", }, thumb: { position: "absolute", @@ -76,7 +76,7 @@ export const slider = defineSlotRecipe({ outline: "none", zIndex: 1, borderRadius: "full", - bg: "white", + bg: "fill.knob", boxShadow: "base", transitionProperty: "transform", transitionDuration: "normal", @@ -88,7 +88,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 49c674f..820d8fc 100644 --- a/packages/ui/src/Slider.tsx +++ b/packages/ui/src/Slider.tsx @@ -117,8 +117,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", @@ -141,7 +141,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: `${percent}%` }} diff --git a/packages/ui/src/Switch.recipe.ts b/packages/ui/src/Switch.recipe.ts index a218f74..d00a81c 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 010c6a4..5cb525b 100644 --- a/packages/ui/src/Toast.recipe.ts +++ b/packages/ui/src/Toast.recipe.ts @@ -64,7 +64,7 @@ export const toast = defineSlotRecipe({ paddingRight: "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 cd519b5..97aa8a7 100644 --- a/packages/ui/src/Tooltip.tsx +++ b/packages/ui/src/Tooltip.tsx @@ -107,7 +107,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..571f3c3 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,201 @@ 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`). + strong: { value: "{colors.black}" }, + 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. + transparentHover: { value: "{colors.blackAlpha.50}" }, + transparentActive: { value: "{colors.blackAlpha.100}" }, + // A control on `surface.inverse`-grade chrome (`toolbar`). + inverse: { value: "{colors.white}" }, + inverseHover: { value: "{colors.whiteAlpha.900}" }, + inverseActive: { value: "{colors.whiteAlpha.800}" }, + // 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 language-dialog cards' text colour (@microbit/ui-patterns' + // LanguageDialog). A component token, not `fg.link`: every + // consumer resolves it differently and not always to a link + // colour (CreateAI privately to brand.600 with no hover change, + // python-editor to brand.500/600 — the default; data-microbit-org + // to black, while its actual links stay blue). + 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: "{colors.blackAlpha.100}" }, + bgActive: { value: "{colors.blackAlpha.200}" }, + }, + // 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 +434,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: { 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); }); From 5675fedfdb3035609158de6091267f247e6793ae Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 16 Aug 2026 21:54:17 +0000 Subject: [PATCH 2/7] ui: flip the on-dark washes, fixing ghost and the toast close button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live defects, both because a role can carry an _onDark condition and a raw ramp stop cannot. Phase 1 built the condition for focus; this is the rest of it. - `ghost` was black text with a black wash: invisible on dark chrome. That is why python-editor carries its own `sidebar` variant. It now renders white with a white wash inside a tagged surface. This does not retire `sidebar` — its hover is a solid white pill and this is a 0.08 wash, which is a design change rather than a substitution — but the variant is now optional rather than necessary. - The Toast close button's blackAlpha.100 hover darkened a dark teal toast, reading as a hole. The card was already tagged, so the flip fixes it with no call-site change. Alphas are not mirrored. A white wash reads weaker than a black one at equal alpha, so the on-dark values are two ramp steps up: 0.08/0.16 against 0.04/0.06 for `ghost`, 0.16/0.24 for the close buttons. 0.08 is Material's hover figure. These want an eyeball in Storybook. `toolbar` had to be pulled out of the roles to get here. It is a white pill that lives ON dark chrome, so it sits inside the tag while being light itself, and a flipping `fg` role rendered its label white on white. It now reads a static buttonToolbar.* component group, and the rule generalises: anything painting its own contrasting fill must opt out of the flips. The whole mechanism is seven custom-property redefinitions in one [data-surface="dark"] block. Resolved-CSS diff against main: still 0 changed declarations in the light context; the dark context is Phase 1's focus flips plus exactly five new ones. --- packages/ui/src/Button.recipe.ts | 11 +++--- packages/ui/src/base-preset.ts | 58 ++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/Button.recipe.ts b/packages/ui/src/Button.recipe.ts index 40ce8e3..10c5be1 100644 --- a/packages/ui/src/Button.recipe.ts +++ b/packages/ui/src/Button.recipe.ts @@ -168,10 +168,13 @@ export const button = defineRecipe({ // 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: "fg.strong", - bg: "fill.inverse", - _hover: { bg: "fill.inverseHover", _disabled: { bg: "fill.inverse" } }, - _active: { bg: "fill.inverseActive" }, + color: "buttonToolbar.fg", + bg: "buttonToolbar.bg", + _hover: { + bg: "buttonToolbar.bgHover", + _disabled: { bg: "buttonToolbar.bg" }, + }, + _active: { bg: "buttonToolbar.bgActive" }, }, }, /** diff --git a/packages/ui/src/base-preset.ts b/packages/ui/src/base-preset.ts index 571f3c3..88e3512 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -225,8 +225,13 @@ export const basePreset = definePreset({ fg: { default: { value: "{colors.gray.800}" }, // Full black, for the buttons that want more weight than body - // text on a light fill (`ghost`, `toolbar`). - strong: { value: "{colors.black}" }, + // 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 @@ -301,12 +306,22 @@ export const basePreset = definePreset({ // Translucent because they must work over an unknown // background; the only place this library uses translucency // for state. - transparentHover: { value: "{colors.blackAlpha.50}" }, - transparentActive: { value: "{colors.blackAlpha.100}" }, - // A control on `surface.inverse`-grade chrome (`toolbar`). - inverse: { value: "{colors.white}" }, - inverseHover: { value: "{colors.whiteAlpha.900}" }, - inverseActive: { value: "{colors.whiteAlpha.800}" }, + // 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 @@ -381,6 +396,19 @@ export const basePreset = definePreset({ }, }, + // 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' text colour (@microbit/ui-patterns' // LanguageDialog). A component token, not `fg.link`: every // consumer resolves it differently and not always to a link @@ -397,8 +425,18 @@ export const basePreset = definePreset({ // 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: "{colors.blackAlpha.100}" }, - bgActive: { value: "{colors.blackAlpha.200}" }, + 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 From fd2b3c09660855f04a56ffefd934d32651077f24 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 16 Aug 2026 22:29:46 +0000 Subject: [PATCH 3/7] ui: give languageDialog.* a justification that is still true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old comment described how each consumer resolves the token, which was accurate but left the impression the value was arbitrary. It descends from a Chakra-era `language` button variant that @microbit/ui-patterns' shared LanguageDialog replaced, and both overriding presets carried its wording forward with it (corrected separately in data-microbit-org and ml-trainer-microbit). The divergence is real regardless of where it came from, and worth stating in its own terms: data-microbit-org uses black because black is its emphasis colour throughout, while its links stay blue — which is exactly why this must not fold into fg.link. The exception is fgHover, which has no live justification: it dates from when this was a text link with no background, and the card now hovers its own background. Both overriding presets set it equal to fg. Kept because dropping it is a visible change in the three apps still on the default, and the decision is to preserve the current look per app. --- packages/ui/src/base-preset.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/base-preset.ts b/packages/ui/src/base-preset.ts index 88e3512..387d667 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -409,12 +409,24 @@ export const basePreset = definePreset({ bgActive: { value: "{colors.whiteAlpha.800}" }, }, - // The language-dialog cards' text colour (@microbit/ui-patterns' - // LanguageDialog). A component token, not `fg.link`: every - // consumer resolves it differently and not always to a link - // colour (CreateAI privately to brand.600 with no hover change, - // python-editor to brand.500/600 — the default; data-microbit-org - // to black, while its actual links stay blue). + // 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}" }, From 1d5f89dcc31902f3739526a7c35ec04d8759e8f3 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 17 Aug 2026 13:46:29 +0000 Subject: [PATCH 4/7] Rewrite the CSS-variable contract for role tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract still described the pre-role-token world. It named `languageText`, `statusBarBg`, `toast*Bg` and `controlCheckedBg` as the extension points a brand preset overrides — all four deleted in the role token work — and gave `focusBorder` a base of brand.700, which is 600. That is not just stale. A brand preset author following it writes a key Panda accepts and silently ignores, which is how python-editor-v3-microbit ended up with four dead toast overrides and its brand build falling back to the library's teal. So the section now says what the layers are rather than listing leaves: ramps versus roles, the four property-namespaced role groups and what the surface/fill split buys, the four component groups and why an idiom gets one, and a pointer to base-preset.ts as authoritative for the inventory. A list here goes stale; that is the failure being fixed, so it should not be replaced with another list. Adds the two silent failures explicitly — an unknown `semanticTokens` key and a missed `colorPalette` stop — with the instruction that a green typecheck does not verify a preset change, and the cssgen command that does. Lists all seven `{ base, _onDark }` tokens rather than the two focus ones, and records the corollary: a component painting its own contrasting fill must not consume them, which is what `buttonToolbar.*` is for. Completes the last Phase 3 exit criterion. --- packages/ui/README.md | 91 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/packages/ui/README.md b/packages/ui/README.md index 54733e2..787c0ed 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -182,7 +182,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. @@ -192,19 +192,83 @@ 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.** Do not rely on a +list in this file — an earlier one went stale and cost a brand preset its +toast colours, for the reason immediately below. + +### Panda does not check these names + +**An unknown `semanticTokens` key is accepted silently.** It typechecks, it +generates, and the override simply never applies — the token keeps the base +preset's value, with no error at build or at runtime. This has bitten +twice: an app whose black buttons reverted to brand blue, and a brand +preset whose toast colours fell back to the library's after a rename. + +A green typecheck is not verification of a preset change. Resolve the +generated CSS and read the values 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.700}", _onDark: "{colors.white}" } }; +focusBorder: { value: { base: "{colors.brand.600}", _onDark: "{colors.white}" } }; ``` +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 @@ -224,7 +288,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: @@ -250,8 +317,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()` From 370940a8ea58558408bc2da759ac09a83ba4efba Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 17 Aug 2026 13:52:50 +0000 Subject: [PATCH 5/7] Add a semantic-token key check for presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panda accepts an unknown `semanticTokens` key without complaint: it typechecks, it generates, and the override never applies — the token keeps this preset's value with no error at build or runtime. So a rename here silently un-does every downstream override of the old name. That has now happened twice. data-microbit-org's `button.*` block was nearly missed when the flat keys became nested, which would have reverted its black buttons to brand blue. python-editor-v3-microbit's four `toast*Bg` overrides actually were missed, and its brand build's toasts fell back to the library's teal until this week. Neither was visible to a typecheck; only a resolved-CSS comparison found them. `unknownSemanticTokens(preset, { introduces })` returns the leaf paths a preset sets that this library does not define and that the preset has not declared as its own. It reads `theme.semanticTokens` and `theme.extend.semanticTokens`, and stops at Panda's `value` key so a `{ base, _onDark }` condition object counts as one leaf rather than two. It cannot be a Panda plugin: the check has to run per consumer, because the library cannot see the presets merged after it. So it ships as an export and each consumer asserts in a test. Verified against the real defect — checked out at the commit before the fix, it names all four dead keys. --- packages/ui/README.md | 30 +++++-- packages/ui/package.json | 1 + packages/ui/src/preset-lint.ts | 94 +++++++++++++++++++++ packages/ui/tests/preset-lint.test.ts | 113 ++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/preset-lint.ts create mode 100644 packages/ui/tests/preset-lint.test.ts diff --git a/packages/ui/README.md b/packages/ui/README.md index 787c0ed..bfefcbb 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -223,20 +223,32 @@ 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.** Do not rely on a -list in this file — an earlier one went stale and cost a brand preset its -toast colours, for the reason immediately below. +**`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 simply never applies — the token keeps the base -preset's value, with no error at build or at runtime. This has bitten -twice: an app whose black buttons reverted to brand blue, and a brand -preset whose toast colours fell back to the library's after a rename. +generates, and the override never applies — the token keeps the base +preset's value, with no error at build or at runtime. -A green typecheck is not verification of a preset change. Resolve the -generated CSS and read the values back: +So assert your preset's keys in a test: + +```ts +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 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/preset-lint.ts b/packages/ui/src/preset-lint.ts new file mode 100644 index 0000000..35d393a --- /dev/null +++ b/packages/ui/src/preset-lint.ts @@ -0,0 +1,94 @@ +/** + * (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 leafPaths = (node: unknown, prefix: string[] = []): string[] => { + if (!isObject(node)) return []; + if ("value" in node) return [prefix.join(".")]; + return Object.entries(node).flatMap(([k, v]) => leafPaths(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 semanticTokensOf = (preset: unknown): string[] => { + if (!isObject(preset)) return []; + const theme = isObject(preset.theme) ? preset.theme : {}; + const extend = isObject(theme.extend) ? theme.extend : {}; + return [ + ...leafPaths(theme.semanticTokens), + ...leafPaths(extend.semanticTokens), + ]; +}; + +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 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/preset-lint.test.ts b/packages/ui/tests/preset-lint.test.ts new file mode 100644 index 0000000..b94da5f --- /dev/null +++ b/packages/ui/tests/preset-lint.test.ts @@ -0,0 +1,113 @@ +/** + * (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 { + 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("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([]); + }); +}); From 27bd80f2420d0649646935e27da80f7d97d6d53f Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Tue, 18 Aug 2026 10:18:27 +0000 Subject: [PATCH 6/7] Finish the role sweep in globalCss and the full-modal gradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role-token sweep converted every recipe but left globalCss painting the body with gray.800/white and *::placeholder with gray.500 — the canonical fg.default, surface.canvas and fg.placeholder call sites, and the ones a dark mode most needs on roles. The full-size Modal's safe-area gradient similarly hardcoded white next to token(colors.surface.statusBar); it now uses surface.raised, the dialog's own surface, so the gradient can't diverge from the panel it caps. Value-identical throughout: every role referenced resolves to the ramp stop it replaces. --- packages/ui/src/Modal.recipe.ts | 5 +++-- packages/ui/src/base-preset.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/Modal.recipe.ts b/packages/ui/src/Modal.recipe.ts index 9ed0c32..9b92cd6 100644 --- a/packages/ui/src/Modal.recipe.ts +++ b/packages/ui/src/Modal.recipe.ts @@ -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.surface.statusBar) 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/base-preset.ts b/packages/ui/src/base-preset.ts index 387d667..66cf7d7 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -532,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: From 92124037450794258114e61a80c56bfc91c8ad61 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Tue, 18 Aug 2026 10:18:27 +0000 Subject: [PATCH 7/7] preset-lint: catch overrides that drop a condition flip unknownSemanticTokens covers unknown names, but the other silent preset failure is one door over: overriding a { base, _onDark } token with a flat value (or a condition object missing a key) merges wholesale and silently loses the flip. droppedConditionTokens flags any override of a base-preset condition token that does not keep all of its condition keys; a deliberate no-flip is stated with both keys equal. Documented beside the seven-token list in the README, which this check also keeps from going stale. --- packages/ui/README.md | 14 ++++++ packages/ui/src/preset-lint.ts | 48 ++++++++++++++++--- packages/ui/tests/preset-lint.test.ts | 67 +++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/packages/ui/README.md b/packages/ui/README.md index 3b40c49..b92fc44 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -297,6 +297,20 @@ Keep the shape: 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 diff --git a/packages/ui/src/preset-lint.ts b/packages/ui/src/preset-lint.ts index 35d393a..70e3413 100644 --- a/packages/ui/src/preset-lint.ts +++ b/packages/ui/src/preset-lint.ts @@ -29,10 +29,15 @@ const isObject = (v: unknown): v is Unknown => * value itself may be a string or a condition object (`{ base, _onDark }`), * so we stop at `value` rather than recursing into it. */ -const leafPaths = (node: unknown, prefix: string[] = []): string[] => { +const leafEntries = ( + node: unknown, + prefix: string[] = [], +): [string, unknown][] => { if (!isObject(node)) return []; - if ("value" in node) return [prefix.join(".")]; - return Object.entries(node).flatMap(([k, v]) => leafPaths(v, [...prefix, k])); + if ("value" in node) return [[prefix.join("."), node.value]]; + return Object.entries(node).flatMap(([k, v]) => + leafEntries(v, [...prefix, k]), + ); }; /** @@ -40,16 +45,19 @@ const leafPaths = (node: unknown, prefix: string[] = []): string[] => { * brand presets sometimes use `theme.semanticTokens`. Accept both, and merge * rather than picking one, so a preset using both is fully checked. */ -const semanticTokensOf = (preset: unknown): string[] => { +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 [ - ...leafPaths(theme.semanticTokens), - ...leafPaths(extend.semanticTokens), + ...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 — @@ -78,6 +86,34 @@ export const unknownSemanticTokens = ( .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 diff --git a/packages/ui/tests/preset-lint.test.ts b/packages/ui/tests/preset-lint.test.ts index b94da5f..cef5f0f 100644 --- a/packages/ui/tests/preset-lint.test.ts +++ b/packages/ui/tests/preset-lint.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { basePreset } from "../src/base-preset"; import { + droppedConditionTokens, reservedSemanticTokens, unknownSemanticTokens, } from "../src/preset-lint"; @@ -92,6 +93,72 @@ describe("unknownSemanticTokens", () => { }); }); +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(