Operational guide for AI agents working in Curve (SURF's design system). Humans should read README.md first; this file captures the conventions and gotchas that are easy to get wrong.
A Turborepo + pnpm monorepo with five packages:
| Package | What | Build | Storybook |
|---|---|---|---|
@surfnet/curve-react |
React components on shadcn/ui + Base UI (published) | Vite (lib mode) + vite-plugin-dts |
@storybook/react-vite |
@surfnet/curve-angular |
Angular components on Spartan (brain + helm) (published) |
ng-packagr |
@storybook/angular (webpack) |
@surfnet/curve-tokens |
DTCG JSON -> Style Dictionary -> tokens.css + typed TS map (private) |
Style Dictionary | — |
@surfnet/curve-contracts |
Per-component as const specs: variant/size names, defaults, docs (private, build-time only) |
tsc --noEmit |
— |
@surfnet/curve-typescript-config |
Shared base tsconfigs |
— | — |
Both component packages style with plain CSS in cascade layers and source their design tokens from @surfnet/curve-tokens: React with co-located CSS Modules, Angular with a co-located hlm-<name>.css per component (curve-* classes). Neither published styles.css contains Tailwind; apps can still add their own Tailwind on top.
The workspace also has an apps/* glob with one demo app:
| App | What | Build | Dev port |
|---|---|---|---|
@surfnet/curve-react-app |
Demo Next.js (App Router) app for testing @surfnet/curve-react components in a real consumer |
next build |
3000 |
It depends on @surfnet/curve-react via workspace:*, lists it under transpilePackages in
apps/react-app/next.config.mjs, and imports the package's compiled @surfnet/curve-react/styles.css.
Turbo wires @surfnet/curve-react-app#build after @surfnet/curve-react#build via ^build automatically, so
build @surfnet/curve-react before running the app. Apps are consumers, not published packages —
keep library code in packages/.
The app also runs its own Tailwind v4 build (@tailwindcss/postcss +
apps/react-app/app/globals.css) so app code can use Tailwind utilities. Because the package's
compiled CSS already ships Tailwind preflight + the design tokens, globals.css imports Tailwind
granularly (tailwindcss/theme.css + tailwindcss/utilities.css, no preflight.css) to avoid
a second base reset, and re-declares the token → --color-* mapping via @theme inline so app
utilities resolve to the same @surfnet/curve-tokens variables as the components.
- Node 22 LTS (pinned in
.nvmrc;enginesalso allows 24). Other versions only warn on install. Runnvm usefirst. - pnpm 11 (pinned via
packageManager). Always use pnpm, never npm/yarn.
pnpm install # whole workspace
pnpm build # turbo: build both libraries
pnpm lint # turbo: type-check
pnpm format # prettier --write across the repo
pnpm storybook # both Storybooks (React :6006, Angular :6007)
pnpm storybook:react # React Storybook (port 6006)
pnpm storybook:angular # Angular Storybook (port 6007)
pnpm build-storybook && pnpm test:visual # story screenshots vs baselines (React + Angular)Always run pnpm lint and pnpm format before considering a change done, and rebuild
the package you touched. Ask the user if they want to snapshot baselines with pnpm test:visual:update
(after pnpm build-storybook). Compare React to Angular when you want with pnpm test:visual:parity.
The repo ships two MCP servers, configured in both .mcp.json (Claude Code, auto-detected)
and .vscode/mcp.json (VS Code / GitHub Copilot — open it and click Start):
shadcn— browse/search/install shadcn + Base UI components for@surfnet/curve-react. Runsnpx shadcn@latest mcp --cwd packages/react, scoped to the React package (that's wherecomponents.jsonlives). The standard shadcn/ui registry needs no setup; add private/third-party registries underregistriesinpackages/react/components.json. Note: MCPadddrops components flat — to keep the one-directory-per-component layout, prefer theadd-componentskill's--path .../<name>/flow.spartan-ui— read-only access to Spartan docs, component APIs, and examples for@surfnet/curve-angular(npx -y @spartan-ng/mcp). It fetches live from spartan.ng and caches on disk; it does not install code — use the Spartan CLI for that.
In Claude Code, /mcp should list both as Connected. Other clients (Cursor, Codex,
OpenCode): npx shadcn@latest mcp init --client <name> for shadcn, and add the
spartan-ui entry manually from the snippet in their MCP config.
- Components are vendored via the shadcn CLI. The package is configured for Base
UI primitives (
components.json→"style": "base-nova") and Phosphor icons ("iconLibrary": "phosphor"). Do not switchstyleback to a Radix value. - One directory per component:
src/components/ui/<name>/holds<name>.tsx, its story, anindex.tsbarrel, and (later) tests. The barrel keeps@/components/ui/<name>imports resolving for other shadcn components. - See the add-component skill (
react.md) for the exact flow. To refresh an already-vendored component from shadcn, see update-component (react.md) — nevershadcn add --overwrite. To refresh an already-vendored component from shadcn, see update-component (react.md) — nevershadcn add --overwrite. - Library build externalises bare imports; relative +
@/aliased imports are bundled (vite.config.ts)..d.tsfiles land underdist/src/— that's whypackage.jsontypespoints atdist/src/index.d.ts.
- Components are vendored via the Spartan CLI (
ng g @spartan-ng/cli:ui): thebrainprimitive is installed from npm, thehelmcode is copied intosrc/lib/ui/<name>/. - The CLI adds a
@spartan-ng/helm/<name>path mapping intsconfig.json; helm files import each other through it, andng-packagrinlines those into the build. - Runtime deps of the library must be listed in
ng-package.json→allowedNonPeerDependencies, orng-packagrfails the build. - See the add-component skill (
angular.md) for the exact flow. To refresh an already-vendored helm component, see update-component (angular.md) — never re-runng g @spartan-ng/cli:uias an overwrite. To refresh an already-vendored helm component, see update-component (angular.md) — never re-runng g @spartan-ng/cli:uias an overwrite.
- React uses the Vite builder; Angular uses the webpack builder (official Angular +
Vite Storybook isn't production-ready). The Angular library itself is still
ng-packagr. - Do not remove
browserTarget: "angular:build"from thestorybook/build-storybooktargets inpackages/angular/angular.json— the Angular dev server throwsAngularLegacyBuildOptionsErrorwithout it. Keep the explicittsConfig(compiles stories) andstyles(the package CSS, the shared story chrome, docs CSS) alongside it. - No Tailwind in either Storybook. Story templates use a small set of Tailwind-named
layout classes from
@surfnet/curve-storybook-config/story-chrome.css(packages/storybook-config/src/story-chrome.css), shared by both Storybooks (React imports it in.storybook/preview.ts, Angular lists it inangular.jsonstyles). Add a class there when a story needs one; components never use these classes. - Both Storybooks share
@storybook/addon-a11y+@storybook/addon-docs, and every story meta setstags: ['autodocs']so each gets a generated Docs page. Keep the two packages' addon sets in sync. - Ports are pinned in the configs so both can run at once: React → 6006 (the
storybookscript's-p 6006inpackages/react/package.json), Angular → 6007 (thestorybooktarget's"port": 6007inpackages/angular/angular.json).
- Screenshots come from the built Storybooks (
pnpm build-storybook), not the Vite/webpack dev servers. Serve them on 6008/6009 so they don't collide withpnpm storybookon 6006/6007. - React and Angular each have a
*.spec.tsthattoHaveScreenshots every story (light + dark). Baselines live in separate folders:tests/visual/__screenshots__/react/and.../angular/(same story ids, different PNGs — never share one flat directory). - Refresh baselines with
pnpm test:visual:update. Optional parity:pnpm test:visual:parity.
- Token source of truth is DTCG JSON only. All color and other semantic token values
live in
packages/tokens/src/tokens*.json. Never hand-edit:rootor.darkblocks in a framework stylesheet — change the DTCG JSON and rebuild@surfnet/curve-tokensinstead. - Figma sync:
pnpm sync:figma(scripts/sync-figma.ts, root, run viajiti) pulls variables from the Figma Variables API and (re)writes the DTCG JSON:tokens.json/tokens.dark.jsonfor the default theme plustokens.<class>.json/tokens.<class>.dark.jsonper extra theme. It writes JSON only — no CSS. NeedsFIGMA_TOKEN+FIGMA_FILE_IDin.env(see.env.example). The JSON is committed, so syncing is optional for a normal build. - Token flow: Figma ->
sync:figma-> DTCG JSON -> Style Dictionary build ->dist/tokens.css(:rootdefault light,.darkdiff,.theme-<class>/.dark.theme-<class>per-theme diffs) +dist/index.{js,d.ts}(typed token map). Both component packages@importthe CSS; Vite (React) and Lightning CSS (Angular,packages/angular/scripts/build-css.ts) inline it into each publishedstyles.css. Switch themes by adding a class to<html>(e.g.class="dark theme-surf-green"). - Parity mechanism:
@surfnet/curve-contractsexports anas constspec (e.g.buttonContract) that declares the canonical variant names, size names, defaults, and docs. Both frameworks enforce this at compile time withsatisfies Record<ButtonVariantName, string>on their variant → class maps. A mismatch (stray variant in one framework, missing size in another) failspnpm lintimmediately. - Contracts are build-time only.
@surfnet/curve-contractsis private and must not appear in any publisheddist— types erase after compilation. Each framework exports its own plain variant option types (e.g.ButtonVariants) andbuttonVariants()helpers. - Do not add runtime utils to the shared packages.
cnstays in React;hlmstays in Angular. The shared packages are intentionally thin.
Versioning and npm publishing run through Changesets. Config
lives in .changeset/config.json; the release automation is
.github/workflows/release.yml. Human-facing docs are in the README's
Releasing & versioning section.
The one rule for agents: when you change a publishable package, add a changeset.
pnpm changeset # interactive: pick packages, bump type, write summaryThis writes a markdown file under .changeset/ — commit it alongside the code change.
Pick the bump type by semver: patch (fixes), minor (additive,
backwards-compatible — e.g. a new component or variant), major (breaking API/token
changes). The summary becomes the public changelog entry, so write it for consumers.
Gotchas:
- Don't run
pnpm version-packagesorpnpm releaseyourself, and don't hand-edit anyversionfield orCHANGELOG.md. CI owns that: pending changesets onmainopen a "Version Packages" PR, and merging it builds + publishes. Your job ends at committing the changeset file. @surfnet/curve-reactand@surfnet/curve-angularare the public packages today. The rest (@surfnet/curve-tokens,@surfnet/curve-contracts,@surfnet/curve-storybook-config,@surfnet/curve-typescript-config) are"private": true, so Changesets versions them but never publishes them. Adding a changeset for a private package is fine (it bumps the version + changelog); it just won't reach npm.- Non-publishing changes (docs, CI, repo tooling) don't need a changeset. CI does not fail when one is missing, so use judgement rather than adding empty noise.
- When a changeset bumps
@surfnet/curve-tokens, packages that depend on it get apatchbump automatically (updateInternalDependencies: "patch") — you don't list them yourself.
- Component vendored via the framework's CLI (don't hand-write primitives).
- Exported from the package entry (
src/index.ts/src/public-api.ts). - A Storybook story covering the component's full surface (variants, sizes, states).
pnpm build,pnpm lint,pnpm format, andpnpm test:visualall pass.- A changeset added (
pnpm changeset) if a publishable package changed.
Task-specific playbooks live in .agents/skills/ (symlinked to .claude/skills):
- add-component — (repo-authored) add a component to
@surfnet/curve-react,@surfnet/curve-angular, or both in parity. TheSKILL.mdindex routes to the per-framework playbooksreact.mdandangular.md. - accessibility — (repo-authored) persona-based a11y review for agents. Canonical source:
.agents/skills/accessibility/(SKILL.md,personas.md,reference.md). Storybook serves the same files under/downloads/accessibility/and ships a zip atpackages/storybook-config/static/accessibility.zip. Regenerate the zip after editing the skill:pnpm --filter @surfnet/curve-storybook-config bundle:accessibility-skill. - update-component — (repo-authored) merge upstream shadcn / Spartan changes into an
already-vendored component without overwriting Curve design or accessibility edits.
Routes to
react.mdandangular.md. Never re-run the add CLIs as a refresh. - shadcn — (upstream, from
shadcn/ui) deep reference for shadcn components, registries, presets, and Base-vs-Radix. - spartan — (upstream, from
spartan-ng/spartan) deep reference for spartan/ui, the Brain/Helm layers, the CLI generators, and component APIs.
The two upstream skills are vendored as plain files in .agents/skills/ (the same place as
our own skills); they reach Claude Code through the .claude/skills symlink. To refresh them,
re-fetch from their repos — do not use skills add without scoping it, as it scatters
copies into ~20 unrelated agent directories.