Skip to content
Back to skills

Web Design System Foundation

ASecurity

Use when the repository has no design system yet (no tokens, no component library - a new site/app, or a task that asks for one), before building any page section.

  • 109 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 2, 2026
ai-agentsgoreactnodetestingapifrontend

Works with

  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 2, 2026

npx -y skills add makifbaysal/tasktrooper --skill web-design-system-foundation --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Web Design System Foundation?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Web Design System Foundation
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/makifbaysal-web-design-system-foundation/badge)](https://www.skillsdirectory.com/skills/makifbaysal-web-design-system-foundation)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: web-design-system-foundation
category: frontend
description: Use when the repository has no design system yet (no tokens, no component library - a new site/app, or a task that asks for one), before building any page section.
source: anthropics/skills frontend-design (Apache-2.0), adapted
---
# Web Design System Foundation

## Overview

A page built before tokens, a `cn()` helper, and the atomic folders exist turns into hex colors, one-off spacing, and a component library that never forms. This skill lays that foundation once, in order, so every page after it is composition, not invention.

**Core principle:** Lay the foundation before the first page section — tokens, folders, and the first atoms come before any feature work.

## Detect first

Greenfield (use this skill) vs existing (map and follow `component-composition`, never restructure):
- `grep -r "@theme\|:root" src/index.css` (v4) or `tailwind.config.*` `theme.extend.colors` (v3) — tokens defined?
- `ls src/components` — do `atoms/molecules/organisms/templates` already exist, or a `components.json` (shadcn already initialized)?
- `cat src/components/INVENTORY.md` present → library exists, reuse it.
- Tailwind major: `grep '"tailwindcss"' package.json` (`^4` → `@theme`; `^3` → config + CSS vars).

Any of these present → stop, this skill doesn't apply; follow `component-composition` against the existing structure.

## Design direction (5 minutes, written into the plan, not a separate doc)

One line each, from the task brief (the brief's own words win over any default below): **audience + tone** (e.g. "B2B finance ops, calm and precise"); **4–6 named brand colors** mapped onto the semantic tokens (primary/accent/destructive at minimum — use the brief's colors if given); **type pairing**, max 2 families, self-hosted or Google Fonts `<link>` with `&display=swap`; **radius + shadow** choice (one `--radius`, elevation via `shadow-sm`/`shadow-md` only); **ASCII wireframe** of the key page at phone (360) and desktop (1440).

Avoid the generic-AI defaults (warm-cream+serif+terracotta; near-black+one acid accent; identical-card SaaS kit) unless the brief asks for one — see `ui-ux-craft`'s anti-generic-look list.

## Foundation steps, in order (each a committable step)

**(a) `@/` alias.** `tsconfig.app.json` → `"compilerOptions": { "paths": { "@/*": ["./src/*"] } }` (no `baseUrl` — TS 6 deprecates it; `paths` alone resolves under `moduleResolution: "bundler"`). `vite.config.ts`:
```ts
import path from 'node:path'
resolve: { alias: { '@': path.resolve(import.meta.dirname, './src') } }
```
Use `import.meta.dirname`, not `__dirname` (Vite 8 warns and will require it).

**(b) Deps.** `npm i clsx tailwind-merge class-variance-authority lucide-react` (runtime dependencies; `lucide-react` is the one icon library). Add `@radix-ui/react-<x>` only when a specific primitive is needed (dialog, popover, tabs...) — install that one package, not a bundle.
Do not run `npx shadcn init` on a Vite 8 + TS 6 + Tailwind 4.3 project: verified it does not resolve the `@/*` tsconfig path and writes `components/ui/` and `lib/utils.ts` into a literal `./@/` directory at the project root instead of `src/`, and it pulls in extra dependencies and tokens this foundation doesn't use (a bundled `radix-ui`/`cn` package, `@fontsource-variable/geist`, `tw-animate-css`, `--chart-*`/`--sidebar-*` CSS vars). Hand-write primitives in `components/ui/` on top of the individually installed `@radix-ui/react-<x>` packages instead.

**(c) Tokens in `src/index.css`** (Tailwind v4 — `@theme` maps semantic names to CSS vars, `:root` holds the light values, `.dark` the dark override):
```css
@import "tailwindcss";

@theme {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-destructive-foreground: var(--destructive-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
  --font-sans: "<body face from the design direction>", ui-sans-serif, system-ui, sans-serif;
  /* --font-display: "<display face>", serif;  optional second family; load faces via <link> in index.html with &display=swap */
}

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.18 0.01 260);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.18 0.01 260);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.18 0.01 260);
  --primary: oklch(0.42 0.17 264);        /* ← brand color from the design direction */
  --primary-foreground: oklch(0.98 0.005 260);
  --secondary: oklch(0.96 0.01 260);
  --secondary-foreground: oklch(0.24 0.02 260);
  --muted: oklch(0.96 0.01 260);
  --muted-foreground: oklch(0.5 0.02 260);
  --accent: oklch(0.93 0.03 264);
  --accent-foreground: oklch(0.24 0.02 260);
  --destructive: oklch(0.55 0.22 27);
  --destructive-foreground: oklch(0.98 0.005 260);
  --border: oklch(0.9 0.01 260);
  --input: oklch(0.9 0.01 260);
  --ring: oklch(0.42 0.17 264);
  --radius: 0.5rem;
}

.dark {
  --background: oklch(0.18 0.01 260);
  --foreground: oklch(0.96 0.005 260);
  /* ...same keys, dark values. Optional: only add if the brief needs dark mode. */
}

@layer base {
  * {
    border-color: var(--color-border);
    outline-color: color-mix(in oklch, var(--color-ring) 50%, transparent);
  }
  body {
    background-color: var(--color-background);
    color: var(--color-foreground);
    color-scheme: light;
    text-rendering: optimizeLegibility;
  }
  .dark body { color-scheme: dark; }
  :focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
  ::selection { background-color: var(--color-primary); color: var(--color-primary-foreground); }
}
```
Verified: `npm run build` includes `.bg-primary`, `.text-muted-foreground`, `.rounded-lg { border-radius: var(--radius-lg) }` in the built CSS from this block alone.

Tailwind v3 equivalent: same `:root`/`.dark` variable block, plus in `tailwind.config.ts` → `theme.extend.colors = { background: "var(--background)", primary: { DEFAULT: "var(--primary)", foreground: "var(--primary-foreground)" }, ... }` for every semantic pair, and `borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)" }`.

**(d) `src/lib/utils.ts`:**
```ts
import { clsx } from 'clsx'
import type { ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

**(e) Folders** — create exactly this tree:
```
src/
  index.css                 tokens (@theme) + base layer
  lib/utils.ts              cn()
  components/
    ui/                     Radix/shadcn primitives (atom-level vendor layer)
    atoms/                  Button, Input, Label, Badge, Icon, Heading, Text, Container, Stack, Link
    molecules/              FormField, NavLink, PriceTag, …
    organisms/              SiteHeader, MobileNav, PricingTable, ContactForm, SiteFooter, …
    templates/              MarketingLayout, AppLayout — page skeletons, own grid + breakpoints, no data
    INVENTORY.md            one line per component: level · name · purpose · variants/props
  pages/                    route components: data + state wiring
  hooks/ , api/             data hooks and API clients — imported by pages only
```
One component per file, PascalCase file = export name, tests co-located (`atoms/Button.tsx` + `atoms/Button.test.tsx`), direct imports via `@/components/atoms/Button` — no barrel `index.ts` files. Start `INVENTORY.md` with the header `One line per component: level · name · purpose · variants/props.` and add a bullet for every component in the same commit that creates it.

**(f) Base atoms first.** `Button` in full (cva variants × sizes, loading keeps the label and adds a spinner, `min-h-11` touch target on phone, `focus-visible` ring, `ref` as a plain prop — React 19 needs no `forwardRef`):
```tsx
import type { ButtonHTMLAttributes, Ref } from 'react'
import { cva } from 'class-variance-authority'
import type { VariantProps } from 'class-variance-authority'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        sm: 'h-9 min-h-11 sm:min-h-9 px-3 text-sm',
        md: 'h-10 min-h-11 sm:min-h-10 px-4 text-sm',
        lg: 'h-11 min-h-11 px-6 text-base',
        icon: 'h-10 w-10 min-h-11 min-w-11 sm:min-h-10 sm:min-w-10',
      },
    },
    defaultVariants: { variant: 'primary', size: 'md' },
  },
)

export interface ButtonProps
  extends ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  loading?: boolean
  ref?: Ref<HTMLButtonElement>
}

export function Button({ className, variant, size, loading = false, disabled, children, ref, ...props }: ButtonProps) {
  return (
    <button
      ref={ref}
      type="button"
      className={cn(buttonVariants({ variant, size }), className)}
      disabled={disabled || loading}
      aria-busy={loading}
      {...props}
    >
      {loading ? <Loader2 className="size-4 animate-spin" aria-hidden /> : null}
      {children}
    </button>
  )
}
```
Then, one line each, same pattern (cva + `cn`, `ref` prop, every state styled, no outer margin):
- **Input** — text/email/etc, `aria-invalid` red ring, `text-base` (16px, no iOS zoom) on phone.
- **Label** — wraps native `<label>`, required-marker slot.
- **Heading** — `level` 1–4 prop mapping to `h1`–`h4` + a fixed size/weight per level, `text-balance`.
- **Text** — body paragraph, `muted` boolean for `text-muted-foreground`, `text-pretty`.
- **Container** — `mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8`, no other styling.
- **Stack** — flex column/row + `gap-*`, the only layout atom that takes a `gap` prop (never margins on siblings).
- **Badge** — status chip, variant per semantic color, never color-only (pair with text/icon).
- **Icon** — thin wrapper around one icon library (`lucide-react`) fixing size via `className="size-4"` etc.

**(g) Templates.** `MarketingLayout` / `AppLayout`: header/main/footer skeleton owning the `Container` width and the responsive grid, no data, children render inside `main`.

**(h) ui-guard**, wired as:
```json
"scripts": { "prebuild": "npm run lint:ui", "lint:ui": "node scripts/ui-guard.mjs", "build": "tsc -b && vite build" }
```
Full script (`scripts/ui-guard.mjs`, dependency-free, verified: clean on correct code, catches every violation below, exits 0 under `--warn`):
```js
#!/usr/bin/env node
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'

const WARN = process.argv.includes('--warn')
const ROOT = resolve(import.meta.dirname, '..')
const SRC = join(ROOT, 'src')

const LEVEL_ORDER = ['ui', 'atoms', 'molecules', 'organisms', 'templates', 'pages']
const PALETTE_RE = /\b(bg|text|border|ring|fill|stroke|from|via|to|outline|decoration|divide|placeholder|shadow)-(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/
const ARBITRARY_RE = /-\[[^\]]+\]/g
const ALLOWED_ARBITRARY = [
  /grid-cols-\[repeat\(auto-(fit|fill),minmax\(/,
  /max-w-\[[\d.]+ch\]/,
  /aspect-\[[^\]]+\]/,
]
const BANNED_UTILS = ['h-screen', 'space-x-', 'space-y-', 'transition-all']
const Z_ARBITRARY_RE = /\bz-\[[^\]]+\]/

function listFiles(dir) {
  const out = []
  for (const entry of readdirSync(dir)) {
    const full = join(dir, entry)
    const st = statSync(full)
    if (st.isDirectory()) out.push(...listFiles(full))
    else if (/\.(ts|tsx)$/.test(entry)) out.push(full)
  }
  return out
}

function levelOf(file) {
  const rel = relative(SRC, file).replace(/\\/g, '/')
  if (rel.startsWith('components/ui/')) return 'ui'
  if (rel.startsWith('components/atoms/')) return 'atoms'
  if (rel.startsWith('components/molecules/')) return 'molecules'
  if (rel.startsWith('components/organisms/')) return 'organisms'
  if (rel.startsWith('components/templates/')) return 'templates'
  if (rel.startsWith('pages/')) return 'pages'
  if (rel.startsWith('hooks/') || rel.startsWith('api/')) return 'data'
  return 'other'
}

function resolveImport(fromFile, spec) {
  if (spec.startsWith('@/')) return join(SRC, spec.slice(2))
  if (spec.startsWith('.')) return resolve(dirname(fromFile), spec)
  return null
}

function findings(file, lines) {
  const found = []
  const fileLevel = levelOf(file)

  lines.forEach((line, i) => {
    const lineNo = i + 1
    const allow = /ui-guard-allow:/.test(line)

    const importMatch = line.match(/from\s+['"]([^'"]+)['"]/)
    if (importMatch && LEVEL_ORDER.includes(fileLevel)) {
      const resolved = resolveImport(file, importMatch[1])
      if (resolved) {
        const targetLevel = levelOf(resolved + '.tsx')
        if (targetLevel === 'data' && fileLevel !== 'pages') {
          found.push(`${lineNo}: import-direction — ${fileLevel} imports hooks/api (only pages/ may)`)
        } else if (LEVEL_ORDER.includes(targetLevel)) {
          const fromIdx = LEVEL_ORDER.indexOf(fileLevel)
          const toIdx = LEVEL_ORDER.indexOf(targetLevel)
          const sameLevelOk = fileLevel === 'organisms' && targetLevel === 'organisms'
          if (toIdx > fromIdx && !sameLevelOk) {
            found.push(`${lineNo}: import-direction — ${fileLevel} may not import ${targetLevel} (fix: move the import to a level at or right of ${fileLevel}, or lift this logic up)`)
          }
        }
      }
    }

    if (!allow) {
      if (PALETTE_RE.test(line)) found.push(`${lineNo}: raw-palette-color — use a semantic token class (bg-primary, text-muted-foreground, …) instead of a raw Tailwind palette class`)
      if (HEX_RE.test(line) && /className|style/.test(line)) found.push(`${lineNo}: hex-color — use a semantic token, not a literal hex value`)

      const arbMatches = line.match(ARBITRARY_RE) || []
      for (const m of arbMatches) {
        const isAllowed = ALLOWED_ARBITRARY.some((re) => re.test(line))
        if (!isAllowed) found.push(`${lineNo}: arbitrary-value — "${m}" is not on the allow-list; use a scale token or add "ui-guard-allow: <reason>" on this line`)
      }

      for (const banned of BANNED_UTILS) {
        if (line.includes(`"${banned}`) || line.includes(`'${banned}`) || line.includes(` ${banned}`) || line.includes(`\`${banned}`)) {
          found.push(`${lineNo}: banned-utility — "${banned}" is disallowed (use min-h-dvh/h-dvh, gap-*, or the explicit transition properties)`)
        }
      }
      if (Z_ARBITRARY_RE.test(line)) found.push(`${lineNo}: arbitrary-z-index — use the fixed z-10/20/30/40/50 scale, not an arbitrary z-[…]`)
      if (/style=\{\{/.test(line)) found.push(`${lineNo}: static-inline-style — use Tailwind classes; style={{}} is reserved for runtime-computed values (add ui-guard-allow: <reason> if genuinely dynamic)`)
    }
  })

  return found
}

function main() {
  const files = listFiles(SRC)
  let violations = 0

  for (const file of files) {
    const content = readFileSync(file, 'utf8')
    const lines = content.split('\n')
    const found = findings(file, lines)
    for (const f of found) {
      violations++
      console.log(`${relative(ROOT, file)}:${f}`)
    }
  }

  if (violations > 0) {
    console.log(`\nui-guard: ${violations} violation(s)${WARN ? ' (--warn: not failing)' : ''}`)
    process.exit(WARN ? 0 : 1)
  }

  console.log('ui-guard: clean')
  process.exit(0)
}

main()
```
On an EXISTING repo, run it with `--warn` only (report mode, always exits 0) unless a human explicitly asks for enforcement — never flip a legacy repo's build red. ESLint-based repos may add `eslint-plugin-boundaries` on top; this script stays the stack-agnostic default (Vite 8 templates ship oxlint, not ESLint).

**(i) Tests for atoms.** Co-located RTL test per `component-testing` (e.g. `Button.test.tsx` next to `Button.tsx`) — behavior-focused, `byRole`, `user-event`.

**(j) Only then page sections** — compose, don't invent; see `component-composition`.

## Worked Example

```
❌ Start writing a hero section straight away: inline `#1a1a2e` background,
   a hand-rolled `<div onClick>` button, `w-[600px]` card, no INVENTORY.md.

✅ 1. tsconfig/vite alias → 2. npm i clsx tailwind-merge class-variance-authority lucide-react
   → 3. tokens in index.css → 4. lib/utils.ts → 5. folders + INVENTORY.md
   → 6. Button, Container, Heading, Text atoms + tests → 7. ui-guard wired,
   `npm run build` green → 8. MarketingLayout template → 9. first page section,
   built entirely from atoms already in INVENTORY.md.
```

## Common Mistakes

- Writing a page section before any tokens exist — every color becomes a one-off hex.
- Running `npx shadcn init` on this stack without checking where it actually wrote files (it writes to `./@/...`, not `./src/...`, verified).
- `baseUrl` in `tsconfig.app.json` — deprecated under TS 6; `paths` alone is enough with `moduleResolution: "bundler"`.
- `__dirname` in `vite.config.ts` — use `import.meta.dirname`.
- Skipping `INVENTORY.md` — the next task re-invents a Button.
- Wiring `lint:ui` but forgetting `prebuild`, so `npm run build` never actually runs ui-guard.

## Red Flags

- A component file with a raw hex color or `bg-blue-500` anywhere in the diff.
- `src/components/` with no `INVENTORY.md`.
- `npm run build` green but `dist/*.css` has no `--color-primary`/`.bg-primary` — tokens aren't wired into Tailwind.
- A primitive hand-rolled in a page file instead of `components/ui/`.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…