@codecademy/gamut
68.6.068.6.1-alpha.bc8b32.0
agent-tools/skills/gamut-accessibility/SKILL.md+
agent-tools/skills/gamut-accessibility/SKILL.mdNew file+214
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
@@ -0,0 +1,214 @@
+---
+name: gamut-accessibility
+description: Deep Gamut accessibility reference (component matrix, overlays, tips, live regions, checklists). Form wiring and validation UX live in **`gamut-forms`**. Universal HTML/ARIA/focus/color rules: always-loaded **`accessibility.mdc`** — read that first; this skill does not duplicate them.
+---
+
+# Gamut Accessibility
+
+Source: `@codecademy/gamut` — **`react-aria-components`** is used only in **[`packages/gamut/src/Tabs/`](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Tabs/)** (`Tabs.tsx`, `TabList.tsx`, `Tab.tsx`, `TabPanel.tsx`). **`react-focus-on`** powers **`FocusTrap`** ([`packages/gamut/src/FocusTrap/index.tsx`](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/FocusTrap/index.tsx)), used by overlays such as **`Overlay`** ([`packages/gamut/src/Overlay/index.tsx`](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Overlay/index.tsx)) and **`Popover`**. Other widgets (e.g. **`Menu`**, **`DatePicker`**) implement keyboard and ARIA in Gamut code — verify behavior in Storybook and source, do not assume React Aria.
+
+**Product-oriented button variants and props:** [`guidelines/components/buttons.md`](../../guidelines/components/buttons.md)
+
+---
+
+## Universal rules
+
+Prefer native HTML, minimal ARIA, correct roles, visible names, focus visibility, semantic color / `ColorMode`, and Gamut primitives — see the always-loaded **Gamut Accessibility Rules**: [`accessibility.mdc`](../../rules/accessibility.mdc). This skill adds **Gamut component behavior** and audit detail below.
+
+---
+
+## How Gamut handles accessibility
+
+**Tabs** use **`react-aria-components`** (see `packages/gamut/src/Tabs/*.tsx`) for roving tabindex and keyboard navigation. **Overlays** (e.g. **`Overlay`**, **`Popover`**) use **`FocusTrap`** → **`react-focus-on`** for focus containment and Escape/outside close. **Other** interactive components (**`Menu`**, **`DatePicker`**, **`Modal`**, **`Dialog`**, etc.) rely on **in-repo implementations** — supply **accessible names**, **wire labels to controls**, and **avoid duplicating** what a component already sets (`aria-live`, `aria-describedby`, tabindex, etc.); confirm in source when auditing.
+
+---
+
+## Component reference (index)
+
+There is **no** exported `<Button>` — use **`FillButton`**, **`TextButton`**, **`StrokeButton`**, **`CTAButton`**, and **`IconButton`** (shared **`ButtonProps`** type). Prefer these over `<div onClick>` or `<span role="button">`.
+
+**Forms** — **`FormGroup`**, **`ConnectedForm`** / **`ConnectedFormGroup`**, **`GridForm`**, field atoms (**`Select`**, **`Checkbox`**, **`Radio`**), validation, **`aria-live`** / **`aria-describedby`**: canonical reference is **[`gamut-forms`](../gamut-forms/SKILL.md)**.
+
+| 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 [`buttons.md`](../../guidelines/components/buttons.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). |
+| **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). |
+| **Menu**, **MenuItem**, **MenuSeparator** | List + **`MenuProvider`** (keyboard / roles depend on variant) | Label **`Menu`** / menubar per pattern; follow Storybook [Molecules / Menu](https://gamut.codecademy.com/?path=/docs-molecules-menu--docs). |
+| **Popover** | **`FocusTrap`** when open (unless **`skipFocusTrap`**), positioning | **`onRequestClose`**, meaningful **`role`** when needed; do not trap focus unnecessarily when **`skipFocusTrap`**. |
+| **Flyout** | **`Overlay`**, **`Drawer`**, visible **`title`**, close **`IconButton`** with **`tip={closeLabel}`** | Pass **`title`** and **`closeLabel`**; name panel content. |
+| **Drawer** | Focuses container when **`expanded`**, **`tabIndex={-1}`** on shell | Drawer is a surface, not a full dialog — supply headings/labels inside for screen readers. |
+| **Disclosure** | **`DisclosureButton`** drives expand/collapse | Provide **`heading`** / structure so the control’s purpose is clear. |
+| **Toggle** | **`ToggleLabel`** + **`htmlFor`** wired to control **`id`** | With no visible **`label`**, pass **`ariaLabel`** (or use `as="button"` pattern per props). |
+| **ToolTip** | Floating mode renders a screen-reader **`role="tooltip"`** branch with **`id`** | Pass the same **`id`** to the trigger’s **`aria-describedby`** when you rely on the tooltip as supplementary description (see component **`id`** JSDoc). |
+| **InfoTip** | — | **`ariaLabel`** or **`ariaLabelledby`** (camelCase) — no automatic fallback. |
+| **PreviewTip** | **`Anchor`**-based preview, focus-driven content | **`linkDescription`** and visible anchor text; do not use the preview as the sole name for an unrelated control. |
+| **SkipToContent** | Skip link behavior | Place early in the tab order; **`href`** target **`id`** must exist on main content. |
+| **Toast** + **Toaster** | **`Toaster`** wraps the stack in **`aria-live="polite"`** | Keep messages concise; avoid stacking many simultaneous assertive announcements. |
+| **Pagination** | Page / control buttons | Ensure current page and actions are perceivable (labels / `aria-current` patterns per Storybook). [Molecules / Pagination](https://gamut.codecademy.com/?path=/docs-molecules-pagination--docs). |
+| **FocusTrap** | Escape, outside click, **`allowPageInteraction`** | Return focus to trigger on close for custom overlays. |
+
+```tsx
+// correct
+<IconButton icon={DeleteIcon} tip="Delete item" onClick={handleDelete} />
+
+// wrong — no accessible name, no keyboard semantics
+<div onClick={handleDelete}><DeleteIcon /></div>
+```
+
+### Dialog / Modal (detail)
+
+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).
+
+```tsx
+<Dialog
+ title="Confirm deletion"
+ confirmCta={{ children: 'Delete', onClick: handleDelete }}
+ onRequestClose={handleClose}
+ isOpen={open}
+/>
+```
+
+### Alert (detail)
+
+Renders with **`aria-live="polite"`** and **`role="status"`** by default. Override with **`aria-live="assertive"`** only for time-sensitive errors requiring immediate interruption. Do not nest **`<Alert>`** inside another live region.
+
+### Tabs (detail)
+
+Built on **`react-aria-components`**. Follows the [ARIA Tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/): arrow keys navigate tabs, Tab moves focus into the active panel, Home/End jump to first/last tab. The tablist is a composite — only the active tab is in the tab sequence (roving tabindex). No manual **`aria-selected`** or keyboard handling needed.
+
+### InfoTip (example)
+
+**`<InfoTip>`** needs **`ariaLabel`** or **`ariaLabelledby`** — see also the always-loaded rules.
+
+```tsx
+<InfoTip ariaLabel="More information about billing" />
+```
+
+### ToolTip (detail)
+
+When **`placement="floating"`**, the component renders a screen-reader-only branch with **`role="tooltip"`** and an optional **`id`**. Pass the **same `id`** to the described element’s **`aria-describedby`** so assistive tech associates the tooltip copy with the trigger. Inline placement uses the wrapper differently — see [Molecules / Tips / ToolTip](https://gamut.codecademy.com/?path=/docs-molecules-tips-tooltip--docs).
+
+### SkipToContent (detail)
+
+Include **`<SkipToContent>`** as the first focusable element in the page shell. The main content region must expose a matching **`id`** for the skip target.
+
+---
+
+## Focus management
+
+**`<FocusTrap>`** is for custom overlay patterns not covered by **`Dialog`** / **`Modal`**.
+
+Key props:
+
+- **`active`** — enable/disable the trap dynamically
+- **`onEscapeKey`** — close handler
+- **`onClickOutside`** — dismiss on outside click
+- **`allowPageInteraction`** — permit scrolling outside the trap without closing
+
+Always return focus to the trigger on close. **`react-focus-on`** (via **`FocusTrap`**) and overlay flows handle much of this for dialogs/popovers; **`Tabs`** inherit focus behavior from **`react-aria-components`**. Custom surfaces must store a ref to the trigger and call **`.focus()`** on close when the library does not.
+
+---
+
+## Composite widgets and managed focus
+
+ARIA composite roles (`listbox`, `menu`, `tree`, `grid`, `tablist`) use **managed focus**: only one element in the composite is in the tab sequence at a time. Tab moves focus to the next element outside the composite; arrow keys move focus within it.
+
+Implementation pattern — roving tabindex:
+
+- Set **`tabIndex={0}`** on the currently active item
+- Set **`tabIndex={-1}`** on all other items
+- On arrow key, update which item holds **`tabIndex={0}`** and call **`.focus()`** on it
+
+**`Tabs`** (`react-aria-components`) implement roving tabindex for the tablist pattern. **`Menu`** and other composites implement focus in Gamut — if you build a **custom** composite, implement roving tabindex yourself. A flat **`tabIndex={0}`** on every item is wrong — it puts every item in the sequential tab order.
+
+---
+
+## Device-independent events
+
+Use **`click`** for activation, not **`mousedown`**. **`click`** follows pointer activation; native **`<button>`** (and similar controls) also fire **`click`** from keyboard (Space and Enter). A focused **`<a href>`** is usually activated with **Enter**, which fires **`click`** — Space often scrolls the page instead of activating the link. **`mousedown`** does not represent keyboard activation, so relying on it alone breaks keyboard-only use.
+
+For custom elements with **`role="button"`**, do not assume the browser will synthesize **`click`** from the keyboard the way it does for native interactive elements (**`<button>`**, **`<a href>`**, and other built-ins). Handle **`keydown`** for Space and Enter explicitly:
+
+```tsx
+const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault();
+ handleActivation();
+ }
+};
+```
+
+Prefer Gamut **`*Button`** components (or **`Anchor`** with a real **`href`**) so you do not reimplement this.
+
+---
+
+## Live regions
+
+| Scenario | Pattern |
+| ------------------------------------------ | --------------------------------------------------- |
+| Status updates, non-critical notifications | **`aria-live="polite"`** |
+| Urgent global interruptions | **`aria-live="assertive"`** (use sparingly) |
+| Frequently updating counts or progress | **`aria-live="polite"`** + **`aria-atomic="true"`** |
+
+Form-bound **`aria-live`** and **`FormError`** patterns: see **Forms** above (do not assume assertive on every field error).
+
+Inject live regions into the DOM before they need to announce. A region added simultaneously with its first announcement may be ignored by some assistive technologies.
+
+Do not elevate unrelated inline errors to **`assertive`** — reserve assertive for urgent interruptions the user did not directly trigger.
+
+---
+
+## ARIA authoring rules
+
+- **No redundant roles**: don't set **`role="button"`** on **`<button>`** or **`role="heading"`** on **`<h2>`**
+- **`aria-hidden` cascades**: placing **`aria-hidden="true"`** on a parent removes the entire subtree from the accessibility tree, including focusable descendants — never put it on an ancestor of a focusable element
+- **`role="presentation"`** and **`aria-hidden`** on focusable elements: both are prohibited on elements that can receive focus — they remove semantics while leaving the element keyboard-reachable, producing an operable but unnamed control
+- **Labelling vs describing**: **`aria-label`** / **`aria-labelledby`** name the control. **`aria-describedby`** provides supplementary context. Both can coexist on the same element
+- **Required fields**: use **`aria-required="true"`** or the HTML **`required`** attribute. Visual asterisks must have an explanatory text string visible on the page; the asterisk glyph itself should carry **`aria-hidden="true"`** — **`<FormGroupLabel>`** already handles this
+- **`display:none` vs `aria-hidden`**: elements with **`display:none`** are already removed from the accessibility tree; adding **`aria-hidden`** is redundant. Use **`aria-hidden="true"`** only when an element is visually present but should be hidden from assistive technology
+
+---
+
+## Color and contrast (non-text)
+
+Semantic tokens, **`ColorMode`**, and **`<Background>`** are covered in the always-loaded **`accessibility.mdc`** rule and the **`gamut-color-mode`** skill. Here: non-text contrast (focus rings, input borders, icon affordances) should meet **~3:1** vs adjacent colors where WCAG **1.4.11** applies — validate in your layout.
+
+---
+
+## Testing checklist
+
+- [ ] Full keyboard navigation: every interactive element reachable and operable without a mouse
+- [ ] Focus is always visible and never lost or unexpectedly trapped
+- [ ] Dialogs trap focus correctly; Escape closes; focus returns to the trigger
+- [ ] Composite widgets (tabs, menus, listboxes) use arrow keys internally, not Tab
+- [ ] All form inputs have programmatically associated labels (not placeholder-only)
+- [ ] Form errors surface through the library’s **`FormError`** / live-region patterns (**Forms** above)
+- [ ] Icon-only controls have accessible names
+- [ ] No content relies solely on color to convey meaning
+- [ ] Screen reader matrix: VoiceOver + Safari (iOS), VoiceOver + Chrome (macOS), NVDA + Chrome (Windows)
+- [ ] 200% zoom: layout intact, no content overflow or disappearance
+
+---
+
+## Common anti-patterns
+
+| Anti-pattern | Fix |
+| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
+| **`<div onClick={…}>`** for actions | **`FillButton`**, **`TextButton`**, **`StrokeButton`**, **`CTAButton`**, or **`IconButton`** (with **`tip`**) |
+| **`placeholder`** as the only label | **`FormGroupLabel`** with matching **`htmlFor`** / **`id`** |
+| **`aria-label`** on a **`<div>`** with no role | Add a meaningful **`role`** or use a semantic element |
+| **`role="button"`** without Space/Enter handlers | Use a Gamut **`*Button`**, **`Anchor`** with **`href`**, or add **`keydown`** |
+| **`tabIndex={0}`** on every item in a composite | Roving tabindex: **`0`** on active item, **`-1`** on rest |
+| Tooltip as the only accessible name for a control | Set **`aria-label`** (or visible text) on the control as well |
+| **`aria-hidden="true"`** on a focusable element | Also remove from tab order (**`tabIndex={-1}`**) or restructure |
+| **`mousedown`** for activation | Use **`click`** |
+| **`outline: none`** without a replacement | Use Gamut’s built-in focus styles |
+| Multiple **`aria-live`** regions for the same content stream | One region per logical stream; reuse it across updates |