Skip to content

SURFnet/DesignSystem

Repository files navigation

surf-design-system

The SURF design system: framework-native component packages in a single Turborepo + pnpm monorepo. Each package builds on the "you own the code" UI library of its ecosystem.

  • @surfnet/react — React components built on shadcn/ui with Base UI primitives, bundled with Vite.
  • @surfnet/angular — Angular components built on Spartan (brain primitives + helm styles), built with ng-packagr.
  • @surfnet/tokens — design tokens: DTCG JSON source built with Style Dictionary into tokens.css (:root/.dark custom properties) and a typed TS map. Published; both component packages import this CSS.
  • @surfnet/contracts — per-component as const specs (variant names, size names, defaults, docs) used at build time to enforce cross-framework parity via satisfies. Private; leaves no trace in published dist.
  • @surfnet/typescript-config — shared base TypeScript configs the packages extend.

Repository layout

surf-design-system/
├── package.json            # root scripts delegate to Turborepo
├── pnpm-workspace.yaml      # workspace = packages/*
├── turbo.json              # task graph (build, dev, storybook, lint)
├── .prettierrc.json        # shared formatting
└── packages/
    ├── typescript-config/  # @surfnet/typescript-config — base.json + react-library.json
    ├── tokens/             # @surfnet/tokens — DTCG JSON -> Style Dictionary -> tokens.css (published)
    ├── contracts/          # @surfnet/contracts — component API specs, build-time only (private)
    ├── react/              # @surfnet/react — Vite library + Storybook (Vite)
    └── angular/            # @surfnet/angular — ng-packagr library + Storybook (webpack)

Architecture

  • Turborepo runs tasks across the workspace. pnpm build at the root builds every package in dependency order (^build first) and caches the results.
  • pnpm workspaces link the packages locally. Both component packages depend on @surfnet/typescript-config via workspace:* and extend its configs.
  • Storybook builders differ by framework, by design. React uses the stable Vite builder (@storybook/react-vite). Angular uses the stable webpack builder (@storybook/angular), because the official Angular + Vite Storybook framework is not yet production-ready. The Angular library itself is built with ng-packagr.

Prerequisites

  • Node.js 22 LTS (pinned in .nvmrc — run nvm use to switch). The engines field also accepts the 24 LTS line; other versions (including odd releases like 23/25) print a warning on pnpm install rather than failing.
  • pnpm 11 (corepack enable picks up the version pinned in package.json).

Getting started

pnpm install          # install the whole workspace

pnpm build            # build both libraries (Turborepo)
pnpm lint             # type-check
pnpm format           # format everything with Prettier

# Storybook (run per package)
pnpm --filter @surfnet/react storybook     # http://localhost:6006
pnpm --filter @surfnet/angular storybook   # http://localhost:6006

Each component ships a Storybook story covering its full surface (variants, sizes, states). Start there to see what's available.

Adding a component

React (shadcn / Base UI)

shadcn components are vendored — copied into the package so you own and can edit them. The package is configured (in components.json) for Base UI primitives ("style": "base-nova") and Tabler icons ("iconLibrary": "tabler"@tabler/icons-react).

We keep one directory per component (component + stories + future tests live together). Pass --path with a trailing slash so the CLI writes the component straight into its own folder:

cd packages/react
# note the trailing slash — it puts card.tsx inside src/components/ui/card/
pnpm dlx shadcn@latest add card --path src/components/ui/card/

This creates src/components/ui/card/card.tsx. Then finish the wiring:

  1. Add a barrel — src/components/ui/card/index.ts with export * from './card';. This keeps @/components/ui/card imports working for other shadcn components.
  2. Re-export it from src/index.ts.
  3. Add a card.stories.tsx in the same folder (mirror button.stories.tsx).

The resulting layout:

src/components/ui/
└── button/
    ├── button.tsx          # the component (yours to edit)
    ├── button.stories.tsx  # Storybook story
    └── index.ts            # barrel → export * from './button'

shadcn pulls the Base UI variant and Tabler icon imports automatically from the style and iconLibrary fields in components.json — don't switch style back to a Radix style.

Icons (React)

Icons come from @tabler/icons-react, an optional peer dependency — install it alongside the package if you use icons:

pnpm add @tabler/icons-react

Each icon is a tree-shaken component prefixed Icon:

import { IconPlus } from '@tabler/icons-react';

<IconPlus className="size-5" />        {/* size with a Tailwind size-* utility */}

<Button>
  <IconPlus data-icon="inline-start" /> {/* inside a button, no size class needed */}
  Add item
</Button>

The button auto-sizes any <svg> it contains per button size; data-icon="inline-start" / data-icon="inline-end" tighten the padding next to text. See the Button stories (IconSizes, WithIcon).

Angular (Spartan)

Spartan splits each component into a brain primitive (installed from npm) and helm styles (copied into the package). The generator is configured via components.json (componentsPath: src/lib/ui, importAlias: @spartan-ng/helm).

cd packages/angular
pnpm exec ng g @spartan-ng/cli:ui <component>   # e.g. card, dialog, input

This copies the helm code into src/lib/ui/<component>/, installs the matching @spartan-ng/brain primitive, and adds a @spartan-ng/helm/<component> path mapping in tsconfig.json. Then:

  1. Re-export it from src/public-api.ts.
  2. Add a *.stories.ts (mirror hlm-button.stories.ts).

The vendored helm files import each other through the @spartan-ng/helm/* path alias, which resolves to local source — ng-packagr inlines them into the build.

Icons (Angular)

Angular uses ng-icons for icons. Install @ng-icons/core (the NgIcon component, an optional peer dependency) plus a glyph set — we use the Tabler set, @ng-icons/tabler-icons:

pnpm add @ng-icons/core @ng-icons/tabler-icons

Register the glyphs you need with provideIcons (named exports like tablerPlus), then render them with <ng-icon>:

import { NgIcon, provideIcons } from '@ng-icons/core';
import { tablerPlus } from '@ng-icons/tabler-icons';

@Component({
  imports: [NgIcon],
  providers: [provideIcons({ tablerPlus })],
  template: `
    <ng-icon name="tablerPlus" size="1.5rem" />              <!-- standalone: size with NgIcon's size input -->
    <button hlmBtn><ng-icon name="tablerPlus" data-icon="inline-start" /> Add item</button>
  `,
})

Inside a <button hlmBtn>, leave size off — the button auto-sizes the <ng-icon> per button size, and data-icon="inline-start" / data-icon="inline-end" tighten the padding next to text. See the Button stories (IconSizes, WithIcon).

Same icon set as React, different package: React uses @tabler/icons-react (Icon* components), Angular uses @ng-icons/tabler-icons (tabler* exports).

AI assistants (MCP servers & skills)

The repo is set up so AI assistants (primarily Claude Code and GitHub Copilot) understand the design system. Two MCP servers are configured in both .mcp.json (Claude Code, auto-detected) and .vscode/mcp.json (VS Code / Copilot — open it and click Start):

  • shadcn — browse/search/install shadcn + Base UI components for the React package (docs). Scoped via --cwd packages/react.
  • spartan-ui — read-only Spartan docs, component APIs, and examples for the Angular package (docs). Reference only — use the Spartan CLI to install code.

In Claude Code, run /mcp to confirm both show Connected. For Cursor/Codex/OpenCode, run npx shadcn@latest mcp init --client <name> and add the spartan-ui entry from the snippet above.

The repo also vendors the upstream shadcn and spartan agent skills (deep component/API references) in .agents/skills/, alongside the repo's own add-component skill. They're exposed to Claude Code through the .claude/skills symlink.

Theming

Design tokens are defined once in packages/tokens/src/tokens.json using the DTCG format and built with Style Dictionary into packages/tokens/dist/tokens.css. Both component packages import that CSS file — never hand-edit the :root or .dark blocks in a framework stylesheet. Change the DTCG JSON and rebuild @surfnet/tokens instead.

Each package's stylesheet then adds its own framework-specific wiring on top: Tailwind @theme inline mappings, the radius scale, and the font stack (Geist for React, system stack for Angular).

Releasing & versioning

Versioning and publishing are managed with Changesets. The flow has two halves: contributors describe their changes, and CI turns those descriptions into version bumps and npm releases.

When you make a change

Every PR that changes a publishable package should include a changeset — a small markdown file describing what changed and how it bumps the version. Add one with:

pnpm changeset

The prompt asks which packages changed, whether each bump is major / minor / patch (follow semver), and for a summary. That summary becomes the changelog entry, so write it for the people consuming the package. The command writes a file under .changeset/ — commit it with your code.

  • Skip the changeset only for changes that don't affect any published package (docs, CI, internal tooling). CI does not fail without one, so use judgement.
  • Need a changeset that doesn't bump anything? Run pnpm changeset and pick no packages (an empty changeset), useful to record that you deliberately skipped a release.
  • Private packages (everything except @surfnet/tokens today) are versioned but never published — Changesets skips publishing any package marked "private": true.

How a release happens (automated)

You do not run version or publish by hand. The .github/workflows/release.yml workflow watches main:

  1. When changesets land on main, the workflow opens (or updates) a "Version Packages" PR. That PR consumes the pending changeset files, bumps each package's version, and writes the CHANGELOG.md entries.
  2. Review and merge that PR when you want to cut a release.
  3. On merge, the same workflow runs pnpm release (build + changeset publish), publishing the changed public packages to npm and pushing git tags.

Publishing requires an NPM_TOKEN repository secret (an npm automation token with publish rights to the @surfnet scope). Add it under Settings → Secrets and variables → Actions. The provided GITHUB_TOKEN handles the PR and tags automatically.

Running it manually (rarely needed)

pnpm changeset            # add a changeset
pnpm version-packages     # apply pending changesets: bump versions + changelogs
pnpm release              # build, then publish to npm (needs npm auth)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors