From c3e406a314e15ee82624147da11a1192bab86dd6 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Mon, 30 Mar 2026 13:11:34 -0400 Subject: [PATCH 1/5] feat(docs): Gamut cursor plugins --- .cursor-plugin/marketplace.json | 26 ++++++ .cursor/rules/gamut-library.mdc | 23 +++++ gamut-cursor-plugins/README.md | 87 +++++++++++++++++++ .../gamut-a11y/.cursor-plugin/plugin.json | 11 +++ .../gamut-a11y/rules/gamut-accessibility.mdc | 20 +++++ .../skills/gamut-accessibility/SKILL.md | 68 +++++++++++++++ .../gamut-core/.cursor-plugin/plugin.json | 11 +++ .../gamut-core/rules/gamut-consumer.mdc | 18 ++++ .../gamut-core/skills/gamut-consumer/SKILL.md | 49 +++++++++++ .../skills/gamut-consumer/reference.md | 55 ++++++++++++ .../gamut-themes/.cursor-plugin/plugin.json | 11 +++ .../gamut-themes/rules/gamut-theming.mdc | 19 ++++ .../skills/gamut-theming/SKILL.md | 65 ++++++++++++++ 13 files changed, 463 insertions(+) create mode 100644 .cursor-plugin/marketplace.json create mode 100644 .cursor/rules/gamut-library.mdc create mode 100644 gamut-cursor-plugins/README.md create mode 100644 gamut-cursor-plugins/gamut-a11y/.cursor-plugin/plugin.json create mode 100644 gamut-cursor-plugins/gamut-a11y/rules/gamut-accessibility.mdc create mode 100644 gamut-cursor-plugins/gamut-a11y/skills/gamut-accessibility/SKILL.md create mode 100644 gamut-cursor-plugins/gamut-core/.cursor-plugin/plugin.json create mode 100644 gamut-cursor-plugins/gamut-core/rules/gamut-consumer.mdc create mode 100644 gamut-cursor-plugins/gamut-core/skills/gamut-consumer/SKILL.md create mode 100644 gamut-cursor-plugins/gamut-core/skills/gamut-consumer/reference.md create mode 100644 gamut-cursor-plugins/gamut-themes/.cursor-plugin/plugin.json create mode 100644 gamut-cursor-plugins/gamut-themes/rules/gamut-theming.mdc create mode 100644 gamut-cursor-plugins/gamut-themes/skills/gamut-theming/SKILL.md diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 00000000000..f878fbd2507 --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,26 @@ +{ + "name": "codecademy-gamut-cursor", + "owner": { + "name": "Codecademy" + }, + "metadata": { + "description": "Cursor plugins for the Gamut design system: core usage, accessibility, and theming." + }, + "plugins": [ + { + "name": "codecademy-gamut", + "source": "gamut-cursor-plugins/gamut-core", + "description": "Core Gamut consumption: layout, system props, gamut-styles utilities, ESLint alignment, Storybook links." + }, + { + "name": "codecademy-gamut-a11y", + "source": "gamut-cursor-plugins/gamut-a11y", + "description": "WCAG-minded Gamut usage and composition when primitives are incomplete." + }, + { + "name": "codecademy-gamut-themes", + "source": "gamut-cursor-plugins/gamut-themes", + "description": "ColorMode, Background, semantic tokens, hooks, and platform themes." + } + ] +} diff --git a/.cursor/rules/gamut-library.mdc b/.cursor/rules/gamut-library.mdc new file mode 100644 index 00000000000..075ea6a8e37 --- /dev/null +++ b/.cursor/rules/gamut-library.mdc @@ -0,0 +1,23 @@ +--- +description: >- + Standards for editing Gamut design system packages (components, styles, tokens). + Use when changing files under packages/gamut, gamut-styles, gamut-patterns, + gamut-icons, gamut-illustrations, or styleguide component docs. +globs: + - packages/gamut/**/* + - packages/gamut-styles/**/* + - packages/gamut-patterns/**/* + - packages/gamut-icons/**/* + - packages/gamut-illustrations/**/* + - packages/styleguide/**/* +alwaysApply: false +--- + +# Gamut library packages + +- Prefer extending existing components in `packages/gamut` over duplicating patterns. +- Read token sources in `packages/gamut-styles/src/variables/` before changing colors, spacing, type, or radii. Avoid hardcoded hex and non-token pixel values. +- Use semantic color keys in component styles so they work under every ColorMode. +- Add or update Storybook MDX in `packages/styleguide` for public API or behavior changes. +- Follow `.cursor/rules/figma-rules.mdc` for icon/pattern/illustration package usage when implementing from design. +- For detailed workflows, use the **gamut-library-authoring** Cursor skill (`/gamut-library-authoring` in chat, or open `.cursor/skills/gamut-library-authoring/SKILL.md`). diff --git a/gamut-cursor-plugins/README.md b/gamut-cursor-plugins/README.md new file mode 100644 index 00000000000..cd7621b1c08 --- /dev/null +++ b/gamut-cursor-plugins/README.md @@ -0,0 +1,87 @@ +# Gamut Cursor plugins + +Three [Cursor plugins](https://cursor.com/docs/plugins) for teams using the [Gamut](https://gamut.codecademy.com/) design system: + +| Plugin | Folder | Purpose | +| --- | --- | --- | +| **codecademy-gamut** | `gamut-core/` | Layout, system props, `gamut-styles` utilities, ESLint alignment, Storybook links | +| **codecademy-gamut-a11y** | `gamut-a11y/` | WCAG-minded usage, keyboard/focus, ARIA, composition patterns | +| **codecademy-gamut-themes** | `gamut-themes/` | `ColorMode`, `Background`, semantic tokens, hooks, platform themes | + +Library authors working **inside** the [Codecademy/gamut](https://github.com/Codecademy/gamut) repository should use the monorepo skill `.cursor/skills/gamut-library-authoring/` and `.cursor/rules/gamut-library.mdc` (not shipped in these plugins). + +## Multi-plugin marketplace + +When this folder lives inside the **Gamut monorepo**, the Team Marketplace manifest is at the **repository root**: [`.cursor-plugin/marketplace.json`](../.cursor-plugin/marketplace.json). It points at `gamut-cursor-plugins/gamut-core`, `gamut-a11y`, and `gamut-themes`. + +If you **extract** `gamut-cursor-plugins/` to its own Git repository, move `.cursor-plugin/marketplace.json` to that repo root and set each plugin `source` to `gamut-core`, `gamut-a11y`, and `gamut-themes` (no prefix). + +Admins can mark **codecademy-gamut** as required and **a11y** / **themes** as optional or required per distribution group. See [Team marketplaces](https://cursor.com/docs/plugins). + +## Testing instructions + +Use these steps whenever you change plugin content and need to confirm Cursor loads rules and skills correctly. See [Creating plugins — Test plugins locally](https://cursor.com/docs/plugins#test-plugins-locally). + +### 1. Install plugins locally (symlink) + +Each published plugin must be its **own folder** under `~/.cursor/plugins/local/`, with `.cursor-plugin/plugin.json` at that folder’s root (the `gamut-core`, `gamut-a11y`, and `gamut-themes` directories satisfy that). + +From a clone of this repo, adjust `GAMUT_ROOT` and run: + +```bash +GAMUT_ROOT="/path/to/gamut" # e.g. ~/code/gamut + +mkdir -p ~/.cursor/plugins/local +ln -sfn "$GAMUT_ROOT/gamut-cursor-plugins/gamut-core" ~/.cursor/plugins/local/codecademy-gamut +ln -sfn "$GAMUT_ROOT/gamut-cursor-plugins/gamut-a11y" ~/.cursor/plugins/local/codecademy-gamut-a11y +ln -sfn "$GAMUT_ROOT/gamut-cursor-plugins/gamut-themes" ~/.cursor/plugins/local/codecademy-gamut-themes +``` + +Using **`-n`** forces the symlink name; the **folder name** under `local/` can match the plugin `name` in `plugin.json` (here: `codecademy-gamut`, etc.) for clarity. + +Alternatively **copy** the three folders into `~/.cursor/plugins/local/` if you prefer not to symlink. + +### 2. Reload Cursor + +- Restart the Cursor app, **or** +- Command Palette → **Developer: Reload Window** + +### 3. Verify in Settings + +1. Open **Settings** (e.g. `Cmd+Shift+J` on macOS). +2. **Rules** — Confirm entries from the plugins appear (e.g. `gamut-consumer`, `gamut-accessibility`, `gamut-theming`). Set modes (**Always** / **Agent Decides** / **Manual**) as your team prefers. +3. **Skills** — Confirm **gamut-consumer**, **gamut-accessibility**, and **gamut-theming** are listed (under Agent Decides / manual invocation per your Cursor version). + +### 4. Smoke-test behavior + +- Open any **`.tsx` / `.jsx`** file (matches rule `globs`). +- Start a chat and invoke a skill by name if supported (e.g. `/gamut-consumer` or the skill picker), or rely on **Agent Decides** so the agent can attach the skill when the description matches. +- Confirm the agent references Gamut patterns (Storybook links, `GridForm`/`ConnectedForm`, `ColorMode`/`Background`, etc.) when relevant. + +### 5. Team Marketplace (optional) + +If you validate **multi-plugin import** from this repo: + +1. Dashboard → **Settings** → **Plugins** → **Team Marketplaces** → **Import** (Teams / Enterprise). +2. Use the **GitHub URL of the Gamut repo** (or a fork) so root [`.cursor-plugin/marketplace.json`](../.cursor-plugin/marketplace.json) is discovered. +3. After import, confirm all three plugins parse; assign **required** vs **optional** per distribution group. +4. On a developer machine, open the marketplace panel and install or confirm auto-install. + +### 6. Monorepo-only pieces (not in these plugins) + +With the **Gamut** repo open, `.cursor/skills/gamut-library-authoring/` and `.cursor/rules/gamut-library.mdc` apply to library work. They are **not** loaded via the three symlinks above—test those by opening this repo and checking **Rules** / **Skills** for the monorepo paths. + +### Troubleshooting + +- **Nothing appears after symlink** — Confirm each path is a directory containing `.cursor-plugin/plugin.json` and `rules/` / `skills/` as expected; reload the window again. +- **Wrong content** — You may be pointing at an old clone; recreate symlinks with `ln -sfn`. +- **Marketplace vs local** — Team Marketplace import does not replace `~/.cursor/plugins/local/` testing; use symlinks for the fastest iteration on file edits. + +## Publishing + +- [Publish to Cursor Marketplace](https://cursor.com/marketplace/publish) (public; open source and review required). +- Or attach this repository as a **team marketplace** in the Cursor dashboard. + +## Versioning + +Bump `version` in each `gamut-*/.cursor-plugin/plugin.json` together when content changes materially. diff --git a/gamut-cursor-plugins/gamut-a11y/.cursor-plugin/plugin.json b/gamut-cursor-plugins/gamut-a11y/.cursor-plugin/plugin.json new file mode 100644 index 00000000000..d95dec762e5 --- /dev/null +++ b/gamut-cursor-plugins/gamut-a11y/.cursor-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "codecademy-gamut-a11y", + "version": "1.0.0", + "description": "Accessibility-focused guidance for building UI with Gamut.", + "author": { + "name": "Codecademy" + }, + "keywords": ["gamut", "accessibility", "a11y", "wcag", "codecademy"], + "homepage": "https://gamut.codecademy.com/", + "repository": "https://github.com/Codecademy/gamut" +} diff --git a/gamut-cursor-plugins/gamut-a11y/rules/gamut-accessibility.mdc b/gamut-cursor-plugins/gamut-a11y/rules/gamut-accessibility.mdc new file mode 100644 index 00000000000..b1aabb4c3d8 --- /dev/null +++ b/gamut-cursor-plugins/gamut-a11y/rules/gamut-accessibility.mdc @@ -0,0 +1,20 @@ +--- +description: >- + Use Gamut components per their documented accessibility patterns: forms, overlays, + navigation, and content. Prefer Storybook component pages over bespoke a11y wiring. +globs: + - "**/*.{tsx,jsx}" +alwaysApply: false +--- + +# Gamut accessibility + +- **Read the component's Storybook page** (especially "Accessibility", "Keyboard", or "Usage" sections on [gamut.codecademy.com](https://gamut.codecademy.com/)) before customizing behavior—Gamut components often manage focus, ARIA, and escape handling by default. +- **Forms:** Use **`GridForm`** or **`ConnectedForm`** with **`ConnectedFormGroup`** / **`useConnectedForm`**; avoid raw **`Form`** / **`FormGroup`** atoms unless the UI does not need full form behavior. Do not use **`ConnectedFormInputs`** outside **`ConnectedFormGroup`**. Render **`FormRequiredText`** from **`useConnectedForm`** when any field is required. For **`GridForm`** **`custom-group`**, you own labels and errors—follow accessible form patterns linked from GridForm Fields. +- **Overlays:** Prefer **`Modal`** / **`Flyout`** / **`Drawer`** (and related patterns) from `@codecademy/gamut` instead of custom portals. For **`Modal`**, wire **`onRequestClose`** and **`isOpen`** so outside click and **Esc** still close (defaults are intentional for a11y); use **`containerFocusRef`** only when you must override default focus. **`InfoTip`**: provide **`ariaLabel`** or **`ariaLabelledby`** for the trigger; respect built-in focus move / **Esc** / layering with modals per component docs. +- **Tabs:** Use **`Tabs`** / **`TabNav`** from Gamut; give **`TabNav`** a unique **`aria-label`** when it is page navigation. Follow docs for keyboard focus when **`TabPanel`** contains interactive content. +- **Page chrome:** Use **`SkipToContent`** with a matching **`SkipToContentTarget`** (`contentId` / `id`) on long chrome-heavy pages so keyboard users can jump to main content. +- **Data viz:** For **`BarChart`**, supply **`title`** or **`aria-labelledby`**; prefer documented props and token colors so contrast stays acceptable—do not strip generated labels for interactive bars. +- **Content:** Follow **[UX Writing — Accessibility guidelines](https://gamut.codecademy.com/?path=/docs-ux-writing-accessibility-guidelines--page)** for copy, headings, links, and alt text; use **`Text`** / imagery patterns as documented when semantics matter. +- **Theming:** Use semantic colors and **`ColorMode`** / **`Background`** (**codecademy-gamut-themes**) so text and surfaces keep readable contrast. +- **Gaps:** If Gamut has no primitive, only then mirror headless patterns ([Radix](https://www.radix-ui.com/primitives), [React ARIA](https://react-spectrum.adobe.com/react-aria/)) and style with **`@codecademy/gamut-styles`** semantic tokens. diff --git a/gamut-cursor-plugins/gamut-a11y/skills/gamut-accessibility/SKILL.md b/gamut-cursor-plugins/gamut-a11y/skills/gamut-accessibility/SKILL.md new file mode 100644 index 00000000000..0a865ebddd4 --- /dev/null +++ b/gamut-cursor-plugins/gamut-a11y/skills/gamut-accessibility/SKILL.md @@ -0,0 +1,68 @@ +--- +name: gamut-accessibility +description: >- + Reviews and implements accessible UI with @codecademy/gamut: WCAG-oriented + patterns, keyboard and focus, documented Gamut components, and MDN-grounded + patterns when building custom widgets. Use when auditing accessibility, + implementing bespoke controls, or fixing a11y bugs in Gamut consumer apps. + Pair with codecademy-gamut (core) and codecademy-gamut-themes for tokens and + color context. +--- + +# Gamut accessibility + +## Principles + +1. **Gamut first** — Use documented Gamut components and props for alerts, forms, navigation, and layout before custom DOM. +2. **Semantics + behavior** — Correct role, name, state, and keyboard interaction; styling follows via semantic tokens from `@codecademy/gamut-styles`. +3. **No accessibility regressions** — Avoid `tabIndex={-1}` on primary actions, click-only handlers without keyboard equivalents, and placeholder-only labels. + +## Keyboard and focus + +- Tab order follows visual order; custom widgets need arrow-key patterns where applicable (listbox, tabs, grid). +- Preserve or replace `:focus-visible` styles; match platform conventions for focus rings. +- After closing overlays, return focus to the triggering control. + +## Forms + +Gamut’s styleguide ([Form elements — About](https://gamut.codecademy.com/?path=/docs-atoms-form-elements-about--page), [ConnectedForm — About](https://gamut.codecademy.com/?path=/docs-organisms-connectedform-about--page)) tells consumers to build forms with **`GridForm`** or **`ConnectedForm`**, not the `Form` / `FormGroup` atoms, unless the UI truly does not need full form behavior (validation, submission, accessible wiring). + +- **`GridForm`** — Use when the design fits a **12-column grid** with consistent vertical rhythm. It composes form elements inside `LayoutGrid`, uses **react-hook-form** for validation, and documents **accessibility behaviors out of the box** ([GridForm — Usage](https://gamut.codecademy.com/?path=/docs-organisms-gridform-usage--page)). +- **`ConnectedForm`** — Use for **flexible layouts** (same reliability as GridForm without the rigid grid). Typical stack: **`useConnectedForm`** → **`ConnectedForm`** → **`ConnectedFormGroup`** → **`ConnectedFormInputs`** (via the `component` prop) and **`SubmitButton`**. **`ConnectedFormGroup`** ties labels, errors, and disabled state to fields with proper accessibility ([ConnectedFormGroup](https://gamut.codecademy.com/?path=/docs-organisms-connectedform-connectedformgroup--docs)). +- **Do not use `ConnectedFormInputs` outside `ConnectedFormGroup`** — the ConnectedFormInputs docs state that bypasses much of the accessibility and type-safety ([ConnectedFormInputs](https://gamut.codecademy.com/?path=/docs-organisms-connectedform-connectedforminputs--docs)). +- From **`useConnectedForm`**, render **`FormRequiredText`** before the form unless every field is optional ([ConnectedForm — Usage](https://gamut.codecademy.com/?path=/docs-organisms-connectedform-connectedform--docs)). +- **`GridForm` `custom-group`** — If you supply a custom `FormGroup`, you own label and error surfacing; follow accessible form patterns (Gamut links to [Deque’s accessible forms guide](https://www.deque.com/blog/anatomy-of-accessible-forms-best-practices/) from GridForm Fields). +- **Escape hatch** — If neither organism applies, still meet WCAG: visible labels or programmatic names, text for errors (not color alone), logical reading order. See [UX Writing — Accessibility guidelines](https://gamut.codecademy.com/?path=/docs-ux-writing-accessibility-guidelines--page) and [Meta — Best practices](https://gamut.codecademy.com/?path=/docs-meta-best-practices--page) for composition habits. + +## Overlays + +- Dialogs: focus trap, `aria-modal`, labelled (`aria-labelledby` / `aria-label`), Escape closes, focus restoration. +- Menus and listboxes: roving tabindex or `aria-activedescendant` patterns per WAI-ARIA. + +## When you need custom components + +If no Gamut primitive fits, base behavior on **[MDN accessibility](https://developer.mozilla.org/en-US/docs/Web/Accessibility)** and **[ARIA](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA)** guidance, and on **[keyboard-navigable JavaScript widgets](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets)**. Prefer native HTML elements and built-in keyboard behavior; add ARIA only when semantics or state cannot be expressed otherwise (see MDN’s ARIA guides for roles, properties, and live regions). + +Style custom UI with **`@codecademy/gamut-styles`** semantic tokens and **`ColorMode` / `Background`** (**codecademy-gamut-themes**), not one-off hex or raw theme object drilling. + +### React focus management + +- Attach **`useRef`** to elements that should receive focus; call **`focus()`** in **event handlers** or **effects tied to open/close/mount**, not on every render ([refs](https://react.dev/learn/manipulating-the-dom-with-refs), [`useRef`](https://react.dev/reference/react/useRef)). +- Prefer **`useLayoutEffect`** when moving focus immediately after a DOM update (e.g. panel just opened) to reduce flicker for sighted users and assistive tech; use **`useEffect`** when synchronous paint is not required. +- When dismissing transient UI (custom overlay, popover, in-page “dialog”), **restore focus** to the element that opened it; store **`document.activeElement`** in a ref on open if the platform does not do this for you. +- Use **`forwardRef`** (or an explicit ref prop) on reusable wrappers so parents can focus inner controls when the UX requires it. +- Reserve **`tabIndex={-1}`** for **programmatic** focus targets (e.g. skip-link destination, focus container), not for hiding primary actions from the tab order. +- Use **`useId`** for stable **`id`** values when wiring **`aria-labelledby`**, **`aria-describedby`**, **`aria-controls`**, or **`htmlFor`**, matching the relationships described in MDN/WAI-ARIA patterns. + +## Docs + +- Styleguide Meta and component pages on [gamut.codecademy.com](https://gamut.codecademy.com/) — check the specific component story for a11y notes. +- Forms: [Form elements — About](https://gamut.codecademy.com/?path=/docs-atoms-form-elements-about--page), [GridForm — Usage](https://gamut.codecademy.com/?path=/docs-organisms-gridform-usage--page), [ConnectedForm](https://gamut.codecademy.com/?path=/docs-organisms-connectedform-connectedform--docs). +- [Best practices](https://gamut.codecademy.com/?path=/docs-meta-best-practices--page) for general composition. + +## Checklists (quick) + +**Dialog:** labelled, focus trap, Escape, restore focus, scroll lock if needed. +**Menu:** keyboard navigation, typeahead, dismiss on blur where correct. +**Form:** prefer `GridForm` or `ConnectedForm` + `ConnectedFormGroup`; `FormRequiredText` when required fields exist; no color-only errors. +**Live region:** politeness level appropriate for the update frequency. diff --git a/gamut-cursor-plugins/gamut-core/.cursor-plugin/plugin.json b/gamut-cursor-plugins/gamut-core/.cursor-plugin/plugin.json new file mode 100644 index 00000000000..7ab1e7d43ed --- /dev/null +++ b/gamut-cursor-plugins/gamut-core/.cursor-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "codecademy-gamut", + "version": "1.0.0", + "description": "Core Gamut design system usage for application code.", + "author": { + "name": "Codecademy" + }, + "keywords": ["gamut", "codecademy", "design-system", "react"], + "homepage": "https://gamut.codecademy.com/", + "repository": "https://github.com/Codecademy/gamut" +} diff --git a/gamut-cursor-plugins/gamut-core/rules/gamut-consumer.mdc b/gamut-cursor-plugins/gamut-core/rules/gamut-consumer.mdc new file mode 100644 index 00000000000..987911d6792 --- /dev/null +++ b/gamut-cursor-plugins/gamut-core/rules/gamut-consumer.mdc @@ -0,0 +1,18 @@ +--- +description: >- + Core standards for using @codecademy/gamut and @codecademy/gamut-styles in + application code: semantic colors, system props, no unsafe nested selectors. +globs: + - "**/*.{tsx,jsx}" +alwaysApply: false +--- + +# Gamut consumer (core) + +- Prefer `Box`, `FlexBox`, `GridBox` and other Gamut components over raw layout divs when building UI. +- Use **semantic** color props and `gamut-styles` utilities (`css`, `variant`, `states`) with semantic token names (`text`, `background`, `primary`, `danger`, etc.). Avoid hardcoded palette values where tokens exist. +- Use responsive system props (object keys `_`, `sm`, `md`, … or array syntax). See Storybook foundations for responsive properties. +- Do not use tag selectors (`div`, `p`, `span`, `*`) or `${Box}`-style nested Gamut component selectors in styled components; use props, `FlexBox`/`GridBox`, and `gamut-styles` helpers instead. +- Align with `eslint-plugin-gamut` when configured (`no-inline-style`, `no-css-standalone`, `gamut-import-paths`, etc.). +- Set color context at the app root appropriately; for **ColorMode**, **Background**, hooks, and platform themes, use the **codecademy-gamut-themes** plugin and [ColorMode docs](https://gamut.codecademy.com/?path=/docs-foundations-colormode--page). +- For accessibility reviews (dialogs, focus, ARIA, forms), enable the **codecademy-gamut-a11y** plugin. diff --git a/gamut-cursor-plugins/gamut-core/skills/gamut-consumer/SKILL.md b/gamut-cursor-plugins/gamut-core/skills/gamut-consumer/SKILL.md new file mode 100644 index 00000000000..cec7ec11410 --- /dev/null +++ b/gamut-cursor-plugins/gamut-core/skills/gamut-consumer/SKILL.md @@ -0,0 +1,49 @@ +--- +name: gamut-consumer +description: >- + Builds and refactors UI using @codecademy/gamut and @codecademy/gamut-styles: + system props, semantic tokens, css/variant/states, eslint-plugin-gamut, and + Storybook documentation links. Use when implementing screens in apps that depend + on Gamut—not when authoring the Gamut library itself. For ColorMode/Background + depth use codecademy-gamut-themes; for WCAG-focused work use codecademy-gamut-a11y. +--- + +# Gamut consumer (core) + +## When to use + +- Application code importing `@codecademy/gamut` or `@codecademy/gamut-styles`. +- Layout, spacing, typography, and component composition with design tokens. + +## Providers (minimal) + +Ensure the app supplies appropriate color/theme context at the root. For `ColorMode`, `Background`, `useColorMode`, platform themes, and troubleshooting contrast or mode bugs, rely on the **codecademy-gamut-themes** plugin and [ColorMode](https://gamut.codecademy.com/?path=/docs-foundations-colormode--page). + +## Semantic colors and tokens + +- Semantic names describe **role** (`text`, `background`, `primary`, `secondary`, …) and resolve per ColorMode. See [Best practices / ColorMode](https://gamut.codecademy.com/?path=/docs-meta-best-practices--page). +- Use `css({ color: 'primary', p: 4 })`, `variant`, and `states` from `@codecademy/gamut-styles` with those semantic keys. +- Prefer `themed(...)` or variants APIs over ad-hoc `theme.colors` access when eslint recommends it. + +## System props and layout + +- Use `Box`, `FlexBox`, `GridBox` with system props; support responsive maps (`{ _: value, md: value }`) and arrays with sparse breakpoints. +- Docs: [Responsive properties](https://gamut.codecademy.com/storybook/?path=/docs-foundations-system-responsive-properties--page), [system compose](https://gamut.codecademy.com/?path=/docs-foundations-system-compose--page). + +## Anti-patterns + +- No `style={{}}` for design-token-level styling where system props or `css()` apply. +- No tag-wide or `*` selectors; no `${Box}`-style nested selectors targeting Gamut internals—use props and layout wrappers. + +## Lint + +If the repo enables `eslint-plugin-gamut`, expect rules such as `no-inline-style`, `no-css-standalone`, and `gamut-import-paths`. Match fixes to Gamut patterns. + +## Sibling plugins + +- **codecademy-gamut-a11y** — forms, dialogs, custom widgets, WCAG reviews, focus/ARIA. +- **codecademy-gamut-themes** — multi-mode layouts, `Background`, `background-current`, hooks, platform themes. + +## Reference + +Stable Storybook URLs and migration snippets: [reference.md](reference.md). diff --git a/gamut-cursor-plugins/gamut-core/skills/gamut-consumer/reference.md b/gamut-cursor-plugins/gamut-core/skills/gamut-consumer/reference.md new file mode 100644 index 00000000000..77c3d0afe3f --- /dev/null +++ b/gamut-cursor-plugins/gamut-core/skills/gamut-consumer/reference.md @@ -0,0 +1,55 @@ +# Gamut consumer — Storybook and sources + +## Published Storybook + +Base URL: [https://gamut.codecademy.com/](https://gamut.codecademy.com/) + +Useful paths (query `path`): + +| Topic | URL | +| --- | --- | +| Meta / Best practices | `?path=/docs-meta-best-practices--page` | +| ColorMode | `?path=/docs-foundations-colormode--page` | +| System compose | `?path=/docs-foundations-system-compose--page` | +| Responsive system props | `?path=/docs-foundations-system-responsive-properties--page` | + +## Source of truth in the Gamut repo + +If you have the monorepo checked out: + +- Best practices MDX: `packages/styleguide/src/lib/Meta/Best practices.mdx` +- ColorMode MDX: `packages/styleguide/src/lib/Foundations/ColorMode/ColorMode.mdx` + +## Package imports + +- Components: `@codecademy/gamut` +- Styles / providers / hooks: `@codecademy/gamut-styles` +- Patterns / icons / illustrations: `@codecademy/gamut-patterns`, `@codecademy/gamut-icons`, `@codecademy/gamut-illustrations` when applicable + +## Styled component example + +```tsx +import { css, variant } from '@codecademy/gamut-styles'; +import styled from '@emotion/styled'; + +const Anchor = styled.a( + variant({ + base: { p: 4 }, + defaultVariant: 'interface', + variants: { + interface: { + color: 'text', + '&:hover': { color: 'text-accent' }, + }, + }, + }) +); +``` + +## System props example + +```tsx +import { Box } from '@codecademy/gamut'; + +; +``` diff --git a/gamut-cursor-plugins/gamut-themes/.cursor-plugin/plugin.json b/gamut-cursor-plugins/gamut-themes/.cursor-plugin/plugin.json new file mode 100644 index 00000000000..037bee8e8a7 --- /dev/null +++ b/gamut-cursor-plugins/gamut-themes/.cursor-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "codecademy-gamut-themes", + "version": "1.0.0", + "description": "ColorMode, Background, and semantic theme usage for Gamut apps.", + "author": { + "name": "Codecademy" + }, + "keywords": ["gamut", "theme", "colormode", "dark-mode", "codecademy"], + "homepage": "https://gamut.codecademy.com/", + "repository": "https://github.com/Codecademy/gamut" +} diff --git a/gamut-cursor-plugins/gamut-themes/rules/gamut-theming.mdc b/gamut-cursor-plugins/gamut-themes/rules/gamut-theming.mdc new file mode 100644 index 00000000000..0c0a4faba93 --- /dev/null +++ b/gamut-cursor-plugins/gamut-themes/rules/gamut-theming.mdc @@ -0,0 +1,19 @@ +--- +description: >- + ColorMode, Background, semantic color tokens, and provider boundaries for + @codecademy/gamut-styles. Use when wiring theme providers or debugging contrast. +globs: + - "**/*.{tsx,jsx,ts,js}" +alwaysApply: false +--- + +# Gamut theming + +- Place **`ColorMode`** from `@codecademy/gamut-styles` **as high in the DOM as practical** (e.g. app or root layout) so descendants share one light/dark/`system` context (`mode="light" | "dark" | "system"`; `system` follows OS preference). Avoid deep or repeated `ColorMode` nesting unless a subtree truly needs a different preference. +- Use **`Background`** with `bg` token names for **page sections** that need a specific surface, a defined **`background-current`** for descendants, or a region that should **not rely on guessing mode**—`Background` picks an accessible mode for that surface and sets up its own context inside. Read [ColorMode / Background](https://gamut.codecademy.com/?path=/docs-foundations-colormode--page). +- `background-current` reflects the active `Background` color—use when a child needs to match an ancestor surface. +- Prefer **semantic** color props (`text`, `background`, `primary`, …) in components so they track the active mode. +- For JS access to mode: `useColorMode`, `useCurrentMode`, or `useTheme` from `@emotion/react` when you need the full theme. +- Avoid fighting the system with raw CSS variables or hardcoded hex for theme surfaces; extend tokens in the **Gamut library** when new semantics are needed. +- Mental model: layered **theme + semantic tokens** (similar in spirit to [Chakra theming](https://chakra-ui.com/docs/styled-system/customize-theme) and [React Spectrum theming](https://react.spectrum.adobe.com/react-spectrum/theming.html))—apps compose providers; token keys stay semantic. +- For layout and generic Gamut usage without provider depth, use **codecademy-gamut** core rules. diff --git a/gamut-cursor-plugins/gamut-themes/skills/gamut-theming/SKILL.md b/gamut-cursor-plugins/gamut-themes/skills/gamut-theming/SKILL.md new file mode 100644 index 00000000000..74cedec25cc --- /dev/null +++ b/gamut-cursor-plugins/gamut-themes/skills/gamut-theming/SKILL.md @@ -0,0 +1,65 @@ +--- +name: gamut-theming +description: >- + Configures and debugs ColorMode, Background, semantic color tokens, and hooks + from @codecademy/gamut-styles in application code. Use when implementing + light/dark/system modes, branded sections, background-current, or platform + themes—not for one-off hex colors. Complements codecademy-gamut (core). +--- + +# Gamut theming + +## Documentation + +Primary reference: [ColorMode / Background](https://gamut.codecademy.com/?path=/docs-foundations-colormode--page) on Storybook. + +Source MDX in monorepo: `packages/styleguide/src/lib/Foundations/ColorMode/ColorMode.mdx` +Implementation: `packages/gamut-styles/src/ColorMode.tsx` and Background APIs. + +## ColorMode + +```tsx +import { ColorMode } from '@codecademy/gamut-styles'; + +{children} +{children} +``` + +`system` resolves to light or dark from `prefers-color-scheme`. + +**Placement:** Prefer **`ColorMode` near the root of the app** (or top-level layout) so the whole tree shares one mode. Add nested `ColorMode` only when a subtree must diverge from the global preference. + +## Background + +```tsx +import { Background } from '@codecademy/gamut-styles'; + +{children}; +``` + +Use **`Background` for sections** that need a **specific branded or alternate surface**, that must expose **`background-current`** to children, or where you want the **surface to drive an accessible mode** instead of manually syncing light/dark—`Background` adjusts mode when contrast would fail and establishes context inside the section. Prefer it over guessing mode per block. + +## Semantic aliases + +Common keys include `text`, `background`, `primary`, `secondary` (set may expand). Components should use these aliases so they work inside any wrapped mode. + +## Hooks + +- `useColorMode()` → `[mode, modeColors, modes]` +- `useCurrentMode()` → active mode key +- `useTheme()` from `@emotion/react` for full Emotion theme when needed + +## Platform / multi-theme + +Platform-specific themes (e.g. Percipio, LX Studio) are documented under styleguide Foundations. App developers usually **consume** the provided theme; adding or changing modes belongs in the Gamut library with Storybook and migration notes. + +## Troubleshooting + +- **Wrong colors** — Check ancestor `ColorMode` / `Background` and semantic prop names. +- **Contrast** — Prefer `Background` for surfaces; avoid hardcoded pairs. +- **Fighting Emotion theme** — Use hooks and semantic tokens instead of copying internal variable names. + +## Related + +- **codecademy-gamut** — system props, layout, eslint, general consumption. +- **codecademy-gamut-a11y** — focus, ARIA, overlays (often interacts with themed surfaces). From 6945447a5f8c5c5e9aafe9c58c74308e079e4f2b Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 1 Apr 2026 10:52:03 -0400 Subject: [PATCH 2/5] workflow changes --- .../validate-cursor-plugins-helpers.mjs | 63 +++ .../validate-cursor-plugins-helpers.test.mjs | 97 +++++ .github/scripts/validate-cursor-plugins.mjs | 401 ++++++++++++++++++ .github/workflows/cursor-plugins.yml | 53 +++ gamut-cursor-plugins/README.md | 25 +- package.json | 1 + 6 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/validate-cursor-plugins-helpers.mjs create mode 100644 .github/scripts/validate-cursor-plugins-helpers.test.mjs create mode 100755 .github/scripts/validate-cursor-plugins.mjs create mode 100644 .github/workflows/cursor-plugins.yml diff --git a/.github/scripts/validate-cursor-plugins-helpers.mjs b/.github/scripts/validate-cursor-plugins-helpers.mjs new file mode 100644 index 00000000000..9824757f722 --- /dev/null +++ b/.github/scripts/validate-cursor-plugins-helpers.mjs @@ -0,0 +1,63 @@ +/** + * Pure helpers for Cursor plugin layout validation (unit-tested). + * @see validate-cursor-plugins.mjs + */ + +export const SEMVER = + /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/; + +/** @param {string} key */ +export function escapeRe(key) { + return key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * @param {string} block + * @param {string} key + */ +export function fmHasKey(block, key) { + return new RegExp(`^${escapeRe(key)}:\\s`, 'm').test(block); +} + +/** + * Semver ordering: numeric core, then prerelease (release beats prerelease). + * @returns {number} positive if a > b, negative if a < b, 0 if equal + */ +export function cmpSemver(a, b) { + const stripBuild = (v) => v.split('+')[0]; + const splitPre = (v) => { + const s = stripBuild(v); + const i = s.indexOf('-'); + if (i === -1) return { core: s, pre: null }; + return { core: s.slice(0, i), pre: s.slice(i + 1) }; + }; + const A = splitPre(a); + const B = splitPre(b); + const pa = A.core.split('.').map((n) => parseInt(n, 10)); + const pb = B.core.split('.').map((n) => parseInt(n, 10)); + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pa[i] - pb[i]; + } + if (A.pre === B.pre) return 0; + if (A.pre === null && B.pre !== null) return 1; + if (A.pre !== null && B.pre === null) return -1; + return A.pre.localeCompare(B.pre); +} + +/** + * @param {string} text full file contents + * @returns {{ block: string } | { error: string }} error message without path prefix + */ +export function extractFrontmatterBlockFromText(text) { + if (!text.startsWith('---')) { + return { + error: 'must start with YAML frontmatter (---)', + }; + } + const rest = text.slice(3); + const end = rest.indexOf('\n---'); + if (end === -1) { + return { error: 'unclosed frontmatter' }; + } + return { block: rest.slice(0, end).replace(/^\n|\n$/g, '') }; +} diff --git a/.github/scripts/validate-cursor-plugins-helpers.test.mjs b/.github/scripts/validate-cursor-plugins-helpers.test.mjs new file mode 100644 index 00000000000..f7ce180b52a --- /dev/null +++ b/.github/scripts/validate-cursor-plugins-helpers.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + cmpSemver, + escapeRe, + extractFrontmatterBlockFromText, + fmHasKey, + SEMVER, +} from './validate-cursor-plugins-helpers.mjs'; + +describe('cmpSemver', () => { + it('returns 0 for equal versions', () => { + assert.equal(cmpSemver('1.0.0', '1.0.0'), 0); + }); + + it('orders patch', () => { + assert.ok(cmpSemver('1.0.1', '1.0.0') > 0); + assert.ok(cmpSemver('1.0.0', '1.0.1') < 0); + }); + + it('orders minor and major', () => { + assert.ok(cmpSemver('1.1.0', '1.0.9') > 0); + assert.ok(cmpSemver('2.0.0', '1.99.99') > 0); + }); + + it('treats release as newer than prerelease', () => { + assert.ok(cmpSemver('1.0.0', '1.0.0-alpha') > 0); + assert.ok(cmpSemver('1.0.0-alpha', '1.0.0') < 0); + }); + + it('ignores build metadata', () => { + assert.equal(cmpSemver('1.0.0+build1', '1.0.0+build2'), 0); + }); + + it('compares prerelease strings', () => { + assert.ok(cmpSemver('1.0.0-beta', '1.0.0-alpha') > 0); + }); +}); + +describe('fmHasKey', () => { + it('detects key at line start', () => { + assert.equal(fmHasKey('description: hello', 'description'), true); + assert.equal(fmHasKey('name: x\ndescription: y', 'description'), true); + }); + + it('does not match indented or inline keys', () => { + assert.equal(fmHasKey(' description: no', 'description'), false); + assert.equal(fmHasKey('text: description: no', 'description'), false); + }); + + it('escapes regex metacharacters in key', () => { + assert.equal(fmHasKey('a.b: 1', 'a.b'), true); + assert.equal(fmHasKey('ab: 1', 'a.b'), false); + }); +}); + +describe('escapeRe', () => { + it('escapes metacharacters', () => { + assert.equal(escapeRe('a+b'), 'a\\+b'); + assert.equal(escapeRe('x.y'), 'x\\.y'); + }); +}); + +describe('extractFrontmatterBlockFromText', () => { + it('returns block between first and second ---', () => { + const r = extractFrontmatterBlockFromText( + '---\ndescription: ok\n---\n# body\n', + ); + assert.ok('block' in r); + assert.equal(r.block, 'description: ok'); + }); + + it('errors when file does not start with ---', () => { + const r = extractFrontmatterBlockFromText('# no frontmatter\n'); + assert.ok('error' in r); + assert.match(r.error, /YAML frontmatter/); + }); + + it('errors when closing --- is missing', () => { + const r = extractFrontmatterBlockFromText('---\ndescription: x\n'); + assert.ok('error' in r); + assert.match(r.error, /unclosed/); + }); +}); + +describe('SEMVER', () => { + it('accepts common semver forms', () => { + assert.equal(SEMVER.test('1.0.0'), true); + assert.equal(SEMVER.test('0.1.0-rc.1'), true); + assert.equal(SEMVER.test('10.20.30+meta'), true); + }); + + it('rejects invalid', () => { + assert.equal(SEMVER.test('1.0'), false); + assert.equal(SEMVER.test('v1.0.0'), false); + }); +}); diff --git a/.github/scripts/validate-cursor-plugins.mjs b/.github/scripts/validate-cursor-plugins.mjs new file mode 100755 index 00000000000..57aa97776f5 --- /dev/null +++ b/.github/scripts/validate-cursor-plugins.mjs @@ -0,0 +1,401 @@ +#!/usr/bin/env node +/** + * Validate Gamut Cursor plugin layout for CI. + * @see https://cursor.com/docs/reference/plugins.md + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + cmpSemver, + extractFrontmatterBlockFromText, + fmHasKey, + SEMVER, +} from './validate-cursor-plugins-helpers.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '../..'); +const MARKETPLACE = path.join(ROOT, '.cursor-plugin', 'marketplace.json'); + +/** @param {string} msg */ +function err(msg) { + console.error(`error: ${msg}`); +} + +/** + * @param {string} sha + * @param {string} posixPath repo-relative path with / + */ +function gitShow(sha, posixPath) { + try { + return execSync(`git show ${sha}:${posixPath}`, { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch { + return null; + } +} + +/** + * @returns {{ plugins: { name: string; source: string }[] } | null } + */ +function readMarketplacePluginEntries() { + if (!fs.existsSync(MARKETPLACE)) return null; + let data; + try { + data = JSON.parse(fs.readFileSync(MARKETPLACE, 'utf8')); + } catch { + return null; + } + const plugins = data.plugins; + if (!Array.isArray(plugins)) return null; + const out = []; + for (const p of plugins) { + if ( + p && + typeof p === 'object' && + typeof p.name === 'string' && + typeof p.source === 'string' + ) { + out.push({ name: p.name, source: p.source.replace(/\\/g, '/') }); + } + } + return { plugins: out }; +} + +/** + * Changed files under a plugin require a strict semver bump in plugin.json. + * Invoked via CLI flag; GitHub Actions runs that only on pull_request. + */ +function validatePrPluginVersionBumps() { + const errors = []; + const base = process.env.CURSOR_PR_BASE_SHA?.trim(); + const head = process.env.CURSOR_PR_HEAD_SHA?.trim(); + if (!base || !head) { + errors.push( + 'version bump check: set CURSOR_PR_BASE_SHA and CURSOR_PR_HEAD_SHA (e.g. from github.event.pull_request)', + ); + return errors; + } + + let changed; + try { + changed = execSync(`git diff --name-only ${base} ${head}`, { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + } catch (e) { + errors.push(`version bump check: git diff failed (${e})`); + return errors; + } + + const mp = readMarketplacePluginEntries(); + if (!mp) return errors; + + for (const { name: pname, source: src } of mp.plugins) { + const prefix = src.endsWith('/') ? src : `${src}/`; + const touched = changed.some((f) => f === src || f.startsWith(prefix)); + if (!touched) continue; + + const manifestPosix = path.posix.join(src, '.cursor-plugin', 'plugin.json'); + const baseRaw = gitShow(base, manifestPosix); + const headRaw = gitShow(head, manifestPosix); + if (!headRaw) { + errors.push( + `plugin '${pname}': cannot read ${manifestPosix} at PR head (required for version bump check)`, + ); + continue; + } + + let headVer; + try { + headVer = JSON.parse(headRaw).version; + } catch { + errors.push(`plugin '${pname}': invalid plugin.json at PR head`); + continue; + } + if (typeof headVer !== 'string' || !SEMVER.test(headVer)) { + errors.push( + `plugin '${pname}': PR head plugin.json must have valid semver version`, + ); + continue; + } + + if (!baseRaw) { + continue; + } + + let baseVer; + try { + baseVer = JSON.parse(baseRaw).version; + } catch { + errors.push( + `plugin '${pname}': invalid plugin.json on base ${base.slice(0, 7)}`, + ); + continue; + } + if (typeof baseVer !== 'string' || !SEMVER.test(baseVer)) { + errors.push( + `plugin '${pname}': base plugin.json must have valid semver version`, + ); + continue; + } + + if (cmpSemver(headVer, baseVer) <= 0) { + errors.push( + `plugin '${pname}': files under ${src}/ changed vs ${base.slice(0, 7)} but version was not bumped (${baseVer} → ${headVer}); increase semver in .cursor-plugin/plugin.json`, + ); + } + } + + return errors; +} + +/** + * @param {string} filePath + * @returns {{ block: string } | { error: string }} + */ +function extractFrontmatterBlock(filePath) { + const text = fs.readFileSync(filePath, 'utf8'); + const rel = path.relative(ROOT, filePath); + const parsed = extractFrontmatterBlockFromText(text); + if ('error' in parsed) { + return { error: `${rel}: ${parsed.error}` }; + } + return { block: parsed.block }; +} + +/** @returns {string[]} */ +function validateMarketplace() { + const errors = []; + if (!fs.existsSync(MARKETPLACE)) { + errors.push(`missing ${path.relative(ROOT, MARKETPLACE)}`); + return errors; + } + + let data; + try { + data = JSON.parse(fs.readFileSync(MARKETPLACE, 'utf8')); + } catch (e) { + errors.push(`marketplace.json: invalid JSON (${e})`); + return errors; + } + + if (!data.name) errors.push('marketplace.json: missing name'); + const owner = data.owner || {}; + if (!owner.name) errors.push('marketplace.json: missing owner.name'); + + const plugins = data.plugins; + if (!Array.isArray(plugins) || plugins.length === 0) { + errors.push('marketplace.json: plugins must be a non-empty array'); + return errors; + } + + const seen = new Set(); + for (let i = 0; i < plugins.length; i++) { + const entry = plugins[i]; + if (!entry || typeof entry !== 'object') { + errors.push(`marketplace.json: plugins[${i}] must be an object`); + continue; + } + const pname = entry.name; + const src = entry.source; + if (!pname || typeof pname !== 'string') { + errors.push(`marketplace.json: plugins[${i}].name is required`); + } else if (seen.has(pname)) { + errors.push(`marketplace.json: duplicate plugin name '${pname}'`); + } else { + seen.add(pname); + } + if (!src || typeof src !== 'string') { + errors.push(`marketplace.json: plugins[${i}].source is required`); + continue; + } + + const pluginRoot = path.join(ROOT, src); + const manifest = path.join(pluginRoot, '.cursor-plugin', 'plugin.json'); + if (!fs.existsSync(pluginRoot) || !fs.statSync(pluginRoot).isDirectory()) { + errors.push(`plugin '${pname}': directory missing: ${src}`); + continue; + } + if (!fs.existsSync(manifest)) { + errors.push( + `plugin '${pname}': missing ${src}/.cursor-plugin/plugin.json`, + ); + continue; + } + + let pm; + try { + pm = JSON.parse(fs.readFileSync(manifest, 'utf8')); + } catch (e) { + errors.push(`plugin '${pname}': invalid plugin.json (${e})`); + continue; + } + + if (!pm.name || typeof pm.name !== 'string') { + errors.push(`plugin '${pname}': plugin.json must include string name`); + } else if (pm.name !== pname) { + errors.push( + `plugin '${pname}': plugin.json name '${pm.name}' must match marketplace entry`, + ); + } + + const ver = pm.version; + if (ver != null) { + if (typeof ver !== 'string' || !SEMVER.test(ver)) { + errors.push( + `plugin '${pname}': plugin.json version must be semver (e.g. 1.0.0), got '${ver}'`, + ); + } + } else { + errors.push(`plugin '${pname}': plugin.json should include semver version`); + } + + const rulesDir = path.join(pluginRoot, 'rules'); + const skillsDir = path.join(pluginRoot, 'skills'); + const hasRules = + fs.existsSync(rulesDir) && fs.statSync(rulesDir).isDirectory(); + const hasSkills = + fs.existsSync(skillsDir) && fs.statSync(skillsDir).isDirectory(); + if (!hasRules && !hasSkills) { + errors.push( + `plugin '${pname}': expected rules/ and/or skills/ under ${src}`, + ); + } + + if (hasRules) { + const mdcs = fs + .readdirSync(rulesDir) + .filter((f) => f.endsWith('.mdc')) + .map((f) => path.join(rulesDir, f)); + if (mdcs.length === 0) { + errors.push(`plugin '${pname}': rules/ has no .mdc files`); + } + for (const mdc of mdcs) { + const result = extractFrontmatterBlock(mdc); + if ('error' in result) { + errors.push(result.error); + } else if (!fmHasKey(result.block, 'description')) { + errors.push( + `${path.relative(ROOT, mdc)}: frontmatter must include description (Cursor rules)`, + ); + } + } + } + + if (hasSkills) { + const skillMds = fs + .readdirSync(skillsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => path.join(skillsDir, d.name, 'SKILL.md')) + .filter((p) => fs.existsSync(p)); + if (skillMds.length === 0) { + errors.push(`plugin '${pname}': skills/*/SKILL.md not found`); + } + for (const sm of skillMds) { + const result = extractFrontmatterBlock(sm); + if ('error' in result) { + errors.push(result.error); + continue; + } + if (!fmHasKey(result.block, 'name')) { + errors.push( + `${path.relative(ROOT, sm)}: frontmatter must include name`, + ); + } + if (!fmHasKey(result.block, 'description')) { + errors.push( + `${path.relative(ROOT, sm)}: frontmatter must include description`, + ); + } + } + } + } + + return errors; +} + +/** Monorepo `.cursor/rules` and `.cursor/skills` (not shipped via marketplace plugins). */ +function validateMonorepoCursor() { + const errors = []; + const cursorRoot = path.join(ROOT, '.cursor'); + if (!fs.existsSync(cursorRoot)) return errors; + + const rulesDir = path.join(cursorRoot, 'rules'); + if (fs.existsSync(rulesDir) && fs.statSync(rulesDir).isDirectory()) { + const mdcs = fs + .readdirSync(rulesDir) + .filter((f) => f.endsWith('.mdc')) + .map((f) => path.join(rulesDir, f)); + for (const mdc of mdcs) { + const result = extractFrontmatterBlock(mdc); + if ('error' in result) { + errors.push(result.error); + } else if (!fmHasKey(result.block, 'description')) { + errors.push( + `${path.relative(ROOT, mdc)}: frontmatter must include description (Cursor rules)`, + ); + } + } + } + + const skillsDir = path.join(cursorRoot, 'skills'); + if (fs.existsSync(skillsDir) && fs.statSync(skillsDir).isDirectory()) { + const skillMds = fs + .readdirSync(skillsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => path.join(skillsDir, d.name, 'SKILL.md')) + .filter((p) => fs.existsSync(p)); + for (const sm of skillMds) { + const result = extractFrontmatterBlock(sm); + if ('error' in result) { + errors.push(result.error); + continue; + } + if (!fmHasKey(result.block, 'name')) { + errors.push( + `${path.relative(ROOT, sm)}: frontmatter must include name`, + ); + } + if (!fmHasKey(result.block, 'description')) { + errors.push( + `${path.relative(ROOT, sm)}: frontmatter must include description`, + ); + } + } + } + + return errors; +} + +const PR_VERSION_BUMPS = '--pr-version-bumps'; + +if (process.argv.includes(PR_VERSION_BUMPS)) { + const prErrors = validatePrPluginVersionBumps(); + if (prErrors.length) { + for (const e of prErrors) err(e); + console.error(`\n${prErrors.length} validation issue(s).`); + process.exit(1); + } + console.log('Cursor plugins: PR semver bump check OK.'); +} else { + const errors = [...validateMarketplace(), ...validateMonorepoCursor()]; + if (errors.length) { + for (const e of errors) err(e); + console.error(`\n${errors.length} validation issue(s).`); + process.exit(1); + } + console.log( + 'Cursor config: marketplace, plugins, and monorepo .cursor rules/skills OK.', + ); +} diff --git a/.github/workflows/cursor-plugins.yml b/.github/workflows/cursor-plugins.yml new file mode 100644 index 00000000000..d4e6d988ccc --- /dev/null +++ b/.github/workflows/cursor-plugins.yml @@ -0,0 +1,53 @@ +name: Cursor plugins + +on: + pull_request: + paths: + - '.cursor/**' + - 'gamut-cursor-plugins/**' + - '.cursor-plugin/**' + - '.github/workflows/cursor-plugins.yml' + - '.github/scripts/validate-cursor-plugins.mjs' + - '.github/scripts/validate-cursor-plugins-helpers.mjs' + - '.github/scripts/validate-cursor-plugins-helpers.test.mjs' + push: + branches: + - main + paths: + - '.cursor/**' + - 'gamut-cursor-plugins/**' + - '.cursor-plugin/**' + - '.github/workflows/cursor-plugins.yml' + - '.github/scripts/validate-cursor-plugins.mjs' + - '.github/scripts/validate-cursor-plugins-helpers.mjs' + - '.github/scripts/validate-cursor-plugins-helpers.test.mjs' + workflow_dispatch: + +concurrency: + group: cursor-plugins-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version-file: '.nvmrc' + - name: Test Cursor plugin validator helpers + run: node --test .github/scripts/validate-cursor-plugins-helpers.test.mjs + - name: Validate Cursor config (marketplace, plugins, .cursor) + run: node .github/scripts/validate-cursor-plugins.mjs + - name: Validate plugin semver bumps vs PR base + if: github.event_name == 'pull_request' + env: + CURSOR_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + CURSOR_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node .github/scripts/validate-cursor-plugins.mjs --pr-version-bumps diff --git a/gamut-cursor-plugins/README.md b/gamut-cursor-plugins/README.md index cd7621b1c08..af5341fa002 100644 --- a/gamut-cursor-plugins/README.md +++ b/gamut-cursor-plugins/README.md @@ -18,6 +18,27 @@ If you **extract** `gamut-cursor-plugins/` to its own Git repository, move `.cur Admins can mark **codecademy-gamut** as required and **a11y** / **themes** as optional or required per distribution group. See [Team marketplaces](https://cursor.com/docs/plugins). +## CI (GitHub Actions) + +Workflow [`.github/workflows/cursor-plugins.yml`](../.github/workflows/cursor-plugins.yml) runs on pull requests and pushes to `main` when **Cursor-related paths** change: [`.cursor/`](../.cursor/) (monorepo rules/skills), `gamut-cursor-plugins/`, [`.cursor-plugin/`](../.cursor-plugin/), or the workflow/script. You can also run it manually via **Actions → Cursor plugins → Run workflow**. It uses Node (see [`.nvmrc`](../.nvmrc)) and runs [`.github/scripts/validate-cursor-plugins.mjs`](../.github/scripts/validate-cursor-plugins.mjs); the job **fails** (red PR check) if validation errors are reported. + +Checks: + +- Root [`.cursor-plugin/marketplace.json`](../.cursor-plugin/marketplace.json) is valid JSON with `name`, `owner.name`, and a non-empty `plugins` list. +- Each plugin `source` directory exists and contains `.cursor-plugin/plugin.json` whose `name` matches the marketplace entry and whose `version` is semver. +- Each plugin has `rules/*.mdc` and/or `skills/*/SKILL.md` with YAML frontmatter including `description` (and `name` for skills). +- Monorepo [`.cursor/rules/*.mdc`](../.cursor/rules) and [`.cursor/skills/*/SKILL.md`](../.cursor/skills) use the same frontmatter requirements when those paths exist. +- **Pull requests only** (second workflow step, `if: github.event_name == 'pull_request'`): if any file under a marketplace plugin `source` tree (e.g. `gamut-cursor-plugins/gamut-core/`) differs from the PR base commit, that plugin’s `.cursor-plugin/plugin.json` **version** must be **strictly greater** than on the base (semver). New plugins with no prior `plugin.json` on the base are exempt from the comparison. The layout step does not run this check; it is `node …/validate-cursor-plugins.mjs --pr-version-bumps`. + +It does **not** call Cursor’s marketplace API, install plugins in Cursor, or lint Markdown body content beyond those structural checks. + +## Human tasks (not automated) + +- **Public marketplace**: Submit or update listings per [Publish to Cursor Marketplace](https://cursor.com/marketplace/publish) (open source and review requirements apply). +- **Update review**: Changes to plugins on the public marketplace are **manually reviewed** before users receive updates; plan for latency. See [Marketplace security](https://cursor.com/help/security-and-privacy/marketplace-security.md). +- **Team marketplace**: Import the repo (or fork) in the Cursor dashboard (**Settings → Plugins → Team Marketplaces**), assign required vs optional plugins per group, and confirm installs on a developer machine (see §5 below). +- **Version bumps**: When you change rules, skills, or `plugin.json` metadata in a meaningful way, bump semver in each affected `gamut-*/.cursor-plugin/plugin.json` so teams can tell updates apart. + ## Testing instructions Use these steps whenever you change plugin content and need to confirm Cursor loads rules and skills correctly. See [Creating plugins — Test plugins locally](https://cursor.com/docs/plugins#test-plugins-locally). @@ -79,8 +100,8 @@ With the **Gamut** repo open, `.cursor/skills/gamut-library-authoring/` and `.cu ## Publishing -- [Publish to Cursor Marketplace](https://cursor.com/marketplace/publish) (public; open source and review required). -- Or attach this repository as a **team marketplace** in the Cursor dashboard. +- [Publish to Cursor Marketplace](https://cursor.com/marketplace/publish) (public; open source and review required)—a human step; CI does not publish. +- Or attach this repository as a **team marketplace** in the Cursor dashboard (also configured outside CI). ## Versioning diff --git a/package.json b/package.json index bd97ef2ded9..7aa8457ff73 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "start": "yarn && yarn start:storybook", "start:storybook": "nx storybook styleguide", "test": "nx run-many --target=test --all", + "test:cursor-plugins-validator": "node --test .github/scripts/validate-cursor-plugins-helpers.test.mjs", "verify": "nx run-many --target=verify --parallel=3 --all", "verify-all": "yarn verify" }, From 042b3138bf720b7780d20009178dea77641687a3 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 1 Apr 2026 12:23:49 -0400 Subject: [PATCH 3/5] fix some rules edits --- ...{figma-rules.mdc => gamut-figma-rules.mdc} | 0 .../skills/gamut-library-authoring/SKILL.md | 51 +++++++++++++++++++ .../gamut-library-authoring/reference.md | 29 +++++++++++ .gitignore | 4 ++ 4 files changed, 84 insertions(+) rename .cursor/rules/{figma-rules.mdc => gamut-figma-rules.mdc} (100%) create mode 100644 .cursor/skills/gamut-library-authoring/SKILL.md create mode 100644 .cursor/skills/gamut-library-authoring/reference.md diff --git a/.cursor/rules/figma-rules.mdc b/.cursor/rules/gamut-figma-rules.mdc similarity index 100% rename from .cursor/rules/figma-rules.mdc rename to .cursor/rules/gamut-figma-rules.mdc diff --git a/.cursor/skills/gamut-library-authoring/SKILL.md b/.cursor/skills/gamut-library-authoring/SKILL.md new file mode 100644 index 00000000000..408e484f685 --- /dev/null +++ b/.cursor/skills/gamut-library-authoring/SKILL.md @@ -0,0 +1,51 @@ +--- +name: gamut-library-authoring +description: >- + Authors and maintains components in the Codecademy Gamut monorepo (packages/gamut, + gamut-styles, patterns, icons, illustrations). Use when adding or changing design + system components, tokens, Storybook MDX, variance/styledOptions, ColorMode-aware + styles, or eslint-plugin-gamut rules in this repository—not when consuming Gamut + from an application. +--- + +# Gamut library authoring + +## Scope + +Work lives under this monorepo: `packages/gamut`, `packages/gamut-styles`, `packages/gamut-patterns`, `packages/gamut-icons`, `packages/gamut-illustrations`, `packages/styleguide`. Do not treat this skill as guidance for app repos that only install `@codecademy/gamut`. + +## Architecture + +- Components: `packages/gamut/src` — extend existing components before adding overlapping primitives. +- Patterns: `packages/gamut-patterns` — page-level compositions. +- Icons / illustrations: `packages/gamut-icons`, `packages/gamut-illustrations`. +- Tokens: single source in `packages/gamut-styles/src/variables/` (`spacing`, `colors`, `typography`, `borderRadii`). No ad-hoc hex or arbitrary pixel strings where a token exists. + +## Styling + +- Emotion + `@codecademy/gamut-styles`: `css`, `variant`, `states`, system props via `system.css`, `styledOptions`, variance `compose` where the codebase already does. +- Semantic color keys only in component styles so components work in any ColorMode. +- Avoid nested tag selectors and `${GamutComponent}` selectors; prefer system props, layout primitives (`FlexBox`, `GridBox`), and explicit wrappers. + +## ColorMode and Background + +- When changing theme behavior, read `packages/styleguide/src/lib/Foundations/ColorMode/ColorMode.mdx` and `packages/gamut-styles/src/ColorMode.tsx` / `Background` implementation. +- Components should consume **semantic** aliases (`text`, `background`, `primary`, etc.), not raw palette names in ways that break mode switching. + +## Documentation and AI-facing MDX + +- New or changed components need Storybook MDX under `packages/styleguide`; keep props tables accurate ([Storybook Autodocs](https://storybook.js.org/docs/writing-docs/autodocs) where used). +- Cross-link [published Storybook](https://gamut.codecademy.com/) paths for reviewers and agents. + +## Accessibility + +- Follow WCAG-minded patterns; use styleguide Meta and per-component pages. Prefer built-in Gamut props and semantics over bespoke DOM. + +## Quality gates + +- Respect `eslint-plugin-gamut` and repo ESLint config for touched packages. +- Add or update stories and visual/docs coverage when behavior or public API changes. + +## Further reading + +See [reference.md](reference.md) for token paths, exemplar workflow, and Figma rule alignment. diff --git a/.cursor/skills/gamut-library-authoring/reference.md b/.cursor/skills/gamut-library-authoring/reference.md new file mode 100644 index 00000000000..3b9c7623619 --- /dev/null +++ b/.cursor/skills/gamut-library-authoring/reference.md @@ -0,0 +1,29 @@ +# Gamut library authoring — reference + +## Token files (read before changing visuals) + +- `packages/gamut-styles/src/variables/spacing.ts` +- `packages/gamut-styles/src/variables/colors.ts` +- `packages/gamut-styles/src/variables/typography.ts` +- `packages/gamut-styles/src/variables/borderRadii.ts` + +## Canonical MDX sources + +- Meta best practices: `packages/styleguide/src/lib/Meta/Best practices.mdx` +- ColorMode / Background: `packages/styleguide/src/lib/Foundations/ColorMode/ColorMode.mdx` +- System compose: Storybook path `/docs/foundations-system-compose--page` on [gamut.codecademy.com](https://gamut.codecademy.com/) + +## Figma and package boundaries + +Project rule `.cursor/rules/figma-rules.mdc` maps Figma output to `gamut`, `gamut-patterns`, `gamut-icons`, `gamut-illustrations` and token file paths. Align new components with that rule. + +## When adding a component (checklist) + +1. Search `packages/gamut/src` for something close; extend if possible. +2. Use semantic colors and token scales from `gamut-styles` variables. +3. Add Storybook MDX under `packages/styleguide` with props and usage. +4. Run package-level lint/tests for touched workspaces. + +## Theme / mode changes + +Document Storybook coverage and any breaking changes for consumers. Platform-specific theme docs live under styleguide Foundations; coordinate with `ColorMode` and theme providers. diff --git a/.gitignore b/.gitignore index e877cbac30b..c22d5ad0076 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,10 @@ report*.json !.cursor/rules .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md +# Share team Cursor skills whose directory name starts with gamut- (e.g. gamut-library-authoring) +!.cursor/skills +.cursor/skills/* +!.cursor/skills/gamut-* .claude/worktrees .claude/settings.local.json From ca7eead7b7f7fd6a7d3c69ea8a8d377069ad2407 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Wed, 1 Apr 2026 13:34:56 -0400 Subject: [PATCH 4/5] cursor tests --- .cursor/rules/gamut-component-building.mdc | 19 +++++++ .cursor/rules/gamut-library.mdc | 3 +- .../skills/gamut-library-authoring/SKILL.md | 44 +++++++++++++- .../gamut-library-authoring/reference.md | 33 +++++++++-- .../styleguide/src/lib/Meta/Contributing.mdx | 2 +- .../Gamut writing guide/Stories/About.mdx | 2 + .../Stories/Building components in Gamut.mdx | 57 +++++++++++++++++++ 7 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 .cursor/rules/gamut-component-building.mdc create mode 100644 packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/Building components in Gamut.mdx diff --git a/.cursor/rules/gamut-component-building.mdc b/.cursor/rules/gamut-component-building.mdc new file mode 100644 index 00000000000..52c0c599dc6 --- /dev/null +++ b/.cursor/rules/gamut-component-building.mdc @@ -0,0 +1,19 @@ +--- +description: >- + Structure, TypeScript (variance), React, and Storybook conventions for building or editing + components in packages/gamut and their docs in packages/styleguide. +globs: + - packages/gamut/**/* + - packages/styleguide/**/* +alwaysApply: false +--- + +# Gamut component building + +- **Folders** — PascalCase directory under `packages/gamut/src/`; entry `index.tsx` or `index.ts` barrel; tests in `__tests__/.test.tsx`. Grow with `shared/`, `elements/`, etc. for large UIs (see `Button/`, `Form/`, `BarChart/`). +- **Public API** — Register exports in `packages/gamut/src/index.tsx`; use `export type { ... }` when only types should surface. +- **Storybook** — Under `packages/styleguide/src/lib///`, pair `ComponentName.stories.tsx` + `ComponentName.mdx`. Use VS Code snippets `component-story`, `component-doc`, `toc-story` from `.vscode/stories.code-snippets`. Flagship story + `Controls`; keep “Show code” copy-paste friendly (see `packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/About.mdx`). +- **TypeScript** — Derive props from implementation: `StyleProps` after `variance.compose` / `variant`; `ComponentProps` for Emotion styled roots; intersect with bases (`ButtonBaseProps & ComponentProps`). Model variant-specific APIs with discriminated unions and `never` for illegal combos. Narrow handlers with `HTMLProps<…>['onClick']` or `ComponentProps[…]`. Exemplars: `Tag/types.tsx`, `Badge/index.tsx`, `Button/shared/types.ts`, `Form/elements/Form.tsx`, `Anchor/index.tsx`. +- **React** — Match neighboring `React.FC` usage; use `forwardRef` when refs matter; follow Rules of Hooks, effect cleanup, and composition over huge prop lists; prefer semantic DOM and Gamut a11y patterns. +- **Tokens / ColorMode** — Semantic colors and gamut-styles tokens: see `.cursor/rules/gamut-library.mdc`. +- **Depth** — Full checklists and links: **gamut-library-authoring** skill (`.cursor/skills/gamut-library-authoring/SKILL.md`) and [reference.md](.cursor/skills/gamut-library-authoring/reference.md). diff --git a/.cursor/rules/gamut-library.mdc b/.cursor/rules/gamut-library.mdc index 075ea6a8e37..40f351a3e6d 100644 --- a/.cursor/rules/gamut-library.mdc +++ b/.cursor/rules/gamut-library.mdc @@ -19,5 +19,6 @@ alwaysApply: false - Read token sources in `packages/gamut-styles/src/variables/` before changing colors, spacing, type, or radii. Avoid hardcoded hex and non-token pixel values. - Use semantic color keys in component styles so they work under every ColorMode. - Add or update Storybook MDX in `packages/styleguide` for public API or behavior changes. +- For **component file layout, variance typing, React conventions, and Storybook file pairing** in `packages/gamut` / `packages/styleguide`, follow `.cursor/rules/gamut-component-building.mdc` and the **gamut-library-authoring** skill below. - Follow `.cursor/rules/figma-rules.mdc` for icon/pattern/illustration package usage when implementing from design. -- For detailed workflows, use the **gamut-library-authoring** Cursor skill (`/gamut-library-authoring` in chat, or open `.cursor/skills/gamut-library-authoring/SKILL.md`). +- For detailed workflows (tokens, styling, MDX, quality gates), use the **gamut-library-authoring** Cursor skill (`/gamut-library-authoring` in chat, or open `.cursor/skills/gamut-library-authoring/SKILL.md`). diff --git a/.cursor/skills/gamut-library-authoring/SKILL.md b/.cursor/skills/gamut-library-authoring/SKILL.md index 408e484f685..b79184ecb19 100644 --- a/.cursor/skills/gamut-library-authoring/SKILL.md +++ b/.cursor/skills/gamut-library-authoring/SKILL.md @@ -4,8 +4,8 @@ description: >- Authors and maintains components in the Codecademy Gamut monorepo (packages/gamut, gamut-styles, patterns, icons, illustrations). Use when adding or changing design system components, tokens, Storybook MDX, variance/styledOptions, ColorMode-aware - styles, or eslint-plugin-gamut rules in this repository—not when consuming Gamut - from an application. + styles, TypeScript prop modeling, React patterns, or eslint-plugin-gamut rules in + this repository—not when consuming Gamut from an application. --- # Gamut library authoring @@ -14,6 +14,8 @@ description: >- Work lives under this monorepo: `packages/gamut`, `packages/gamut-styles`, `packages/gamut-patterns`, `packages/gamut-icons`, `packages/gamut-illustrations`, `packages/styleguide`. Do not treat this skill as guidance for app repos that only install `@codecademy/gamut`. +**Cursor rules:** `.cursor/rules/gamut-library.mdc` (tokens, ColorMode, Figma boundaries) and `.cursor/rules/gamut-component-building.mdc` (component structure, TS, React, Storybook pairing for `packages/gamut` + `packages/styleguide`). + ## Architecture - Components: `packages/gamut/src` — extend existing components before adding overlapping primitives. @@ -21,6 +23,41 @@ Work lives under this monorepo: `packages/gamut`, `packages/gamut-styles`, `pack - Icons / illustrations: `packages/gamut-icons`, `packages/gamut-illustrations`. - Tokens: single source in `packages/gamut-styles/src/variables/` (`spacing`, `colors`, `typography`, `borderRadii`). No ad-hoc hex or arbitrary pixel strings where a token exists. +## Component structure (`packages/gamut`) + +- Default: one **PascalCase** folder with `index.tsx` (or `index.ts` re-exporting siblings) and `__tests__/.test.tsx`. +- Large UIs: add `shared/` for types/styles/variants, `elements/` for presentational pieces, or domain subfolders (`layout/`, `inputs/`) following `Button/`, `Form/`, `BarChart/`, `GridForm/`. +- **Barrel:** every public component or type consumers need must be exported from `packages/gamut/src/index.tsx`. Use `export type { … }` when values should not be re-exported. + +## Storybook and snippets (`packages/styleguide`) + +- Place docs under `packages/styleguide/src/lib/` in the atomic layer that matches the component (Atoms, Molecules, Organisms, Layouts, Typography, etc.); folder structure mirrors the Storybook sidebar. +- For each component: **`ComponentName.stories.tsx`** + **`ComponentName.mdx`** (kebab-case filenames) in that component’s folder. +- VS Code (repo root): type **`component-story`**, **`component-doc`**, or **`toc-story`** to insert templates from `.vscode/stories.code-snippets`. +- Include a flagship/default story, **`Controls`** where appropriate, and prose in MDX (`parameters` with `title`, `subtitle`, `design`, `status`, `source.githubLink`). Prefer examples that **Show code** as copy-paste-ready (avoid heavy indirection in the snippet users copy). +- Meta guides: `packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/About.mdx`, `Component story documentation.mdx`, `Component code examples.mdx`. + +## TypeScript and variance + +- **Derive props from styling:** after `const x = variance.compose(system.space, …)` or `variant({ … })`, extend with `StyleProps`. Chain multiple `StyleProps` when variants and states are separate (see `Anchor`, `Tag/types.tsx`). +- **Derive from styled component:** `export type FooProps = ComponentProps` for Emotion roots built with `styled('tag', styledOptions<'tag'>())(…)`. +- **Compose with bases:** e.g. `ButtonBaseProps & ComponentProps` so system props and the underlying component stay aligned. +- **Variants:** use **discriminated unions** (`export type Props = A | B | C`) when `variant` or mode changes required props. Use **`never`** on disallowed props per branch so invalid combinations fail at compile time (`Tag`, `Badge` standard vs `custom`). +- **DOM handlers:** prefer `HTMLProps['onClick']`, `ComponentProps['onClick']`, etc., over `Function` or `any`. +- **Shared types:** reuse `WithChildrenProp`, `IconComponentType`, `Partial` from `packages/gamut/src/utils/types.ts`; follow generics like `InlineIconButtonProps` in `Button/shared/types.ts` for polymorphic wrappers. +- **Gold components:** adding a variant usually means a new union member and fixing consumers; avoid new `as any`; reserve exceptions for documented edge cases only. + +## React + +- Match **local file style** (`React.FC` is common in Gamut). +- Use **`forwardRef`** when consumers or libraries need the underlying DOM ref. +- **Rules of Hooks**; name shared logic `use*`. Effects: correct dependency arrays, cleanup for subscriptions/timers; avoid mirroring props into state when derived state or a `key` reset is clearer ([You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)). +- **Memoization:** `useMemo` / `useCallback` / `React.memo` when profiling or stable identity is required—not by default. +- **Composition:** prefer `children` and subcomponents over flat prop explosion; page templates belong in `gamut-patterns`. +- **Lists:** stable keys; avoid index keys for reorderable/dynamic lists. +- **Forms:** be explicit about controlled vs uncontrolled behavior; align with `ConnectedForm` / `GridForm` when touching those flows. +- **Accessibility:** semantic elements first (`button`, `a`, `label` + `htmlFor`); use `aria-*` for bespoke widgets. See styleguide Meta and `Best practices.mdx`. + ## Styling - Emotion + `@codecademy/gamut-styles`: `css`, `variant`, `states`, system props via `system.css`, `styledOptions`, variance `compose` where the codebase already does. @@ -36,6 +73,7 @@ Work lives under this monorepo: `packages/gamut`, `packages/gamut-styles`, `pack - New or changed components need Storybook MDX under `packages/styleguide`; keep props tables accurate ([Storybook Autodocs](https://storybook.js.org/docs/writing-docs/autodocs) where used). - Cross-link [published Storybook](https://gamut.codecademy.com/) paths for reviewers and agents. +- Human-oriented overview for contributors: `packages/styleguide/src/lib/Meta/Gamut writing guide/Building components in Gamut.mdx` (if present). ## Accessibility @@ -48,4 +86,4 @@ Work lives under this monorepo: `packages/gamut`, `packages/gamut-styles`, `pack ## Further reading -See [reference.md](reference.md) for token paths, exemplar workflow, and Figma rule alignment. +See [reference.md](reference.md) for token paths, exemplar source files, snippet names, Meta MDX paths, and Figma rule alignment. diff --git a/.cursor/skills/gamut-library-authoring/reference.md b/.cursor/skills/gamut-library-authoring/reference.md index 3b9c7623619..d40b0f6cc7f 100644 --- a/.cursor/skills/gamut-library-authoring/reference.md +++ b/.cursor/skills/gamut-library-authoring/reference.md @@ -7,11 +7,35 @@ - `packages/gamut-styles/src/variables/typography.ts` - `packages/gamut-styles/src/variables/borderRadii.ts` -## Canonical MDX sources +## TypeScript and structure exemplars (`packages/gamut/src`) +| Topic | Files | +| --- | --- | +| Discriminated unions + `never` | `Tag/types.tsx` | +| Variant branches + conflicting props | `Badge/index.tsx` | +| `StyleProps` + `ComponentProps` intersection | `Button/shared/types.ts`, `ButtonBase/ButtonBase.tsx` | +| `ComponentProps` | `Form/elements/Form.tsx` | +| Multiple `StyleProps` + anchor variants | `Anchor/index.tsx` | +| `variance.compose` for layout system props | `Box/props.ts`, `Layout/LayoutGrid.tsx` | + +## VS Code snippets (repo root) + +Prefix in editor → choose snippet from `.vscode/stories.code-snippets`: + +- `component-story` — `ComponentName.stories.tsx` CSF template +- `component-doc` — `ComponentName.mdx` doc template +- `toc-story` — table-of-contents category page + +## Meta / Storybook MDX (human docs) + +- Contributing (props JSDoc, tests): `packages/styleguide/src/lib/Meta/Contributing.mdx` +- Stories guide hub: `packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/About.mdx` +- MDX structure: `…/Stories/Component story documentation.mdx` +- `.stories.tsx` patterns: `…/Stories/Component code examples.mdx` +- Building components in Gamut (overview): `packages/styleguide/src/lib/Meta/Gamut writing guide/Building components in Gamut.mdx` - Meta best practices: `packages/styleguide/src/lib/Meta/Best practices.mdx` - ColorMode / Background: `packages/styleguide/src/lib/Foundations/ColorMode/ColorMode.mdx` -- System compose: Storybook path `/docs/foundations-system-compose--page` on [gamut.codecademy.com](https://gamut.codecademy.com/) +- System compose (published): Storybook path `/docs/foundations-system-compose--page` on [gamut.codecademy.com](https://gamut.codecademy.com/) ## Figma and package boundaries @@ -21,8 +45,9 @@ Project rule `.cursor/rules/figma-rules.mdc` maps Figma output to `gamut`, `gamu 1. Search `packages/gamut/src` for something close; extend if possible. 2. Use semantic colors and token scales from `gamut-styles` variables. -3. Add Storybook MDX under `packages/styleguide` with props and usage. -4. Run package-level lint/tests for touched workspaces. +3. Model props with `StyleProps` / `ComponentProps` / unions per SKILL.md; export via `packages/gamut/src/index.tsx`. +4. Add `*.stories.tsx` + `*.mdx` under `packages/styleguide/src/lib///`. +5. Run package-level lint/tests for touched workspaces. ## Theme / mode changes diff --git a/packages/styleguide/src/lib/Meta/Contributing.mdx b/packages/styleguide/src/lib/Meta/Contributing.mdx index ef7fc160975..52b8245d512 100644 --- a/packages/styleguide/src/lib/Meta/Contributing.mdx +++ b/packages/styleguide/src/lib/Meta/Contributing.mdx @@ -65,7 +65,7 @@ limit: number; ### Unit tests -Your component should have unit tests in a `__tests__/MyComponent-test.tsx` file within its directory. +Your component should have unit tests in a `__tests__/MyComponent.test.tsx` file within its directory. Use `setupRtl` from `gamut-tests` to test it. We generally try to unit test all component logic, with the exception of class names in components that contain other logic. diff --git a/packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/About.mdx b/packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/About.mdx index e1226c7125b..48749e62920 100644 --- a/packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/About.mdx +++ b/packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/About.mdx @@ -7,6 +7,7 @@ import { } from '~styleguide/blocks'; import { parameters as aboutPagesParameters } from './About pages.mdx'; +import { parameters as buildingComponentsParameters } from './Building components in Gamut.mdx'; import { parameters as componentCodeExamplesParameters } from './Component code examples.mdx'; import { parameters as componentStoryDocumentationParameters } from './Component story documentation.mdx'; @@ -50,6 +51,7 @@ Explore the pages below for detailed guidelines on each part of the story struct + + + +This page summarizes how we build and document components in the Gamut monorepo. It aligns with the **gamut-library-authoring** Cursor skill and project rules **gamut-library** / **gamut-component-building** (under `.cursor/rules/` and `.cursor/skills/gamut-library-authoring/` in the repo). + +## Packages and placement + +- **UI primitives** live in `packages/gamut` (and shared tokens in `packages/gamut-styles`). +- **Page-level compositions** belong in `packages/gamut-patterns`. +- **Icons and illustrations** use `packages/gamut-icons` and `packages/gamut-illustrations`. + +Prefer extending an existing component before adding an overlapping primitive. + +## Folder and export shape (`packages/gamut`) + +- One **PascalCase** directory per component or family, with `index.tsx` or an `index.ts` barrel. +- Tests: `__tests__/.test.tsx` with `setupRtl` from `gamut-tests` (see Contributing). +- Register public exports in `packages/gamut/src/index.tsx`; use `export type { … }` when only types should be public. +- Large components split helpers into `shared/`, `elements/`, or domain subfolders (see `Button/`, `Form/`, `BarChart/`). + +## TypeScript and variance + +Derive props from implementation instead of duplicating shapes: + +- **`StyleProps`** after `variance.compose` or `variant`. +- **`ComponentProps`** for Emotion styled roots. +- **Discriminated unions** and **`never`** for variant-specific or mutually exclusive props. + +Exemplars: `Tag/types.tsx`, `Badge/index.tsx`, `Button/shared/types.ts`, `Form/elements/Form.tsx`, `Anchor/index.tsx`. + +## React + +Match neighboring files (`React.FC` is common). Use `forwardRef` when refs matter. Follow Rules of Hooks, clean up effects, and favor composition over huge prop lists. Prefer semantic HTML and documented a11y patterns (see Best practices where applicable). + +## Storybook (`packages/styleguide`) + +- Add `ComponentName.stories.tsx` and `ComponentName.mdx` under `packages/styleguide/src/lib///`. +- Use repo snippets: **`component-story`**, **`component-doc`**, **`toc-story`** (`.vscode/stories.code-snippets`). +- For narrative structure and Canvas/Controls patterns, see Stories and the **Component story documentation** / **Component code examples** pages in this section. + +## Further reading + +- Contributing — props documentation and PR expectations. +- Published Storybook: [gamut.codecademy.com](https://gamut.codecademy.com/). From 8b6ed432bd28a0ab12eb8fd8daf81e76319ca2af Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Tue, 7 Apr 2026 14:48:31 -0400 Subject: [PATCH 5/5] formatted --- .../Stories/Building components in Gamut.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/Building components in Gamut.mdx b/packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/Building components in Gamut.mdx index c8ae03664ef..02e53afb0af 100644 --- a/packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/Building components in Gamut.mdx +++ b/packages/styleguide/src/lib/Meta/Gamut writing guide/Stories/Building components in Gamut.mdx @@ -53,5 +53,6 @@ Match neighboring files (`React.FC` is common). Use `forwardRef` when refs matte ## Further reading -- Contributing — props documentation and PR expectations. +- Contributing — props documentation and + PR expectations. - Published Storybook: [gamut.codecademy.com](https://gamut.codecademy.com/).