@codecademy/gamut
72.3.072.3.1-alpha.f273fd.0
+
Added (56 files)
~
Modified (11 files)
Index: package/package.json
===================================================================
--- package/package.json
+++ package/package.json
@@ -1,16 +1,16 @@
{
"name": "@codecademy/gamut",
"description": "Styleguide & Component library for Codecademy",
- "version": "72.3.0",
+ "version": "72.3.1-alpha.f273fd.0",
"author": "Codecademy Engineering <[email protected]>",
"bin": "./bin/gamut.mjs",
"dependencies": {
- "@codecademy/gamut-icons": "9.57.10",
- "@codecademy/gamut-illustrations": "0.58.15",
- "@codecademy/gamut-patterns": "0.10.34",
- "@codecademy/gamut-styles": "20.0.2",
- "@codecademy/variance": "0.26.1",
+ "@codecademy/gamut-icons": "9.57.11-alpha.f273fd.0",
+ "@codecademy/gamut-illustrations": "0.58.16-alpha.f273fd.0",
+ "@codecademy/gamut-patterns": "0.10.35-alpha.f273fd.0",
+ "@codecademy/gamut-styles": "20.0.3-alpha.f273fd.0",
+ "@codecademy/variance": "0.26.2-alpha.f273fd.0",
"@formatjs/intl-locale": "5.3.1",
"@react-aria/interactions": "3.25.0",
"@types/marked": "^4.0.8",
"@vidstack/react": "^1.12.12", Index: package/agent-tools/DESIGN.LXStudio.md
===================================================================
--- package/agent-tools/DESIGN.LXStudio.md
+++ package/agent-tools/DESIGN.LXStudio.md
@@ -140,90 +140,99 @@
**Design philosophy**:
- Larger border radii (`sm`–`lg`) for a softer, more modern feel
-- Brand blue (`sapphire` / `primary`) drives CTAs, buttons, and links
+- Brand blue (`sapphire` / `primary`) drives CTAs, buttons, and links in **light mode** — in dark mode `primary` falls back to Core's `yellow-500`, not `sapphire` (see Semantic Color Aliases)
- Soft shadows (`shadow-primary` → `navy-200`)
- Skillsoft Text (`base`) and Skillsoft Sans (`accent`) for all UI typography
+- Dark mode is supported but not bespoke — `lxStudioTheme` only overrides light-mode tokens (see `packages/gamut-styles/src/themes/lxStudio.ts`); dark mode falls back to Core's dark palette unmodified
---
## Themes
-LX Studio uses a single Gamut theme — light mode only.
+LX Studio uses a single Gamut theme with both light and dark modes.
-| Theme | Use case | Base font | Dark mode |
-| ------------- | -------------------------------------- | --------------------- | ---------- |
-| **LX Studio** | Skillsoft LX Studio authoring platform | Skillsoft Text / Sans | light only |
+| Theme | Use case | Base font | Dark mode |
+| ------------- | -------------------------------------- | --------------------- | ----------------------------------------------------- |
+| **LX Studio** | Skillsoft LX Studio authoring platform | Skillsoft Text / Sans | ✓ light + dark (dark inherited from Core, unmodified) |
-The active theme is set at the app root via `<GamutProvider theme={lxStudioTheme}>`.
+The active theme is set at the app root via `<GamutProvider theme={lxStudioTheme}>`. `lxStudioTheme` calls `.addColorModes('light', { light: {...} })` with **only** a `light` override map — there is no `dark` key in the theme definition, so every semantic token falls back to Core's `dark` values for that token when the app is in dark mode. This is different from `adminTheme`/`platformTheme`, which define their own explicit `dark` overrides on top of Core.
---
## Semantic Color Aliases
-Use these token names when specifying colors. LX Studio is light mode only — there are no dark mode counterparts.
+Use these token names when specifying colors — they work in both light and dark mode via `<ColorMode>`. **Light** below is LX Studio's own value where it overrides Core (in `lxStudio.ts`); everywhere else, Light is simply inherited from Core. **Dark** is always Core's unmodified dark value for that token, since `lxStudioTheme` never defines a `dark` override map.
### Text
-| Token | Resolves to | Use for |
-| ---------------- | ----------- | --------------------------- |
-| `text` | `navy-800` | Default body and UI text |
-| `text-accent` | `navy-900` | Stronger emphasis text |
-| `text-secondary` | `navy-600` | Supporting / secondary copy |
-| `text-disabled` | `navy-500` | Disabled state labels |
+Not overridden by LX Studio at all — these are Core's values in both modes.
+| Token | Light | Dark | Use for |
+| ---------------- | ---------- | ----------- | --------------------------- |
+| `text` | `navy-800` | `white` | Default body and UI text |
+| `text-accent` | `navy-900` | `beige` | Stronger emphasis text |
+| `text-secondary` | `navy-600` | `white-600` | Supporting / secondary copy |
+| `text-disabled` | `navy-500` | `white-500` | Disabled state labels |
+
### Background
-| Token | Resolves to | Use for |
-| --------------------- | ------------------- | --------------------------------- |
-| `background` | `white` | Default page/component background |
-| `background-primary` | `lxStudioBgPrimary` | Slightly elevated surfaces |
-| `background-contrast` | `white` | Maximum contrast surface |
-| `background-selected` | `navy-100` | Selected row / item |
-| `background-hover` | `navy-200` | Hover state overlay |
-| `background-disabled` | `navy-200` | Disabled surface |
-| `background-success` | `green-0` | Success state container |
-| `background-warning` | `yellow-0` | Warning state container |
-| `background-error` | `red-0` | Error state container |
+| Token | Light | Dark | Use for |
+| --------------------- | ------------------- | ------------ | --------------------------------- |
+| `background` | `white` | `navy-800` | Default page/component background |
+| `background-primary` | `lxStudioBgPrimary` | `navy-900` | Slightly elevated surfaces |
+| `background-contrast` | `white` | `black` | Maximum contrast surface |
+| `background-selected` | `navy-100` | `white-100` | Selected row / item |
+| `background-hover` | `navy-200` | `white-200` | Hover state overlay |
+| `background-disabled` | `navy-200` | `white-200` | Disabled surface |
+| `background-success` | `green-0` | `green-900` | Success state container |
+| `background-warning` | `yellow-0` | `yellow-900` | Warning state container |
+| `background-error` | `red-0` | `red-900` | Error state container |
+`background-primary` is the only row LX Studio overrides in light mode (`lxStudioBgPrimary`); its dark value is Core's plain `navy-900`, not an LX-specific token.
+
### Interactive
-| Token | Resolves to | Use for |
-| ----------------- | ------------ | ------------------------------------ |
-| `primary` | `sapphire` | Primary CTA, links, focus rings |
-| `primary-hover` | `navy-800` | Hover state of primary interactive |
-| `primary-inverse` | `yellow-500` | Primary on a colored background |
-| `secondary` | `navy-800` | Secondary CTA, ghost buttons |
-| `secondary-hover` | `navy-700` | Hover state of secondary interactive |
-| `danger` | `red-500` | Destructive actions, error states |
-| `danger-hover` | `red-600` | Hover on danger interactive |
+| Token | Light | Dark | Use for |
+| ----------------- | ------------ | ------------ | ------------------------------------ |
+| `primary` | `sapphire` | `yellow-500` | Primary CTA, links, focus rings |
+| `primary-hover` | `navy-800` | `yellow-400` | Hover state of primary interactive |
+| `primary-inverse` | `yellow-500` | `hyper-500` | Primary on a colored background |
+| `secondary` | `navy-800` | `white` | Secondary CTA, ghost buttons |
+| `secondary-hover` | `navy-700` | `white-700` | Hover state of secondary interactive |
+| `danger` | `red-500` | `red-300` | Destructive actions, error states |
+| `danger-hover` | `red-600` | `red-400` | Hover on danger interactive |
+**`primary` does not stay LX Studio's brand blue in dark mode.** `sapphire`/`navy-800` are light-mode-only overrides; dark mode uses Core's `yellow-500`/`yellow-400` (the same values Core itself uses), since `lxStudioTheme` never defines a `dark` override. If a design calls for `sapphire` to persist in dark mode, that requires an actual theme change, not a documentation fix.
+
### Border
-| Token | Resolves to | Use for |
-| ------------------ | ----------- | ------------------------------- |
-| `border-primary` | `navy-400` | Standard input and card borders |
-| `border-secondary` | `navy-600` | Medium-weight borders |
-| `border-tertiary` | `navy-800` | Strong structural borders |
-| `border-disabled` | `navy-300` | Disabled input borders |
+| Token | Light | Dark | Use for |
+| ------------------ | ---------- | ----------- | ------------------------------- |
+| `border-primary` | `navy-400` | `white` | Standard input and card borders |
+| `border-secondary` | `navy-600` | `white-600` | Medium-weight borders |
+| `border-tertiary` | `navy-800` | `white-300` | Strong structural borders |
+| `border-disabled` | `navy-300` | `white-500` | Disabled input borders |
-`border-primary` resolves to `navy-400` — mid-weight borders for inputs and cards.
+`border-primary` resolves to `navy-400` in light mode — mid-weight borders for inputs and cards. `border-secondary` is the only row here LX Studio does not override; it's inherited from Core in both modes.
### Feedback
-| Token | Resolves to | Use for |
-| ------------------ | ----------------- | -------------------------------- |
-| `feedback-error` | `red-600` | Error messages, validation |
-| `feedback-success` | `lxStudioSuccess` | Success messages, confirmations |
-| `feedback-warning` | `yellow-500` | Warning messages, caution states |
+| Token | Light | Dark | Use for |
+| ------------------ | ----------------- | ----------- | -------------------------------- |
+| `feedback-error` | `red-600` | `red-300` | Error messages, validation |
+| `feedback-success` | `lxStudioSuccess` | `green-400` | Success messages, confirmations |
+| `feedback-warning` | `yellow-500` | `yellow-0` | Warning messages, caution states |
+`feedback-error` and `feedback-warning` are inherited from Core in light mode too (not LX-specific); `feedback-success` is LX Studio's own light override, but its dark value falls back to Core's `green-400`, not `lxStudioSuccess`.
+
### Shadow
-| Token | Resolves to |
-| ------------------ | ----------- |
-| `shadow-primary` | `navy-200` |
-| `shadow-secondary` | `navy-600` |
+| Token | Light | Dark |
+| ------------------ | ---------- | ----------- |
+| `shadow-primary` | `navy-200` | `white` |
+| `shadow-secondary` | `navy-600` | `white-600` |
Use `shadow-primary` for standard elevated surfaces.
---
@@ -379,10 +388,10 @@
### Colors
- **Do** use semantic color aliases (`primary`, `text`, `background`, etc.) — never hardcode hex values.
-- **Do** use `primary` (resolves to palette `sapphire`) for buttons and links.
-- **Don't** attempt dark mode — LX Studio is light only.
+- **Do** use `primary` (resolves to palette `sapphire` in light mode) for buttons and links.
+- **Don't** assume LX Studio has a bespoke dark palette — it inherits Core's dark values unmodified (see Semantic Color Aliases). `primary` in particular becomes Core's `yellow-500` in dark mode, not `sapphire` — verify visually rather than assuming brand color persists.
- **Don't** use raw palette swatches for adaptive UI — use semantic aliases.
### Typography Index: package/agent-tools/DESIGN.Percipio.md
===================================================================
--- package/agent-tools/DESIGN.Percipio.md
+++ package/agent-tools/DESIGN.Percipio.md
@@ -129,91 +129,95 @@
**Density**: Medium. Information-dense layouts with softer shadows and a muted neutral palette.
**Design philosophy**:
-- Light mode only — no dark mode support
-- Brand blue (`sapphire` / `primary`) for buttons, links, and focus rings
+- Dark mode is supported but not bespoke — `percipioTheme` only overrides light-mode tokens (see `packages/gamut-styles/src/themes/percipio.ts`); dark mode falls back to Core's dark palette unmodified (see Semantic Color Aliases below)
+- Brand blue (`sapphire` / `primary`) for buttons, links, and focus rings in **light mode** — in dark mode `primary` resolves to Core's `yellow-500`, not `sapphire`
- Text uses dedicated Percipio palette tokens (`percipioTextPrimary`, etc.)
- Shadows are soft and minimal (`shadow-primary` → `navy-200`)
- Title font weight is **500** via `fontWeight="title"` — use semantic weight, not literal `700`
---
## Themes
-Percipio uses a single Gamut theme — light mode only.
+Percipio uses a single Gamut theme with both light and dark modes.
-| Theme | Use case | Base font | Dark mode |
-| ------------ | --------------------------- | --------------------- | ---------- |
-| **Percipio** | Skillsoft Percipio platform | Skillsoft Text / Sans | light only |
+| Theme | Use case | Base font | Dark mode |
+| ------------ | --------------------------- | --------------------- | ----------------------------------------------------- |
+| **Percipio** | Skillsoft Percipio platform | Skillsoft Text / Sans | ✓ light + dark (dark inherited from Core, unmodified) |
-The active theme is set at the app root via `<GamutProvider theme={percipioTheme}>`.
+The active theme is set at the app root via `<GamutProvider theme={percipioTheme}>`. `percipioTheme` calls `.addColorModes('light', { light: {...} })` with **only** a `light` override map — there is no `dark` key in the theme definition, so every semantic token falls back to Core's `dark` values for that token when the app is in dark mode. This is different from `adminTheme`/`platformTheme`, which define their own explicit `dark` overrides on top of Core.
---
## Semantic Color Aliases
-Use these token names when specifying colors. Percipio is light mode only — there are no dark mode counterparts.
+Use these token names when specifying colors — they work in both light and dark mode via `<ColorMode>`. **Light** below is Percipio's own value (overridden in `percipio.ts` where noted); **Dark** is Core's unmodified dark value for that same token, since `percipioTheme` never defines a `dark` override map. A token whose Light column is a plain palette name (not a `percipio*` token) is itself inherited from Core in light mode too — Percipio didn't touch it in either mode.
### Text
-| Token | Resolves to | Use for |
-| ---------------- | ----------------------- | ------------------------------------------------ |
-| `text` | `percipioTextPrimary` | Default body and UI text |
-| `text-accent` | `percipioTextPrimary` | Emphasis text (same value as `text` in Percipio) |
-| `text-secondary` | `percipioTextSecondary` | Supporting / secondary copy |
-| `text-disabled` | `percipioTextDisabled` | Disabled state labels |
+| Token | Light | Dark | Use for |
+| ---------------- | ----------------------------------------------------- | ----------- | --------------------------- |
+| `text` | `percipioTextPrimary` | `white` | Default body and UI text |
+| `text-accent` | `percipioTextAccent` (same hex as `text` in Percipio) | `beige` | Stronger emphasis text |
+| `text-secondary` | `percipioTextSecondary` | `white-600` | Supporting / secondary copy |
+| `text-disabled` | `percipioTextDisabled` | `white-500` | Disabled state labels |
### Background
-| Token | Resolves to | Use for |
-| --------------------- | ------------------- | --------------------------------- |
-| `background` | `white` | Default page/component background |
-| `background-primary` | `percipioBgPrimary` | Slightly elevated surfaces |
-| `background-selected` | `navy-100` | Selected row / item |
-| `background-hover` | `navy-200` | Hover state overlay |
-| `background-disabled` | `navy-200` | Disabled surface |
-| `background-success` | `percipioBgSuccess` | Success state container |
-| `background-warning` | `percipioBgWarning` | Warning state container |
-| `background-error` | `percipioBgError` | Error state container |
+| Token | Light | Dark | Use for |
+| --------------------- | ------------------- | ------------ | --------------------------------- |
+| `background` | `white` | `navy-800` | Default page/component background |
+| `background-primary` | `percipioBgPrimary` | `navy-900` | Slightly elevated surfaces |
+| `background-selected` | `navy-100` | `white-100` | Selected row / item |
+| `background-hover` | `navy-200` | `white-200` | Hover state overlay |
+| `background-disabled` | `navy-200` | `white-200` | Disabled surface |
+| `background-success` | `percipioBgSuccess` | `green-900` | Success state container |
+| `background-warning` | `percipioBgWarning` | `yellow-900` | Warning state container |
+| `background-error` | `percipioBgError` | `red-900` | Error state container |
+Note the dark values for `background-primary`/`success`/`warning`/`error` are **Core's** dark tokens, not Percipio-specific — the `percipioBg*` tokens Percipio defines have no dark counterpart.
+
### Interactive
-| Token | Resolves to | Use for |
-| ----------------- | ------------------------------ | ------------------------------------ |
-| `primary` | `sapphire` | Primary CTA, links, focus rings |
-| `primary-hover` | `percipioActionPrimaryHover` | Hover state of primary interactive |
-| `primary-inverse` | `white` | Primary on a colored background |
-| `secondary` | `percipioActionSecondary` | Secondary CTA, ghost buttons |
-| `secondary-hover` | `percipioActionSecondaryHover` | Hover state of secondary interactive |
-| `danger` | `percipioDanger` | Destructive actions, error states |
-| `danger-hover` | `percipioActionDangerHover` | Hover on danger interactive |
+| Token | Light | Dark | Use for |
+| ----------------- | ------------------------------ | ------------ | ------------------------------------ |
+| `primary` | `sapphire` | `yellow-500` | Primary CTA, links, focus rings |
+| `primary-hover` | `percipioActionPrimaryHover` | `yellow-400` | Hover state of primary interactive |
+| `primary-inverse` | `white` | `hyper-500` | Primary on a colored background |
+| `secondary` | `percipioActionSecondary` | `white` | Secondary CTA, ghost buttons |
+| `secondary-hover` | `percipioActionSecondaryHover` | `white-700` | Hover state of secondary interactive |
+| `danger` | `percipioDanger` | `red-300` | Destructive actions, error states |
+| `danger-hover` | `percipioActionDangerHover` | `red-400` | Hover on danger interactive |
+**`primary` does not stay Percipio's brand blue in dark mode.** `sapphire` is a light-mode-only override; dark mode uses Core's `yellow-500` (the same value Core itself uses, since `percipioTheme` never defines a `dark` override). If a design calls for `sapphire` to persist in dark mode, that requires an actual theme change — adding a `dark` override to `percipioTheme` — not a documentation fix. Flag it rather than assuming the token already does this.
+
### Border
-Percipio's border weights use a non-standard order: `primary` is mid-weight, `secondary` is very light, `tertiary` is the strongest (solid navy). Use them for their semantic intent, not their numeric rank.
+Percipio's border weights use a non-standard order in light mode: `primary` is mid-weight, `secondary` is very light, `tertiary` is the strongest (solid navy). Use them for their semantic intent, not their numeric rank. Dark values are Core's unmodified dark borders.
-| Token | Resolves to | Use for |
-| ------------------ | ----------- | ----------------------------------- |
-| `border-primary` | `navy-400` | Standard input and card borders |
-| `border-secondary` | `navy-200` | Subtle dividers, section separators |
-| `border-tertiary` | `navy-800` | Strong structural borders |
-| `border-disabled` | `navy-300` | Disabled input borders |
+| Token | Light | Dark | Use for |
+| ------------------ | ---------- | ----------- | ----------------------------------- |
+| `border-primary` | `navy-400` | `white` | Standard input and card borders |
+| `border-secondary` | `navy-600` | `white-600` | Subtle dividers, section separators |
+| `border-tertiary` | `navy-800` | `white-300` | Strong structural borders |
+| `border-disabled` | `navy-300` | `white-500` | Disabled input borders |
### Feedback
-| Token | Resolves to | Use for |
-| ------------------ | ------------------------- | -------------------------------- |
-| `feedback-error` | `percipioDanger` | Error messages, validation |
-| `feedback-success` | `percipioFeedbackSuccess` | Success messages, confirmations |
-| `feedback-warning` | `percipioFeedbackWarning` | Warning messages, caution states |
+| Token | Light | Dark | Use for |
+| ------------------ | ------------------------- | ----------- | -------------------------------- |
+| `feedback-error` | `percipioDanger` | `red-300` | Error messages, validation |
+| `feedback-success` | `percipioFeedbackSuccess` | `green-400` | Success messages, confirmations |
+| `feedback-warning` | `percipioFeedbackWarning` | `yellow-0` | Warning messages, caution states |
### Shadow
-| Token | Resolves to |
-| ------------------ | ----------- |
-| `shadow-primary` | `navy-200` |
-| `shadow-secondary` | `navy-400` |
+| Token | Light | Dark |
+| ------------------ | ---------- | ----------- |
+| `shadow-primary` | `navy-200` | `white` |
+| `shadow-secondary` | `navy-400` | `white-600` |
Use `shadow-primary` for standard elevated surfaces.
---
@@ -377,10 +381,10 @@
### Colors
- **Do** use semantic color aliases (`primary`, `text`, `background`, etc.) — never hardcode hex values.
-- **Do** use `primary` (resolves to palette `sapphire`) as the brand interactive color.
-- **Don't** attempt dark mode — Percipio is light only.
+- **Do** use `primary` (resolves to palette `sapphire` in light mode) as the brand interactive color.
+- **Don't** assume Percipio has a bespoke dark palette — it inherits Core's dark values unmodified (see Semantic Color Aliases). `primary` in particular becomes Core's `yellow-500` in dark mode, not `sapphire` — verify visually rather than assuming brand color persists.
### Typography
- **Do** use title weight (500) for headlines, CTAs, and buttons — not 700. Index: package/agent-tools/skills/gamut-accessibility/SKILL.md
===================================================================
--- package/agent-tools/skills/gamut-accessibility/SKILL.md
+++ package/agent-tools/skills/gamut-accessibility/SKILL.md
@@ -1,7 +1,7 @@
---
name: gamut-accessibility
-description: Use this skill when implementing accessibility for a specific Gamut component, building a custom overlay or composite widget, or auditing component usage against WCAG — complements the always-loaded `accessibility.mdc` with Gamut component-specific patterns. Form wiring lives in `gamut-forms`.
+description: Use this skill when implementing accessibility for a specific Gamut component, building a custom overlay or composite widget, or auditing component usage against WCAG — complements the always-loaded `accessibility.mdc` with Gamut component-specific patterns. Form wiring lives in `gamut-forms`; Modal/Dialog composition detail lives in `gamut-modal`.
---
# Gamut Accessibility
@@ -32,10 +32,10 @@
| Component(s) | Handled in library | App / author responsibilities |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `FillButton`, `TextButton`, `StrokeButton`, `CTAButton` | Render `<button>` (or `<a>` when `href` is set); native click + keyboard activation | Visible text or `href` purpose; follow [`gamut-buttons`](../gamut-buttons/SKILL.md) for variants and `disabled` vs `aria-disabled`. |
| `IconButton` | `tip` feeds the accessible name for icon-only controls | Always pass `tip` when the button has no visible text. |
-| `Dialog` | `Overlay` (shroud, Escape, focus), `role="dialog"`, `aria-modal`, close control with configurable `closeButtonProps.tip` | Provide a clear `title` (and meaningful body copy). Confirm naming with [Molecules / Modals / Dialog](https://gamut.codecademy.com/?path=/docs-molecules-modals-dialog--docs). |
-| `Modal` | Same overlay/focus stack; optional `aria-label`; multi-`views` support | `title` / view titles; pass `aria-label` when there is no visible title string. [Molecules / Modals / Modal](https://gamut.codecademy.com/?path=/docs-molecules-modals-modal--docs). |
+| `Dialog` | `Overlay` (shroud, Escape, focus), `role="dialog"`, `aria-modal`, close control with configurable `closeButtonProps.tip` | Provide a clear `title` (and meaningful body copy). Full props/composition: [`gamut-modal`](../gamut-modal/SKILL.md). Confirm naming with [Molecules / Modals / Dialog](https://gamut.codecademy.com/?path=/docs-molecules-modals-dialog--docs). |
+| `Modal` | Same overlay/focus stack; optional `aria-label`; multi-`views` support | `title` / view titles; pass `aria-label` when there is no visible title string. Full props/composition: [`gamut-modal`](../gamut-modal/SKILL.md). [Molecules / Modals / Modal](https://gamut.codecademy.com/?path=/docs-molecules-modals-modal--docs). |
| `Alert` | Default `aria-live="polite"`, `role="status"` | Use `aria-live="assertive"` only for urgent interruptions; do not nest inside another live region. |
| `Tabs`, `Tab`, `TabList`, `TabPanel` | `react-aria-components` in `packages/gamut/src/Tabs/` — roving tabindex, arrows, Home/End | Name each tab; Tab moves into the active panel per APG. |
| Forms | See Forms above | [`gamut-forms`](../gamut-forms/SKILL.md) |
| `DatePicker` + `DatePickerInput` | Segmented input + calendar behavior inside `FormGroup` | Provide `label` / `name` / `form` as for any input; keep `DatePickerInput` inside `DatePicker`. When embedded in `FormGroup` / `GridForm`, follow [`gamut-forms`](../gamut-forms/SKILL.md). [Organisms / DatePicker](https://gamut.codecademy.com/?path=/docs-organisms-datepicker--docs). |
@@ -62,8 +62,10 @@
```
### Dialog / Modal (detail)
+Full composition, props, and the focus-trap/autofocus/dismiss mechanics in detail: [`gamut-modal`](../gamut-modal/SKILL.md). Summary:
+
Both use `Overlay` and `FocusTrap` (`react-focus-on`) patterns: focus moves into the surface, Escape closes (when enabled), focus should return to the trigger on close.
Prefer a visible title so the dialog has a clear name; on `Modal`, pass `aria-label` when there is no suitable visible title string. Close control: `IconButton` with `closeButtonProps.tip` (defaults documented in source). Index: package/agent-tools/skills/gamut-datalist/SKILL.md
===================================================================
--- package/agent-tools/skills/gamut-datalist/SKILL.md
+++ package/agent-tools/skills/gamut-datalist/SKILL.md
@@ -8,9 +8,9 @@
Item-focused list for managing, engaging with, and expanding individual rows. Use when users interact with items — opening details, selecting for bulk actions, or viewing expanded layouts — rather than scanning and comparing data across rows.
Source: `@codecademy/gamut` — `packages/gamut/src/DataList/DataList.tsx`
-See also: [`gamut-datatable`](../gamut-datatable/SKILL.md) — query-focused table for bulk data comparison. [`gamut-list`](../gamut-list/SKILL.md) — lower-level list primitives for fully custom layouts. [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — ARIA and keyboard interaction.
+See also: [`gamut-datatable`](../gamut-datatable/SKILL.md) — query-focused table for bulk data comparison. [`gamut-list`](../gamut-list/SKILL.md) — lower-level list primitives for fully custom layouts. [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — ARIA and keyboard interaction. [`gamut-z-index`](../gamut-z-index/SKILL.md) — the `zIndex` values used by the sticky header row and `EmptyRows`/`emptyMessage` below.
Storybook: [Organisms / Lists & Tables / DataList](https://gamut.codecademy.com/?path=/docs-organisms-lists-tables-datalist--docs)
## Components
@@ -205,9 +205,9 @@
For server-side filtering or pagination, manage `query` and `onQueryChange` externally instead of using `useLocalQuery`.
## Empty state
-DataList shows a default empty state when `rows` is empty. Override with `emptyMessage`.
+DataList shows a default empty state when `rows` is empty. Override with `emptyMessage`. The default and the example below both use `zIndex={1}` to sit above table rows without a token — see [`gamut-z-index`](../gamut-z-index/SKILL.md) before changing it.
```tsx
<DataList
id="my-list" Index: package/agent-tools/skills/gamut-datatable/SKILL.md
===================================================================
--- package/agent-tools/skills/gamut-datatable/SKILL.md
+++ package/agent-tools/skills/gamut-datatable/SKILL.md
@@ -8,9 +8,9 @@
Structured, query-capable table for bulk data analysis and comparison. Sorting, filtering, loading, and empty states are built in. Use when the goal is to scan and compare information across rows — not to manage individual items.
Source: `@codecademy/gamut` — `packages/gamut/src/DataList/DataTable.tsx`
-See also: [`gamut-datalist`](../gamut-datalist/SKILL.md) — item-focused list with expansion and selection. [`gamut-list`](../gamut-list/SKILL.md) — lower-level list primitives for fully custom layouts. [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — ARIA and keyboard interaction. [`gamut-color-mode`](../gamut-color-mode/SKILL.md) — dark/light mode with `Background`.
+See also: [`gamut-datalist`](../gamut-datalist/SKILL.md) — item-focused list with expansion and selection. [`gamut-list`](../gamut-list/SKILL.md) — lower-level list primitives for fully custom layouts. [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — ARIA and keyboard interaction. [`gamut-color-mode`](../gamut-color-mode/SKILL.md) — dark/light mode with `Background`. [`gamut-z-index`](../gamut-z-index/SKILL.md) — why the row-menu-opens-Modal pattern below needs `inline` to work.
Storybook: [Organisms / Lists & Tables / DataTable](https://gamut.codecademy.com/?path=/docs-organisms-lists-tables-datatable--docs)
## Components
@@ -226,9 +226,9 @@
Key points:
- `closeOnViewportExit` on `PopoverContainer` closes the menu when its row scrolls out of view.
- `allowPageInteraction` lets users interact with the table while the menu is open.
-- Modals opened from menu items render at `zIndex={3}` by default, above the table header.
+- Modals opened from menu items render at `zIndex={3}` by default, above the table header — this only holds because the row menu's `PopoverContainer` stays `inline`; see [`gamut-z-index`](../gamut-z-index/SKILL.md) for why a portalling menu would break this.
## Scrollable table
`scrollable` defaults to `true` on DataTable. The first `type: 'header'` column sticks to the left. Add `shadow` for a visual overflow indicator. Index: package/agent-tools/skills/gamut-forms/SKILL.md
===================================================================
--- package/agent-tools/skills/gamut-forms/SKILL.md
+++ package/agent-tools/skills/gamut-forms/SKILL.md
@@ -21,12 +21,62 @@
`htmlFor` / `id` pairing — universal rule in [`accessibility.mdc`](../../rules/accessibility.mdc) (Form label association). Form-specific notes:
- `FormGroupLabel` → control `id` (or stable `name` when that is your field’s id convention).
-- Checkbox, Radio, Select: same pairing; checkbox/radio use the visually hidden input pattern from `@codecademy/gamut-styles` where applicable.
+- Checkbox, Select: same pairing; checkbox uses the visually hidden input pattern from `@codecademy/gamut-styles` where applicable.
+- Radio: see [`Radio` and `RadioGroup`](#radio-and-radiogroup) below — its label/group wiring differs from a single Checkbox/Select field.
---
+## `Radio` and `RadioGroup`
+
+`packages/gamut/src/Form/inputs/Radio.tsx` · `packages/gamut/src/Form/inputs/RadioGroup.tsx` · reference: `packages/styleguide/src/lib/Atoms/FormInputs/Radio/Radio.mdx`
+
+### No `variant`/`size` props — state is derived, not chosen
+
+`Radio` does not take a consumer-facing `variant` or `size` prop. Its visual state is derived internally from the booleans you already pass:
+
+- `error?: boolean` and `disabled?: boolean` (from `BaseInputProps`) feed a `conditionalStyleState(error, disabled)` helper that resolves to `'error' | 'disabled' | undefined`, which is then applied via `variant()` (from `@codecademy/gamut-styles`) on both the input and its label (`conditionalRadioInputStyles` / `conditionalRadioLabelStyles` in `Form/styles/Radio-styles.ts`).
+- `error` wins over `disabled` when both are true — do not pass both expecting a combined visual; pick the one that matches the actual field state.
+- There is no size scale — all Radio inputs render at a fixed 20×20 indicator. If a design calls for a different size, that's a design deviation to flag, not a prop to reach for.
+
+### Visually-hidden-input pattern
+
+The real `<input type="radio">` (`RadioInput`) is styled with `screenReaderOnly` from `@codecademy/gamut-styles` — it stays in the accessibility tree and keeps native keyboard/focus/change behavior, but is visually hidden. The circle indicator the user sees is rendered on the sibling `RadioLabel`'s `::before` (outer ring) and `::after` (fill dot) pseudo-elements, driven by CSS sibling selectors keyed to the hidden input's `:checked`, `:hover`, and `:focus` state (`InputSelectors.CHECKED_AFTER`, `HOVER_FOCUS_BEFORE`, etc.).
+
+Do not rebuild this with a `className`/inline-`style` override or a separate decorative `<span>` — that duplicates state the hidden input already owns and drifts out of sync on focus/hover/checked. If a design needs a different indicator look, change `Radio-styles.ts`, not the call site.
+
+### Group and label wiring
+
+`RadioGroup` clones its `Radio` children and injects three things you should _not_ also pass on each child: `name`, `onChange`, and `htmlFor` (built from `htmlForPrefix` + child index, e.g. `example-radio-0`). Wire it as:
+
+- `RadioGroup` gets `htmlForPrefix`, `name`, and (optionally) `onChange`/`disabled`/`selected`.
+- Each `Radio` gets its own `label`, `value`, and optionally `infotip` — not `name` or `htmlFor`, those come from the parent.
+- To label the group itself (not an individual option), wrap `RadioGroup` in `FormGroup` and pass `infotip` to `FormGroup`, not to a `Radio` — `FormGroup` links it via `aria-labelledby` to the group label automatically. Individual `Radio`s can still carry their own `infotip` for per-option context; the two are independent (see the worked example below).
+
+### Example — `RadioGroup` inside `FormGroup`, with mixed InfoTips
+
+```tsx
+<FormGroup
+ htmlFor="plan-select"
+ infotip={{ info: 'Applies to the whole group', placement: 'floating' }}
+ label="Select a plan"
+>
+ <RadioGroup htmlForPrefix="plan-select" name="plan-select">
+ <Radio
+ label="Pro"
+ value="pro"
+ infotip={{ info: 'Includes priority support' }}
+ />
+ <Radio label="Basic" value="basic" />
+ </RadioGroup>
+</FormGroup>
+```
+
+For an error state on the group, pass `error` to each `Radio` (there is no group-level `error` prop on `RadioGroup` itself) and surface the message the same way as any other field — via `FormGroup`'s `error` prop, not a second live region (see "Live regions — do not double up" below).
+
+---
+
## `FormGroup` (baseline)
`packages/gamut/src/Form/elements/FormGroup.tsx` Index: package/agent-tools/skills/gamut-menu/SKILL.md
===================================================================
--- package/agent-tools/skills/gamut-menu/SKILL.md
+++ package/agent-tools/skills/gamut-menu/SKILL.md
@@ -8,9 +8,9 @@
`Menu`, `MenuItem`, and `MenuSeparator` compose lists of actions, options, or navigation links.
Source: `@codecademy/gamut` — [Menu.tsx](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Menu/Menu.tsx)
-See also: [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — ARIA roles and focus rules. [`gamut-list`](../gamut-list/SKILL.md) — lower-level List primitives when `Menu` is too opinionated.
+See also: [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — ARIA roles and focus rules. [`gamut-list`](../gamut-list/SKILL.md) — lower-level List primitives when `Menu` is too opinionated. [`gamut-z-index`](../gamut-z-index/SKILL.md) — stacking order for floating menus, including opening one inside a `Modal`.
Storybook: [Molecules / Menu](https://gamut.codecademy.com/?path=/docs-molecules-menu--docs)
## Components
@@ -146,9 +146,9 @@
```
## Floating menus with PopoverContainer
-Offset the `PopoverContainer` y-axis by `48` (normal spacing) or `32` (condensed) per item to align the popover with the triggering item. Floating menus with actions should use `role="menu"`.
+Offset the `PopoverContainer` y-axis by `48` (normal spacing) or `32` (condensed) per item to align the popover with the triggering item. Floating menus with actions should use `role="menu"`. Keep `inline` set (as below) rather than letting `PopoverContainer` portal — this matters more when the menu opens inside a `Modal` or other floating surface; see [`gamut-z-index`](../gamut-z-index/SKILL.md).
```tsx
<PopoverContainer
alignment="top-right" Index: package/agent-tools/skills/gamut-review/SKILL.md
===================================================================
--- package/agent-tools/skills/gamut-review/SKILL.md
+++ package/agent-tools/skills/gamut-review/SKILL.md
@@ -1,18 +1,18 @@
---
name: gamut-review
-description: Use this skill when auditing existing code for Gamut usage and you need a consolidated report — checks dependencies, setup, import patterns, hardcoded colors, and test setup, with pointers to remediation skills.
+description: Use this skill when auditing existing code for Gamut usage and you need a consolidated report — checks dependencies, setup, import patterns, styled() wrapping that bypasses system props, hardcoded colors, bespoke component duplication, and test setup, with pointers to remediation skills.
---
# Gamut Review
Audit existing code at the path the user provides (default: current working directory). Find violations and misuse; do not generate new code.
When `DESIGN.md` is present at the audit root, use it as the authoritative reference for product design intent, token names, and component patterns. It is copied from `DESIGN.Codecademy.md`, `DESIGN.Percipio.md`, or `DESIGN.LXStudio.md` in `@codecademy/gamut` agent-tools (via `gamut plugin install --theme <name>`). When a finding maps to a skill, note it in the report so the developer knows where to get remediation guidance.
-Run Check 0 first, then Checks 1–5, then print a single consolidated report using the format at the end of this file.
+Run Check 0 first, then Checks 1–6, then print a single consolidated report using the format at the end of this file.
-Remediation skills: [`gamut-theming`](../gamut-theming/SKILL.md) · [`gamut-color-mode`](../gamut-color-mode/SKILL.md) · [`gamut-system-props`](../gamut-system-props/SKILL.md) · [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) · [`gamut-typography`](../gamut-typography/SKILL.md) · [`gamut-testing`](../gamut-testing/SKILL.md)
+Remediation skills: [`gamut-theming`](../gamut-theming/SKILL.md) · [`gamut-color-mode`](../gamut-color-mode/SKILL.md) · [`gamut-system-props`](../gamut-system-props/SKILL.md) · [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) · [`gamut-typography`](../gamut-typography/SKILL.md) · [`gamut-testing`](../gamut-testing/SKILL.md) · [`gamut-z-index`](../gamut-z-index/SKILL.md) · [`gamut-component-first`](../gamut-component-first/SKILL.md)
---
## Check 0 — DESIGN.md present
@@ -72,11 +72,11 @@
Report each violation as `file:line`.
---
-## Check 3b — SCSS/CSS module imports and className on Gamut components
+## Check 3b — SCSS/CSS module imports, className, and inline styles on Gamut components
-Gamut components are styled via the variance system (system props, `css()`, `variant()`, `states()` from `@codecademy/gamut-styles`). Importing SCSS/CSS modules and passing `className` to Gamut components bypasses this system entirely, breaks ColorMode token propagation, and prevents system props from composing correctly.
+Gamut components are styled via the variance system (system props, `css()`, `variant()`, `states()` from `@codecademy/gamut-styles`). Importing SCSS/CSS modules, passing `className`, and passing an inline `style` prop to Gamut components all bypass this system, break ColorMode token propagation, and prevent system props from composing correctly.
**Step 1 — SCSS/CSS module imports**
Grep source files (`.ts`, `.tsx`, `.js`, `.jsx`) for:
@@ -111,10 +111,32 @@
Each match is an error. Report as `file:line <ComponentName className={...}>`.
Severity note: `className` is not always forbidden — some Gamut components accept it for integration with third-party tools (e.g. passing a class to an external drag-and-drop library). Downgrade to ⚠ warning only when the usage is clearly an integration seam, not styling.
-Remediation: replace SCSS module rules with system props directly on the Gamut component — use semantic ColorMode tokens as values (`color="text"`, `bg="background"`, `borderColor="border-primary"`, etc.) rather than hardcoded hex or palette names; use `css()`, `variant()`, or `states()` from `@codecademy/gamut-styles` (with `styled` from `@emotion/styled`) for styles not expressible as system props; delete the SCSS file when all rules are migrated.
+**Step 3 — inline `style` prop**
+Grep source files (`.ts`, `.tsx`, `.js`, `.jsx`) for an inline `style` prop on any JSX element, and specifically on the known Gamut component names from Step 2:
+
+```
+style=\{\{
+```
+
+and
+
+```
+<(Box|FlexBox|Column|LayoutGrid|GridBox|Card|Text|Anchor|FillButton|StrokeButton|TextButton|CTAButton|IconButton|Toggle|List|ListRow|ListCol|Background|Disclosure)\b[^>]*\bstyle=\{
+```
+
+Skip `node_modules`, `dist`. Each match is an error — an inline `style` object silently bypasses ColorMode and the variance system exactly like `className` does, and its values are almost always hardcoded hex or pixel literals rather than tokens.
+
+Before reporting a match, check the matched line and the line immediately above it for an `eslint-disable` / `eslint-disable-next-line` comment referencing a style-related rule (e.g. `react/forbid-component-props`, `react/forbid-dom-props`, or any bare `eslint-disable(-next-line)` directly above the match). If found, still report the match but annotate it `(eslint-disabled)` and downgrade to ⚠ warning — someone made a deliberate, reviewed exception, but the suppression may be stale or broader than intended, so surface it rather than silently skip it.
+
+Report as `file:line <ComponentName style={{...}}>` (or the bare JSX tag when not a known Gamut component).
+
+Severity note: like `className`, downgrade to ⚠ warning only for a clear third-party integration seam (e.g. a style object required by an external widget's API) — not for layout or color values that a Gamut component or system prop could express.
+
+Remediation: replace SCSS module rules and inline `style` objects with system props directly on the Gamut component — use semantic ColorMode tokens as values (`color="text"`, `bg="background"`, `borderColor="border-primary"`, etc.) rather than hardcoded hex, pixel literals, or inline objects; use `css()`, `variant()`, or `states()` from `@codecademy/gamut-styles` (with `styled` from `@emotion/styled`) for styles not expressible as system props; delete the SCSS file when all rules are migrated.
+
Skill references: [`gamut-system-props`](../gamut-system-props/SKILL.md) · [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) · [`gamut-color-mode`](../gamut-color-mode/SKILL.md)
---
@@ -174,8 +196,84 @@
Skill references: [`gamut-system-props`](../gamut-system-props/SKILL.md) · [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md)
---
+## Check 3d — `styled(GamutComponent)` bypassing system props
+
+Wrapping an already-Gamut component in `styled()` and writing raw CSS (a tagged template or plain object, not `css()`/`variant()`/`states()`) is the same bypass as `className`/inline `style` on a Gamut component wearing a different hat — none of it gets ColorMode token resolution, responsive-prop scaling, or the variance pipeline, and it duplicates an API the wrapped component already exposes as props.
+
+**Step 1 — scope to files that already import from `@codecademy/gamut`**
+
+Same technique as Check 3c Step 2 (enumerating every component name by hand is brittle and misses new additions). Find files containing:
+
+```
+from '@codecademy/gamut'
+```
+
+**Step 2 — grep those files for `styled(PascalCaseName)` not wrapped in `css()`/`variant()`/`states()`**
+
+Two patterns, both violations:
+
+```
+styled\([A-Z][A-Za-z]+\)`
+```
+
+```
+styled\([A-Z][A-Za-z]+\)\(\s*\{
+```
+
+The first catches the tagged-template form (`` styled(Box)`display: flex;` ``); the second catches the plain-object function-call form (`styled(Box)({ display: 'flex' })`). `styled(Box)(css({...}))`, `styled(Box)(variant({...}))`, and `styled(Box)(states({...}))` do **not** match either pattern — the character right after the opening `(` is a letter, not a backtick or `{`. That's the compliant form; don't flag it.
+
+Confirm the matched name is actually the Gamut import in that file (not an unrelated same-named local component) before reporting.
+
+**Step 3 — classify each match**
+
+Read the properties inside the flagged block:
+
+- **Every property has a direct system-prop equivalent** (layout/flex/space/color/border/positioning/typography — see [`gamut-system-props`](../gamut-system-props/SKILL.md) prop groups) → ✗ error. Remediation: delete the `styled()` wrapper; pass the same values as props directly on the JSX element. Flag `display: 'flex'`/`display:flex` specifically — recommend `FlexBox` instead of `Box` + a `display` prop. Note that `background` (unlike `bg`) has no token scale and takes any CSS value as-is — a gradient string is already valid as a plain `background` prop and does **not** by itself justify a `styled()` wrapper.
+- **Something in the block isn't expressible as a prop** (`background-clip`, `background-blend-mode`, a variant that should branch on a prop, pseudo-selectors) → ⚠ warning. Remediation: keep `styled(ComponentName)`, but move the object into `css()`, `variant()`, or `states()` from `@codecademy/gamut-styles` instead of a raw literal — and pull the properties that _are_ expressible (padding, display, plain colors, gradients via `background`) back out to props rather than leaving them in the escape hatch just because one sibling property forced it.
+
+**Step 4 — raw CSS property names inside `css()` silently skip the scale lookup**
+
+Being wrapped in `css()` (the compliant form from Step 2) isn't enough on its own to guarantee token-scale treatment. `css()`'s scale-aware behavior only applies to the _alias_ keys variance defines (`p`, `m`, `bg`, `borderRadius`, `fontSize`, `fontWeight`, `lineHeight`, …) — not the literal CSS property name. `padding: 24` inside a `css({...})` call is **not** the same as `p: 24`: variance's static-CSS extraction (`getStaticCss` in `packages/variance/src/core.ts`) passes any key it doesn't recognize as an alias straight through as literal, unscaled CSS. A block that _looks_ correct because it's wrapped in `css()` can still bypass the spacing/typography scale entirely if the property inside is spelled out as the raw CSS name instead of its alias.
+
+Grep for raw CSS property names as keys inside a `css({...})` call, where a shorter alias exists:
+
+```
+css\(\s*\{[^}]*\b(padding|margin|background-color|border-radius|font-size|font-weight|line-height)\s*:
+```
+
+(non-exhaustive — the pattern is: any CSS property name used as a key where its alias — `p`/`m`/`bg`/`borderRadius`/`fontSize`/`fontWeight`/`lineHeight` — belongs instead.)
+
+Each match is **✗ error**, not a style nit — the value silently doesn't get the intended token/rem conversion regardless of what number is written, which is a functional bug, not a preference. This check applies independently of Step 3's classification — a block can pass Step 3 (genuinely needs `css()` for one property) and still fail Step 4 (a _different_ property inside that same block used the wrong key name).
+
+**Step 5 — plain `<Box display="flex">`/`<Box display="grid">` (heuristic, not a bypass)**
+
+Unlike Steps 1–4, this isn't a system-props bypass — `display` is a real system prop and the code works correctly. It's a semantic-consistency nit: `FlexBox`/`GridBox` already default to `display: flex`/`display: grid`, so reaching for `Box` + a `display` prop says the same thing more verbosely and loses the more legible component name at the call site.
+
+Grep files already confirmed to import `Box` from `@codecademy/gamut` (Step 1) for:
+
+```
+<Box\b[^>]*\bdisplay=\{?["']flex["']\}?
+<Box\b[^>]*\bdisplay=\{?["']grid["']\}?
+```
+
+Always **⚠ warning**, never ✗ — don't flag `display="inline-flex"`/`"inline-grid"`, or a conditionally-computed `display` (e.g. `display={isOpen ? 'flex' : 'none'}`); both are legitimate reasons to stay on `Box`.
+
+Report as `file:line styled(ComponentName)` with the classification and the specific properties found, e.g.:
+
+```
+src/HeroSection.tsx:14 styled(Box)`...` — display, flex-direction, padding, color: white → delete wrapper, use FlexBox + props (color: use a semantic token)
+src/ColumnTitle.tsx:8 styled(Box)`...` — background-clip: text + padding → wrap in css(), move padding to a prop
+src/GlowShell.tsx:15 css({ padding: 24, ... }) — 'padding' is not a recognized alias; use 'p' or the value bypasses the spacing scale entirely (renders as an unscaled literal px value), even though the block correctly stays in css() for the gradient
+src/Panel.tsx:9 <Box display="flex" ...> — same defaults as FlexBox, more legible as FlexBox
+src/Grid.tsx:14 <Box display="grid" ...> — same defaults as GridBox, more legible as GridBox
+```
+
+Skill references: [`gamut-system-props`](../gamut-system-props/SKILL.md#dont-wrap-a-gamut-component-in-styled-to-hand-write-css) · [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md)
+
+---
+
## Check 4 — Hardcoded colors (semantic-first)
Rule: Inline hex literals in application UI code are violations. Remediation is not “replace hex with `navy-800`” — prefer semantic ColorMode tokens (`text`, `background`, `primary`, …) so light/dark and theme switches stay correct. Reserve raw palette tokens for colors that must stay fixed and for `bg` on `<Background>` from `@codecademy/gamut-styles` (section surfaces with content).
@@ -329,8 +427,50 @@
Skill reference for remediation: [`gamut-testing`](../gamut-testing/SKILL.md)
---
+## Check 6 — Bespoke component duplication
+
+Unlike Checks 1–5, this check is heuristic, not deterministic — every match needs a human glance before acting. **Every Check 6 finding is reported with `⚠`. There is no `✗` option for this check** — if a match feels like a clear-cut violation, that feeling is exactly the failure mode this rule exists to catch (a confident-looking ARIA-role/hand-rolled-listener match is still just a pattern match, not a certainty).
+
+The goal: find custom-built UI that duplicates something `@codecademy/gamut` already provides — a hand-rolled modal, dropdown, tooltip, or focus trap living next to the library that already solves it. See [`gamut-component-first`](../gamut-component-first/SKILL.md) for the full decision table and the "signals" list this check is built from.
+
+**Step 1 — Suspicious ARIA roles without a matching Gamut import**
+
+Grep source files (`.ts`, `.tsx`, `.js`, `.jsx`) for hand-set roles:
+
+```
+role=["']?(dialog|menu|tooltip|listbox|alert)["']?
+```
+
+For each match, check whether the same file imports the corresponding component from `@codecademy/gamut` (`Modal`/`Dialog` for `dialog`, `Menu` for `menu`, `ToolTip`/`PreviewTip`/`InfoTip`/`Tip` for `tooltip`, `SelectDropdown` for `listbox`, `Alert` for `alert`). If the import is absent, report as `⚠ <file>:<line> role="..." with no matching Gamut import`.
+
+**Step 2 — Component files named after a Gamut component that don't import it**
+
+Look for source files whose filename (not import path) matches a known Gamut component name — `Modal`, `Dialog`, `Dropdown`, `Tooltip`, `Popover`, `Menu`, `Toast`, `Accordion`, `Tabs`, `Pagination`, `Avatar`, `Badge`, `Tag` — and check whether that file imports the matching name from `@codecademy/gamut`. A same-named local file that does _not_ import from the library is a strong signal of a parallel implementation. Report as `⚠ <file> filename matches a Gamut component; no @codecademy/gamut import found`.
+
+**Step 3 — Hand-rolled dismiss/focus-trap logic**
+
+Grep for manual Escape-key or outside-click dismiss handling that isn't going through Gamut's `Overlay`/`FocusTrap`/`PopoverContainer`:
+
+```
+(key === ['"]Escape['"]|addEventListener\(['"]keydown)
+```
+
+in files that don't import `Overlay`, `FocusTrap`, or `PopoverContainer` from `@codecademy/gamut`. Same idea for manual outside-click listeners (`addEventListener('click'` at the document/window level alongside a "contains" check). Report as `⚠ <file>:<line> hand-rolled dismiss logic, no FocusTrap/Overlay/PopoverContainer import`.
+
+**Step 4 — SCSS/CSS module files named after a Gamut component**
+
+Cross-reference with Check 3b's SCSS import list: a stylesheet named `Modal.scss`, `Dropdown.module.css`, `Tooltip.scss`, etc. is worth a second look even if Check 3b already flagged the import generically — the filename is the signal that this isn't just "some CSS," it's a parallel version of a specific Gamut component. Report as `⚠ <file> stylesheet named after a Gamut component`.
+
+**Reporting:** for each match, name the likely Gamut component from the [decision table](../gamut-component-first/SKILL.md#decision-table-common-needs) and note this needs manual confirmation — a real product-specific one-off will look identical to a grep tool.
+
+**Before finalizing the report**, re-scan every line under this section specifically for a `✗` icon. If you find one, that's a mistake — change it to `⚠`. **When computing the final `<N> error(s), <N> warning(s)` tally, count every Check 6 match toward the warning total, never the error total, even if a `✗` slipped through above** — this is the one place a stray icon can't corrupt the report's headline numbers.
+
+Skill reference for remediation: [`gamut-component-first`](../gamut-component-first/SKILL.md)
+
+---
+
## Output format
```
Gamut Review — <absolute path>
@@ -357,15 +497,18 @@
✗ Deep src imports 2 occurrences
src/Thing.tsx:7
src/Other.tsx:12
-SCSS modules & className [→ gamut-system-props] [→ gamut-style-utilities]
+SCSS modules, className & inline style [→ gamut-system-props] [→ gamut-style-utilities] [→ gamut-color-mode]
✗ SCSS/CSS imports 14 files — migrate to system props and css()/variant()
src/components/Card/Card.scss
src/components/Nav/Nav.scss (+ 12 more)
✗ className on Gamut components 9 occurrences
src/components/Card/Card.tsx:14 <Box className={styles.wrapper}>
src/components/Nav/Nav.tsx:7 <Text className={styles.title}>
+ ✗ inline style on Gamut components 3 occurrences — use system props or css()/variant()/states() with semantic tokens
+ src/components/Hero/Hero.tsx:31 <Box style={{ color: '#10162F' }}>
+ src/components/Nav/Nav.tsx:19 <Text style={{ marginTop: 8 }}> (eslint-disabled) ⚠
Nested selectors [→ gamut-system-props] [→ gamut-style-utilities]
⚠ Tag selectors 3 occurrences — replace with system props or layout components (FlexBox, GridBox)
src/components/Nav/Nav.tsx:18 div { ... }
@@ -373,8 +516,18 @@
⚠ Gamut component selectors 1 occurrence — use system props directly instead
src/components/Layout/Layout.tsx:12 ${Box} { align-self: start; }
(or: ✓ none found)
+styled(GamutComponent) bypassing system props [→ gamut-system-props] [→ gamut-style-utilities]
+ ✗ styled(Box) raw CSS 1 occurrence — every property has a prop equivalent
+ src/HeroSection.tsx:14 display, flex-direction, padding, color: white → delete wrapper, use FlexBox + props
+ ⚠ styled(Box) raw CSS 1 occurrence — partially expressible, needs css() not a raw literal
+ src/ColumnTitle.tsx:8 background-clip: text + padding → wrap in css(), move padding to a prop
+ ⚠ Box used where FlexBox/GridBox fits 2 occurrences — same defaults, more legible name
+ src/Panel.tsx:9 <Box display="flex" ...> → FlexBox
+ src/Grid.tsx:14 <Box display="grid" ...> → GridBox
+ (or: ✓ none found)
+
Hardcoded colors [→ gamut-color-mode]
✗ src/Card.tsx:22 '#10162F' → semantic: text | palette: navy-800 | note: Core light body copy
⚠ src/Hero.tsx:14 '#1557FF' → semantic: primary (if link/CTA) | palette: blue-500 | note: no exact semantic; confirm theme
⚠ src/Nav.tsx:8 '#BADA55' → semantic: (n/a) | palette: — | note: no Gamut token
@@ -387,12 +540,17 @@
src/components/Bar/__tests__/Bar.test.tsx:5
⚠ direct component-test-setup import 1 occurrence — import from @codecademy/gamut-tests
src/components/Baz/__tests__/Baz.test.tsx:2
+Bespoke component duplication (heuristic — confirm manually) [→ gamut-component-first]
+ ⚠ src/components/ConfirmDialog/ConfirmDialog.tsx:9 role="dialog" with no Modal/Dialog import — likely reinventing gamut-modal
+ ⚠ src/components/Dropdown/Dropdown.tsx filename matches a Gamut component; no @codecademy/gamut import found — compare against SelectDropdown
+ (or: ✓ none found)
+
══════════════════════════════════════════════════
<N> error(s), <N> warning(s) found. (or "All checks passed." if none)
```
Icons: `✓` = pass, `⚠` = warning (recommended, not required), `✗` = error (required).
`[→ skill-name]` annotations indicate which Gamut skill has remediation guidance for that category.
-After printing the report, offer one sentence of prioritized next-step advice based on what was found.
+After printing the report, offer one sentence of prioritized next-step advice based on what was found, then ask the user whether they'd like you to fix the findings — do not apply any remediation unprompted. Index: package/agent-tools/skills/gamut-style-utilities/SKILL.md
===================================================================
--- package/agent-tools/skills/gamut-style-utilities/SKILL.md
+++ package/agent-tools/skills/gamut-style-utilities/SKILL.md
@@ -14,18 +14,37 @@
Use `css()`, `variant()`, and `states()` from `@codecademy/gamut-styles` for typed, token-scaled style objects (same scales as composed `system.*` props). Prefer semantic color keys so styles track ColorMode and theme.
For layout-heavy styled components, prefer composing `system.*` via `variance.compose()` (see `gamut-system-props`) instead of re-stating every longhand in `css()`.
+**Wrapping an existing Gamut component** (`styled(Box)`, `styled(Text)`, …) in a raw template literal or plain object instead of `css()`/`variant()`/`states()` is a common bypass — before reaching for any of the APIs below on an already-Gamut component, read [`gamut-system-props`](../gamut-system-props/SKILL.md#dont-wrap-a-gamut-component-in-styled-to-hand-write-css) first. Most of the time the fix is deleting the `styled()` wrapper entirely, not picking a different one of these three functions.
+
+**Every value inside `css()`/`variant()`/`states()` is only as good as the scale key you type — and a plausible-sounding key that doesn't exist won't warn you, it just silently fails to apply.** Don't infer a key name from what it should logically be called (e.g. `borderRadius: 'max'` for "fully rounded" — the real key is `full`); check the actual scale before typing it:
+
+| Prop group | Scale file | Real keys (non-exhaustive) |
+| -------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
+| `borderRadius` | `packages/gamut-styles/src/variables/borderRadii.ts` | `none`, `sm`, `md`, `lg`, `xl`, `full` |
+| `p`/`m`/spacing | `packages/gamut-styles/src/variables/spacing.ts` | `0`, `4`, `8`, `12`, `16`, `24`, `32`, `40`, `48`, `64`, `96` |
+| `fontSize` | `packages/gamut-styles/src/variables/typography.ts` | `14`, `16`, `18`, `20`, `22`, … |
+| `fontWeight` | same file | `base`, `title`, `400`, `700` |
+| `lineHeight` | same file | `base`, `title`, `spacedTitle` |
+| `color`/`bg`/`borderColor` | `packages/gamut-styles/src/variables/colors.ts` + theme `modes` in `packages/gamut-styles/src/themes/*.ts` | palette names and the semantic aliases covered above |
+
+If you're not certain a key exists, grep the relevant file rather than guessing — a wrong key compiles (these are typically loosely typed as strings at the call site) and produces no error, it just doesn't render the intended style.
+
## `css()` — static style objects
```tsx
import { css } from '@codecademy/gamut-styles';
import styled from '@emotion/styled';
+// fixed color — deliberately does NOT adapt to color mode (rare; see caveat below)
const Box = styled.div(css({ bg: 'navy-400', p: 4 }));
+// semantic color — adapts to color mode (the default choice, not the exception)
const Text = styled.div(css({ color: 'primary', p: 4 }));
```
+**Read [`gamut-color-mode`](../gamut-color-mode/SKILL.md) before typing a raw palette name (`navy-400`, `green-0`, `yellow-900`, …) into a `css()`/`variant()`/`states()` color value.** The `navy-400` example above is the _exception_, not a template to copy — it's there to show the fixed-color escape hatch exists, not to model the default path. If you're defining a status/feedback color (success, warning, error, info-adjacent), check first whether a semantic token already covers it — `background-success`/`feedback-success`, `background-warning`/`feedback-warning`, etc. already exist and adapt correctly between light/dark mode; a same-hue raw palette pair (`green-0`/`green-900`) looks similar in whichever mode you tested in but silently stops adapting. Reach for a raw palette value only when the color must **not** adapt (illustrations, brand marks, a permanently fixed surface) — the same exception `gamut-review` Check 4 and `gamut-system-props` use for this exact judgment call.
+
## `variant()` and `states()` — branching and toggles
- `variant()` — mutually exclusive modes: `base`, `defaultVariant`, and a `variants` map (semantic colors, spacing shorthands, nested selectors such as `'&:hover'`).
@@ -98,4 +117,21 @@
const theme = useTheme();
return <path strokeWidth={theme.spacing[4]} d="M0 0 L10 10" />;
};
```
+
+**For colors, `theme.colors[key]` is usually enough — it works with aliases and doesn't need a second lookup step.** `theme.colors[key]` (including semantic aliases like `'primary'` or `'background-current'`) returns a live CSS variable reference (`var(--color-key)`), which the browser resolves through the normal light/dark cascade. This works as a plain attribute value on DOM-rendered elements — including SVG presentation attributes like `stroke`/`fill` — exactly like `RadialProgress` does it in production: `stroke={theme.colors['background-current']}` (`packages/gamut/src/RadialProgress/index.tsx`). Reach for this first; it's simpler and it's what real Gamut components actually do for imperative SVG color.
+
+**The exception**: a `var(...)` string doesn't mean anything to a consumer outside the DOM/CSS rendering pipeline — canvas 2D `fillStyle`, WebGL, or any color-math (contrast calculations, blending) that needs the actual resolved value. For those, use `useColorModes()` instead of bare `useTheme()` — but its `getColorValue()` only accepts a **raw palette key** (`hyper-500`, `navy-800`, …), not an alias: `getColorValue('primary')` silently returns `undefined`, since aliases are never merged into the raw-key map `getColorValue` reads. Resolve the alias first with `isColorAlias`, exactly like `packages/gamut-styles/src/Background.tsx` does before computing contrast:
+
+```tsx
+import { isColorAlias, useColorModes } from '@codecademy/gamut-styles';
+
+const useResolvedColor = (color: Colors) => {
+ const [, activeColors, , getColorValue] = useColorModes();
+ return isColorAlias(activeColors, color)
+ ? getColorValue(activeColors[color])
+ : getColorValue(color);
+};
+```
+
+Don't reach for this chain by default — it's for the narrower case where you need a literal value, not for every plain-JS color read. Index: package/agent-tools/skills/gamut-system-props/SKILL.md
===================================================================
--- package/agent-tools/skills/gamut-system-props/SKILL.md
+++ package/agent-tools/skills/gamut-system-props/SKILL.md
@@ -6,9 +6,9 @@
# Gamut System Props
Source: `@codecademy/gamut-styles` — `packages/gamut-styles/src/variance/config.ts` (definitions) and `packages/gamut-styles/src/variance/props.ts` (`variance.create` groups). `Box`, `FlexBox`, and `GridBox` compose the same groups in `packages/gamut/src/Box/props.ts`.
-See also: [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) (`css`, `variant`, `states`, `StyleProps`). [Styleguide — Best practices](https://gamut.codecademy.com/?path=/docs-meta-best-practices--page) (semantic colors, responsive examples) and Storybook [Responsive properties](https://gamut.codecademy.com/storybook/?path=/docs-foundations-system-responsive-properties--page).
+See also: [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) (`css`, `variant`, `states`, `StyleProps`). [`gamut-color-mode`](../gamut-color-mode/SKILL.md) — **read this before picking a value for `color`/`bg`/`borderColor`**; this skill only tells you the prop exists, not which token belongs in it. [Styleguide — Best practices](https://gamut.codecademy.com/?path=/docs-meta-best-practices--page) (semantic colors, responsive examples) and Storybook [Responsive properties](https://gamut.codecademy.com/storybook/?path=/docs-foundations-system-responsive-properties--page).
## Overview
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`.
@@ -177,8 +177,42 @@
```
Full typings and behavior: [Responsive properties (Storybook)](https://gamut.codecademy.com/storybook/?path=/docs-foundations-system-responsive-properties--page).
+## Don't wrap a Gamut component in `styled()` to hand-write CSS
+
+`Box`, `FlexBox`, `GridBox`, `Text`, and the rest of `@codecademy/gamut` already compose the prop groups above. Writing ` styled(Box)`` display: flex; padding: 16px; `` (a tagged template) or `styled(Box)({ display: 'flex', padding: 16 })`(a plain object, not`css()`) throws that API away — the wrapper's raw CSS gets none of the token scaling, responsive-object/array syntax, or ColorMode resolution the same properties would get as props, and it duplicates an API the component already exposes directly. This is the same bypass as `className`or an inline`style` prop on a Gamut component (see [`gamut-review`](../gamut-review/SKILL.md) Check 3b) — it's just wearing a `styled()` costume.
+
+```tsx
+// wrong — Box already has all of these as props
+const HeroContainer = styled(Box)`
+ display: flex;
+ flex-direction: column;
+ padding: 16px;
+ margin-top: 24px;
+ color: white;
+`;
+
+// correct — two separate fixes, don't do just one:
+// (1) delete the wrapper — display:flex → use FlexBox, not Box + display="flex"
+// (2) color changed from the raw palette name "white" to the semantic token
+// "text" — see the callout below, this is not a typo
+<FlexBox flexDirection="column" p={16} mt={24} color="text">
+```
+
+Check every property in the block against the prop groups above (`system.layout`, `system.space`, `system.color`, `system.flex`, `system.positioning`, …) before reaching for `styled()` at all. If everything in the block has a direct prop equivalent, there should be no `styled()` wrapper — the values belong inline on the JSX element.
+
+**Fixing the wrapper is not the whole job when the property is a color.** A request phrased as a literal color name ("white text", "navy background") describes what the design should _look like_, not necessarily which token to pass — read [`gamut-color-mode`](../gamut-color-mode/SKILL.md) to decide between:
+
+- **Most cases**: the color should adapt to light/dark — map the described appearance to the semantic alias that currently resolves to it (e.g. "white text" on a component that should read normally in both modes is usually `text` — which resolves to `white` in dark mode already — not a literal `color="white"` that stays white even in light mode).
+- **Fixed-color cases**: the color must **not** adapt (illustrations, brand marks, a permanently-dark hero surface) — here a raw palette name like `white` or `navy-800` is the _correct_, deliberate choice, same exception `gamut-review` Check 4 uses for hex literals. Don't "fix" this by forcing a semantic token onto something that was never supposed to adapt.
+
+Either way, decide which case you're in — don't default to typing the literal color name from the request just because it's the fastest way to satisfy the sentence.
+
+**Check the `system.background` prop before assuming a gradient needs `styled()`.** `background` (unlike `bg`) has no token scale — it passes straight through to the CSS `background` property, so a full gradient string (`background="radial-gradient(...)"` or `linear-gradient(...)`) is already valid as a plain prop, no wrapper needed. A gradient is not, on its own, a reason to reach for `styled()`.
+
+**If something genuinely isn't expressible as a prop** (`background-clip: text`, `background-blend-mode`, a variant that should branch on a prop rather than live as a boolean pile, pseudo-selectors like `&:hover`) — keep wrapping the component, but wrap the style value in `css()`, `variant()`, or `states()` from `@codecademy/gamut-styles` instead of a raw template literal or plain object. That keeps every _other_ property in the same block token-typed and theme-aware, and is the only form of `styled(GamutComponent)` this rule doesn't flag. See [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) for `css()`/`variant()`/`states()`. Don't let one non-expressible property drag otherwise-plain properties (`padding`, `display`) into the same raw-CSS escape hatch — move those back out to props.
+
## Using `css()` for styled definitions
For static styles in styled components, use `css()` from `@codecademy/gamut-styles` (same implementation as `system.css` on the `system` namespace).