@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).