Skip to content
14 changes: 7 additions & 7 deletions packages/ui-patterns/src/LanguageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export const LanguageDialog = ({
{translationLinkHref && (
<ExternalLink
href={translationLinkHref}
css={{ fontSize: "md", color: "brand.500" }}
css={{ fontSize: "md", color: "fg.link" }}
>
<FormattedMessage
{...uiPatternsMessage("ui-patterns.help-translate")}
Expand Down Expand Up @@ -286,8 +286,8 @@ const LanguageCard = ({
height: "100%",
borderRadius: "xl",
borderWidth: "2px",
borderColor: "gray.200",
_hover: { bg: "gray.100" },
borderColor: "border.default",
_hover: { bg: "surface.highlight" },
}}
/>
<VStack
Expand All @@ -298,10 +298,10 @@ const LanguageCard = ({
pointerEvents: "none",
py: 4,
px: 5,
color: "languageText",
color: "languageDialog.fg",
// The visible content is a sibling of the button, not a child, so
// the hover recolour needs an explicit sibling selector.
"button[data-hovered] + &": { color: "languageTextHover" },
"button[data-hovered] + &": { color: "languageDialog.fgHover" },
}}
>
<Text
Expand All @@ -321,7 +321,7 @@ const LanguageCard = ({
lang="en"
fontWeight="normal"
fontSize="sm"
color="gray.700"
color="fg.muted"
>
{enName}
{/* Visual pointer to the footnote; the accessible link is the
Expand All @@ -332,7 +332,7 @@ const LanguageCard = ({
<Box
css={{
pointerEvents: "auto",
color: "gray.500",
color: "fg.subtle",
display: "inline-flex",
}}
>
Expand Down
117 changes: 105 additions & 12 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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:

Expand All @@ -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()`
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/Avatar.recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
57 changes: 30 additions & 27 deletions packages/ui/src/Button.recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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" },
},
},
/**
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/Card.recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const card = defineSlotRecipe({
position: "relative",
minWidth: 0,
wordWrap: "break-word",
bg: "white",
bg: "surface.raised",
borderRadius: "md",
color: "inherit",
},
Expand All @@ -39,7 +39,7 @@ export const card = defineSlotRecipe({
container: {
borderWidth: "1px",
borderStyle: "solid",
borderColor: "gray.200",
borderColor: "border.default",
},
},
},
Expand Down
Loading