@codecademy/gamut 68.6.0 → 68.6.1-alpha.edab62.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 (35) hide show
  1. package/agent-tools/.claude-plugin/marketplace.json +16 -0
  2. package/agent-tools/.claude-plugin/plugin.json +7 -0
  3. package/agent-tools/.cursor-plugin/plugin.json +7 -0
  4. package/agent-tools/DESIGN.Codecademy.md +643 -0
  5. package/agent-tools/DESIGN.LXStudio.md +444 -0
  6. package/agent-tools/DESIGN.Percipio.md +435 -0
  7. package/agent-tools/DESIGN.md +1 -0
  8. package/agent-tools/agents/.gitkeep +0 -0
  9. package/agent-tools/commands/gamut-review.md +170 -0
  10. package/agent-tools/guidelines/components/buttons.md +44 -0
  11. package/agent-tools/guidelines/components/overview.md +44 -0
  12. package/agent-tools/guidelines/foundations/color.md +86 -0
  13. package/agent-tools/guidelines/foundations/modes.md +45 -0
  14. package/agent-tools/guidelines/foundations/spacing.md +66 -0
  15. package/agent-tools/guidelines/foundations/typography.md +50 -0
  16. package/agent-tools/guidelines/overview.md +35 -0
  17. package/agent-tools/guidelines/setup.md +42 -0
  18. package/agent-tools/rules/accessibility.mdc +69 -0
  19. package/agent-tools/skills/gamut-accessibility/SKILL.md +239 -0
  20. package/agent-tools/skills/gamut-color-mode/SKILL.md +99 -0
  21. package/agent-tools/skills/gamut-system-props/SKILL.md +173 -0
  22. package/agent-tools/skills/gamut-testing/SKILL.md +181 -0
  23. package/agent-tools/skills/gamut-theming/SKILL.md +113 -0
  24. package/agent-tools/skills/gamut-typography/SKILL.md +123 -0
  25. package/bin/commands/plugin/install.mjs +173 -0
  26. package/bin/commands/plugin/list.mjs +105 -0
  27. package/bin/commands/plugin/remove.mjs +116 -0
  28. package/bin/commands/plugin/update.mjs +49 -0
  29. package/bin/gamut.mjs +92 -0
  30. package/bin/lib/claude.mjs +52 -0
  31. package/bin/lib/cursor.mjs +40 -0
  32. package/bin/lib/figma.mjs +49 -0
  33. package/bin/lib/resolve-plugin-dir.mjs +38 -0
  34. package/bin/lib/run-command.mjs +22 -0
  35. package/package.json +11 -8
@@ -0,0 +1,239 @@
1
+ ---
2
+ name: gamut-accessibility
3
+ description: Use this skill when implementing or auditing accessibility in Gamut UIs — interactive widgets, forms, focus and keyboard flows, live regions, ARIA, or contrast — including fixes for screen readers, WCAG issues, or Gamut + React Aria patterns.
4
+ ---
5
+
6
+ # Gamut Accessibility
7
+
8
+ Source: `@codecademy/gamut` — interactive components wrap `react-aria-components`, `@react-aria/interactions`, and `react-focus-on`.
9
+
10
+ ---
11
+
12
+ ## General rules
13
+
14
+ Use ARIA sparingly and only when it's the best option available.
15
+
16
+ ### Prefer HTML over ARIA
17
+
18
+ Unnecessary ARIA can cause harm. If a native HTML element or attribute with the semantics and behavior you need already exists, use it. Reach for ARIA only when native HTML is genuinely insufficient for the pattern.
19
+
20
+ ### A Role is a Promise
21
+
22
+ ARIA roles modify the accessibility tree and _imply_ behavior. Always ensure that the implied keyboard behavior, focusability, and interactivity exists when a role is used.
23
+
24
+ ### ARIA can both cloak and enhance
25
+
26
+ ARIA can augment native semantics (`aria-pressed` on a `<button>`) or override them entirely (`role="menuitem"` on an `<a>`). Both capabilities are powerful and dangerous. Override only when native HTML genuinely doesn't fit the pattern; when augmenting, don't contradict the native semantics.
27
+
28
+
29
+ ### Align accessible names with visible copy
30
+
31
+ Prefer wiring names through visible text and native `<label>` / control text / `alt` over using `aria-label`. Point `aria-labelledby` at the visible heading or label that should define the name if it's not possible to name elements from their content. Use bare `aria-label` when there is no suitable visible label.
32
+
33
+ ### Treat missing visible labels as a design smell
34
+
35
+ When there is no visible text for a nameable element, consider this a sign that the content design could be improved, but not a requirement that it is changed. This is not an accessibility violation.
36
+
37
+ ```html
38
+ <!-- smell: this list has no conceptual name, so we have to create one using ARIA -->
39
+ <ul aria-label="List heading">
40
+ <li>...</li>
41
+ </ul>
42
+
43
+ <!-- better: the list's name is visible and can be used for its accessible name -->
44
+ <h2 id="list-name">List heading</h2>
45
+ <ul aria-labelledby="list-name">
46
+ <li>...</li>
47
+ </ul>
48
+ ```
49
+
50
+ ---
51
+
52
+ ## How Gamut handles accessibility
53
+
54
+ Gamut wraps React Aria for most interactive widget primitives. Roving tabindex, keyboard event handling, and ARIA attribute management are implemented for you in `<Tabs>`, `<Dialog>`, `<Modal>`, and form components. The developer's responsibilities are: supply accessible names, wire labels to inputs, and avoid overriding what the library already provides.
55
+
56
+ ---
57
+
58
+ ## Component reference
59
+
60
+ ### Button / IconButton
61
+
62
+ `<Button>` renders a semantic `<button>` — no `role` needed. For icon-only buttons use `<IconButton>` with a `tip` prop; `tip` becomes the button's `aria-label`. Do not use `<div onClick>` or `<a>` without `href` for actions.
63
+
64
+ ```tsx
65
+ // correct
66
+ <IconButton icon={DeleteIcon} tip="Delete item" onClick={handleDelete} />
67
+
68
+ // wrong — no accessible name, no keyboard semantics
69
+ <div onClick={handleDelete}><DeleteIcon /></div>
70
+ ```
71
+
72
+ ### Dialog / Modal
73
+
74
+ Both use `<FocusTrap>` (`react-focus-on`) internally. Focus locks to the dialog on open and returns to the trigger on close. Escape closes the dialog automatically.
75
+
76
+ Always provide an accessible name. **Prefer `aria-labelledby`** when a visible heading or title defines the dialog name; use **`aria-label`** only when there is no suitable visible title string.
77
+
78
+ ```tsx
79
+ <Dialog aria-labelledby="confirm-title">
80
+ <h2 id="confirm-title">Confirm deletion</h2>
81
+ </Dialog>
82
+ ```
83
+
84
+ ### Alert
85
+
86
+ Renders with `aria-live="polite"` and `role="status"` by default. Override with `aria-live="assertive"` only for time-sensitive errors requiring immediate interruption. Do not nest `<Alert>` inside another live region.
87
+
88
+ ### Tabs
89
+
90
+ Built on `react-aria-components`. Follows the [ARIA Tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/): arrow keys navigate tabs, Tab moves focus into the active panel, Home/End jump to first/last tab. The tablist is a composite — only the active tab is in the tab sequence (roving tabindex). No manual `aria-selected` or keyboard handling needed.
91
+
92
+ ### Forms
93
+
94
+ `<FormGroup>` + `<FormGroupLabel>` + input element is the complete accessible pattern:
95
+
96
+ ```tsx
97
+ <FormGroup htmlFor="email-input" description="Used for login" error={errors.email}>
98
+ <FormGroupLabel htmlFor="email-input">Email</FormGroupLabel>
99
+ <Input id="email-input" type="email" />
100
+ </FormGroup>
101
+ ```
102
+
103
+ - `htmlFor` on `<FormGroupLabel>` and `id` on the input must match
104
+ - `error` prop on `<FormGroup>` renders into an `aria-live="assertive"` region automatically
105
+ - `description` prop renders into an `aria-live="polite"` region automatically
106
+ - Do not add `aria-describedby` or `aria-errormessage` manually — `<FormGroup>` manages these
107
+
108
+ **Checkbox / Radio**: `htmlFor` is required; the input is visually hidden via `screenReaderOnly` from `@codecademy/gamut-styles` but remains in the accessibility tree.
109
+
110
+ ### InfoTip
111
+
112
+ Unlike `<IconButton>`, `<InfoTip>` has no automatic label fallback. Always provide `ariaLabel` or `ariaLabelledby` (camelCase props).
113
+
114
+ ```tsx
115
+ <InfoTip ariaLabel="More information about billing" />
116
+ ```
117
+
118
+ ### SkipToContent
119
+
120
+ Include `<SkipToContent>` as the first focusable element in the page shell. The main content region must have a matching `id` for the skip link to target.
121
+
122
+ ### Text (screen-reader-only)
123
+
124
+ `<Text screenreader>` is the supported pattern for visually hidden but announced content. `<HiddenText>` is deprecated.
125
+
126
+ ```tsx
127
+ <Text screenreader>Loading results…</Text>
128
+ ```
129
+
130
+ ---
131
+
132
+ ## Focus management
133
+
134
+ `<FocusTrap>` is available for custom overlay patterns not covered by `<Dialog>` or `<Modal>`.
135
+
136
+ Key props:
137
+ - `active` — enable/disable the trap dynamically
138
+ - `onEscapeKey` — close handler
139
+ - `onClickOutside` — dismiss on outside click
140
+ - `allowPageInteraction` — permit scrolling outside the trap without closing
141
+
142
+ Always return focus to the trigger on close. React Aria components do this automatically; custom implementations must store a ref to the trigger and call `.focus()` on unmount.
143
+
144
+ ---
145
+
146
+ ## Composite widgets and managed focus
147
+
148
+ ARIA composite roles (`listbox`, `menu`, `tree`, `grid`, `tablist`) use **managed focus**: only one element in the composite is in the tab sequence at a time. Tab moves focus to the next element outside the composite; arrow keys move focus within it.
149
+
150
+ Implementation pattern — roving tabindex:
151
+ - Set `tabIndex={0}` on the currently active item
152
+ - Set `tabIndex={-1}` on all other items
153
+ - On arrow key, update which item holds `tabIndex={0}` and call `.focus()` on it
154
+
155
+ Gamut's `<Tabs>` implements this via React Aria. If you build a custom composite widget, you must implement roving tabindex manually. A flat `tabIndex={0}` on every item is wrong — it puts every item in the sequential tab order, which is not the composite pattern.
156
+
157
+ ---
158
+
159
+ ## Device-independent events
160
+
161
+ Use `click` for activation, not `mousedown`. `click` fires for both pointer and keyboard (Space/Enter on native buttons and links). `mousedown` is pointer-only and silently breaks keyboard access.
162
+
163
+ For custom elements with `role="button"`, `click` alone is not sufficient — it only fires on keyboard when the element is a native `<button>` or `<a href>`. You must also handle `keydown` for Space and Enter explicitly:
164
+
165
+ ```tsx
166
+ const handleKeyDown = (e: React.KeyboardEvent) => {
167
+ if (e.key === ' ' || e.key === 'Enter') {
168
+ e.preventDefault();
169
+ handleActivation();
170
+ }
171
+ };
172
+ ```
173
+
174
+ This is another reason to use `<Button>` — it handles this correctly and you don't.
175
+
176
+ ---
177
+
178
+ ## Live regions
179
+
180
+ | Scenario | Pattern |
181
+ |---|---|
182
+ | Status updates, non-critical notifications | `aria-live="polite"` |
183
+ | Form validation errors on submit | `aria-live="assertive"` |
184
+ | Frequently updating counts or progress | `aria-live="polite"` + `aria-atomic="true"` |
185
+
186
+ Inject live regions into the DOM before they need to announce. A region added simultaneously with its first announcement may be ignored by some assistive technologies.
187
+
188
+ `<FormGroup>` already uses `aria-live="assertive"` for the `error` prop. Don't elevate unrelated inline errors to assertive — reserve it for interruptions the user didn't directly trigger.
189
+
190
+ ---
191
+
192
+ ## ARIA authoring rules
193
+
194
+ - **No redundant roles**: don't set `role="button"` on `<button>` or `role="heading"` on `<h2>`
195
+ - **aria-hidden cascades**: placing `aria-hidden="true"` on a parent removes the entire subtree from the accessibility tree, including focusable descendants — never put it on an ancestor of a focusable element
196
+ - **role="presentation" and aria-hidden on focusable elements**: both are prohibited on elements that can receive focus — they remove semantics while leaving the element keyboard-reachable, producing an operable but unnamed control
197
+ - **Labelling vs describing**: `aria-label` / `aria-labelledby` name the control. `aria-describedby` provides supplementary context. Both can coexist on the same element
198
+ - **Required fields**: use `aria-required="true"` or the HTML `required` attribute. Visual asterisks must have an explanatory text string visible on the page; the asterisk glyph itself should carry `aria-hidden="true"` — `<FormGroupLabel>` already handles this
199
+ - **display:none vs aria-hidden**: elements with `display:none` are already removed from the accessibility tree; adding `aria-hidden` is redundant. Use `aria-hidden="true"` only when an element is visually present but should be hidden from assistive technology
200
+
201
+ ---
202
+
203
+ ## Color and contrast
204
+
205
+ Gamut's `ColorMode` and semantic color tokens are designed to meet WCAG AA (4.5:1 for normal text, 3:1 for large text and non-text UI components). Hardcoding hex values bypasses this guarantee and breaks in dark mode. See the `gamut-color-mode` skill for semantic token usage.
206
+
207
+ Non-text contrast (focus indicators, input borders, icon-only controls) requires a minimum 3:1 ratio against adjacent colors per WCAG 1.4.11.
208
+
209
+ ---
210
+
211
+ ## Testing checklist
212
+
213
+ - [ ] Full keyboard navigation: every interactive element reachable and operable without a mouse
214
+ - [ ] Focus is always visible and never lost or unexpectedly trapped
215
+ - [ ] Dialogs trap focus correctly; Escape closes; focus returns to the trigger
216
+ - [ ] Composite widgets (tabs, menus, listboxes) use arrow keys internally, not Tab
217
+ - [ ] All form inputs have programmatically associated labels (not placeholder-only)
218
+ - [ ] Form errors are announced via live region
219
+ - [ ] Icon-only controls have accessible names
220
+ - [ ] No content relies solely on color to convey meaning
221
+ - [ ] Screen reader matrix: VoiceOver + Safari (iOS), VoiceOver + Chrome (macOS), NVDA + Chrome (Windows)
222
+ - [ ] 200% zoom: layout intact, no content overflow or disappearance
223
+
224
+ ---
225
+
226
+ ## Common anti-patterns
227
+
228
+ | Anti-pattern | Fix |
229
+ |---|---|
230
+ | `<div onClick={…}>` for actions | `<Button>` |
231
+ | `placeholder` as the only label | `<FormGroupLabel>` with matching `htmlFor`/`id` |
232
+ | `aria-label` on a `<div>` with no role | Add a meaningful `role` or use a semantic element |
233
+ | `role="button"` without Space/Enter handlers | Use `<Button>`, or add `keydown` handler |
234
+ | `tabIndex={0}` on every item in a composite | Roving tabindex: `0` on active item, `-1` on rest |
235
+ | Tooltip as the only accessible name for a control | Set `aria-label` directly on the control as well |
236
+ | `aria-hidden="true"` on a focusable element | Also remove from tab order (`tabIndex={-1}`) or restructure |
237
+ | `mousedown` for activation | Use `click` |
238
+ | `outline: none` without a replacement | Use Gamut's built-in focus styles |
239
+ | Multiple `aria-live` regions for the same content stream | One region per logical stream; reuse it across updates |
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: gamut-color-mode
3
+ description: Use this skill when implementing light/dark behavior, semantic color aliases, the Background component for contrast-safe surfaces, or color-mode hooks in Gamut — including replacing hardcoded hex, fixing mode bugs, or reviewing color usage.
4
+ ---
5
+
6
+ # Gamut ColorMode
7
+
8
+ Source: `@codecademy/gamut-styles` — [`ColorMode.tsx`](https://github.com/Codecademy/gamut/blob/main/packages/gamut-styles/src/ColorMode.tsx)
9
+
10
+ ## Overview
11
+
12
+ Gamut's color system uses **semantic aliases** instead of raw color tokens. This means components automatically adapt across light and dark modes without configuration.
13
+
14
+ ### Semantic color aliases
15
+
16
+ | Alias | Purpose |
17
+ |---|---|
18
+ | `text` | Standard text color for all type |
19
+ | `background` | Base background color |
20
+ | `primary` | Interactive elements with primary action |
21
+ | `secondary` | Interactive elements with secondary action |
22
+
23
+ **Key principle**: Always use these aliases in component styles — never hardcode specific color tokens like `navy-400` for anything that needs to change per mode.
24
+
25
+ ## `<ColorMode />`
26
+
27
+ Wraps content in a color mode context. Nest these to create scoped mode regions.
28
+
29
+ ```tsx
30
+ import { ColorMode } from '@codecademy/gamut-styles';
31
+
32
+ // Explicit light or dark mode
33
+ const Page = ({ children }) => (
34
+ <ColorMode mode="light">{children}</ColorMode>
35
+ );
36
+
37
+ // Follows the user's OS preference (prefers-color-scheme)
38
+ const Page = ({ children }) => (
39
+ <ColorMode mode="system">{children}</ColorMode>
40
+ );
41
+ ```
42
+
43
+ **Props**: `mode="light" | "dark" | "system"`
44
+
45
+ ## `<Background />`
46
+
47
+ Use `<Background>` — not raw `bg` prop — whenever you need a specific background color for a section (card, landing page, hero, etc.). It **automatically switches the color mode** to ensure the content inside meets contrast requirements.
48
+
49
+ ```tsx
50
+ import { Background } from '@codecademy/gamut-styles';
51
+
52
+ // Single background — mode switches automatically if needed
53
+ const Card = ({ children }) => (
54
+ <Background bg="hyper">{children}</Background>
55
+ );
56
+
57
+ // Nested backgrounds — each creates its own accessible color context
58
+ const Page = () => (
59
+ <Background bg="black">
60
+ <Background bg="paleGreen" />
61
+ </Background>
62
+ );
63
+ ```
64
+
65
+ ### `background-current` CSS variable
66
+
67
+ When `<Background>` is rendered, it sets a `background-current` CSS variable on the theme. Use this to reference an ancestor's background color (e.g. for simulating transparency or masking content).
68
+
69
+ ## Color mode hooks
70
+
71
+ ```tsx
72
+ import { useColorMode, useCurrentMode, usePrefersDarkMode } from '@codecademy/gamut-styles';
73
+
74
+ // Returns [currentModeKey, currentModeColors, allModes]
75
+ const [current, currentColors, modes] = useColorMode();
76
+
77
+ // Returns just the active mode key: "light" | "dark"
78
+ const current = useCurrentMode();
79
+
80
+ // Returns boolean from window.matchMedia('(prefers-color-scheme: dark)')
81
+ const prefersDark = usePrefersDarkMode();
82
+ ```
83
+
84
+ ## Decision guide
85
+
86
+ | Need | Use |
87
+ |---|---|
88
+ | Set a page or section to a specific mode | `<ColorMode mode="light|dark|system">` |
89
+ | Place content on a colored background with automatic contrast | `<Background bg="...">` |
90
+ | Read the current mode in JavaScript | `useCurrentMode()` |
91
+ | Access all modes and their color variables | `useColorMode()` |
92
+ | Detect OS dark mode preference | `usePrefersDarkMode()` |
93
+ | Access full emotion theme | `useTheme()` from `@emotion/react` |
94
+
95
+ ## Common mistakes to avoid
96
+
97
+ - Do not use raw color tokens (e.g. `color: 'navy-400'`) for text, backgrounds, or borders that need to be accessible across modes — use semantic aliases instead.
98
+ - Do not use raw `bg` prop for colored section backgrounds that contain text or interactive elements — use `<Background>` so contrast is guaranteed.
99
+ - Do not manually toggle modes based on `usePrefersDarkMode()` — use `<ColorMode mode="system">` instead.
@@ -0,0 +1,173 @@
1
+ ---
2
+ name: gamut-system-props
3
+ description: Use this skill when building or refactoring styled Gamut components that need layout, spacing, color, border, background, typography, positioning, grid, flex, shadow, or responsive values from @codecademy/gamut-styles — including composing system prop groups with variance.
4
+ ---
5
+
6
+ # Gamut System Props
7
+
8
+ Source: `@codecademy/gamut-styles` — [`variance/config.ts`](https://github.com/Codecademy/gamut/blob/main/packages/gamut-styles/src/variance/config.ts)
9
+
10
+ ## Overview
11
+
12
+ System props are strongly-typed, theme-connected CSS prop groups from `@codecademy/gamut-styles`. They give styled components a consistent, responsive API. All props are built on top of `@codecademy/variance`.
13
+
14
+ Each prop group has:
15
+ - **`properties`**: The CSS properties it controls
16
+ - **`scale`**: Token scale it's restricted to (theme colors, spacing values, etc.)
17
+ - **`transform`**: Optional transform applied before output (e.g. `width={0.5}` → `width: 50%`)
18
+
19
+ ## Basic usage
20
+
21
+ ```tsx
22
+ import styled from '@emotion/styled';
23
+ import { system } from '@codecademy/gamut-styles';
24
+
25
+ // Apply a single group
26
+ const Box = styled.div(system.layout);
27
+
28
+ // Compose multiple groups
29
+ import { variance } from '@codecademy/variance';
30
+
31
+ const FlexBox = styled.div(
32
+ variance.compose(system.layout, system.flex, system.space)
33
+ );
34
+
35
+ <FlexBox display="flex" p={16} gap={8} width="100%" />;
36
+ ```
37
+
38
+ ## Prop groups
39
+
40
+ ### `system.layout`
41
+
42
+ Controls dimensions, display, overflow, and container behavior.
43
+
44
+ ```tsx
45
+ const Box = styled.div(system.layout);
46
+
47
+ <Box display="flex" width="50%" height="300px" verticalAlign="middle" />;
48
+ ```
49
+
50
+ Key props: `display`, `width`, `height`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `overflow`, `overflowX`, `overflowY`, `verticalAlign`
51
+
52
+ ### `system.space`
53
+
54
+ Margin and padding using the theme's spacing scale. Supports logical properties (switches based on `useLogicalProperties` in `<GamutProvider>`).
55
+
56
+ ```tsx
57
+ const Box = styled.div(system.space);
58
+
59
+ // Single value
60
+ <Box p={8} m={16} />;
61
+
62
+ // Responsive array syntax: [mobile, tablet, desktop]
63
+ <Box my={[16, 24, 32]} px={[8, 16]} />;
64
+ ```
65
+
66
+ Key props: `p`, `pt`, `pr`, `pb`, `pl`, `px`, `py`, `m`, `mt`, `mr`, `mb`, `ml`, `mx`, `my`
67
+
68
+ ### `system.color`
69
+
70
+ Foreground, background, and border colors restricted to the theme's color palette.
71
+
72
+ ```tsx
73
+ const Box = styled.div(system.color);
74
+
75
+ <Box bg="navy" textColor="gray-100" borderColor="blue" />;
76
+ ```
77
+
78
+ Key props: `bg` (background-color shorthand), `textColor`, `borderColor`
79
+
80
+ ### `system.typography`
81
+
82
+ Text styling connected to theme typography scales.
83
+
84
+ ```tsx
85
+ const Text = styled.p(system.typography);
86
+
87
+ <Text fontSize={16} fontFamily="accent" textTransform="uppercase" lineHeight={1.5} />;
88
+ ```
89
+
90
+ Key props: `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `textAlign`, `textTransform`, `textDecoration`, `letterSpacing`, `whiteSpace`
91
+
92
+ ### `system.border`
93
+
94
+ Border width, style, radius, and color.
95
+
96
+ Key props: `border`, `borderTop`, `borderRight`, `borderBottom`, `borderLeft`, `borderRadius`, `borderWidth`, `borderStyle`
97
+
98
+ ### `system.background`
99
+
100
+ Background image, size, position, and repeat (for images/patterns — use `system.color` for solid background colors).
101
+
102
+ ```tsx
103
+ import myBg from './myBg.png';
104
+
105
+ const Box = styled.div(system.background);
106
+
107
+ <Box background={`url(${myBg})`} backgroundSize="cover" backgroundPosition="center" />;
108
+ ```
109
+
110
+ Key props: `background`, `backgroundImage`, `backgroundSize`, `backgroundPosition`, `backgroundRepeat`
111
+
112
+ ### `system.flex`
113
+
114
+ Flexbox child and container properties.
115
+
116
+ Key props: `flex`, `flexDirection`, `flexWrap`, `flexGrow`, `flexShrink`, `flexBasis`, `alignItems`, `alignSelf`, `justifyContent`, `justifySelf`, `gap`, `rowGap`, `columnGap`
117
+
118
+ ### `system.grid`
119
+
120
+ CSS Grid container and child properties.
121
+
122
+ Key props: `gridTemplateColumns`, `gridTemplateRows`, `gridTemplateAreas`, `gridColumn`, `gridRow`, `gridArea`, `gridAutoFlow`
123
+
124
+ ### `system.positioning`
125
+
126
+ Position and offset properties.
127
+
128
+ ```tsx
129
+ const Overlay = styled.div(
130
+ variance.compose(system.layout, system.positioning)
131
+ );
132
+
133
+ <Overlay position="absolute" top={0} left={0} width="100%" height="100%" />;
134
+ ```
135
+
136
+ Key props: `position`, `top`, `right`, `bottom`, `left`, `zIndex`
137
+
138
+ ### `system.shadow`
139
+
140
+ Box and text shadow.
141
+
142
+ Key props: `boxShadow`, `textShadow`
143
+
144
+ ## Responsive values
145
+
146
+ All system props accept an **array of values** for responsive breakpoints (Gamut uses a mobile-first approach):
147
+
148
+ ```tsx
149
+ // [mobile, tablet, desktop]
150
+ <Box width={['100%', '50%', '33%']} p={[8, 16, 24]} />;
151
+ ```
152
+
153
+ ## Using `css()` for styled definitions
154
+
155
+ For static styles in styled components, use `css()` from `@codecademy/gamut-styles`:
156
+
157
+ ```tsx
158
+ import { css } from '@codecademy/gamut-styles';
159
+ import styled from '@emotion/styled';
160
+
161
+ // Static color using raw token
162
+ const Box = styled.div(css({ bg: 'navy-400', p: 4 }));
163
+
164
+ // Semantic color (adapts to color mode)
165
+ const Text = styled.div(css({ color: 'primary', p: 4 }));
166
+ ```
167
+
168
+ ## Key principles
169
+
170
+ - Compose `system.*` groups via `variance.compose()` — don't apply multiple groups by chaining `styled.div(system.a)(system.b)`.
171
+ - Use `system.color` (semantic aliases) for colors that need to adapt to dark/light mode; use raw tokens only for colors that should stay fixed regardless of mode.
172
+ - Use `system.space` values that reference the spacing scale rather than arbitrary pixel values to maintain visual rhythm.
173
+ - For background images/patterns use `system.background`; for solid background colors that may switch mode use `<Background>` from ColorMode.
@@ -0,0 +1,181 @@
1
+ ---
2
+ name: gamut-testing
3
+ description: Use this skill when writing or fixing unit tests for React components that use Gamut — setupRtl, MockGamutProvider, ColorMode in tests, emotion matchers, or removing jest.mock of @codecademy/gamut / gamut-styles.
4
+ ---
5
+
6
+ # Gamut Testing
7
+
8
+ Source: `@codecademy/gamut-tests` — [`index.tsx`](https://github.com/Codecademy/gamut/blob/main/packages/gamut-tests/src/index.tsx)
9
+
10
+ ---
11
+
12
+ ## Core rule: never mock Gamut components
13
+
14
+ Do not use `jest.mock('@codecademy/gamut')` or manually mock individual Gamut components in test files. This silently bypasses emotion's theme context, produces tests that can't catch real rendering failures, and requires every test file to maintain its own fragile mock list.
15
+
16
+ Use `MockGamutProvider` or `setupRtl` — both are designed for exactly this purpose.
17
+
18
+ ---
19
+
20
+ ## What `MockGamutProvider` does
21
+
22
+ `MockGamutProvider` wraps `GamutProvider` with test-safe settings:
23
+
24
+ - `useCache={false}` — disables emotion's style injection cache so styles are predictable across tests
25
+ - `useGlobals={false}` — disables global CSS (Reboot, Typography, CSS Variables) to avoid side effects between test files
26
+ - `theme={coreTheme}` — provides the full Gamut token set so styled components resolve correctly
27
+
28
+ You should never construct a `GamutProvider` manually in a test. Use `MockGamutProvider`.
29
+
30
+ ---
31
+
32
+ ## Decision guide
33
+
34
+ | Scenario | Use |
35
+ |---|---|
36
+ | Standard component unit test | `setupRtl` from `@codecademy/gamut-tests` |
37
+ | Test that needs to vary `useLogicalProperties` | `render` + `MockGamutProvider` directly |
38
+ | Test that needs `ColorMode` context | `render` + `MockGamutProvider` + `<ColorMode>` |
39
+ | Visual test mock / Storybook wrapper | `MockGamutProvider` directly |
40
+
41
+ ---
42
+
43
+ ## `setupRtl` — preferred pattern
44
+
45
+ `setupRtl` from `@codecademy/gamut-tests` wraps `setupRtl` from `component-test-setup` with `MockGamutProvider` automatically. You do not need to add `MockGamutProvider` yourself.
46
+
47
+ ```tsx
48
+ import { setupRtl } from '@codecademy/gamut-tests';
49
+
50
+ import { MyComponent } from '../MyComponent';
51
+
52
+ const renderView = setupRtl(MyComponent, {
53
+ label: 'Default label',
54
+ onClick: jest.fn(),
55
+ });
56
+
57
+ it('renders the label', () => {
58
+ const { view } = renderView();
59
+ expect(view.getByText('Default label')).toBeInTheDocument();
60
+ });
61
+
62
+ it('accepts prop overrides', () => {
63
+ const { view } = renderView({ label: 'Override' });
64
+ expect(view.getByText('Override')).toBeInTheDocument();
65
+ });
66
+ ```
67
+
68
+ `renderView` returns `{ view, props, update }`:
69
+ - `view` — RTL `RenderResult` (getByRole, getByText, etc.)
70
+ - `props` — the resolved props passed to the component (useful for asserting on jest.fn() mocks)
71
+ - `update` — re-render with updated props without remounting
72
+
73
+ ### Accessing mock functions via `props`
74
+
75
+ ```tsx
76
+ it('calls onClick when clicked', async () => {
77
+ const { view, props } = renderView();
78
+ await userEvent.click(view.getByRole('button'));
79
+ expect(props.onClick).toHaveBeenCalled();
80
+ });
81
+ ```
82
+
83
+ ---
84
+
85
+ ## `MockGamutProvider` directly — when you need more control
86
+
87
+ Use `render` + `MockGamutProvider` when `setupRtl` doesn't give enough control over the wrapper — for example, when testing both logical and physical CSS property modes, or when adding `ColorMode`.
88
+
89
+ ```tsx
90
+ import { MockGamutProvider } from '@codecademy/gamut-tests';
91
+ import { render } from '@testing-library/react';
92
+
93
+ it('uses logical properties when enabled', () => {
94
+ const { container } = render(
95
+ <MockGamutProvider useLogicalProperties>
96
+ <MyComponent width="200px" />
97
+ </MockGamutProvider>
98
+ );
99
+ // assert on inlineSize rather than width
100
+ });
101
+ ```
102
+
103
+ ### Testing both logical and physical property modes
104
+
105
+ ```tsx
106
+ describe.each([
107
+ { useLogicalProperties: true, widthProp: 'inlineSize' },
108
+ { useLogicalProperties: false, widthProp: 'width' },
109
+ ])(
110
+ 'useLogicalProperties=$useLogicalProperties',
111
+ ({ useLogicalProperties, widthProp }) => {
112
+ it(`uses ${widthProp}`, () => {
113
+ const { container } = render(
114
+ <MockGamutProvider useLogicalProperties={useLogicalProperties}>
115
+ <MyComponent width="200px" />
116
+ </MockGamutProvider>
117
+ );
118
+ expect(container.firstChild).toHaveStyle({ [widthProp]: '200px' });
119
+ });
120
+ }
121
+ );
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Emotion style assertions
127
+
128
+ Install `@emotion/jest` matchers once per test file to enable CSS-in-JS assertions:
129
+
130
+ ```tsx
131
+ import { matchers } from '@emotion/jest';
132
+
133
+ expect.extend(matchers);
134
+ ```
135
+
136
+ Then assert on styles:
137
+
138
+ ```tsx
139
+ expect(element).toHaveStyle({ borderRadius: '2px' });
140
+ expect(element).toHaveStyleRule('padding', '1rem');
141
+ ```
142
+
143
+ Use `theme` from `@codecademy/gamut-styles` to avoid hardcoding token values:
144
+
145
+ ```tsx
146
+ import { theme } from '@codecademy/gamut-styles';
147
+
148
+ expect(element).toHaveStyle({ columnGap: theme.spacing[40] });
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Visual test wrappers
154
+
155
+ When creating mock components for visual tests or Storybook, wrap with `MockGamutProvider` and `ColorMode`:
156
+
157
+ ```tsx
158
+ import { MockGamutProvider } from '@codecademy/gamut-tests';
159
+ import { ColorMode } from '@codecademy/gamut-styles';
160
+
161
+ export const MyComponentMock: React.FC<ComponentProps<typeof MyComponent>> = (props) => (
162
+ <MockGamutProvider>
163
+ <ColorMode mode="light">
164
+ <MyComponent {...props} />
165
+ </ColorMode>
166
+ </MockGamutProvider>
167
+ );
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Common anti-patterns
173
+
174
+ | Anti-pattern | Fix |
175
+ |---|---|
176
+ | `jest.mock('@codecademy/gamut', () => ({ ... }))` | Remove mock; use `setupRtl` or `MockGamutProvider` |
177
+ | `jest.mock('@codecademy/gamut-styles', ...)` | Remove mock; `MockGamutProvider` handles theme context |
178
+ | Wrapping with `GamutProvider` directly in tests | Use `MockGamutProvider` — it sets `useCache={false}` and `useGlobals={false}` |
179
+ | Repeating `render(<MockGamutProvider>...</MockGamutProvider>)` in every test | Extract with `setupRtl`; define `renderView` once above the `describe` block |
180
+ | One `setupRtl` call per `it` block | Define `renderView` once outside `describe`, call it inside each `it` |
181
+ | Asserting on raw CSS token strings | Import `theme` from `@codecademy/gamut-styles` and use `theme.spacing[n]`, `theme.fontSize`, etc. |