@microbit/ui 0.1.0-alpha.27 → 0.1.0-alpha.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -197,6 +197,50 @@ Semantic tokens (`languageText`, `statusBarBg`, `danger.*`, `toast*Bg`,
197
197
  brand presets override; they resolve through var indirection, so overrides
198
198
  apply wherever the token is consumed.
199
199
 
200
+ `focusRing` and `focusBorder` need more care: their values are condition
201
+ objects and a merge replaces a value wholesale, so the flat form drops the
202
+ on-dark flip below. Keep the shape:
203
+
204
+ ```ts
205
+ focusBorder: { value: { base: "{colors.brand.700}", _onDark: "{colors.white}" } };
206
+ ```
207
+
208
+ ## Dark surfaces
209
+
210
+ Focus indicators are surface-aware through one tag. The default focus ring
211
+ is ink; on a dark surface it must be white, so tag the surface element by
212
+ spreading the exported constant:
213
+
214
+ ```tsx
215
+ import { darkSurface } from "@microbit/ui";
216
+
217
+ <header {...darkSurface}>…</header>; // a black toolbar, a coloured sidebar bar
218
+ ```
219
+
220
+ Custom properties inherit, so tagging the bar covers the bar itself and
221
+ every control inside it — including ones added later. If the tagged element
222
+ is focusable, tag one level in instead: its own ring is drawn _outside_ it,
223
+ on whatever is behind it. (The Toast does this — the card is dark and
224
+ focusable, so the tag sits on its close button.) Portalled overlays (a
225
+ modal opened from a dark toolbar) escape the tag with the DOM, which is
226
+ correct. Under the hood it is `data-surface="dark"`, which the preset's
227
+ `onDark` condition scopes the `focusRing`/`focusBorder` token flips to.
228
+
229
+ Two rules:
230
+
231
+ - **Dark surfaces must tag** — the ink ring is near-invisible on them, and
232
+ there is no automatic detection.
233
+ - **Tag surfaces, not themes**: "dark" describes the surface's own
234
+ luminance, so tag surfaces that are dark by design (a black toolbar, a
235
+ coloured sidebar bar) rather than anything relative to the app's overall
236
+ look. This is what will let the tag survive a dark mode if we ship one:
237
+ designed-dark surfaces stay dark and keep their tags; everything else
238
+ stays untagged, and a dark mode would flip the untagged defaults via
239
+ token conditions, never via markup.
240
+
241
+ Rule of thumb for coloured bars: tag when the surface lacks 3:1 contrast
242
+ against ink — roughly, darker than the grey ramp's 500.
243
+
200
244
  ## Runtime token lookups
201
245
 
202
246
  For values that feed _computation_ rather than stylesheets (canvas painting,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microbit/ui",
3
- "version": "0.1.0-alpha.27",
3
+ "version": "0.1.0-alpha.29",
4
4
  "description": "micro:bit design-system primitives: react-aria-components + Panda CSS with a design language ported from Chakra UI v2. Ships as source; see README for the consumption setup.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "peerDependencies": {
33
33
  "@pandacss/dev": "^1.11.4",
34
34
  "react": "^18.3.1",
35
+ "react-aria": "^3.50.0",
35
36
  "react-aria-components": "^1.19.0",
36
37
  "react-icons": "^4.12.0 || ^5.0.0",
37
38
  "react-intl": "^6.6.8 || ^7.0.0"
@@ -47,6 +48,7 @@
47
48
  "@vitejs/plugin-react": "^4.5.2",
48
49
  "jsdom": "^29.1.1",
49
50
  "react": "^18.3.1",
51
+ "react-aria": "^3.50.0",
50
52
  "react-aria-components": "^1.19.0",
51
53
  "react-dom": "^18.3.1",
52
54
  "react-icons": "^4.12.0",
@@ -6,9 +6,10 @@
6
6
  import { defineRecipe } from "@pandacss/dev";
7
7
 
8
8
  // The common transition-property list, inlined (Panda has no
9
- // transitionProperty token category).
9
+ // transitionProperty token category). No box-shadow: focus indication
10
+ // must never fade in.
10
11
  const transitionCommon =
11
- "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform";
12
+ "background-color, border-color, color, fill, stroke, opacity, transform";
12
13
 
13
14
  /**
14
15
  * Button recipe — the family button base + sizes, with the house
@@ -19,11 +20,9 @@ const transitionCommon =
19
20
  *
20
21
  * A config recipe (not a component cva): styles land in the `recipes` layer so
21
22
  * call sites can override with plain style props, and presets extend the
22
- * variants. This file holds the brand-independent variant set the core
23
- * variants plus the family-wide `language`/`toolbar` variants; a
24
- * consuming app's preset extends it with app vocabulary (e.g. ml-trainer's
25
- * `led`/`record*`/`secondary-disabled`). Brand divergence within a variant is
26
- * token-driven (see the `button.*` and `languageText` semantic tokens).
23
+ * variants. Colour reaches a variant either through the `button.*` semantic
24
+ * tokens (the app's button idiom) or through `tone`, never through a per-app
25
+ * fork of a shape.
27
26
  *
28
27
  * Registered in the base preset (base-preset.ts).
29
28
  */
@@ -46,7 +45,7 @@ export const button = defineRecipe({
46
45
  verticalAlign: "middle",
47
46
  outline: "none",
48
47
  _focusVisible: {
49
- focusShadow: "outline",
48
+ focusRing: "outline",
50
49
  },
51
50
  _disabled: {
52
51
  opacity: 0.4,
@@ -130,42 +129,72 @@ export const button = defineRecipe({
130
129
  },
131
130
  _active: { bg: "button.primaryActiveBg" },
132
131
  },
133
- // The *destructive* outline (text darker than 500 for contrast).
134
- // Two other outline shapes are currently restated per call site in
135
- // classroom a neutral outline (1px gray.200, inherited text,
136
- // gray.50/gray.100 hover/press) and an on-colour outline (white 2px +
137
- // white text over a coloured bar, whiteAlpha hover/press) — worth
138
- // considering as variants if a second consumer wants them.
139
- warning: {
132
+ // `solid`/`outline` are coloured by `tone`, and reference only the
133
+ // stops it guarantees (50/500/600/700). They are not `primary`/
134
+ // `secondary` in another colour: those follow the app's button idiom,
135
+ // which is black-on-white in half the family and so can't be a palette.
136
+ solid: {
137
+ color: "white",
138
+ bg: "colorPalette.500",
139
+ _hover: {
140
+ bg: "colorPalette.600",
141
+ _disabled: { bg: "colorPalette.500" },
142
+ },
143
+ _active: { bg: "colorPalette.700" },
144
+ },
145
+ // Border at 500 (a boundary needs only 3:1) so a `solid`/`outline`
146
+ // toggle pair shows the same edge either way; the label needs 600.
147
+ //
148
+ // Classroom restates two other outlines per call site — a neutral 1px
149
+ // grey one and a white-on-colour one. Promote either if a second
150
+ // consumer appears.
151
+ outline: {
140
152
  borderWidth: "2px",
141
- borderColor: "danger.600",
142
- color: "danger.600",
153
+ borderColor: "colorPalette.500",
154
+ color: "colorPalette.600",
143
155
  bg: "transparent",
144
- _hover: { borderColor: "danger.700", color: "danger.700" },
145
- _active: { bg: "danger.50" },
156
+ _hover: { borderColor: "colorPalette.600", color: "colorPalette.700" },
157
+ _active: { bg: "colorPalette.50" },
146
158
  },
147
- // The destructive solid (confirm buttons). Same values as ml-trainer's
148
- // `record` today, but a separate variant so recording UI and
149
- // destructive actions can diverge - hence danger tokens here, red.*
150
- // literals there.
151
- warningSolid: {
152
- color: "white",
153
- bg: "danger.500",
154
- _hover: { bg: "danger.600", _disabled: { bg: "danger.500" } },
155
- _active: { bg: "danger.700" },
159
+ // Not a palette shape: a light fill under dark text is a different
160
+ // button from `solid`, and 350 exists in no other ramp.
161
+ neutral: {
162
+ color: "gray.800",
163
+ bg: "gray.100",
164
+ _hover: { bg: "gray.300", _disabled: { bg: "gray.100" } },
165
+ _active: { bg: "gray.350" },
156
166
  },
157
167
  // Family-wide variant (every censused app has toolbar-class buttons).
168
+ // No ring override: the bar decides, and a dark one must spread
169
+ // `darkSurface` — an app adopting this variant needs the tag with it.
158
170
  toolbar: {
159
171
  color: "black",
160
172
  bg: "white",
161
173
  _hover: { bg: "whiteAlpha.900", _disabled: { bg: "white" } },
162
174
  _active: { bg: "whiteAlpha.800" },
163
- _focusVisible: { focusShadow: "outlineDark" },
164
175
  },
165
176
  },
177
+ /**
178
+ * The palette behind `solid`/`outline`. An allowlist rather than Panda's
179
+ * open `colorPalette` prop, because a palette missing a stop a shape
180
+ * reads renders it as nothing at all, with no error anywhere
181
+ * (docs/hints.md): this is where a palette is vetted. A tone should
182
+ * alias a whole ramp, so it has nowhere to fall through. Apps may add
183
+ * their own.
184
+ */
185
+ tone: {
186
+ brand: { colorPalette: "brand" },
187
+ // The destructive role, which a brand preset can re-point.
188
+ danger: { colorPalette: "danger" },
189
+ // Conventional red that isn't destructive (record buttons), so
190
+ // deliberately not following a brand's error colour.
191
+ red: { colorPalette: "red" },
192
+ },
166
193
  },
167
194
  defaultVariants: {
168
195
  variant: "secondary",
169
196
  size: "md",
197
+ // Never absent: a shape with no palette renders nothing.
198
+ tone: "brand",
170
199
  },
171
200
  });
package/src/Button.tsx CHANGED
@@ -71,6 +71,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
71
71
  {
72
72
  variant,
73
73
  size,
74
+ tone,
74
75
  css: cssProp,
75
76
  className,
76
77
  leftIcon,
@@ -96,7 +97,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
96
97
  <RACButton
97
98
  ref={ref}
98
99
  className={cx(
99
- button({ variant, size }),
100
+ button({ variant, size, tone }),
100
101
  cssProp ? css(cssProp) : undefined,
101
102
  className,
102
103
  )}
@@ -38,8 +38,6 @@ export const checkbox = defineSlotRecipe({
38
38
  alignItems: "center",
39
39
  justifyContent: "center",
40
40
  flexShrink: 0,
41
- transitionProperty: "box-shadow",
42
- transitionDuration: "normal",
43
41
  borderWidth: "2px",
44
42
  borderStyle: "solid",
45
43
  borderRadius: "sm",
@@ -68,7 +66,7 @@ export const checkbox = defineSlotRecipe({
68
66
  _hover: { bg: "gray.200", borderColor: "gray.200" },
69
67
  },
70
68
  "&[data-focus-visible]": {
71
- focusShadow: "outline",
69
+ focusRing: "outline",
72
70
  },
73
71
  },
74
72
  icon: {
@@ -57,7 +57,7 @@ export const CloseButton = forwardRef<HTMLButtonElement, CloseButtonProps>(
57
57
  transitionDuration: "normal",
58
58
  _hover: { bg: "blackAlpha.100" },
59
59
  _active: { bg: "blackAlpha.200" },
60
- _focusVisible: { focusShadow: "outline" },
60
+ _focusVisible: { focusRing: "outline" },
61
61
  },
62
62
  size === "sm"
63
63
  ? { width: "6", height: "6", fontSize: "2xs" }
package/src/ComboBox.tsx CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  Popover,
21
21
  PopoverProps,
22
22
  } from "react-aria-components";
23
+ import { useFocusVisible } from "react-aria";
23
24
  import { useIntl } from "react-intl";
24
25
  import { RiArrowDownSLine } from "react-icons/ri";
25
26
  import { css, cx } from "styled-system/css";
@@ -136,6 +137,10 @@ const ComboBoxInner = <T extends object>(
136
137
  const fieldSlots = field({ size: variantProps.size, labelPosition });
137
138
  // Anchor the card to the whole control, not to the bare input inside it —
138
139
  // otherwise it hangs off the text baseline and is as narrow as the input.
140
+ // Global modality (text-input key filter): unlike the input's own RAC
141
+ // attributes, it survives react-aria's synthetic blur during virtual
142
+ // focus — the select recipe's ring rule pairs it with :has(input:focus).
143
+ const { isFocusVisible } = useFocusVisible({ isTextInput: true });
139
144
  const triggerRef = useRef<HTMLDivElement>(null);
140
145
  // RAC's --trigger-width measures the input it anchors a ComboBox to, which
141
146
  // is the control's content box — so a card sized from it is narrower than
@@ -176,6 +181,7 @@ const ComboBoxInner = <T extends object>(
176
181
  )}
177
182
  <div
178
183
  ref={triggerRef}
184
+ data-keyboard-modality={isFocusVisible || undefined}
179
185
  className={cx(
180
186
  slots.trigger,
181
187
  triggerCss ? css(triggerCss) : undefined,
@@ -50,7 +50,7 @@ export const gridList = defineSlotRecipe({
50
50
  _hover: { bg: "gray.100" },
51
51
  "&:has([aria-expanded=true])": { bg: "gray.100" },
52
52
  },
53
- "&[data-focus-visible]": { focusShadow: "outline" },
53
+ "&[data-focus-visible]": { focusRing: "outline" },
54
54
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
55
55
  },
56
56
  },
@@ -8,25 +8,16 @@ import { defineRecipe } from "@pandacss/dev";
8
8
  // The common transition-property list, inlined (Panda has no
9
9
  // transitionProperty token category).
10
10
  const transitionCommon =
11
- "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform";
11
+ "background-color, border-color, color, fill, stroke, opacity, transform";
12
12
 
13
13
  /**
14
14
  * Input recipe — the outline text field with an sm/md/lg size scale. Used by
15
15
  * the shared-ui Input and NativeSelect, and by TextField's input slot.
16
16
  *
17
- * Focus matches both native `:focus-visible` (plain inputs; browsers treat any
18
- * focus in a text field as focus-visible) and react-aria's `data-focused`
19
- * (inputs inside RAC TextField).
20
- *
21
- * Hover, invalid and focus all set `borderColor`, so their precedence has to be
22
- * hover < invalid < focus. Declaration order will not buy that: Panda sorts a
23
- * recipe's state rules itself, ranking selectors against a fixed
24
- * link/visited/focus/hover/active table, which puts `_hover` *after* focus and
25
- * after anything the table doesn't mention (`[data-invalid]`). Equal-specificity
26
- * rules then leave hover winning. So the ladder is spelled with repeated `&`
27
- * instead — `&&` and `&&&` emit `.input.input` and `.input.input.input`, making
28
- * precedence specificity rather than order, which nothing downstream can
29
- * resort. Variants still override freely; they land in a later cascade layer.
17
+ * hover < focused < invalid is a specificity ladder (`&&`, `&&&`): Panda
18
+ * sorts state rules against its own pseudo-class table, not declaration
19
+ * order, so equal-specificity borderColor ties would leave hover winning.
20
+ * Variants still override freely (later cascade layer).
30
21
  *
31
22
  * Registered in the base preset (base-preset.ts), which also has the
32
23
  * `staticCss` entry that keeps the runtime-prop size variants generated.
@@ -42,27 +33,30 @@ export const input = defineRecipe({
42
33
  font: "inherit",
43
34
  transitionProperty: transitionCommon,
44
35
  transitionDuration: "normal",
45
- border: "1px solid",
46
- // The accessible outline stops: gray.400 is the ramp's 3:1-on-white
47
- // boundary grey (WCAG 1.4.11), and hover steps darker, never lighter.
48
- borderColor: "gray.400",
36
+ border: "2px solid",
37
+ // Deliberately below 3:1 valid while something else visually
38
+ // identifies the field (label, or a ≥3:1 icon/placeholder).
39
+ // Checkbox/Radio keep gray.400: their box IS the identifier.
40
+ // Rationale: ui-private docs/a11y-positions.md.
41
+ borderColor: "gray.300",
49
42
  bg: "inherit",
50
43
  color: "inherit",
51
44
  _hover: { borderColor: "gray.500" },
52
- "&&:is([data-invalid], :user-invalid)": {
45
+ // Any focus, pointer included; the keyboard ring composes on top.
46
+ // zIndex so the border — and the ring with it — paint over
47
+ // attached-group neighbours.
48
+ "&&:is(:focus, [data-focused])": { zIndex: 1, borderColor: "focusBorder" },
49
+ "&&&:is([data-invalid], :user-invalid)": {
53
50
  borderColor: "danger.500",
54
- boxShadow: "0 0 0 1px token(colors.danger.500)",
55
- },
56
- "&&&:is(:focus-visible, [data-focused])": {
57
- zIndex: 1,
58
- borderColor: "focusBorder",
59
- boxShadow: "0 0 0 1px token(colors.focusBorder)",
60
- // Focus indicator for forced-colors modes, which strip the box-shadow
61
- // and force the border colour (the focusShadow utility's technique; the
62
- // ring here is the 1px border tint, not an outline* shadow token).
63
- outline: "2px solid transparent",
64
- outlineOffset: "2px",
65
51
  },
52
+ // Modality-tracked: RAC's attribute, or Input.tsx's. Native
53
+ // :focus-visible is the fallback for a bare element wearing the recipe,
54
+ // but not text-entry ones — browsers match it there on a pointer click,
55
+ // which is what the tracking exists to exclude.
56
+ "&&&:is([data-focus-visible], :focus-visible:not([data-rac], input, textarea))":
57
+ {
58
+ focusRing: "outline",
59
+ },
66
60
  "&:is(:disabled, [data-disabled])": {
67
61
  opacity: 0.4,
68
62
  cursor: "not-allowed",
package/src/Input.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
  import { forwardRef, InputHTMLAttributes } from "react";
7
+ import { mergeProps, useFocusRing } from "react-aria";
7
8
  import { css, cx } from "styled-system/css";
8
9
  import { input, InputVariantProps } from "styled-system/recipes";
9
10
  import { SystemStyleObject } from "styled-system/types";
@@ -30,15 +31,19 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
30
31
  // groups to the recipe (classroom adds `variant`), and cherry-picking would
31
32
  // silently drop them onto the DOM as unknown attributes instead.
32
33
  const [variantProps, rest] = input.splitVariantProps(props);
34
+ // A native input never gets RAC's data-focus-visible, which the recipe's
35
+ // ring keys off — track modality ourselves, as TextField's input does.
36
+ const { isFocusVisible, focusProps } = useFocusRing({ isTextInput: true });
33
37
  return (
34
38
  <input
35
39
  ref={ref}
40
+ data-focus-visible={isFocusVisible || undefined}
36
41
  className={cx(
37
42
  input(variantProps),
38
43
  cssProp ? css(cssProp) : undefined,
39
44
  className,
40
45
  )}
41
- {...rest}
46
+ {...mergeProps(rest, focusProps)}
42
47
  />
43
48
  );
44
49
  });
package/src/Link.tsx CHANGED
@@ -18,6 +18,6 @@ export const Link = styled("a", {
18
18
  "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform",
19
19
  transitionDuration: "normal",
20
20
  _hover: { textDecoration: "underline" },
21
- _focusVisible: { focusShadow: "outline" },
21
+ _focusVisible: { focusRing: "outline" },
22
22
  },
23
23
  });
@@ -48,6 +48,7 @@ export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
48
48
  {
49
49
  variant,
50
50
  size,
51
+ tone,
51
52
  css: cssProp,
52
53
  className,
53
54
  leftIcon,
@@ -61,7 +62,7 @@ export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
61
62
  <RACLink
62
63
  ref={ref}
63
64
  className={cx(
64
- button({ variant, size }),
65
+ button({ variant, size, tone }),
65
66
  css(linkReset, cssProp),
66
67
  className,
67
68
  )}
@@ -36,7 +36,7 @@ export const listBox = defineSlotRecipe({
36
36
  transitionTimingFunction: "ease-in",
37
37
  _hover: { bg: "gray.50" },
38
38
  "&[data-selected]": { bg: "gray.100", _hover: { bg: "gray.100" } },
39
- "&[data-focus-visible]": { focusShadow: "outline" },
39
+ "&[data-focus-visible]": { focusRing: "outline" },
40
40
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
41
41
  },
42
42
  },
@@ -66,12 +66,11 @@ export const menu = defineSlotRecipe({
66
66
  color: "inherit",
67
67
  textDecoration: "none",
68
68
  outline: "none",
69
- transitionProperty: "background",
70
- transitionDuration: "ultra-fast",
71
- transitionTimingFunction: "ease-in",
72
- // RAC highlights the active item (keyboard or pointer) with data-focused;
73
- // data-pressed is the pressed state.
69
+ // data-focused is RAC's active item, either modality. The highlight
70
+ // is focus indication: no transition (it snaps with the ring, which
71
+ // keyboard nav adds; inset, the rows being full-bleed).
74
72
  "&[data-focused]": { bg: "gray.100" },
73
+ "&[data-focus-visible]": { focusRing: "outlineInset" },
75
74
  "&[data-pressed]": { bg: "gray.200" },
76
75
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
77
76
  },
package/src/Modal.tsx CHANGED
@@ -368,7 +368,7 @@ export const ModalCloseButton = ({
368
368
  transitionDuration: "normal",
369
369
  _hover: { bg: "blackAlpha.100" },
370
370
  _active: { bg: "blackAlpha.200" },
371
- _focusVisible: { focusShadow: "outline" },
371
+ _focusVisible: { focusRing: "outline" },
372
372
  }),
373
373
  )}
374
374
  >
@@ -25,6 +25,15 @@ export const numberField = defineSlotRecipe({
25
25
  group: {
26
26
  position: "relative",
27
27
  zIndex: 0,
28
+ // The stepper overlays the input as a sibling, so the input's own
29
+ // :hover misses it — the group carries the hover tint. The :not()
30
+ // list is required: slot recipes layer above plain recipes
31
+ // (recipes.slots), so this would otherwise override focused/invalid
32
+ // (see docs/hints.md). :user-invalid takes its own :not(): :not() is
33
+ // not forgiving, so one list would drop the whole selector on a
34
+ // browser that doesn't know the pseudo-class (Safari < 16.5).
35
+ "&:hover input:not(:focus, [data-focused], [data-invalid]):not(:user-invalid)":
36
+ { borderColor: "gray.500" },
28
37
  },
29
38
  // A column overlaying the input's right edge, inset by the input border.
30
39
  stepper: {
@@ -33,8 +42,8 @@ export const numberField = defineSlotRecipe({
33
42
  position: "absolute",
34
43
  insetEnd: "0",
35
44
  top: "0",
36
- height: "calc(100% - 2px)",
37
- margin: "1px",
45
+ height: "calc(100% - 4px)",
46
+ margin: "2px",
38
47
  width: "6",
39
48
  zIndex: 1,
40
49
  },
@@ -52,18 +61,18 @@ export const numberField = defineSlotRecipe({
52
61
  borderColor: "gray.200",
53
62
  transitionProperty: "background",
54
63
  transitionDuration: "ultra-fast",
55
- // Follow the input's corners, less the 1px border the stepper is inset
64
+ // Follow the input's corners, less the 2px border the stepper is inset
56
65
  // by, so the hover and pressed fills curve away with the border instead
57
66
  // of squaring off over its arc. Radii track the input recipe's size
58
67
  // scale, so the `sm` variant restates them.
59
68
  "&:first-child": {
60
- borderStartEndRadius: "calc(token(radii.md) - 1px)",
69
+ borderStartEndRadius: "calc(token(radii.md) - 2px)",
61
70
  },
62
71
  "&:last-child": {
63
72
  borderTop: "1px solid",
64
73
  borderTopColor: "gray.200",
65
74
  marginTop: "-1px",
66
- borderEndEndRadius: "calc(token(radii.md) - 1px)",
75
+ borderEndEndRadius: "calc(token(radii.md) - 2px)",
67
76
  },
68
77
  "&[data-hovered]": { bg: "gray.100" },
69
78
  "&[data-pressed]": { bg: "gray.200" },
@@ -84,10 +93,10 @@ export const numberField = defineSlotRecipe({
84
93
  stepperButton: {
85
94
  fontSize: "calc(token(fontSizes.sm) * 0.75)",
86
95
  "&:first-child": {
87
- borderStartEndRadius: "calc(token(radii.sm) - 1px)",
96
+ borderStartEndRadius: "calc(token(radii.sm) - 2px)",
88
97
  },
89
98
  "&:last-child": {
90
- borderEndEndRadius: "calc(token(radii.sm) - 1px)",
99
+ borderEndEndRadius: "calc(token(radii.sm) - 2px)",
91
100
  },
92
101
  },
93
102
  },
@@ -38,8 +38,6 @@ export const radio = defineSlotRecipe({
38
38
  alignItems: "center",
39
39
  justifyContent: "center",
40
40
  flexShrink: 0,
41
- transitionProperty: "box-shadow",
42
- transitionDuration: "normal",
43
41
  borderWidth: "2px",
44
42
  borderStyle: "solid",
45
43
  borderRadius: "full",
@@ -78,7 +76,7 @@ export const radio = defineSlotRecipe({
78
76
  _hover: { bg: "gray.200", borderColor: "gray.200" },
79
77
  },
80
78
  "&[data-focus-visible]": {
81
- focusShadow: "outline",
79
+ focusRing: "outline",
82
80
  },
83
81
  },
84
82
  label: {
@@ -7,8 +7,10 @@ import { defineSlotRecipe } from "@pandacss/dev";
7
7
 
8
8
  // The common transition-property list, inlined (Panda has no
9
9
  // transitionProperty token category).
10
+ // No box-shadow: nothing in this recipe uses one, and the focus ring's
11
+ // layers must never fade in.
10
12
  const transitionCommon =
11
- "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform";
13
+ "background-color, border-color, color, fill, stroke, opacity, transform";
12
14
 
13
15
  /**
14
16
  * Select slot recipe — the dropdown pair, shared by `Select` (a listbox behind
@@ -62,47 +64,35 @@ export const select = defineSlotRecipe({
62
64
  cursor: "pointer",
63
65
  transitionProperty: transitionCommon,
64
66
  transitionDuration: "normal",
65
- border: "1px solid",
66
- // As the input recipe: the ramp's accessible outline stop, with hover
67
- // stepping darker. The dropdown card below keeps the light gray.200 —
68
- // it's a surface edge, not a form-control boundary.
69
- borderColor: "gray.400",
67
+ border: "2px solid",
68
+ // As the input recipe (rationale there). The dropdown card below
69
+ // keeps gray.200 a surface edge, not a form-control boundary.
70
+ borderColor: "gray.300",
70
71
  bg: "white",
71
72
  color: "inherit",
72
- // As the input recipe, so a Select, a NativeSelect and a TextField in one
73
- // form all tint together on hover. (react-aria's TextField has no hover
74
- // effect, but matching the family beats matching their docs.)
73
+ // As the input recipe, so mixed fields tint together on hover.
75
74
  _hover: { borderColor: "gray.500" },
76
75
  // `data-invalid` lands on the root — and, in a ComboBox, on the input —
77
76
  // but never on the trigger: a RAC Button has no validity state, and our
78
77
  // ComboBox control is a plain div. So it comes down from the parent.
79
78
  // `> &` rather than a descendant selector, so an app's own invalid form
80
- // wrapper cannot paint every control inside it red.
81
- //
82
- // Doubled `&` for the same reason as the input recipe: hover, invalid and
83
- // focus all set `borderColor`, and Panda sorts state rules by its own
84
- // pseudo-class table rather than declaration order, so hover would win
85
- // these ties. The repeated `&` makes the hover < invalid < focus ladder a
86
- // matter of specificity instead.
87
- "[data-invalid] > &&": {
79
+ // wrapper cannot paint every control inside it red. Repeated `&`: the
80
+ // hover < focused < invalid specificity ladder, as the input recipe.
81
+ "&&:is([data-focused], :has(input:focus))": {
82
+ borderColor: "focusBorder",
83
+ },
84
+ // Border colour alone, as the input recipe.
85
+ "[data-invalid] > &&&": {
88
86
  borderColor: "danger.500",
89
- boxShadow: "0 0 0 1px token(colors.danger.500)",
90
87
  },
91
- // Two focus cases. `data-focus-visible` is Select's button on keyboard
92
- // focus only (RAC leaves it unset for mouse, matching the react-aria
93
- // docs' Select). The `:has()` arm is ComboBox: its control is a plain
94
- // div wrapping an input, so it gets no RAC attributes itself, and as a
95
- // text field it should show focus on any modality. That arm watches
96
- // native `:focus` rather than the input's `data-focused`, because
97
- // react-aria dispatches a synthetic blur at the input whenever virtual
98
- // focus moves to an option (aria-activedescendant) — which strips RAC's
99
- // attribute for as long as the list has an active option, real focus
100
- // never having left. Select's trigger holds no input, so it can't match.
101
- "&&&[data-focus-visible], &&&:has(input:focus)": {
102
- boxShadow: "0 0 0 1px token(colors.focusBorder)",
103
- borderColor: "focusBorder",
104
- outline: "2px solid transparent",
105
- outlineOffset: "2px",
88
+ // Keyboard-only ring, two cases. Select's button: RAC's attribute.
89
+ // ComboBox: the input's RAC attributes are stripped by react-aria's
90
+ // synthetic blur during virtual focus, so its case combines the two
91
+ // signals that survive native :focus plus the global-modality
92
+ // attribute the component renders (see ComboBox.tsx). No flicker
93
+ // during list navigation; can't match Select (no input).
94
+ "&&&[data-focus-visible], &&&[data-keyboard-modality]:has(input:focus)": {
95
+ focusRing: "outline",
106
96
  },
107
97
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
108
98
  },
@@ -181,10 +171,11 @@ export const select = defineSlotRecipe({
181
171
  cursor: "pointer",
182
172
  color: "inherit",
183
173
  outline: "none",
184
- transitionProperty: "background",
185
- transitionDuration: "ultra-fast",
186
- transitionTimingFunction: "ease-in",
174
+ // As the menu recipe: the highlight is focus indication (no
175
+ // transition — it snaps with the ring, which keyboard nav adds;
176
+ // inset, the rows being full-bleed).
187
177
  "&[data-focused]": { bg: "gray.100" },
178
+ "&[data-focus-visible]": { focusRing: "outlineInset" },
188
179
  "&[data-pressed]": { bg: "gray.200" },
189
180
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
190
181
  },
@@ -85,7 +85,7 @@ export const slider = defineSlotRecipe({
85
85
  },
86
86
  transform: "translate(-50%, -50%)",
87
87
  "&[data-focus-visible]": {
88
- focusShadow: "outline",
88
+ focusRing: "outline",
89
89
  },
90
90
  "&[data-disabled]": {
91
91
  bg: "gray.300",
@@ -42,7 +42,7 @@ export const switchRecipe = defineSlotRecipe({
42
42
  bg: "controlCheckedBg",
43
43
  },
44
44
  "&[data-focus-visible]": {
45
- focusShadow: "outline",
45
+ focusRing: "outline",
46
46
  },
47
47
  "&[data-disabled]": { opacity: 0.4 },
48
48
  // Forced-colors modes strip author backgrounds, flattening track and
@@ -67,6 +67,11 @@ export const toast = defineSlotRecipe({
67
67
  color: "white",
68
68
  maxW: "sm",
69
69
  pointerEvents: "auto",
70
+ // The card is focusable (RAC gives it tabindex), so it takes the
71
+ // house ring rather than the UA default. Ink, because the ring is
72
+ // drawn on the page, not the card — Toast.tsx tags the close button.
73
+ outline: "none",
74
+ _focusVisible: { focusRing: "outline" },
70
75
  },
71
76
  // The icon has to be inside the alert region to be announced with the
72
77
  // message, so this row — not the root — is what lays the two out.
@@ -115,7 +120,7 @@ export const toast = defineSlotRecipe({
115
120
  // Hover is a subtle dark overlay (blackAlpha), not a bright highlight.
116
121
  _hover: { bg: "blackAlpha.100" },
117
122
  _active: { bg: "blackAlpha.200" },
118
- _focusVisible: { focusShadow: "outline" },
123
+ _focusVisible: { focusRing: "outline" },
119
124
  },
120
125
  },
121
126
  variants: {
package/src/Toast.tsx CHANGED
@@ -23,6 +23,7 @@ import { toast as toastRecipe } from "styled-system/recipes";
23
23
  import { CloseIcon } from "./CloseIcon";
24
24
  import { Icon } from "./Icon";
25
25
  import { uiMessage } from "./messages";
26
+ import { darkSurface } from "./system";
26
27
 
27
28
  export type ToastStatus = "info" | "success" | "warning" | "error";
28
29
 
@@ -162,6 +163,11 @@ export const ToastProvider = () => {
162
163
  <RACButton
163
164
  slot="close"
164
165
  aria-label={intl.formatMessage(uiMessage("ui.close-action"))}
166
+ // The card is dark at every status, and no app can tag a
167
+ // surface the package ships. Tagged here, not on the card:
168
+ // the tag covers the element it sits on, whose own ring is
169
+ // drawn outside it, on the page.
170
+ {...darkSurface}
165
171
  className={slots.closeButton}
166
172
  >
167
173
  <CloseIcon />
@@ -56,7 +56,7 @@ const triggerStyle: SystemStyleObject = {
56
56
  lineHeight: "1",
57
57
  cursor: "pointer",
58
58
  borderRadius: "50%",
59
- _focusVisible: { focusShadow: "outline" },
59
+ _focusVisible: { focusRing: "outline" },
60
60
  };
61
61
 
62
62
  export interface TooltipButtonProps {
@@ -55,10 +55,11 @@ import { toast } from "./Toast.recipe";
55
55
  // - 350 is the decorative/state fill stop (~2.1:1): avatar discs, skeleton
56
56
  // pulse, pressed fills. Never text or boundaries.
57
57
  // - 400–900 are ink stops (outlines, placeholders, text) with a contrast
58
- // contract on white: 400 ≥ 3:1, the accessible form-outline stop
59
- // (WCAG 1.4.11); 500 4.5:1, text-safe secondary (placeholders, muted
60
- // icons). Presets may re-tint these only luminance-matched — the
61
- // contrast figures are the contract, hue is free.
58
+ // contract on white: 400 ≥ 3:1, the floor for boundaries that identify
59
+ // a control (checkbox-family boxes; fields rest lighter see the input
60
+ // recipe); 500 ≥ 4.5:1, text-safe secondary. Presets may re-tint these
61
+ // only luminance-matched — the contrast figures are the contract, hue
62
+ // is free.
62
63
  //
63
64
  // Override values, never names: raw var(--colors-gray-*) references and
64
65
  // paired private presets depend on the names, so a rename is a breaking
@@ -82,10 +83,30 @@ const gray = {
82
83
  900: { value: "#1a1a1a" },
83
84
  };
84
85
 
86
+ // The family red, on the gray ramp's ladder — it is the conventional colour
87
+ // for errors and recording, not a brand colour, so it can be graded rather
88
+ // than negotiated. 400 was already exactly gray's 3:1 and stays verbatim,
89
+ // anchoring the hue and saturation the darker stops hold while their
90
+ // lightness solves for gray's ratio. The washes have no contract and keep
91
+ // their values.
92
+ const red = {
93
+ 50: { value: "#FFF5F5" },
94
+ 100: { value: "#FED7D7" },
95
+ 200: { value: "#FEB2B2" },
96
+ 300: { value: "#FC8181" },
97
+ 400: { value: "#F56565" }, // 3.03:1
98
+ 500: { value: "#e22b2b" }, // 4.55:1 — the white-text fill stop
99
+ 600: { value: "#ac1818" },
100
+ 700: { value: "#811212" },
101
+ 800: { value: "#4f0b0b" },
102
+ 900: { value: "#380808" },
103
+ };
104
+
85
105
  /**
86
106
  * The base preset: the complete, working micro:bit design system. The base
87
107
  * token scales (base-tokens.ts), the micro:bit house style
88
- * (pill `radii.button`, `outline*` focus shadows, Helvetica fonts, the
108
+ * (pill `radii.button`, the `focusRing` utility/token pair, Helvetica
109
+ * fonts, the
89
110
  * `toolbar` button variant in Button.recipe.ts, the
90
111
  * `languageText`/`toast*Bg`/`statusBarBg` semantic tokens), the shared-ui
91
112
  * component recipes, the react-aria condition widening, the `globalCss`
@@ -139,6 +160,7 @@ export const basePreset = definePreset({
139
160
  colors: {
140
161
  ...colors,
141
162
  gray,
163
+ red,
142
164
  // OSS default brand ramps (see the brand contract above). `brand`
143
165
  // aliases the blue ramp; `brand2` is a frozen legacy alias of the
144
166
  // slate gray in base-tokens, deliberately decoupled from the neutral
@@ -166,11 +188,6 @@ export const basePreset = definePreset({
166
188
  },
167
189
  shadows: {
168
190
  ...shadows,
169
- // The 4px focus outline shadow, plus dark/light-surface
170
- // companions. Consumed via the `focusShadow` utility.
171
- outline: { value: "0 0 0 4px rgba(66, 153, 225, 0.6)" },
172
- outlineDark: { value: "0 0 0 4px rgba(0, 0, 0, 0.5)" },
173
- outlineLight: { value: "0 0 0 4px rgba(255, 255, 255, 0.8)" },
174
191
  },
175
192
  fonts: {
176
193
  // Helvetica heading/body (4/4 apps); a brand preset leaves these and
@@ -189,23 +206,42 @@ export const basePreset = definePreset({
189
206
  },
190
207
  semanticTokens: {
191
208
  colors: {
192
- // Checked/focus states of form controls: Checkbox/Switch/Radio
193
- // checked backgrounds and the Input/Select focus border. Semantic
194
- // so a brand can diverge them from its ramp — e.g. a light
195
- // brand.500 needing a darker 3:1 focus border.
209
+ // Checked states of form controls: Checkbox/Switch/Radio checked
210
+ // backgrounds. Semantic so a brand can diverge them from its ramp.
196
211
  controlCheckedBg: { value: "{colors.brand.500}" },
197
212
  controlCheckedHoverBg: { value: "{colors.brand.600}" },
198
- focusBorder: { value: "{colors.brand.500}" },
199
- // Error/destructive ramp. Destructive button variants,
200
- // field error states and the error toast; the record* button
201
- // variants deliberately stay on red.* (recording vocabulary, not
202
- // danger).
213
+ // Focused form-control border, any modality: the dark brand stop
214
+ // (all-ink read flat next to the ink ring). Flips white under the
215
+ // dark-surface tag, like `focusRing`.
216
+ // Both flips are condition objects, and a merge replaces a token
217
+ // value wholesale: an override must keep the `{ base, _onDark }`
218
+ // shape or silently lose the flip.
219
+ focusBorder: {
220
+ value: { base: "{colors.brand.600}", _onDark: "{colors.white}" },
221
+ },
222
+ // The focus ring colour: ink, or white inside `data-surface="dark"`
223
+ // (the `onDark` condition). The var inherits — tag the bar, cover
224
+ // its controls; portalled overlays escape with the DOM. Dark
225
+ // surfaces MUST tag (ink is near-invisible there). Opaque
226
+ // deliberately: translucent rings washed out (classroom #780).
227
+ // Both tag states: the Button "Variants" story.
228
+ focusRing: {
229
+ value: { base: "{colors.gray.900}", _onDark: "{colors.white}" },
230
+ },
231
+ // Error/destructive ramp: field error states, the error toast, and
232
+ // the `danger` button tone. Aliased whole, not just at the stops in
233
+ // use, so a tone has nowhere to fall through (Button.recipe.ts).
203
234
  danger: {
204
235
  50: { value: "{colors.red.50}" },
205
236
  100: { value: "{colors.red.100}" },
237
+ 200: { value: "{colors.red.200}" },
238
+ 300: { value: "{colors.red.300}" },
239
+ 400: { value: "{colors.red.400}" },
206
240
  500: { value: "{colors.red.500}" },
207
241
  600: { value: "{colors.red.600}" },
208
242
  700: { value: "{colors.red.700}" },
243
+ 800: { value: "{colors.red.800}" },
244
+ 900: { value: "{colors.red.900}" },
209
245
  },
210
246
  // The language-dialog cards' text colour (@microbit/ui-patterns'
211
247
  // LanguageDialog) follows the primary interactive brand: every
@@ -247,7 +283,9 @@ export const basePreset = definePreset({
247
283
  toastInfoBg: { value: "{colors.teal.800}" },
248
284
  toastSuccessBg: { value: "{colors.teal.800}" },
249
285
  toastWarningBg: { value: "{colors.teal.800}" },
250
- toastErrorBg: { value: "{colors.danger.600}" },
286
+ // 500, the text-safe stop, rather than following the teal toasts'
287
+ // 800: white on it is 4.55:1 and an error toast should read as red.
288
+ toastErrorBg: { value: "{colors.danger.500}" },
251
289
  // The native app's status-bar area colour, shared by the ActionBar
252
290
  // and the full-size dialog's safe-area gradient.
253
291
  statusBarBg: { value: "{colors.brand2.500}" },
@@ -287,6 +325,11 @@ export const basePreset = definePreset({
287
325
  html: {
288
326
  textRendering: "optimizeLegibility",
289
327
  touchAction: "manipulation",
328
+ // Alias preset-base's ring plumbing to our colour, so a stray use
329
+ // of its focusVisibleRing/focusRing* utilities renders in our
330
+ // ink rather than #005FCC. Still don't use them: un-gated focus
331
+ // selector, and this alias resolves on <html> (no tag awareness).
332
+ "--global-color-focus-ring": "var(--colors-focus-ring)",
290
333
  },
291
334
  body: {
292
335
  // No `position: relative` (Chakra had it): it breaks react-aria's
@@ -367,7 +410,13 @@ export const basePreset = definePreset({
367
410
  // too — otherwise the class lands on the element with no rule behind it
368
411
  // and the button silently falls back to the base size.
369
412
  avatar: ["*"],
370
- button: [{ size: ["*"], responsive: true }, { variant: ["*"] }],
413
+ // `tone` generates as its own rule, not crossed with `variant`: it
414
+ // only assigns the palette custom properties a shape reads.
415
+ button: [
416
+ { size: ["*"], responsive: true },
417
+ { variant: ["*"] },
418
+ { tone: ["*"] },
419
+ ],
371
420
  checkbox: ["*"],
372
421
  heading: ["*"],
373
422
  card: ["*"],
@@ -391,19 +440,51 @@ export const basePreset = definePreset({
391
440
  },
392
441
  utilities: {
393
442
  extend: {
394
- // The app-wide focus indicator, usually inside `_focusVisible`. Values
395
- // are the outline* shadow token names. The transparent outline is for
396
- // forced-colors modes, which strip box-shadows but recolour outlines to
397
- // a visible system colour. (Named to avoid preset-base's outline-based
398
- // `focusRing` utility, whose values would break this transform.)
399
- focusShadow: {
400
- className: "focus-shadow",
401
- values: ["outline", "outlineDark", "outlineLight"],
402
- transform: (value: string, { token }) => ({
403
- outline: "2px solid transparent",
404
- outlineOffset: "2px",
405
- boxShadow: token(`shadows.${value}`),
406
- }),
443
+ // The app-wide focus indicator, usually inside `_focusVisible`: a
444
+ // 2px `focusRing`-coloured outline at 2px offset the surface shows
445
+ // through the gap, and call sites never pick a ring per background.
446
+ // A real outline, so forced-colors modes keep a ring; longhands
447
+ // because Panda resolves tokens per-property. Thickness/contrast
448
+ // rationale: ui-private docs/a11y-positions.md. Shadows
449
+ // preset-base's `focusRing`: our transform replaces theirs, but the
450
+ // values arrays union, so its outside/inside/mixed/none typecheck
451
+ // here. `none` is honoured the alternative is a permanent un-gated
452
+ // ring; the other three fall through to the standard one. Don't use
453
+ // them.
454
+ focusRing: {
455
+ className: "focus-ring",
456
+ // `outlineInset` draws the ring just inside — for full-bleed rows
457
+ // whose outward ring would overhang their popover.
458
+ values: ["outline", "outlineInset"],
459
+ transform: (value: string, { token }) =>
460
+ value === "none"
461
+ ? { outlineStyle: "none" }
462
+ : {
463
+ outlineStyle: "solid",
464
+ outlineWidth: "2px",
465
+ outlineColor: token("colors.focusRing"),
466
+ outlineOffset: value === "outlineInset" ? "-2px" : "2px",
467
+ },
468
+ },
469
+ // preset-base's remaining ring plumbing sets --focus-ring-* custom
470
+ // properties only its own utilities read. Repointed at the outline
471
+ // longhands ours draws with, rather than left as no-ops that read
472
+ // like working knobs.
473
+ focusRingWidth: {
474
+ className: "focus-ring-w",
475
+ values: "borderWidths",
476
+ transform: (value: string) => ({ outlineWidth: value }),
477
+ },
478
+ focusRingOffset: {
479
+ className: "focus-ring-o",
480
+ values: "spacing",
481
+ transform: (value: string) => ({ outlineOffset: value }),
482
+ },
483
+ focusRingStyle: {
484
+ className: "focus-ring-s",
485
+ values: "borderStyles",
486
+ // `outlineStyle` is a keyword union, hence the cast.
487
+ transform: (value: string) => ({ outlineStyle: value as "solid" }),
407
488
  },
408
489
  },
409
490
  },
@@ -414,9 +495,18 @@ export const basePreset = definePreset({
414
495
  extend: {
415
496
  hover: "&:is(:hover, [data-hovered])",
416
497
  active: "&:is(:active, [data-pressed])",
417
- focusVisible: "&:is(:focus-visible, [data-focus-visible])",
498
+ // Native :focus-visible counts only on elements RAC doesn't manage:
499
+ // react-aria's modality tracking is stricter than the browser's
500
+ // (e.g. focus restored from a menu after mouse-only use).
501
+ focusVisible:
502
+ "&:is(:focus-visible:not([data-rac]), [data-focus-visible])",
418
503
  disabled:
419
504
  "&:is(:disabled, [disabled], [data-disabled], [aria-disabled=true])",
505
+ // A dark-by-design surface (spread the exported `darkSurface` onto
506
+ // the bar element); scopes the focusRing/focusBorder flips. Never
507
+ // theme-relative: a future dark mode flips untagged defaults via
508
+ // token conditions, not markup.
509
+ onDark: '[data-surface="dark"] &',
420
510
  // High-contrast/forced-palette modes (e.g. Windows High Contrast), which
421
511
  // strip author backgrounds and box-shadows.
422
512
  forcedColors: "@media (forced-colors: active)",
@@ -960,9 +960,6 @@ export const shadows = {
960
960
  "2xl": {
961
961
  value: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
962
962
  },
963
- outline: {
964
- value: "0 0 0 3px rgba(66, 153, 225, 0.6)",
965
- },
966
963
  inner: {
967
964
  value: "inset 0 2px 4px 0 rgba(0,0,0,0.06)",
968
965
  },
package/src/system.ts CHANGED
@@ -43,3 +43,15 @@ export {
43
43
  Wrap,
44
44
  styled,
45
45
  } from "styled-system/jsx";
46
+
47
+ /**
48
+ * Spread onto a surface element that is dark by design — a black toolbar,
49
+ * a coloured sidebar header — so focus indicators inside it flip to their
50
+ * on-dark colours: `<header {...darkSurface}>`.
51
+ *
52
+ * Tag a surface's own designed luminance, never anything theme-relative
53
+ * (a future dark mode flips untagged defaults via token conditions, not
54
+ * markup). One inherited data attribute (the `onDark` condition): tag the
55
+ * bar, cover its controls; portalled overlays escape with the DOM.
56
+ */
57
+ export const darkSurface = { "data-surface": "dark" } as const;