Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
19 changes: 19 additions & 0 deletions .cursor/rules/gamut-component-building.mdc
Original file line number Diff line number Diff line change
@@ -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__/<Name>.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/<Atoms|Molecules|…>/<Component>/`, 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<typeof composed>` after `variance.compose` / `variant`; `ComponentProps<typeof StyledX>` for Emotion styled roots; intersect with bases (`ButtonBaseProps & ComponentProps<typeof ButtonBase>`). Model variant-specific APIs with discriminated unions and `never` for illegal combos. Narrow handlers with `HTMLProps<…>['onClick']` or `ComponentProps<typeof Child>[…]`. Exemplars: `Tag/types.tsx`, `Badge/index.tsx`, `Button/shared/types.ts`, `Form/elements/Form.tsx`, `Anchor/index.tsx`.
- **React** — Match neighboring `React.FC<Props>` 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).
File renamed without changes.
24 changes: 24 additions & 0 deletions .cursor/rules/gamut-library.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
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.
- 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 (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`).
89 changes: 89 additions & 0 deletions .cursor/skills/gamut-library-authoring/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
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, TypeScript prop modeling, React patterns, 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`.

**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.
- 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.

## Component structure (`packages/gamut`)

- Default: one **PascalCase** folder with `index.tsx` (or `index.ts` re-exporting siblings) and `__tests__/<Component>.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<typeof x>`. Chain multiple `StyleProps<typeof …>` when variants and states are separate (see `Anchor`, `Tag/types.tsx`).
- **Derive from styled component:** `export type FooProps = ComponentProps<typeof StyledFoo>` for Emotion roots built with `styled('tag', styledOptions<'tag'>())(…)`.
- **Compose with bases:** e.g. `ButtonBaseProps & ComponentProps<typeof ButtonBase>` 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<HTMLAnchorElement>['onClick']`, `ComponentProps<typeof SubComponent>['onClick']`, etc., over `Function` or `any`.
- **Shared types:** reuse `WithChildrenProp`, `IconComponentType`, `Partial<IconComponentType>` 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<Props>` 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.
- 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.
- Human-oriented overview for contributors: `packages/styleguide/src/lib/Meta/Gamut writing guide/Building components in Gamut.mdx` (if present).

## 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 source files, snippet names, Meta MDX paths, and Figma rule alignment.
54 changes: 54 additions & 0 deletions .cursor/skills/gamut-library-authoring/reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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`

## 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<typeof StyledForm>` | `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 (published): 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. 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/<layer>/<Component>/`.
5. 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.
63 changes: 63 additions & 0 deletions .github/scripts/validate-cursor-plugins-helpers.mjs
Original file line number Diff line number Diff line change
@@ -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, '') };
}
97 changes: 97 additions & 0 deletions .github/scripts/validate-cursor-plugins-helpers.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading