diff --git a/.nx/version-plans/version-plan-1780940298971.md b/.nx/version-plans/version-plan-1780940298971.md new file mode 100644 index 00000000000..c71c5c19e33 --- /dev/null +++ b/.nx/version-plans/version-plan-1780940298971.md @@ -0,0 +1,5 @@ +--- +gamut: minor +--- + +Update ai-skills with better setup instructions + DESIGN.md generation diff --git a/packages/gamut/agent-tools/.claude-plugin/plugin.json b/packages/gamut/agent-tools/.claude-plugin/plugin.json index 1061b0b02f3..28d0f3ec686 100644 --- a/packages/gamut/agent-tools/.claude-plugin/plugin.json +++ b/packages/gamut/agent-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gamut-design-system", - "version": "0.0.1", + "version": "0.0.2", "description": "Gamut design system agent tools: skills and rules for AI-assisted development.", "license": "MIT", "keywords": ["codecademy", "gamut", "design-system", "agent-skills"] diff --git a/packages/gamut/agent-tools/.cursor-plugin/plugin.json b/packages/gamut/agent-tools/.cursor-plugin/plugin.json index daa7f1bfab6..075b1a75d1c 100644 --- a/packages/gamut/agent-tools/.cursor-plugin/plugin.json +++ b/packages/gamut/agent-tools/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "gamut-design-system", "displayName": "Gamut Design System", - "version": "0.0.1", + "version": "0.0.2", "description": "Gamut design system agent tools: skills and rules for AI-assisted development.", "keywords": ["codecademy", "gamut", "design-system", "agent-skills"] } diff --git a/packages/gamut/agent-tools/skills/gamut-buttons/SKILL.md b/packages/gamut/agent-tools/skills/gamut-buttons/SKILL.md index 5f50aaec562..18a5e0825df 100644 --- a/packages/gamut/agent-tools/skills/gamut-buttons/SKILL.md +++ b/packages/gamut/agent-tools/skills/gamut-buttons/SKILL.md @@ -87,6 +87,60 @@ Hover, active, and disabled colors are handled by the component. Do not override - `IconButton`: provide an accessible name via `tip` (used as `aria-label` when `aria-label` is omitted). See ToolTip / IconButton Storybook pages. - `ButtonBase` is not exported from `@codecademy/gamut` (only the `ButtonBaseElements` type is). Prefer stock atoms; custom button styling belongs in Gamut itself or via `css` / `variant` from `gamut-styles`, not by importing `ButtonBase`. +## Focus management — buttons with ToolTip + +`ToolTip` opens on **hover or focus** and closes when neither is active. The two rendering paths behave differently: + +- **Inline (default):** CSS-only via `:hover` and `:focus-within` on the wrapper. No JS involved; tooltip visibility tracks pointer and focus state automatically. +- **Floating (`placement="floating"`):** JS-driven. Tracks hover (`mouseenter`/`mouseleave`) and focus (`focus`/`blur`) separately with a small delay on each. Escape key always closes the tooltip by calling `.blur()` on the trigger automatically. + +**When the tooltip lingers after a click:** This only occurs with `FloatingTip` when the button was **keyboard-focused before the click**. `FloatingTip` keeps an `isFocused` flag; while that flag is true, `mouseleave` does not close the tooltip. If the click action does not naturally move DOM focus elsewhere, the button stays focused and the tooltip stays open. + +Mouse-initiated clicks do not have this problem: `TargetContainer` has `onMouseDown={(e) => e.preventDefault()}` which prevents the button from gaining focus via mouse, so `isFocused` stays `false` and the tooltip closes when the pointer moves away. + +**Pattern — explicit blur when focus won't move naturally:** + +```tsx +const handleClick = () => { + (document.activeElement as HTMLElement)?.blur(); + openPanel(); +}; + +; +``` + +Or with a ref: + +```tsx +const ref = useRef(null); + + { + ref.current?.blur(); + openPanel(); + }} +/>; +``` + +**When to apply (floating placement, keyboard-triggered clicks only):** + +- Click opens a modal, drawer, or panel that does NOT auto-focus an element inside it +- Click triggers an in-place state toggle (e.g. show/hide inline editor) +- Click dispatches a mutation with no focus side-effect + +**When NOT needed:** + +- Click opens a modal with a proper focus trap — the trap moves focus automatically, blurring the button +- Click navigates to a new route — component unmounts +- Click reveals a `Popover` or `FloatingTip`-managed dropdown — focus is moved by that system +- Tooltip uses the default inline (non-floating) placement — CSS handles visibility, no lingering issue +- User pressed Escape — built-in `escapeKeyPressHandler` already calls `.blur()` + +Call `.blur()` synchronously before the action; this keeps tooltip dismissal atomic with the user interaction. + ## Rules - Use `FillButton` for primary actions and `StrokeButton` for secondary — do not use both at equal weight on the same screen. diff --git a/packages/gamut/agent-tools/skills/gamut-datalist/SKILL.md b/packages/gamut/agent-tools/skills/gamut-datalist/SKILL.md new file mode 100644 index 00000000000..83a1e3e35b5 --- /dev/null +++ b/packages/gamut/agent-tools/skills/gamut-datalist/SKILL.md @@ -0,0 +1,274 @@ +--- +name: gamut-datalist +description: Use this skill when building a DataList for item-focused layouts with row expansion, row selection, or rich per-item content — including expandedContent, onRowSelect, onRowExpand, variant (default/card), useLocalQuery, and empty/loading states. Do not use for bulk data comparison tables (see gamut-datatable), or for small static lists (see gamut-list). +--- + +# Gamut DataList + +Item-focused list for managing, engaging with, and expanding individual rows. Use when users interact with items — opening details, selecting for bulk actions, or viewing expanded layouts — rather than scanning and comparing data across rows. + +Source: `@codecademy/gamut` — [DataList.tsx](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/DataList/DataList.tsx) + +See also: [`gamut-datatable`](../gamut-datatable/SKILL.md) — query-focused table for bulk data comparison. [`gamut-list`](../gamut-list/SKILL.md) — lower-level list primitives for fully custom layouts. [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — ARIA and keyboard interaction. + +Storybook: [Organisms / Lists & Tables / DataList](https://gamut.codecademy.com/?path=/docs-organisms-lists-tables-datalist--docs) + +## Components + +```tsx +import { DataList } from '@codecademy/gamut'; +import { useLocalQuery } from '@codecademy/gamut'; +``` + +| Symbol | Role | +| --------------- | ---------------------------------------------------------------------------------------- | +| `DataList` | Root component; supports expansion, selection, and card/default variants | +| `useLocalQuery` | Client-side hook for sort/filter state; spread its return value directly into `DataList` | + +## When to use DataList + +- Users **open, expand, or engage** with individual items (content libraries, assignment lists, course catalogs). +- Rows need **expandable detail panels** with rich layouts. +- Users need to **select rows** for bulk actions. +- Items contain **icons, graphics, or complex layouts** rather than plain metrics. +- Optional filtering or sorting across shared attributes is needed at the list level. + +**Do not use DataList when:** + +- The goal is to **compare data across rows** (scores, metrics, statuses in columns) → use [`DataTable`](../gamut-datatable/SKILL.md). +- The list is small, static, and needs fully custom row layouts → use [`List`](../gamut-list/SKILL.md) directly. +- You need a table with horizontal scrolling → use `DataTable` (DataList is not scrollable). + +## Design principles + +- **Engage with individual items**: design each row to support the action users take on it (open, launch, select, expand). +- **Customize items with rich content**: icons, progress indicators, graphics, and other atoms are appropriate within rows. +- **Keep item controls visible**: action controls should be on the right side of the row. +- **Place lists inside main containers**: avoid overflow by placing DataList in appropriately sized parent layouts. +- **Use DataTable for comparison-first designs**: if the design is primarily about scanning data between items, reach for DataTable instead. + +## Variants + +```tsx + +``` + +| `variant` | Use for | +| --------- | ---------------------------------------------------------------------------------- | +| `default` | Standard bordered rows; best for most item-management layouts | +| `card` | Rows with vertical gap; best for content that doesn't need to be visually adjacent | + +## Props + +| Prop | Type | Default | Notes | +| ----------------------- | ------------------------------------------------------ | ------------------- | ------------------------------------------------------------------------ | +| `id` | `string` | required | Unique ID for the list | +| `idKey` | `keyof Row` | required | Row identifier — must be a `string \| number` field | +| `rows` | `Row[]` | required | Data array | +| `columns` | `ColumnConfig[]` | required | Column definitions | +| `query` | `Query` | — | Current sort/filter state | +| `onQueryChange` | `OnQueryChange` | — | Called when sort or filter changes | +| `selected` | `Row[IdKey][]` | — | Array of selected row IDs | +| `onRowSelect` | `RowStateChange<'select' \| 'select-all', Row[IdKey]>` | — | Called on row or select-all toggle | +| `expanded` | `Row[IdKey][]` | — | Array of expanded row IDs | +| `onRowExpand` | `RowStateChange<'expand', Row[IdKey]>` | — | Called when a row is expanded or collapsed | +| `expandedContent` | `ExpandRow` | — | Render function for expanded row content; receives `{ row, onCollapse }` | +| `variant` | `'default' \| 'card'` | `'default'` | Row visual style | +| `header` | `boolean` | — | Whether to show a header row | +| `hideSelectAll` | `boolean` | `false` | Hides the select-all checkbox in the header | +| `loading` | `boolean` | `false` | Replaces row content with shimmer placeholders | +| `spacing` | `'condensed' \| 'normal'` | `'condensed'` | Row padding | +| `emptyMessage` | `ReactNode` | default empty state | Rendered when `rows` is empty | +| `disableContainerQuery` | `boolean` | `false` | Falls back to media queries instead of container queries | + +> DataList always sets `scrollable={false}`. For a horizontally scrollable table, use `DataTable`. + +## ColumnConfig + +Identical to DataTable — see [`gamut-datatable`](../gamut-datatable/SKILL.md#columnconfig) for the full table. + +## Basic usage + +```tsx +import { DataList, useLocalQuery } from '@codecademy/gamut'; + +const columns = [ + { key: 'title', header: 'Title', size: 'md', type: 'header', fill: true }, + { key: 'status', header: 'Status', size: 'sm' }, +]; + +const MyList = ({ data }) => { + const queryData = useLocalQuery({ idKey: 'id', rows: data, columns }); + + return ; +}; +``` + +## Expandable rows + +Pass `expandedContent`, `expanded`, and `onRowExpand` together. The `expandedContent` render function receives `{ row, onCollapse }` — call `onCollapse` from within the expanded panel to close it programmatically. + +```tsx +const [expanded, setExpanded] = useState([]); + +const handleExpand = ({ type, payload: { rowId, toggle } }) => { + if (type === 'expand') { + setExpanded((prev) => + toggle ? prev.filter((id) => id !== rowId) : [...prev, rowId] + ); + } +}; + + ( + + {row.title}: additional details here + Collapse + + )} +/>; +``` + +## Selectable rows + +Pass `selected` and `onRowSelect` to enable checkboxes. The callback receives `{ type, payload: { rowId, toggle } }` where `type` is `'select'` or `'select-all'`. + +```tsx +const [selected, setSelected] = useState([]); + +const handleSelect = ({ type, payload: { rowId, toggle } }) => { + if (type === 'select-all') { + setSelected((prev) => (prev.length > 0 ? [] : data.map((r) => r.id))); + } else if (type === 'select') { + setSelected((prev) => + toggle ? prev.filter((id) => id !== rowId) : [...prev, rowId] + ); + } +}; + +; +``` + +To hide row checkboxes entirely, omit `onRowSelect` and `selected`. To hide only the select-all checkbox, pass `hideSelectAll`. + +## Expansion + selection combined + +Both can be active at once — pass all four props together. + +```tsx + } +/> +``` + +## Sorting and filtering with useLocalQuery + +`useLocalQuery` handles client-side sort and filter state. Spread its return value directly into `DataList`. Mark columns as `sortable` or provide `filters` in the column config. + +```tsx +const columns = [ + { key: 'title', header: 'Title', size: 'md', sortable: true }, + { + key: 'type', + header: 'Type', + size: 'sm', + filters: ['Course', 'Path', 'Project'], + }, +]; + +const queryData = useLocalQuery({ idKey: 'id', rows: data, columns }); + +; +``` + +For server-side filtering or pagination, manage `query` and `onQueryChange` externally instead of using `useLocalQuery`. + +## Empty state + +DataList shows a default empty state when `rows` is empty. Override with `emptyMessage`. + +```tsx + + + + No items yet. + + + + } +/> +``` + +## Loading state + +Pass `loading` to replace row content with shimmer placeholders while data fetches. + +```tsx + +``` + +## Container queries + +DataList uses CSS container queries by default. Disable only when the list lives in a constrained container or you are managing your own responsive logic. + +```tsx +
+ +
+``` + +## Accessibility + +- DataList renders as a semantic `` via the underlying `List as="table"`. +- Expanded rows receive an `aria-live="polite"` region automatically — do not add a duplicate live region. +- The expanded content region is labeled by the row's header column text; provide meaningful header column values. +- Selection checkboxes are grouped as a checkbox list; the select-all checkbox is in the ``. +- For custom `emptyMessage`, use `tbody > tr > th` structure for valid table semantics. diff --git a/packages/gamut/agent-tools/skills/gamut-datatable/SKILL.md b/packages/gamut/agent-tools/skills/gamut-datatable/SKILL.md new file mode 100644 index 00000000000..bdbe3ff4be1 --- /dev/null +++ b/packages/gamut/agent-tools/skills/gamut-datatable/SKILL.md @@ -0,0 +1,306 @@ +--- +name: gamut-datatable +description: Use this skill when building a DataTable for bulk data analysis, comparison, sorting, or filtering — including column configuration, the useLocalQuery hook, row action menus, empty/loading states, and color mode. Do not use for item management with row expansion or selection (see gamut-datalist), or for small static lists (see gamut-list). +--- + +# Gamut DataTable + +Structured, query-capable table for bulk data analysis and comparison. Sorting, filtering, loading, and empty states are built in. Use when the goal is to scan and compare information across rows — not to manage individual items. + +Source: `@codecademy/gamut` — [DataTable.tsx](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/DataList/DataTable.tsx) + +See also: [`gamut-datalist`](../gamut-datalist/SKILL.md) — item-focused list with expansion and selection. [`gamut-list`](../gamut-list/SKILL.md) — lower-level list primitives for fully custom layouts. [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — ARIA and keyboard interaction. [`gamut-color-mode`](../gamut-color-mode/SKILL.md) — dark/light mode with `Background`. + +Storybook: [Organisms / Lists & Tables / DataTable](https://gamut.codecademy.com/?path=/docs-organisms-lists-tables-datatable--docs) + +## Components + +```tsx +import { DataTable } from '@codecademy/gamut'; +import { useLocalQuery } from '@codecademy/gamut'; +``` + +| Symbol | Role | +| --------------- | ----------------------------------------------------------------------------------------- | +| `DataTable` | Root component; always renders as a `table`-variant `List` with scrolling enabled | +| `useLocalQuery` | Client-side hook for sort/filter state; spread its return value directly into `DataTable` | + +## When to use DataTable + +- Displaying data that users need to **compare across rows** (metrics, scores, statuses, dates). +- When the data set needs **sorting, filtering, or both** and that logic lives client-side. +- Dashboards, admin tables, reports, and data-dense views. +- When rows are **not individually selectable or expandable** — use `DataList` if you need those. + +**Do not use DataTable when:** + +- Users engage with items individually (expand for details, select for bulk actions) → use [`DataList`](../gamut-datalist/SKILL.md). +- The list is small, static, and needs fully custom row layouts → use [`List`](../gamut-list/SKILL.md) directly. +- There is no data at all and you just need a layout container. + +## Design principles + +- **Prioritize comparison**: arrange columns to encourage scanning and finding patterns, not storytelling. +- **Surface secondary info on drill-down**: use Coachmarks, Tooltips, Modals, or Flyovers rather than cramming detail into table cells. +- **Avoid information overload**: determine what belongs en-masse vs. what should live on a detail surface. Order columns by priority; collapse lower-priority columns at smaller sizes. +- **Use cell-level interactions carefully**: anchors and links for navigation; popovers for in-context actions. + +## Props + +| Prop | Type | Default | Notes | +| ----------------------- | ------------------------- | ------------------- | ------------------------------------------------------------------- | +| `id` | `string` | required | Unique ID for the table | +| `idKey` | `keyof Row` | required | Row identifier — must be a `string \| number` field | +| `rows` | `Row[]` | required | Data array | +| `columns` | `ColumnConfig[]` | required | Column definitions | +| `query` | `Query` | — | Current sort/filter state; use `useLocalQuery` or manage externally | +| `onQueryChange` | `OnQueryChange` | — | Called when sort or filter changes | +| `loading` | `boolean` | `false` | Replaces row content with shimmer placeholders | +| `spacing` | `'condensed' \| 'normal'` | `'condensed'` | Row padding | +| `scrollable` | `boolean` | `true` | Enables horizontal scroll with sticky first column | +| `shadow` | `boolean` | `false` | Shows a right-side scroll-indicator shadow | +| `height` | `string` | `'100%'` | Container height when `scrollable` is true | +| `minHeight` | `number` | `0` | Minimum container height | +| `emptyMessage` | `ReactNode` | default empty state | Rendered when `rows` is empty | +| `showOverflow` | `boolean` | — | Shows overflow indicators in cells | +| `disableContainerQuery` | `boolean` | `false` | Falls back to media queries instead of container queries | +| `scrollToTopOnUpdate` | `boolean` | `false` | Scrolls to top when rows change | +| `wrapperWidth` | `string` | — | Custom wrapper width override | + +## ColumnConfig + +| Field | Type | Notes | +| ---------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `key` | `keyof Row` | Required; maps to a row data field | +| `header` | `string` | Column header label | +| `type` | `'header' \| 'control'` | `'header'` makes the column sticky when scrollable; `'control'` right-aligns and removes padding for action buttons | +| `size` | `'sm' \| 'md' \| 'lg' \| 'xl'` | Column width; omit to fit content | +| `fill` | `boolean` | Expands the column to fill remaining width | +| `justify` | `'left' \| 'right'` | Cell alignment | +| `sortable` | `boolean` | Adds a sort toggle to the column header | +| `filters` | `string[]` | Adds a filter dropdown with these string values | +| `options` | `Array` | Alternative to `filters` when display text differs from value | +| `render` | `(row: Row) => ReactElement \| null` | Custom cell renderer | + +## Basic usage + +```tsx +import { DataTable, useLocalQuery } from '@codecademy/gamut'; + +const columns = [ + { key: 'name', header: 'Name', size: 'md', sortable: true }, + { + key: 'role', + header: 'Role', + size: 'md', + filters: ['Engineer', 'Design', 'Product'], + }, + { + key: 'score', + header: 'Score', + size: 'sm', + sortable: true, + justify: 'right', + }, +]; + +const MyTable = ({ data }) => { + const queryData = useLocalQuery({ idKey: 'id', rows: data, columns }); + + return ( + + ); +}; +``` + +## Custom cell render + +Use `render` for cells that need non-text content (status badges, progress bars, action buttons). + +```tsx +const columns = [ + { key: 'name', header: 'Name', size: 'md', type: 'header' }, + { + key: 'status', + header: 'Status', + size: 'sm', + render: (row) => ( + + {row.status} + + ), + }, +]; +``` + +## Row action menus + +Use a `type: 'control'` column with `PopoverContainer` and `Menu` for row-level actions. + +```tsx +import { + DataTable, + IconButton, + Menu, + MenuItem, + Modal, + PopoverContainer, +} from '@codecademy/gamut'; +import { MiniKebabMenuIcon } from '@codecademy/gamut-icons'; +import { useRef, useState } from 'react'; + +const RowActions = ({ rowId }) => { + const [isOpen, setIsOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const ref = useRef(null); + + return ( + + setIsOpen(!isOpen)} + /> + setIsOpen(false)} + > + + setIsOpen(false)}>Edit + { + setIsOpen(false); + setIsModalOpen(true); + }} + > + View details + + + + setIsModalOpen(false)} + > + {/* detail content */} + + + ); +}; + +const columns = [ + { key: 'name', header: 'Name', size: 'md', type: 'header' }, + { + key: 'id', + header: '', + size: 'sm', + type: 'control', + justify: 'right', + render: (row) => , + }, +]; +``` + +Key points: + +- `closeOnViewportExit` on `PopoverContainer` closes the menu when its row scrolls out of view. +- `allowPageInteraction` lets users interact with the table while the menu is open. +- Modals opened from menu items render at `zIndex={3}` by default, above the table header. + +## Scrollable table + +`scrollable` defaults to `true` on DataTable. The first `type: 'header'` column sticks to the left. Add `shadow` for a visual overflow indicator. + +```tsx + +``` + +## Empty state + +DataTable shows a default empty state when `rows` is empty. Override with `emptyMessage`. + +```tsx + + + + No results found. + + + + } +/> +``` + +## Loading state + +Pass `loading` to replace row content with shimmer placeholders while data fetches. + +```tsx + +``` + +## Color mode + +DataTable inherits background color from the `current-background` token. Wrap in `Background` from `@codecademy/gamut-styles` to apply a surface color and automatically switch to dark mode contrast. + +```tsx +import { Background } from '@codecademy/gamut-styles'; + + + +; +``` + +## Container queries + +DataTable uses CSS container queries by default for responsive column stacking. Disable only when: + +- The table lives in a container narrower than its breakpoint. +- You are managing your own responsive logic. + +```tsx +
+ +
+``` + +## Accessibility + +- DataTable renders as a semantic `
` element automatically. +- Sort controls receive `aria-sort` automatically when `sortable` is set on a column. +- Filter controls are keyboard-accessible via the column header dropdowns. +- For custom `emptyMessage`, use `tbody > tr > th` structure for valid table semantics (see empty state example above). diff --git a/packages/gamut/agent-tools/skills/gamut-layout/SKILL.md b/packages/gamut/agent-tools/skills/gamut-layout/SKILL.md index c9fcca8c13b..bc342cf577d 100644 --- a/packages/gamut/agent-tools/skills/gamut-layout/SKILL.md +++ b/packages/gamut/agent-tools/skills/gamut-layout/SKILL.md @@ -1,6 +1,6 @@ --- name: gamut-layout -description: Use this skill when applying Gamut spacing scale, border radii, viewport or container breakpoints, or page layout grid (LayoutGrid vs GridBox) — complements gamut-system-props for system.space and responsive props. +description: Use this skill when applying Gamut spacing scale, border radii, viewport or container breakpoints, screen sizes, responsive layouts, media queries, or page layout grid (LayoutGrid vs GridBox) — including migrating breakpoint or screen-size logic, responsive prop patterns, useWindowSize / useBreakpoint hooks, and mobile-first design. Complements gamut-system-props for system.space and responsive props. --- # Gamut Layout diff --git a/packages/gamut/agent-tools/skills/gamut-list/SKILL.md b/packages/gamut/agent-tools/skills/gamut-list/SKILL.md index d99dacdba87..ffafc25c819 100644 --- a/packages/gamut/agent-tools/skills/gamut-list/SKILL.md +++ b/packages/gamut/agent-tools/skills/gamut-list/SKILL.md @@ -9,7 +9,7 @@ Structured, repeating layouts built from `List`, `ListRow`, `ListCol`, and `Tabl Source: `@codecademy/gamut` — [List.tsx](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/List/List.tsx) -See also: [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — ARIA, focus, and keyboard interaction rules. [`gamut-layout`](../gamut-layout/SKILL.md) — spacing tokens and system props. +See also: [`gamut-datatable`](../gamut-datatable/SKILL.md) — use instead of List when data needs sorting, filtering, or query state. [`gamut-datalist`](../gamut-datalist/SKILL.md) — use instead of List when rows need expansion or selection. [`gamut-accessibility`](../gamut-accessibility/SKILL.md) — ARIA, focus, and keyboard interaction rules. [`gamut-layout`](../gamut-layout/SKILL.md) — spacing tokens and system props. Storybook: @@ -32,8 +32,16 @@ import { List, ListRow, ListCol, TableHeader } from '@codecademy/gamut'; ## When to use List +List is for **fully custom, manually composed layouts**. Reach for a higher-level component first: + +- Data that needs **sorting, filtering, or query state** → use [`DataTable`](../gamut-datatable/SKILL.md) or [`DataList`](../gamut-datalist/SKILL.md) instead of wiring these up manually in List. +- Rows that need **expansion or row selection** → use [`DataList`](../gamut-datalist/SKILL.md). + +Use List directly when: + +- You need fully custom row/column composition that DataTable or DataList cannot accommodate. - Displaying repetitive content where individual rows may contain interactive elements, metrics, or controls — use List, not Card. -- Comparing data across rows — use `variant="table"` (or `as="table"`) rather than a plain `
`. +- Comparing data across rows with a fully custom layout — use `variant="table"` (or `as="table"`) rather than a plain `
`. - Needing numbered rows — use `as="ol"`. - **Needing multiple expandable/disclosure-style items** — use List's expandable row pattern (see [Expandable rows](#expandable-rows)), not multiple standalone `Disclosure` components. diff --git a/packages/gamut/agent-tools/skills/gamut-review/SKILL.md b/packages/gamut/agent-tools/skills/gamut-review/SKILL.md index b42c10d01a0..49c9152dfbf 100644 --- a/packages/gamut/agent-tools/skills/gamut-review/SKILL.md +++ b/packages/gamut/agent-tools/skills/gamut-review/SKILL.md @@ -1,6 +1,6 @@ --- name: gamut-review -description: Use this skill when auditing existing code for Gamut usage — dependencies, GamutProvider, deep imports, hardcoded hex colors, and test patterns — and you need a consolidated report with pointers to matching Gamut skills. +description: Use this skill when auditing existing code for Gamut usage — dependencies, GamutProvider, deep imports, SCSS modules, className on Gamut components, nested selectors, hardcoded hex colors, non-Gamut CSS variables, and test patterns — and you need a consolidated report with pointers to matching Gamut skills. --- # Gamut Review @@ -11,7 +11,7 @@ When `DESIGN.md` is present at the audit root, use it as the authoritative refer Run Check 0 first, then Checks 1–5, then print a single consolidated report using the format at the end of this file. -Remediation skills: [`gamut-theming`](../gamut-theming/SKILL.md) · [`gamut-color-mode`](../gamut-color-mode/SKILL.md) · [`gamut-testing`](../gamut-testing/SKILL.md) +Remediation skills: [`gamut-theming`](../gamut-theming/SKILL.md) · [`gamut-color-mode`](../gamut-color-mode/SKILL.md) · [`gamut-system-props`](../gamut-system-props/SKILL.md) · [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) · [`gamut-typography`](../gamut-typography/SKILL.md) · [`gamut-testing`](../gamut-testing/SKILL.md) --- @@ -30,11 +30,12 @@ Resolve the audit root from the user's path if provided, otherwise the current w Read `package.json` at the audit root. Inspect `dependencies`, `devDependencies`, and `peerDependencies` combined. -| Package | Expectation | -| -------------------------- | ------------------------------------------------------- | -| `@codecademy/gamut` | Required — core component library | -| `@codecademy/gamut-styles` | Recommended — design tokens and theme primitives | -| `@codecademy/variance` | Recommended — style-prop system used by Gamut internals | +| Package | Expectation | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@codecademy/gamut` | Required — core component library | +| `@codecademy/gamut-styles` | Recommended — design tokens and theme primitives | +| `@codecademy/variance` | Recommended — style-prop system used by Gamut internals | +| `@codecademy/gamut-kit` | Acceptable alternative meta-package — re-exports `@codecademy/gamut`, `@codecademy/gamut-styles`, `@codecademy/variance`, and more. Treat its presence as satisfying the three rows above; do not separately flag those packages as missing. **Caveat:** requires npm or yarn with `nodeLinker: node-modules`; not compatible with yarn Plug'n'Play. Flag as ⚠ warning if `.yarnrc.yml` shows `nodeLinker: pnp`. | --- @@ -53,6 +54,8 @@ Search source files (`.ts`, `.tsx`, `.js`, `.jsx`) for these symbols. Skip `node For each found symbol report the first file path where it appears. +**Conditional — `StyleProps` with `states()`/`variant()`**: If source files contain `states(` or `variant(` from `@codecademy/gamut-styles`, check whether component prop interfaces use `StyleProps` from `@codecademy/variance`. When `states()`/`variant()` are present but `StyleProps` is absent from associated component interfaces, report as ⚠ warning: `StyleProps not used — state/variant props may be untyped`. Remediation: `import { StyleProps } from '@codecademy/variance'` and add `extends StyleProps` to the component interface. Skill reference: [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md). + --- ## Check 3 — Import patterns @@ -70,6 +73,108 @@ Report each violation as `file:line`. --- +## Check 3b — SCSS/CSS module imports and className on Gamut components + +Gamut components are styled via the variance system (system props, `css()`, `variant()`, `states()` from `@codecademy/gamut-styles`). Importing SCSS/CSS modules and passing `className` to Gamut components bypasses this system entirely, breaks ColorMode token propagation, and prevents system props from composing correctly. + +**Step 1 — SCSS/CSS module imports** + +Grep source files (`.ts`, `.tsx`, `.js`, `.jsx`) for: + +``` +import .* from '.*\.(scss|css)' +``` + +Skip `node_modules`, `dist`. Each match is an error unless: + +- The import targets a third-party stylesheet (e.g. a carousel or date-picker vendor sheet that cannot be replaced) — flag as ⚠ warning with note "third-party vendor styles". +- The file is a global reset or application shell (not a component) — flag as ⚠ warning. + +Report the count and list of files. If there are more than 5 files, group by directory and report totals rather than listing every file. + +**Step 2 — className on Gamut components** + +Grep source files for `className=` appearing on any of the core Gamut component names in the same JSX element opening tag. The known Gamut components to check: + +``` +Box, FlexBox, Column, LayoutGrid, GridBox, Card, Text, Anchor, +FillButton, StrokeButton, TextButton, CTAButton, IconButton, Toggle, +List, ListRow, ListCol, Background, Disclosure +``` + +Pattern (grep, case-sensitive): + +``` +<(Box|FlexBox|Column|LayoutGrid|GridBox|Card|Text|Anchor|FillButton|StrokeButton|TextButton|CTAButton|IconButton|Toggle|List|ListRow|ListCol|Background|Disclosure)\b[^>]*\bclassName= +``` + +Each match is an error. Report as `file:line `. + +Severity note: `className` is not always forbidden — some Gamut components accept it for integration with third-party tools (e.g. passing a class to an external drag-and-drop library). Downgrade to ⚠ warning only when the usage is clearly an integration seam, not styling. + +Remediation: replace SCSS module rules with system props directly on the Gamut component — use semantic ColorMode tokens as values (`color="text"`, `bg="background"`, `borderColor="border-primary"`, etc.) rather than hardcoded hex or palette names; use `css()`, `variant()`, or `states()` from `@codecademy/gamut-styles` (with `styled` from `@emotion/styled`) for styles not expressible as system props; delete the SCSS file when all rules are migrated. + +Skill references: [`gamut-system-props`](../gamut-system-props/SKILL.md) · [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) · [`gamut-color-mode`](../gamut-color-mode/SKILL.md) + +--- + +## Check 3c — Nested selectors + +Nested selectors inside styled-component or Emotion template literals cause hard-to-isolate side effects and make consistent updates difficult. The [Gamut Best Practices](https://gamut.codecademy.com/?path=/docs-meta-best-practices--page) page flags two kinds as "at your own risk": tag selectors and Gamut component selectors. + +**Step 1 — Tag selectors** + +Grep source files (`.ts`, `.tsx`, `.js`, `.jsx`) for bare HTML tag names appearing as CSS selector lines inside styled-component or Emotion template literals. Skip `node_modules`, `dist`, `.next`, `build`, `.turbo`. + +- Pattern A (named tags): + ``` + ^\s*(div|span|p|ul|li|ol|a|img|h[1-6]|table|thead|tbody|tr|td|th|form|section|header|footer|nav|main)\s*\{ + ``` +- Pattern B (universal selector): + ``` + ^\s*\*\s*\{ + ``` + False-positive risk: `*` appears in JSDoc comment bodies (` * {`). Post-filter matches where the line is a comment (starts with `//` or matches `\s*\*\s`). For remaining matches, verify context before marking as a violation. + +Do NOT include SVG primitive tag names (`path`, `rect`, `circle`, `line`, `polyline`, `svg`) — styled SVG primitives in icon and form components are normal and not the target of this rule. + +Exemptions (downgrade to `ℹ note`, not warning): + +- Files that import `Global` from `@emotion/react` — intentional global reset/injection stylesheets. +- Files whose name matches `*reboot*`, `*reset*`, `*global*`, or `*base-styles*`. + +**Step 2 — Gamut component selectors** + +Rather than enumerating every Gamut component by name (brittle, misses new additions), scope to files that already import from `@codecademy/gamut`, then grep those files for any PascalCase identifier used as a CSS selector: + +1. Find files that contain `from '@codecademy/gamut'`. +2. In those files, grep for: + ``` + \$\{[A-Z][A-Za-z]+\}[^{]*\{ + ``` + This matches any `${PascalCaseName}` followed by a rule block — i.e., a component used as a CSS child selector. + +Each match means a component is being targeted from a parent styled wrapper rather than styled directly. Report as `file:line ${ComponentName} { ... }`. + +Severity note: `&:pseudo ${ComponentName}` (pseudo-class combinator preceding the interpolation) is lower risk — downgrade those to ⚠ warning with a note to verify scope. Bare `${ComponentName} { }` selector blocks are the primary target. + +**Severity:** ⚠ warning for all matches (per Best Practices: "you may still do so, but at your own risk"). + +**Remediation:** + +_Tag selectors_ — plain HTML elements do not need to become Gamut components. Two valid paths: + +- Use `Box`, `FlexBox`, or `GridBox` with the `as` prop to render as the intended element — no extra DOM node needed: ``, ``. +- Style in place with `styled.div(css({ color: 'text', p: 16 }))` using semantic ColorMode tokens from `@codecademy/gamut-styles` — keeps the element but brings it into the design system token graph. + +Replace the parent's nested selector rule with one of the above and remove the selector block. + +_Gamut component selectors_ — pass system props directly to the component (`alignSelf`, `mt`, etc.) rather than targeting it from a parent wrapper. Where dynamic behavior spans multiple children, prefer `css()` with `variant()` or `states()` from `@codecademy/gamut-styles` keyed to data attributes or boolean props on the parent. + +Skill references: [`gamut-system-props`](../gamut-system-props/SKILL.md) · [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) + +--- + ## Check 4 — Hardcoded colors (semantic-first) Rule: Inline hex literals in application UI code are violations. Remediation is not “replace hex with `navy-800`” — prefer semantic ColorMode tokens (`text`, `background`, `primary`, …) so light/dark and theme switches stay correct. Reserve raw palette tokens for colors that must stay fixed and for `bg` on `` from `@codecademy/gamut-styles` (section surfaces with content). @@ -185,6 +290,27 @@ Case-insensitive. Use to label `palette:` in the report; do not stop at this ste | `#006d82` | `teal-500` / `teal` | | `#b3ccff` | `purple-300` / `purple` | +### Step 2 — Non-Gamut CSS custom properties (SCSS/CSS/Less files) + +After the hex scan, grep `.scss`, `.css`, and `.less` files for `var(--` occurrences. For each custom property name found, classify it: + +_Gamut-issued variables_ — skip these: + +- `--color-*` (ColorMode semantic aliases) +- `--space*` (spacing scale) +- `--font*` (font-size scale) +- `--lineHeight*` (line-height scale) +- `--borderWidth*` (border-width tokens) +- `--fontFamily*` (font-family tokens) +- `--fontWeight*` (font-weight tokens) + +_Non-Gamut variables_ — flag these: +Any other name — especially camel-cased semantic names like `--darkNeutralColor`, `--whiteColor`, `--lightPrimaryColor`, `--colorNavy800`, `--borderGreyColor` — indicates a parallel token system (Skillsoft/Percipio globals, legacy design tokens, or ad-hoc project variables). These variables are NOT set by `GamutProvider`/`ColorMode` and will be undefined inside a Gamut-scoped tree unless the host shell also loads them. + +Severity: ✗ error for color-semantic variables (invisible in tests/Storybook without the host stylesheet); ⚠ warning for spacing/sizing variables that duplicate Gamut scale tokens. + +Reporting: count unique non-Gamut variable names and list the top offenders with frequency. Do not enumerate every call-site — just the variable names and usage counts. Suggest the nearest Gamut semantic alias where obvious (e.g. `--darkNeutralColor` → `--color-text`, `--whiteColor` → `--color-background`). + --- ## Check 5 — Test setup @@ -198,6 +324,7 @@ Grep test files (`**/__tests__/**/*.{ts,tsx}`, `**/*.test.{ts,tsx}`, `**/*.spec. | `from '@codecademy/gamut-tests'` | Good — report count of files using it | Correct import for `setupRtl` and `MockGamutProvider` | | `from 'component-test-setup'` (without gamut-tests) | Warning | Should import `setupRtl` from `@codecademy/gamut-tests`, not directly from `component-test-setup` — the gamut-tests wrapper adds `MockGamutProvider` automatically | | `new GamutProvider` or ` + src/components/Nav/Nav.tsx:7 + +Nested selectors [→ gamut-system-props] [→ gamut-style-utilities] + ⚠ Tag selectors 3 occurrences — replace with system props or layout components (FlexBox, GridBox) + src/components/Nav/Nav.tsx:18 div { ... } + src/components/Hero/Hero.tsx:9 * { ... } (verify scope — may be JSDoc false positive) + ⚠ Gamut component selectors 1 occurrence — use system props directly instead + src/components/Layout/Layout.tsx:12 ${Box} { align-self: start; } + (or: ✓ none found) + Hardcoded colors [→ gamut-color-mode] ✗ src/Card.tsx:22 '#10162F' → semantic: text | palette: navy-800 | note: Core light body copy ⚠ src/Hero.tsx:14 '#1557FF' → semantic: primary (if link/CTA) | palette: blue-500 | note: no exact semantic; confirm theme ⚠ src/Nav.tsx:8 '#BADA55' → semantic: (n/a) | palette: — | note: no Gamut token + ✗ Non-Gamut CSS vars --darkNeutralColor (8 uses), --whiteColor (5 uses) → --color-text, --color-background Test setup [→ gamut-testing] ✓ @codecademy/gamut-tests used in 12 test files diff --git a/packages/gamut/bin/__tests__/design.test.mjs b/packages/gamut/bin/__tests__/design.test.mjs new file mode 100644 index 00000000000..1a50f096f46 --- /dev/null +++ b/packages/gamut/bin/__tests__/design.test.mjs @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, it } from 'node:test'; + +import { installDesignMd } from '../lib/design.mjs'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Create a minimal agent-tools directory with one DESIGN source file. + * @param {string} dir + * @param {string | null} [pkgVersion] + */ +async function makeSourceRoot(dir, pkgVersion = '71.0.0') { + const sourceRoot = join(dir, 'agent-tools'); + await mkdir(sourceRoot); + + // Minimal package.json adjacent to agent-tools + if (pkgVersion !== null) { + await writeFile( + join(dir, 'package.json'), + JSON.stringify({ name: '@codecademy/gamut', version: pkgVersion }), + 'utf8' + ); + } + + // Minimal DESIGN source file + await writeFile( + join(sourceRoot, 'DESIGN.Codecademy.md'), + '---\nversion: alpha\nname: Codecademy\n', + 'utf8' + ); + + return sourceRoot; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('installDesignMd', () => { + /** @type {string} */ + let tmp; + + before(async () => { + tmp = await mkdtemp(join(tmpdir(), 'gamut-design-test-')); + }); + + after(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('stamps the gamut version in an HTML comment at the top of DESIGN.md', async () => { + const dir = join(tmp, 'stamp'); + await mkdir(dir); + const sourceRoot = await makeSourceRoot(dir, '99.0.0'); + const cwd = join(dir, 'app'); + await mkdir(cwd); + + await installDesignMd(sourceRoot, cwd, 'core'); + + const content = await readFile(join(cwd, 'DESIGN.md'), 'utf8'); + assert.match(content, /^\n`; + + const content = await readFile(src, 'utf8'); + await writeFile(dest, header + content, 'utf8'); return { dest, label }; } diff --git a/packages/gamut/package.json b/packages/gamut/package.json index 1819205b9db..23ad9f9669b 100644 --- a/packages/gamut/package.json +++ b/packages/gamut/package.json @@ -55,6 +55,7 @@ "build": "nx build @codecademy/gamut", "build:watch": "yarn build && onchange ./src -- yarn build", "compile": "babel ./src --out-dir ./dist --extensions \".ts,.tsx\"", + "test:bin": "node --test bin/__tests__/design.test.mjs", "verify": "tsc --noEmit && tsc --project tsconfig.bin.json" }, "sideEffects": [ diff --git a/packages/gamut/project.json b/packages/gamut/project.json index 3e2bef14b11..f94a7846862 100644 --- a/packages/gamut/project.json +++ b/packages/gamut/project.json @@ -19,8 +19,15 @@ "parallel": false } }, + "test-bin": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/gamut", + "command": "node --test bin/__tests__/design.test.mjs" + } + }, "test": { - "dependsOn": ["^build"], + "dependsOn": ["^build", "test-bin"], "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/packages/gamut"], "options": {