@recursica/mui-adapter 0.21.1 → 0.22.0

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.
Files changed (65) hide show
  1. package/ARCHITECTURE.md +3 -0
  2. package/CHANGELOG.md +52 -0
  3. package/dist/index.d.ts +90 -35
  4. package/dist/mui-adapter.cjs +68 -68
  5. package/dist/mui-adapter.cjs.map +1 -1
  6. package/dist/mui-adapter.css +1 -1
  7. package/dist/mui-adapter.js +5660 -5582
  8. package/dist/mui-adapter.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/components/Accordion/ACCORDION_IMPLEMENTATION_NOTES.md +63 -0
  11. package/src/components/Accordion/Accordion.module.css +21 -2
  12. package/src/components/Accordion/Accordion.stories.tsx +38 -0
  13. package/src/components/Accordion/Accordion.tsx +27 -12
  14. package/src/components/AssistiveElement/ASSISTIVEELEMENT_IMPLEMENTATION_NOTES.md +59 -0
  15. package/src/components/AssistiveElement/AssistiveElement.module.css +8 -1
  16. package/src/components/AssistiveElement/AssistiveElement.tsx +17 -7
  17. package/src/components/Autocomplete/Autocomplete.tsx +18 -2
  18. package/src/components/Avatar/AVATAR_IMPLEMENTATION_NOTES.md +36 -0
  19. package/src/components/Avatar/Avatar.tsx +6 -9
  20. package/src/components/Badge/Badge.module.css +0 -12
  21. package/src/components/Badge/Badge.tsx +0 -4
  22. package/src/components/Card/Card.tsx +5 -5
  23. package/src/components/Checkbox/Checkbox.module.css +17 -21
  24. package/src/components/Checkbox/Checkbox.tsx +24 -18
  25. package/src/components/Chip/Chip.module.css +16 -7
  26. package/src/components/Chip/Chip.tsx +1 -1
  27. package/src/components/Flex/Flex.tsx +1 -1
  28. package/src/components/FormControlWrapper/FORMCONTROLWRAPPER_IMPLEMENTATION_NOTES.md +35 -0
  29. package/src/components/FormControlWrapper/FormControlWrapper.tsx +24 -8
  30. package/src/components/Grid/Grid.tsx +1 -1
  31. package/src/components/Group/Group.tsx +1 -1
  32. package/src/components/HoverCard/HoverCard.tsx +1 -1
  33. package/src/components/Label/Label.module.css +15 -2
  34. package/src/components/Label/Label.tsx +7 -6
  35. package/src/components/Menu/Menu.tsx +1 -1
  36. package/src/components/Modal/Modal.tsx +1 -1
  37. package/src/components/NumberInput/NumberInput.tsx +1 -1
  38. package/src/components/Pagination/Pagination.tsx +1 -1
  39. package/src/components/Panel/Panel.tsx +1 -1
  40. package/src/components/Radio/Radio.module.css +33 -38
  41. package/src/components/Radio/Radio.tsx +58 -44
  42. package/src/components/Radio/RadioGroup.tsx +5 -0
  43. package/src/components/SegmentedControl/SegmentedControl.module.css +0 -7
  44. package/src/components/SegmentedControl/SegmentedControl.tsx +4 -8
  45. package/src/components/Slider/Slider.tsx +1 -1
  46. package/src/components/Stack/Stack.tsx +1 -1
  47. package/src/components/Stepper/Stepper.tsx +3 -3
  48. package/src/components/Switch/SWITCH_IMPLEMENTATION_NOTES.md +123 -0
  49. package/src/components/Switch/Switch.module.css +152 -89
  50. package/src/components/Switch/Switch.tsx +140 -33
  51. package/src/components/Switch/SwitchGroup.tsx +37 -9
  52. package/src/components/Table/Table.tsx +1 -1
  53. package/src/components/Tabs/IMPLEMENTATION_NOTES.md +10 -0
  54. package/src/components/Tabs/Tabs.module.css +120 -43
  55. package/src/components/Tabs/Tabs.stories.tsx +5 -2
  56. package/src/components/Tabs/Tabs.tsx +1 -1
  57. package/src/components/Tabs/USAGE.md +27 -11
  58. package/src/components/TextField/TextField.tsx +1 -1
  59. package/src/components/TimePicker/TimePicker.tsx +1 -1
  60. package/src/components/Timeline/Timeline.tsx +1 -1
  61. package/src/components/Timeline/TimelineItem.tsx +2 -2
  62. package/src/components/Toast/Toast.tsx +4 -4
  63. package/src/components/Tooltip/Tooltip.tsx +1 -1
  64. package/src/components/Tree/Tree.tsx +1 -1
  65. package/src/types/mantine.d.ts +0 -7
@@ -17,6 +17,7 @@ import {
17
17
  FormControlLayout,
18
18
  type FormControlLayoutProps,
19
19
  } from "../FormControlLayout/FormControlLayout";
20
+ import { AssistiveElement } from "../AssistiveElement/AssistiveElement";
20
21
 
21
22
  import styles from "./Radio.module.css";
22
23
 
@@ -78,38 +79,36 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
78
79
  delete restRecord["size"];
79
80
  delete restRecord["color"];
80
81
 
82
+ // NOTE: MUI's actual prop is "classes", not "classNames" (that's Mantine's naming) — this
83
+ // was reading the wrong key and silently doing nothing. Fixed. Also "body"/"inner"/"radio"/
84
+ // "icon"/"labelWrapper"/"label" below were never real MUI `classes` slots (MUI's Radio only
85
+ // recognizes root/checked/disabled/colorPrimary/colorSecondary/sizeSmall, plus "input" from
86
+ // the underlying SwitchBase) — those slot names are Mantine's own Radio classNames API,
87
+ // copy-pasted over verbatim. Since none of them existed as real slots, MUI silently dropped
88
+ // them, so the circle (all its background/border CSS lived under `.radio`) never rendered —
89
+ // only the label showed. Fixed by keeping only the real slots here and drawing the circle as
90
+ // our own combined icon/checkedIcon node instead (see radioNode below), same pattern already
91
+ // used by Checkbox.
81
92
  const mergedClassNames: Partial<Record<string, string>> = {
82
93
  root: styles.root,
83
- body: styles.body,
84
- inner: styles.inner,
85
- radio: styles.radio,
86
- icon: styles.icon,
87
- labelWrapper: styles.labelWrapper,
88
- label: styles.label,
94
+ checked: styles.checked,
95
+ disabled: styles.disabled,
89
96
  };
90
97
 
91
- const classNamesProp = restRecord.classNames;
98
+ const classesProp = restRecord.classes;
92
99
  if (
93
- classNamesProp &&
94
- typeof classNamesProp === "object" &&
95
- !Array.isArray(classNamesProp)
100
+ classesProp &&
101
+ typeof classesProp === "object" &&
102
+ !Array.isArray(classesProp)
96
103
  ) {
97
- const o = classNamesProp as Partial<Record<string, string>>;
104
+ const o = classesProp as Partial<Record<string, string>>;
98
105
  mergedClassNames.root = o.root ? `${styles.root} ${o.root}` : styles.root;
99
- mergedClassNames.body = o.body ? `${styles.body} ${o.body}` : styles.body;
100
- mergedClassNames.inner = o.inner
101
- ? `${styles.inner} ${o.inner}`
102
- : styles.inner;
103
- mergedClassNames.radio = o.radio
104
- ? `${styles.radio} ${o.radio}`
105
- : styles.radio;
106
- mergedClassNames.icon = o.icon ? `${styles.icon} ${o.icon}` : styles.icon;
107
- mergedClassNames.labelWrapper = o.labelWrapper
108
- ? `${styles.labelWrapper} ${o.labelWrapper}`
109
- : styles.labelWrapper;
110
- mergedClassNames.label = o.label
111
- ? `${styles.label} ${o.label}`
112
- : styles.label;
106
+ mergedClassNames.checked = o.checked
107
+ ? `${styles.checked} ${o.checked}`
108
+ : styles.checked;
109
+ mergedClassNames.disabled = o.disabled
110
+ ? `${styles.disabled} ${o.disabled}`
111
+ : styles.disabled;
113
112
  }
114
113
 
115
114
  const classNameProp = restRecord.className as string | undefined;
@@ -144,17 +143,28 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
144
143
  return <>{roNode}</>;
145
144
  }
146
145
 
147
- // We omit Mui's sizing/coloring so we rely strictly on variables from Radio.module.css
146
+ // MUI's icon/checkedIcon fully replace the visual (they're the only child SwitchBase
147
+ // renders alongside the invisible native input) — so the ring + dot both have to live in
148
+ // one combined node per state, same pattern as Checkbox's icon/checkedIcon divs.
149
+ // sanitizedProps spreads FIRST: these render-critical props (icon/checkedIcon/className/
150
+ // classes/disabled/disableRipple/sx) must win over anything of the same name coming through
151
+ // from the caller, or they'd silently clobber the exact rendering this component depends on.
148
152
  const radioNode = (
149
153
  <MuiRadio
150
154
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
155
  ref={ref as any}
152
- icon={<RadioIcon />}
153
- checkedIcon={<RadioIcon className={styles.checked} />}
154
- className={finalClass}
156
+ {...(sanitizedProps as unknown as MuiRadioProps)}
157
+ icon={<div className={styles.radio} />}
158
+ checkedIcon={
159
+ <div className={`${styles.radio} ${styles.radioChecked}`}>
160
+ <RadioIcon className={styles.icon} />
161
+ </div>
162
+ }
163
+ className={!label ? `${finalClass} ${styles.inner}` : styles.inner}
155
164
  classes={mergedClassNames}
156
165
  disabled={readOnly || disabled}
157
- {...(sanitizedProps as unknown as MuiRadioProps)}
166
+ disableRipple
167
+ sx={label ? { padding: 0 } : undefined}
158
168
  />
159
169
  );
160
170
 
@@ -170,27 +180,31 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
170
180
  >
171
181
  {label as React.ReactNode}
172
182
  </label>
173
- {description && (
174
- <div
175
- className={styles.description}
176
- data-disabled={readOnly || disabled ? true : undefined}
177
- >
178
- {description}
179
- </div>
180
- )}
181
- {error && (
182
- <div
183
- className={styles.error}
184
- data-disabled={readOnly || disabled ? true : undefined}
183
+ {/* description/error are mutually exclusive — error takes precedence */}
184
+ {error ? (
185
+ <AssistiveElement
186
+ assistiveVariant="error"
187
+ assistiveWithIcon={false}
185
188
  >
186
189
  {error}
187
- </div>
190
+ </AssistiveElement>
191
+ ) : (
192
+ description && (
193
+ <AssistiveElement
194
+ assistiveVariant="help"
195
+ assistiveWithIcon={false}
196
+ >
197
+ {description}
198
+ </AssistiveElement>
199
+ )
188
200
  )}
189
201
  </div>
190
202
  </div>
191
203
  </div>
192
204
  ) : (
193
- radioNode
205
+ <div className={finalClass} style={style as React.CSSProperties}>
206
+ {radioNode}
207
+ </div>
194
208
  );
195
209
 
196
210
  if (formLayout) {
@@ -70,6 +70,7 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
70
70
  emptyValueComponent,
71
71
  value,
72
72
  defaultValue,
73
+ onChange,
73
74
  ...rest
74
75
  } = props;
75
76
  const sanitizedProps = filterStylingProps(rest, overStyled);
@@ -111,6 +112,10 @@ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
111
112
  {...(sanitizedProps as unknown as MuiRadioGroupProps)}
112
113
  value={value}
113
114
  defaultValue={defaultValue}
115
+ // MUI natively calls onChange(event, value); the Recursica contract is
116
+ // single-argument (value only), matching Mantine's native RadioGroup
117
+ // onChange (the cross-adapter source of truth) — normalize here.
118
+ onChange={onChange ? (_event, val) => onChange(val) : undefined}
114
119
  className={styles.groupRoot}
115
120
  data-layout={formLayout}
116
121
  >
@@ -98,13 +98,6 @@
98
98
  display: flex;
99
99
  align-items: center;
100
100
  justify-content: center;
101
- }
102
-
103
- /* Mantine natively wraps label contents in an innerLabel span */
104
- .root .label :global(.mantine-SegmentedControl-innerLabel) {
105
- display: flex;
106
- align-items: center;
107
- justify-content: center;
108
101
  gap: var(
109
102
  --recursica_ui-kit_components_segmented-control-item_properties_item_icon-text-gap
110
103
  );
@@ -38,7 +38,6 @@ function useSegmentedControlClassNames(restRecord: Record<string, unknown>): {
38
38
  root: styles.root,
39
39
  control: styles.control,
40
40
  label: styles.label,
41
- indicator: styles.indicator,
42
41
  };
43
42
 
44
43
  const classNamesProp = restRecord.classNames;
@@ -55,9 +54,6 @@ function useSegmentedControlClassNames(restRecord: Record<string, unknown>): {
55
54
  mergedClassNames.label = o.label
56
55
  ? `${styles.label} ${o.label}`
57
56
  : styles.label;
58
- mergedClassNames.indicator = o.indicator
59
- ? `${styles.indicator} ${o.indicator}`
60
- : styles.indicator;
61
57
  }
62
58
 
63
59
  const classNameProp = restRecord.className as string | undefined;
@@ -101,6 +97,10 @@ const _SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
101
97
  return (
102
98
  <MuiSegmentedControl
103
99
  ref={ref}
100
+ {...(sanitizedProps as Omit<
101
+ MuiSegmentedControlProps,
102
+ "variant" | "size" | "value" | "onChange"
103
+ >)}
104
104
  className={stylingParams.className}
105
105
  classes={stylingParams.classNames}
106
106
  orientation={orientation}
@@ -113,10 +113,6 @@ const _SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
113
113
  typeof MuiSegmentedControl
114
114
  >["onChange"]
115
115
  }
116
- {...(sanitizedProps as Omit<
117
- MuiSegmentedControlProps,
118
- "variant" | "size" | "value" | "onChange"
119
- >)}
120
116
  >
121
117
  {data.map((item) => {
122
118
  const itemValue = typeof item === "string" ? item : item.value;
@@ -235,6 +235,7 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
235
235
 
236
236
  <div className={styles.sliderTrackWrapper}>
237
237
  <MuiSlider
238
+ {...(sanitizedProps as unknown as MuiSliderProps)}
238
239
  classes={mergedClassNames}
239
240
  disabled={disabled}
240
241
  value={resolvedValue}
@@ -257,7 +258,6 @@ export const Slider = forwardRef<HTMLDivElement, SliderProps>(
257
258
  ) => React.ReactNode)
258
259
  : undefined
259
260
  }
260
- {...(sanitizedProps as unknown as MuiSliderProps)}
261
261
  />
262
262
  </div>
263
263
 
@@ -29,10 +29,10 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(function Stack(
29
29
  return (
30
30
  <MUIStack
31
31
  ref={ref}
32
+ {...safeProps}
32
33
  spacing={resolvedGap}
33
34
  alignItems={align}
34
35
  justifyContent={justify}
35
- {...safeProps}
36
36
  >
37
37
  {children}
38
38
  </MUIStack>
@@ -48,6 +48,7 @@ export const Stepper = forwardRef<HTMLDivElement, StepperProps>(
48
48
  <MuiStepper
49
49
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
50
  ref={ref as any}
51
+ {...(sanitizedProps as MuiStepperProps)}
51
52
  orientation={orientation}
52
53
  connector={
53
54
  <MuiStepConnector
@@ -62,7 +63,6 @@ export const Stepper = forwardRef<HTMLDivElement, StepperProps>(
62
63
  classes={{
63
64
  root: styles.steps,
64
65
  }}
65
- {...(sanitizedProps as MuiStepperProps)}
66
66
  />
67
67
  </div>
68
68
  );
@@ -79,9 +79,9 @@ export const Step = forwardRef<HTMLDivElement, StepProps>(
79
79
  return (
80
80
  <MuiStep
81
81
  ref={ref}
82
+ {...(filterStylingProps(rest, overStyled) as MuiStepProps)}
82
83
  classes={{ root: styles.step }}
83
84
  className={className || ""}
84
- {...(filterStylingProps(rest, overStyled) as MuiStepProps)}
85
85
  />
86
86
  );
87
87
  },
@@ -116,6 +116,7 @@ export const StepLabel = forwardRef<HTMLDivElement, StepLabelProps>(
116
116
  return (
117
117
  <MuiStepLabel
118
118
  ref={ref}
119
+ {...(filterStylingProps(rest, overStyled) as MuiStepLabelProps)}
119
120
  className={className || ""}
120
121
  classes={{
121
122
  label: styles.stepLabel,
@@ -128,7 +129,6 @@ export const StepLabel = forwardRef<HTMLDivElement, StepLabelProps>(
128
129
  rest.optional
129
130
  )
130
131
  }
131
- {...(filterStylingProps(rest, overStyled) as MuiStepLabelProps)}
132
132
  />
133
133
  );
134
134
  },
@@ -0,0 +1,123 @@
1
+ # Switch Implementation Notes
2
+
3
+ Decisions and gotchas specific to wrapping `@mui/material`'s `Switch` for the UI Kit's Switch.
4
+
5
+ ## Label/description/error
6
+
7
+ MUI's `Switch` has no built-in label/description/error rendering — built by hand here.
8
+ The label is a plain `<label>`; description/error render through the shared
9
+ `AssistiveElement` component (`assistiveWithIcon={false}`) instead of local styled divs,
10
+ so they inherit real design tokens rather than hardcoded Mantine defaults.
11
+
12
+ ## `Switch.Group` compound export
13
+
14
+ `SwitchGroup.tsx` existed but wasn't attached as `Switch.Group`. Fixed.
15
+
16
+ ## `SwitchGroup` didn't control its children — now fixed
17
+
18
+ MUI's `FormGroup` has no controlled-value concept — no equivalent to Mantine's real
19
+ `Switch.Group`, which threads a `string[]` of checked values and `checked`/`onChange` to each
20
+ child via context. This adapter's `SwitchGroup` used to destructure and discard
21
+ `value`/`defaultValue`/`onChange` entirely — purely cosmetic.
22
+
23
+ Fixed the same way this adapter's own `Checkbox`/`CheckboxGroup` already solved the identical
24
+ problem (a closer, in-repo precedent than `Accordion`, which the notes previously pointed to
25
+ without checking Checkbox first): a `SwitchGroupContext` provider on `SwitchGroup` carrying
26
+ `value`/`onChange`/`name`/`readOnly`, consumed by `Switch` to compute `isChecked` from array
27
+ membership and to translate each toggle into an add/remove against that array before calling
28
+ the group's `onChange`. Standalone (ungrouped) `Switch` usage is untouched — the group-derived
29
+ `checked`/`onChange` are only spread onto `<MuiSwitch>` when a `SwitchGroupContext` is present.
30
+ Verified live: initial `value`/`defaultValue` now renders correctly, clicking a sibling
31
+ switch toggles only its own entry in the array, and `readOnly` on the group disables every
32
+ child — all confirmed against the existing `SwitchGroup.stories.tsx` scenarios via Playwright.
33
+
34
+ ## Thumb icon (check/x glyph)
35
+
36
+ MUI's `icon`/`checkedIcon` props are the _same mechanism_ MUI uses internally to render its
37
+ own default circular thumb (`icon: <the circle>, checkedIcon: <the circle>`, then
38
+ spread-overwritten by whatever public `icon`/`checkedIcon` the caller passes) — confirmed
39
+ against MUI's own `Switch.js`/`SwitchBase.js` source. Passing a raw glyph into them replaces
40
+ the whole thumb circle, it doesn't add an icon inside it.
41
+
42
+ Fixed the same way `Checkbox` in this adapter already solved the identical problem: build our
43
+ own combined node — a `.thumb`-styled `<span>` wrapping `.thumbIconWrapper` with both
44
+ `CheckIcon`/`CloseIcon` (inline SVGs, same paths as Mantine's, copied rather than imported
45
+ since mui-adapter doesn't depend on `@mantine/core`) — and pass _that same node reference_ as
46
+ both `icon` and `checkedIcon` (mirroring MUI's own `icon={icon} checkedIcon={icon}` pattern).
47
+ Same reference means it never unmounts/remounts on toggle, which is what lets the two glyphs
48
+ crossfade via CSS opacity instead of a hard swap — matching Mantine's `thumbIcon` behavior.
49
+ Verified against a live Storybook via adapter-tester (geometry + SVG count now match Mantine
50
+ 1:1 across Default/Static Variations stories).
51
+
52
+ ## Track/thumb geometry, checked-state selectors, focus ring
53
+
54
+ Original CSS was a copy of mantine-adapter's file, but MUI's DOM is structurally different
55
+ (`track`/`switchBase` are siblings, not nested; MUI hardcodes its own root box, hit-area
56
+ padding, and thumb travel distance for its own 20px default thumb). Fixed by driving
57
+ `.switchRoot`/`.switchBase`/`.thumb` sizing entirely from our tokens, replacing
58
+ `input:checked + .track` sibling selectors (never matched in MUI's DOM) with `:has()`-scoped
59
+ selectors, adding an explicit focus ring from `--recursica_brand_states_focus_*` tokens, and
60
+ hardcoding `color="default"` so MUI's own primary-blue hover/focus circle never activates.
61
+ Verified via live measurement (adapter-tester): track/thumb box dimensions now match Mantine
62
+ exactly (64×28 track, 20×20 thumb).
63
+
64
+ Two gotchas worth flagging for future MUI adapter work:
65
+
66
+ 1. `classes.root` targets MUI's own switch span, but this component's outer wrapper `<div>`
67
+ (switch+label row) also uses `.root` — sizing one squeezes the other. Split into
68
+ `.switchRoot` (MUI switch only) vs `.root` (outer wrapper, cursor only).
69
+ 2. CSS Modules hashes literal MUI state classes (`Mui-checked`, `Mui-focusVisible`) unless
70
+ wrapped in `:global()` — same pattern already used in `Accordion`/`Button`.
71
+
72
+ ## Disabled-state opacity didn't reach the thumb
73
+
74
+ **Found 2026-08-14, reported by Matt:** the Read Only story's thumb stayed full-color/opacity
75
+ while Mantine's dimmed along with the track. Same root cause as the geometry work above:
76
+ Mantine nests `.thumb` inside `.track`, so an `opacity` on `.track` visually dims the thumb for
77
+ free; MUI has them as siblings, so the disabled-opacity selectors (which only targeted
78
+ `.track`) never touched the thumb at all. Fixed by adding `.thumb` to both selectors alongside
79
+ `.track`. Verified live: thumb now dims identically to Mantine in both the plain-disabled and
80
+ disabled-checked cases.
81
+
82
+ ## Track opacity — MUI's own always-on defaults, unrelated to our tokens
83
+
84
+ MUI's `SwitchTrack` styled component sets `opacity: 0.38` unconditionally (its own faded-pill
85
+ baseline), and separately `.Mui-checked + .track { opacity: 0.5 }` when checked — neither has
86
+ an equivalent in our token schema, and neither was being cancelled. Result: every enabled track
87
+ was dimmed by MUI's own opacity, most visibly on the checked state (deep red washed out to
88
+ pink). Fixed with an explicit `opacity: 1` in both the base `.track` rule and the
89
+ `.root:has(input:checked) .track` rule (needed in both — MUI's checked-specific selector is
90
+ more specific than its own base rule, so overriding only the base wasn't enough). Disabled-state
91
+ opacity is unaffected; those selectors are already more specific than either of these.
92
+
93
+ ## Focus ring was on the thumb's hit-area circle, not the track
94
+
95
+ `.switchBase:global(.Mui-focusVisible)` is MUI's actual focusable element, but it's sized to
96
+ the thumb's 20×20 hit area, not the 64×28 track — so the ring drew a small circle instead of
97
+ outlining the switch. Moved to `.switchBase:global(.Mui-focusVisible) ~ .track` (siblings under
98
+ `.switchRoot`, switchBase first) so the ring renders on the track itself. Mantine-adapter had
99
+ the mirror-image bug: no override at all, so Mantine's own default outline (blue) showed on the
100
+ hidden `<input>` — fixed there with `.root input:focus-visible { outline: none }` +
101
+ `.root input:focus-visible + .track { ... }`, drawing the same Recursica-token ring in the same
102
+ place.
103
+
104
+ ## `classNames` prop-name bug — external override was silently inert
105
+
106
+ The external override merge read `restRecord.classNames`, but MUI's actual prop is `classes`
107
+ (that's Mantine's naming, copied over by mistake) — fixed. Note only `root`/`track`/
108
+ `switchBase` are real MUI `classes` slots; `body`/`trackLabel`/`labelWrapper`/`label` are this
109
+ component's own hardcoded elements (styled directly in JSX, not via MUI's `classes` prop) and
110
+ remain unreachable through this override path — a separate, pre-existing gap, not something the
111
+ prop-name fix could address.
112
+
113
+ ## `SwitchGroup` side-by-side layout always rendered as if stacked
114
+
115
+ **Found 2026-08-14, reported by Matt:** the `SideBySideLayout` story showed the group label
116
+ above the switches instead of beside them. Root cause: `SwitchGroup` passed the switch-item's
117
+ own inline label max-width token (200px) as the group's `controlMaxWidth` — but the mandatory
118
+ side-by-side label column is a fixed 224px, wider than that cap, so the label always overflowed
119
+ onto its own line regardless of layout mode. Reproduced identically in Mantine (same code
120
+ pattern, not a MUI-only bug). Fixed by not capping the group's control width at all — each
121
+ switch's own label already wraps at 200px via `.labelWrapper`, so no group-level cap is needed.
122
+ Verified live in both adapters: side-by-side now shows the label column at left with the
123
+ switches beside it; stacked layout unaffected.