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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
29 changes: 19 additions & 10 deletions .agents/skills/add-component/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ states, and story coverage.
Variants, Sizes, states, …) so the two Storybooks read identically. Mirror the button
stories: `packages/react/src/components/ui/button/button.stories.tsx` and
`packages/angular/src/lib/ui/button/src/lib/hlm-button.stories.ts`.
- **Tokens:** both packages share the same oklch token names. If you add a new token,
add it to **both** `react/src/index.css` and `angular/src/styles.css`.
- **Tokens:** semantic values live in `@surfnet/curve-tokens` DTCG JSON (rebuild tokens,
don't hand-edit `:root`). React maps them in `packages/react/src/index.css` (`@theme
inline`); Angular in `packages/angular/src/styles.css`. Keep names aligned across both.

6. **Verify both:**

Expand All @@ -69,6 +70,7 @@ states, and story coverage.
# parity check — both should list the same story ids:
node -e "console.log(Object.keys(require('./packages/react/storybook-static/index.json').entries))"
node -e "console.log(Object.keys(require('./packages/angular/storybook-static/index.json').entries))"
pnpm test:visual # React snapshots + React/Angular screenshot comparison
```

## Contract step
Expand Down Expand Up @@ -149,22 +151,29 @@ Then:
```

The per-framework playbooks (`react.md`, `angular.md`) each have a matching step that ties
the component to the contract **for every axis it has**: a `cva` map carries
`satisfies Record<CardVariantName, string>`; an inline-union prop (no `cva`) is typed as
`size?: CardSizeName`. Either way a name mismatch fails `pnpm lint` at compile time. A
description-only contract has nothing to enforce — wiring it into the story's docs is
enough.
the component to the contract **for every axis it has**:

- **React:** a CSS Module class map (or `data-*` attribute + CSS) carries
`satisfies Record<CardVariantName, string>` on the TS side; inline-union props are typed as
`size?: CardSizeName`.
- **Angular:** Spartan `cva` / `hlm` maps carry the same `satisfies Record<…>` pattern.

Either way a name mismatch fails `pnpm lint` at compile time. A description-only contract
has nothing to enforce — wiring it into the story's docs is enough.

## Definition of done

- Component vendored via the framework CLI(s) — never hand-write primitives.
- A `<name>Contract` `as const` entry exists in `@surfnet/curve-contracts` for **every** component
(description-only when it has no axis) and is exported from its `index.ts`.
- For each axis the component exposes, both frameworks are tied to the contract: `cva` maps
carry `satisfies Record<...>`, inline-union props are typed as the contract's `*Name`
(see per-framework playbooks). Description-only contracts have nothing to enforce.
- For each axis the component exposes, both frameworks are tied to the contract (React:
CSS Module class maps + `satisfies Record<...>`; Angular: `cva`/`hlm` + `satisfies`).
Inline-union props are typed as the contract's `*Name` (see per-framework playbooks).
Description-only contracts have nothing to enforce.
- Exported from each package's entry (`src/index.ts` / `src/public-api.ts`).
- A Storybook story per package covering the component's full surface; when added to both,
the story sets match.
- `pnpm build`, `pnpm lint`, `pnpm format`, and the relevant `build-storybook` runs pass.
- Visual tests pass (`pnpm test:visual`). React snapshot baselines are refreshed with
`pnpm test:visual:update`.
- Any unavoidable React/Angular difference is documented, not hidden.
4 changes: 4 additions & 0 deletions .agents/skills/add-component/angular.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ pnpm --filter @surfnet/curve-contracts lint # contract types still compile
pnpm --filter @surfnet/curve-angular build # ng-packagr (FESM + d.ts); satisfies check runs here
pnpm --filter @surfnet/curve-angular build-storybook
pnpm format
pnpm test:visual # after both Storybooks are built
```

## Definition of done
Expand All @@ -116,6 +117,9 @@ pnpm format
- Story covers full variant/size/state surface, sourcing its description and axis lists from
the contract object.
- `pnpm build`, `pnpm format`, and `build-storybook` all pass.
- Visual tests pass (`pnpm test:visual`). Parity screenshots compare this story to its React
counterpart by story id — keep the story names in sync. Tag stories `skip-visual` if they
cannot be snapshotted stably.

## Notes

Expand Down
150 changes: 97 additions & 53 deletions .agents/skills/add-component/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ Part of the **add-component** skill — see [`SKILL.md`](SKILL.md) for scope/par

Components are **vendored** with the shadcn CLI — copied into the package so we own and
edit them. The package is pre-configured (`packages/react/components.json`) for **Base
UI** primitives (`"style": "base-nova"`) and **Phosphor** icons (`"iconLibrary": "phosphor"`).
UI** primitives (`"style": "base-vega"`) and **Phosphor** icons (`"iconLibrary": "phosphor"`).
Never hand-write a primitive or switch `style` to a Radix value.

**Styling:** library components use **co-located CSS Modules** (`*.module.css`) and Curve
tokens (`var(--primary)`, `var(--border)`, …). The shadcn CLI may still emit Tailwind
utility strings — **migrate them to a module** before considering the component done. See
[`packages/react/docs/css-modules-pilot.md`](../../packages/react/docs/css-modules-pilot.md)
for the pattern, migration table, and gaps (e.g. overlay motion not ported).

> **Available tooling:** the **`shadcn` skill** (`.agents/skills/shadcn/`) is the deep
> reference for component APIs, registries, and presets, and the repo exposes a **shadcn
> MCP server** (`.mcp.json` / `.vscode/mcp.json`, scoped to this package) for
Expand Down Expand Up @@ -34,82 +40,111 @@ Never hand-write a primitive or switch `style` to a Radix value.
`components.json`. (A `--dry-run` preview _displays_ a flat path — ignore that; the
real write nests correctly.)

3. **Tie the component to the contract — for every axis it has.** Import the `*Name` unions
from `@surfnet/curve-contracts` and wire them in. There are two wiring styles depending on how
the vendored component models the axis:
3. **Add `card.module.css` and wire styles.** Prefer semantic tokens, not Tailwind
utilities or shadcn/css `--color-*` scales.

- Put layout, states, and variant rules in the module; use `data-variant`, `data-size`,
`data-orientation`, etc. on the element when selectors need them.
- Import the module in `card.tsx` and merge with `cn(styles.root, className)` from
`@/lib/utils` (lets consumers pass extra classes, including app-level Tailwind).
- TypeScript: `packages/react/src/vite-env.d.ts` already declares `*.module.css`. With
`noUncheckedIndexedAccess`, assert the import when you need dotted access:

```ts
type CardModuleClasses = { root: string; variantOutline: string };
const styles = cardStyles as CardModuleClasses;
```

- **Do not** add new `class-variance-authority` / Tailwind `cva` maps in React — use
module classes + optional exported `cardVariants()` helpers (see `button.tsx`).

**a. `cva` map → `satisfies Record<…>`** (e.g. `button`, `field`):
4. **Tie the component to the contract — for every axis it has.** Import the `*Name` unions
from `@surfnet/curve-contracts` and wire them in:

**a. Variant / size class map → `satisfies Record<…>`** (e.g. `button`, `badge`,
`toggle`):

```ts
import type { CardVariantName, CardSizeName } from '@surfnet/curve-contracts';

const cardVariants = cva('...', {
variants: {
variant: {
default: '...',
outline: '...',
} satisfies Record<CardVariantName, string>,
size: {
default: '...',
sm: '...',
lg: '...',
} satisfies Record<CardSizeName, string>,
},
});
const variantClass: Record<CardVariantName, string> = {
default: styles.variantDefault,
outline: styles.variantOutline,
} satisfies Record<CardVariantName, string>;

export function cardVariants({
variant = 'default',
size = 'default',
className,
}: {
variant?: CardVariantName;
size?: CardSizeName;
className?: string;
} = {}) {
return cn(styles.card, variantClass[variant], sizeClass[size], className);
}
```

**b. inline-union prop (no `cva`) → type the prop** (e.g. `avatar` size,
`select` trigger size, `dropdown-menu` item variant). Replace the hand-written union with
the contract type:
Export `cardVariants` when another component composes your styles (Calendar →
`buttonVariants`, Toggle group → `toggleVariants`).

**b. Inline-union prop → type the prop** (e.g. `select` trigger size, `dropdown-menu`
item variant):

```ts
import type { AvatarSizeName } from '@surfnet/curve-contracts';

function Avatar({ size = 'default', ...props }: AvatarPrimitive.Root.Props & {
size?: AvatarSizeName; // was: 'default' | 'sm' | 'lg'
}) { /* … */ }
size?: AvatarSizeName;
}) {
return (
<AvatarPrimitive.Root data-size={size} className={cn(styles.root, className)} />
);
}
```

A **description-only** contract has no axis, so there's nothing to wire here — skip to the
barrel. Confirm `pnpm lint` passes — a name mismatch between the component and the
contract type is a compile error.
A **description-only** contract has no axis to enforce — skip wiring, but still add a
module and pull `docs.description` from the contract in Storybook.

4. **Add a barrel** `src/components/ui/card/index.ts`:
Confirm `pnpm lint` passes — a name mismatch between the component and the contract
type is a compile error.

5. **Add a barrel** `src/components/ui/card/index.ts`:

```ts
export * from './card';
```

This keeps `@/components/ui/card` imports resolving for other shadcn components.

5. **Re-export from the package entry** `src/index.ts`:
6. **Re-export from the package entry** `src/index.ts`:

```ts
export * from '@/components/ui/card';
```

6. **Add a story** `src/components/ui/card/card.stories.tsx`. Mirror
7. **Add a story** `src/components/ui/card/card.stories.tsx`. Mirror
`src/components/ui/button/button.stories.tsx`: a `Playground` with `argTypes`/`args`,
plus stories covering every variant/size/state the component offers. Source the docs
description **and** any axis lists from the contract object (`cardContract.description`,
`cardContract.variants`, `cardContract.variantDocs`, etc.) instead of duplicating
literals — a description-only component still pulls its `docs.description.component` from
the contract. Use `@storybook/react-vite` types (`Meta`, `StoryObj`).
literals.

**Story layout:** Tailwind utilities in `*.stories.tsx` are fine for demo chrome
(`flex gap-3`, `w-80`) — they are not shipped as the component API. Keep component
`className` overrides in stories minimal so visuals reflect the module.

**Every control you declare must be live.** A control in `argTypes`/`args` only does
something if a story actually consumes those args — an args-driven Playground (`export
const Default: Story = {}`, or `render: (args) => <Card {...args} />`). If _every_ story
hardcodes its props in `render`, the control is dead: it shows in the panel but moving it
changes nothing. So keep one args-driven Playground per declared control, reserve
hardcoded `render` for static showcase stories (Variants, Sizes, …), and don't declare a
control no story consumes. (Storybook here only surfaces controls you declare explicitly —
it does not infer them from props — so an unused `argTypes` entry is always dead weight.)
hardcodes its props in `render`, the control is dead. See existing button stories.

## Target layout

```
src/components/ui/card/
├── card.tsx # vendored component (yours to edit)
├── card.tsx # vendored component (Base UI + module classes)
├── card.module.css # component styles (Curve tokens)
├── card.stories.tsx # Storybook story
└── index.ts # export * from './card'
```
Expand All @@ -119,33 +154,42 @@ src/components/ui/card/
```bash
pnpm --filter @surfnet/curve-contracts lint # contract types still compile
pnpm --filter @surfnet/curve-react lint # tsc --noEmit (satisfies check runs here)
pnpm --filter @surfnet/curve-react build # vite lib build + d.ts
pnpm --filter @surfnet/curve-react build # vite lib build + d.ts; CSS → dist/styles.css
pnpm --filter @surfnet/curve-react build-storybook
pnpm format
pnpm test:visual # after both Storybooks are built
```

## Definition of done

- Component vendored via the shadcn CLI — never hand-written.
- A `<name>Contract` entry exists in `@surfnet/curve-contracts` (description-only if the component
has no axis). For every axis, the component is tied to the contract — `cva` maps carry
`satisfies Record<...>`, inline-union props are typed as the contract's `*Name`; `pnpm
lint` fails if either side adds or removes a name.
- Co-located `*.module.css`; no Tailwind utility strings left in the component `.tsx`.
- A `<name>Contract` entry exists in `@surfnet/curve-contracts` (description-only if the
component has no axis). For every axis, the component is tied to the contract via
`satisfies Record<...>` and/or `*Name` prop types; `pnpm lint` fails on name drift.
- Barrel `index.ts` in place; component exported from `src/index.ts`.
- Story covers full variant/size/state surface, sourcing its description and axis lists from
the contract object.
- `pnpm build`, `pnpm lint`, `pnpm format`, and `build-storybook` all pass.
- Visual tests pass (`pnpm test:visual`). Refresh baselines with `pnpm test:visual:update`
when appearance changed. Tag stories `skip-visual` if they cannot be snapshotted stably.

## Notes

- Icons: import from `@phosphor-icons/react` (components are suffixed `Icon`, e.g.
`PlusIcon`). The package is an **optional peer dependency** (kept as a devDependency so
stories build); never move it back to `dependencies`. Render icons directly (`<PlusIcon
className="size-5" />`) or inside a button — the button auto-sizes the SVG per size, and
`data-icon="inline-start"` / `data-icon="inline-end"` tightens the padding next to text.
See `button.stories.tsx` (`IconSizes`, `WithIcon`) for the established pattern.
- If the component pulls in sibling shadcn components, they're vendored flat by default;
apply the same per-directory + barrel treatment to each if you want them nested.
- The `@surfnet/curve-contracts` import is a `devDependency` only — it must not appear in the
published `dist`. The `satisfies` annotation is erased by TypeScript at compile time, so
no import survives into the built output.
- **Published CSS:** consumers import `@surfnet/curve-react/styles.css` for tokens, base
styles, **semantic color utilities** (`text-*` / `bg-*` / `border-*` on theme names),
and compiled CSS Modules. Full Tailwind (layout, spacing, opacity modifiers) is **not**
shipped — apps add their own Tailwind or plain CSS. Storybook uses `.storybook/story-chrome.css`.
- **Icons:** import from `@phosphor-icons/react` (suffix `Icon`, e.g. `PlusIcon`). Optional
peer for consumers; devDependency for Storybook. Size/color icons in the module or via
parent `svg` rules — see `button.stories.tsx` (`IconSizes`, `WithIcon`).
- **Native alternatives:** for simple form controls, consider a `Native*` sibling +
description-only contract (see `css-modules-pilot.md` table). Do not replace Base UI
overlays without an explicit product decision.
- If the component pulls in sibling shadcn components, apply the same per-directory +
barrel + module treatment to each.
- The `@surfnet/curve-contracts` import is a `devDependency` only — it must not appear in
published `dist`. Types and `satisfies` erase at compile time.
- **No Tailwind in `@surfnet/curve-react`** — not in the lib build or Storybook. New story
layout classes go in `.storybook/story-chrome.css` if needed. See
[`css-modules-pilot.md`](../../packages/react/docs/css-modules-pilot.md).
5 changes: 5 additions & 0 deletions .changeset/alert-badge-semantic-variants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-react': patch
---

Map Alert and Badge CSS Module variants to the contract names (`info`, `success`, `warning`, `danger`) instead of the leftover `destructive` keys.
5 changes: 5 additions & 0 deletions .changeset/css-modules-button-pilot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-react': minor
---

Expand CSS Modules migration to overlays and menus: Dialog, Sheet, Select, Sidebar, Calendar, Accordion, Alert dialog, Carousel, Data table, Dropdown/Context menu, Command, Input group, Combobox, Navigation menu, plus earlier components. Base UI primitives unchanged; motion/enter animations largely not ported.
5 changes: 5 additions & 0 deletions .changeset/react-drop-tailwind-bundle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-react': minor
---

Stop shipping Tailwind in `styles.css`. Published CSS now includes tokens, minimal base styles, semantic color utility classes (`text-primary`, `bg-muted`, etc.), and CSS Modules. Storybook uses plain `.storybook/story-chrome.css` for demo layout. Removed `class-variance-authority` and Tailwind devDependencies from the React package.
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

- [ ] `pnpm build`, `pnpm lint`, and `pnpm format` pass
- [ ] Storybook story added/updated to cover the change (variants, sizes, states)
- [ ] Visual snapshots updated (`pnpm test:visual:update` after `pnpm build-storybook`) if appearance changed
- [ ] Changeset added (`pnpm changeset`) — required for any change to `@surfnet/curve-react` or `@surfnet/curve-angular`; N/A for docs/CI-only changes
- [ ] React and Angular kept in parity, if applicable (contract updated in `@surfnet/curve-contracts`)

Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,47 @@ jobs:
- run: pnpm lint

- run: pnpm build

visual:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm

- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-

- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

- run: pnpm install --frozen-lockfile

- run: pnpm build-storybook

- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium

- name: Visual regression (React + Angular story snapshots)
run: pnpm test:visual

- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 14
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ storybook-static
# angular
.angular

# playwright
test-results
playwright-report
blob-report
playwright/.cache

# logs
*.log
npm-debug.log*
Expand Down
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ storybook-static
.turbo
.next
pnpm-lock.yaml
test-results
playwright-report
blob-report

# vendored upstream skills — keep them byte-for-byte as fetched
.agents/skills
Expand Down
Loading
Loading