Compare commits

..

8 Commits

Author SHA1 Message Date
3e7de78721 Remove internal project files from tracking and update .gitignore
Stop tracking .claude/, CLAUDE.md, ARCHITECTURE.md, and plans/ — these are
local-only working files for Claude Code and should not be on the shared
Gitea remote. Files remain on disk, only removed from git index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 14:05:36 +10:00
07be9d7314 Add Input, Checkbox, Radio, and Switch form components with semantic token layer
Build four form primitives from Figma references with brand-aligned creative
decisions: restrained press states (scale-95 instead of highlight splashes),
clean iconless Switch, and consistent error states with inline warning icons.

Introduce form-control semantic tokens (--color-control-*) in tokens.css so
all form components share a single source for borders, checked states, focus
rings, labels, and errors. Retrofit Input to use these tokens instead of
direct palette references.

Update CLAUDE.md and ARCHITECTURE.md with token layer documentation, token
discipline rule (no palette references in components), and component tier
decision criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 14:00:56 +10:00
0e1b06b376 Add project plans for brand colours, typography, workflow, and Figma migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 11:56:10 +10:00
40d53f86dd Add Button component with NSW DS variants, colours, sizes, and icons
Pill-shaped button with three variants (primary/secondary/tertiary),
four colour schemes (navy/red/light/surface), three sizes, and optional
left/right icon slots. 17 Storybook stories cover all combinations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 11:56:03 +10:00
ba796fb247 Update grey-01 to NSW DS value and add semantic colour aliases
Grey-01 updated from #3D3D3D to #22272B to match the NSW Design System.
Semantic aliases (primary, error, success, warning, text, surface, etc.)
now reference palette variables as the single layer of indirection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 11:55:59 +10:00
2205862c2f Add workflow tooling: claude2figma skills, Storybook addons, Figma permissions
Install @storybook/addon-designs for embedding Figma frames in stories
and claude2figma skills (preflight, component-rules, style-binding,
reference-interpreter) for enforcing design token compliance when
writing to Figma. Add PostToolUse hook for use_figma QA reminders and
pre-allow Figma MCP + Code Connect tools in settings.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 11:28:15 +10:00
b57aab01aa Add typography system with Public Sans and 16 type styles
Self-host Public Sans via @fontsource-variable (weights 100-900, normal
+ italic). Add 6 composed @utility classes in typography.css for styles
that combine multiple properties (body-strong, small-strong, label,
overline, body-link, small-link). Matching text styles created in the
Figma design file via MCP for designer use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 10:05:34 +10:00
afba95fbaf Migrate from Penpot to Figma and add NSW brand colour palette
Replace self-hosted Penpot MCP references with the official Figma Remote
MCP server (OAuth, HTTP transport). Swap placeholder semantic colours in
tokens.css for the NSW Design System brand palette extracted from Figma
(blues, reds, oranges, greens, greys) and add typography size tokens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 10:05:25 +10:00
25 changed files with 1731 additions and 540 deletions

View File

@@ -1,51 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm run dev)",
"Bash(npm run build)",
"Bash(npm run storybook)",
"Bash(npm run lint)",
"Bash(npx tsc --noEmit)",
"Bash(npx storybook *)",
"Bash(npx prettier *)",
"Bash(npx vitest *)",
"Bash(node --check *)",
"Bash(npm install *)",
"Bash(npm list *)",
"Bash(npm outdated *)",
"Bash(git status *)",
"Bash(git log *)",
"Bash(git diff *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git push *)",
"Bash(git pull *)",
"Bash(git remote *)",
"Bash(kill %*)",
"Bash(lsof -i *)",
"WebSearch",
"WebFetch(domain:storybook.js.org)",
"WebFetch(domain:tailwindcss.com)",
"WebFetch(domain:react.dev)",
"WebFetch(domain:vitejs.dev)",
"WebFetch(domain:vite.dev)",
"WebFetch(domain:github.com)",
"WebFetch(domain:npmjs.com)",
"WebFetch(domain:help.penpot.app)",
"WebFetch(domain:penpot.app)",
"WebFetch(domain:developer.mozilla.org)"
]
},
"mcpServers": {
"storybook": {
"type": "http",
"url": "http://localhost:6006/mcp"
},
"penpot": {
"type": "http",
"url": "http://localhost:4401/mcp"
}
}
}

6
.gitignore vendored
View File

@@ -25,3 +25,9 @@ dist-ssr
*storybook.log *storybook.log
storybook-static storybook-static
# Claude Code (local-only project files)
.claude/
CLAUDE.md
ARCHITECTURE.md
plans/

View File

@@ -9,6 +9,7 @@ const config: StorybookConfig = {
"@storybook/addon-vitest", "@storybook/addon-vitest",
"@storybook/addon-a11y", "@storybook/addon-a11y",
"@storybook/addon-docs", "@storybook/addon-docs",
"@storybook/addon-designs",
"@storybook/addon-mcp" "@storybook/addon-mcp"
], ],
"framework": "@storybook/react-vite" "framework": "@storybook/react-vite"

View File

@@ -1,112 +0,0 @@
# ARCHITECTURE.md — SDC-Frontend
This is the living architecture document for the SDC-Frontend design system. All structural decisions are recorded here. Update this document when the architecture evolves — never let the codebase and this document drift apart.
---
## 1. Overview
SDC-Frontend is a React component library and design system that will serve as the frontend for the Research Synthesiser. It is built in phases:
1. **Design system** — tokens, primitives, composite components
2. **Application screens** — the synthesiser UI rebuilt on top of the design system
---
## 2. Token Pipeline
```
Penpot (design tool, self-hosted)
↓ Penpot MCP / manual export
src/tokens/tokens.css (@theme block)
↓ Tailwind CSS v4 reads @theme
↓ Generates utility classes + CSS custom properties
Components (use Tailwind utilities or var() references)
↓ Rendered in
Storybook (visual verification)
```
### Token Categories
- **Colours**: `--color-*` (bg, surface, border, text, primary, success, warning, error)
- **Radii**: `--radius-*` (sm, default, lg, full)
- **Shadows**: `--shadow-*` (default, md)
### How Tailwind v4 @theme Works
Declaring `--color-primary: #2563eb` inside `@theme` in `tokens.css` automatically generates utilities like `bg-primary`, `text-primary`, `border-primary`. No JavaScript config file needed — the CSS file is the config.
---
## 3. Component Taxonomy
### `src/components/ui/` — Primitives
Atomic, reusable building blocks. Each is self-contained with no domain logic.
- Button, Input, Textarea, Select
- Card, Badge, Tag
- Dialog, Tooltip, Popover
### `src/components/composite/` — Composed Components
Built from primitives, may carry light domain semantics.
- StatusMessage (success/error/warning/info)
- TabBar, TabPanel
- ThemeCard (maps to synthesiser's theme display)
### `src/components/layout/` — Layout
Page-level structural components.
- AppShell (header + sidebar + content area)
- PageHeader
---
## 4. Styling Approach
- **Primary**: Tailwind utility classes
- **Conditional classes**: `cn()` from `@/lib/utils` (clsx + tailwind-merge)
- **Token values**: Always from `src/tokens/tokens.css`, never hardcoded
- **No CSS modules, no styled-components, no inline styles** (except truly dynamic values)
- **Class ordering**: Enforced by `prettier-plugin-tailwindcss`
---
## 5. Storybook Conventions
- Every component has a co-located `.stories.tsx` file
- All stories use `tags: ['autodocs']` for auto-generated docs
- Stories cover: default state, all variants, edge cases, disabled/error states
- A11y addon runs on all stories — violations should be addressed
- MCP addon enabled at `localhost:6006/mcp` for AI-assisted development
---
## 6. Project Structure
```
src/
├── components/
│ ├── ui/ # Primitives
│ ├── composite/ # Composed components
│ └── layout/ # Layout components
├── tokens/
│ └── tokens.css # Design tokens (@theme block)
├── styles/
│ └── global.css # Tailwind imports + base styles
├── lib/
│ └── utils.ts # cn() utility
├── hooks/ # Custom React hooks
├── pages/ # App screens (Phase 2)
├── App.tsx # Root component
└── main.tsx # Vite entry point
```
---
## 7. Design Tool Integration
### Penpot (self-hosted at 192.168.50.211:9001)
- MCP server runs locally via `npx @penpot/mcp@stable` (port 4401)
- MCP plugin must be installed and active in the Penpot browser session
- Tools available: `high_level_overview`, `execute_code`, `penpot_api_info`, `export_shape`, `import_image`
- Design tokens in Penpot use W3C DTCG format — export as JSON, convert to `@theme` values
### Storybook MCP
- Available at `localhost:6006/mcp` when Storybook dev server is running
- Provides: component listing, documentation retrieval, story generation, a11y testing

150
CLAUDE.md
View File

@@ -1,150 +0,0 @@
# CLAUDE.md — SDC-Frontend Design System
You are the lead engineer on **SDC-Frontend**, a React component library and design system for the Research Synthesiser, built for UX researchers at the NSW Department of Education.
---
## How I Want You to Work
These are non-negotiable working principles for this project.
### Plan Before You Build
Before beginning any significant piece of work, write a plan as a markdown file in `plans/`. Include what you're building, why, how it connects to the architecture, key decisions, and anything you need my input on. Share it with me before starting. Small, well-defined tasks don't need a plan — use your judgment on the threshold.
### Evaluate Before You Commit
Before choosing a library, pattern, or approach: identify your options, research the latest documentation (don't rely on training knowledge for version-specific details), verify it works within the project constraints, and state your reasoning — especially when you considered and rejected alternatives.
### Ask, Don't Assume
If you need information you don't have — about infrastructure, the team's workflow, the Penpot setup, design intent, or anything unclear — ask me. Do not fill gaps with assumptions. A question costs a moment; a wrong assumption costs hours.
### Architecture Governance
`ARCHITECTURE.md` is the living contract for this project. Before starting any task, re-read the relevant section. If you need to deviate, propose the change to me first, then update the document. If you discover something new, add it. Never let the codebase and architecture drift apart.
### Fail Gracefully, Build Honestly
Test components with realistic props and edge cases, not only happy-path defaults. When something breaks, surface clear errors. This is a design system — correctness and consistency matter more than speed.
---
## Technical Stack
| Tool | Version | Notes |
|---|---|---|
| React | 19.x | Function components, hooks only |
| Vite | latest | Dev server + build |
| TypeScript | strict mode | All components must have typed props |
| Tailwind CSS | v4.3+ | CSS-first config via `@theme` in `src/tokens/tokens.css` |
| Storybook | 10.x | Component dev, docs, visual testing, MCP addon |
| clsx + tailwind-merge | latest | Combined as `cn()` in `src/lib/utils.ts` |
| ESLint + Prettier | latest | With `prettier-plugin-tailwindcss` for class ordering |
---
## Design Tokens
All design tokens live in `src/tokens/tokens.css` as a `@theme` block. This is the **single source of truth**. Do not hardcode colour values, radii, or shadows anywhere else.
Use Tailwind utilities (`bg-primary`, `text-error`, `rounded-default`, etc.) or CSS variables (`var(--color-primary)`) when utilities don't cover the case.
Token naming convention:
- Colours: `--color-{name}` (e.g., `--color-primary`, `--color-text-secondary`)
- Radii: `--radius-{size}` (e.g., `--radius-default`, `--radius-lg`)
- Shadows: `--shadow-{size}` (e.g., `--shadow-default`, `--shadow-md`)
---
## Component Conventions
### File Structure
Each component lives in its own directory:
```
src/components/ui/Button/
Button.tsx — Component implementation
Button.stories.tsx — Storybook stories
index.ts — Barrel export
```
### Component Taxonomy
- `src/components/ui/` — Primitive components (Button, Input, Card, Badge)
- `src/components/composite/` — Composed components (StatusMessage, TabBar)
- `src/components/layout/` — Layout components (AppShell, Sidebar)
### TypeScript
- Export a named props interface: `ButtonProps`, `CardProps`, etc.
- Use `React.forwardRef` for components that wrap native elements.
- Extend native element props where appropriate (e.g., `ButtonHTMLAttributes<HTMLButtonElement>`).
### Styling
- Use Tailwind utility classes as the primary styling method.
- Use the `cn()` utility from `@/lib/utils` for conditional classes.
- Never use inline styles except for truly dynamic values.
- Never use CSS modules or styled-components.
### Stories
- Every component MUST have a Storybook story file.
- Include at minimum: a `Default` story, and stories for each significant variant/state.
- Use Storybook Controls for interactive prop exploration.
- Use `tags: ['autodocs']` for automatic documentation generation.
### Accessibility
- All interactive components must be keyboard-navigable.
- Use semantic HTML elements (`<button>`, `<nav>`, `<main>`, not `<div onClick>`).
- Include `aria-` attributes where needed.
- Storybook a11y addon is configured — check for violations.
---
## Code Patterns
### Conditional Classes
```tsx
import { cn } from '@/lib/utils'
<button className={cn(
'px-4 py-2 rounded-default font-medium',
variant === 'primary' && 'bg-primary text-white hover:bg-primary-hover',
variant === 'secondary' && 'bg-surface border border-border text-text',
disabled && 'opacity-50 cursor-not-allowed',
)}>
```
### Barrel Exports
Every component directory has an `index.ts`:
```ts
export { Button } from './Button'
export type { ButtonProps } from './Button'
```
### Path Alias
Use `@/` for imports from `src/`:
```ts
import { cn } from '@/lib/utils'
```
---
## MCP Integrations
- **Penpot MCP** — reads design specs from the self-hosted Penpot instance. Requires Penpot open in browser with the MCP plugin active, and `npx @penpot/mcp@stable` running locally.
- **Storybook MCP** — runs at `localhost:6006/mcp` alongside the Storybook dev server. Provides component docs, story generation, and a11y testing.
### Design-to-Code Workflow
1. Use Penpot MCP `high_level_overview` to understand the design file
2. Use `execute_code` with `findShapes()` to inspect specific components
3. Extract CSS with `penpot.generateStyle()`
4. Build React component using design tokens from `src/tokens/tokens.css`
5. Verify in Storybook that the component matches the Penpot design
---
## Reference Project
The existing synthesiser at `../SDC-Synthesiser/` contains the UI patterns, design tokens, and component designs this system is based on. Consult it when building components that map to the existing interface.
---
## Useful Subagents and Tools
- **Explore agent** — for codebase research spanning multiple files
- **webapp-testing skill** — for browser-based UI testing via Playwright
- **WebSearch/WebFetch** — for checking latest library documentation before making dependency decisions
- **Storybook MCP** — for AI-assisted story generation and component verification

79
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "sdc-frontend", "name": "sdc-frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@fontsource-variable/public-sans": "^5.2.7",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"react": "^19.2.6", "react": "^19.2.6",
@@ -19,6 +20,7 @@
"@chromatic-com/storybook": "^5.2.1", "@chromatic-com/storybook": "^5.2.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.4.0", "@storybook/addon-a11y": "^10.4.0",
"@storybook/addon-designs": "^11.1.3",
"@storybook/addon-docs": "^10.4.0", "@storybook/addon-docs": "^10.4.0",
"@storybook/addon-mcp": "^0.6.0", "@storybook/addon-mcp": "^0.6.0",
"@storybook/addon-vitest": "^10.4.0", "@storybook/addon-vitest": "^10.4.0",
@@ -915,6 +917,36 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@figspec/components": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@figspec/components/-/components-2.1.0.tgz",
"integrity": "sha512-PFKBX2oFz+vhThKTNsu7Mh4ZT3X7YCiM694UkAMT36j/p0tdmXs9Je0Sf88stTEcMgwYvNv9TZtvniYmgaE+bw==",
"dev": true,
"license": "MIT"
},
"node_modules/@figspec/react": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@figspec/react/-/react-2.0.1.tgz",
"integrity": "sha512-xflqJ3XQZVzm8+7dsm8OFxVAmBNNA3Mg65sqwNHiq7VRSMSD7qwH4BPsBy07ZaX+9nHeaacBpFZd3Q0aIsISqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@figspec/components": "^2.0.1",
"@lit-labs/react": "^2.0.0"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@fontsource-variable/public-sans": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource-variable/public-sans/-/public-sans-5.2.7.tgz",
"integrity": "sha512-4mvade2J3slKkvwRkS+p8T3szet/0vhWoSnuUJTVU81Uo2pRpSZY/Y8bSLRqpSwzIPxjVmRJ53oq6JKP/l/PSg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1046,6 +1078,26 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lit-labs/react": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-2.1.3.tgz",
"integrity": "sha512-OD9h2JynerBQUMNzb563jiVpxfvPF0HjQkKY2mx0lpVYvD7F+rtJpOGz6ek+6ufMidV3i+MPT9SX62OKWHFrQg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@lit/react": "^1.0.3"
}
},
"node_modules/@lit/react": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz",
"integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==",
"dev": true,
"license": "BSD-3-Clause",
"peerDependencies": {
"@types/react": "17 || 18 || 19"
}
},
"node_modules/@mdx-js/react": { "node_modules/@mdx-js/react": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
@@ -2115,6 +2167,33 @@
"storybook": "^10.4.0" "storybook": "^10.4.0"
} }
}, },
"node_modules/@storybook/addon-designs": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/@storybook/addon-designs/-/addon-designs-11.1.3.tgz",
"integrity": "sha512-AK+ij478Y6S16TCNPwm7H90OipVe2wZApOlHjC6qDvMW61zyd4yP1icrRtjehSadw5SCoz8HcAmIYfQCOY6E4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@figspec/react": "^2.0.0"
},
"peerDependencies": {
"@storybook/addon-docs": "^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0"
},
"peerDependenciesMeta": {
"@storybook/addon-docs": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@storybook/addon-docs": { "node_modules/@storybook/addon-docs": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.0.tgz", "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.0.tgz",

View File

@@ -12,6 +12,7 @@
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/public-sans": "^5.2.7",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"react": "^19.2.6", "react": "^19.2.6",
@@ -23,6 +24,7 @@
"@chromatic-com/storybook": "^5.2.1", "@chromatic-com/storybook": "^5.2.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.4.0", "@storybook/addon-a11y": "^10.4.0",
"@storybook/addon-designs": "^11.1.3",
"@storybook/addon-docs": "^10.4.0", "@storybook/addon-docs": "^10.4.0",
"@storybook/addon-mcp": "^0.6.0", "@storybook/addon-mcp": "^0.6.0",
"@storybook/addon-vitest": "^10.4.0", "@storybook/addon-vitest": "^10.4.0",

View File

@@ -1,177 +0,0 @@
# SDC-Frontend Project Setup Plan
## Context
The existing Research Synthesiser at `../SDC-Synthesiser/` is a vanilla HTML/CSS/JS app. This new project creates a React-based design system and component library that will eventually become the full frontend replacement. The design system is driven by Penpot (self-hosted at `192.168.50.211:9001`) via MCP, with Storybook for component development and documentation.
The project carries forward the same working principles (plan before building, evaluate before committing, ask don't assume, architecture governance) and the existing design tokens from the synthesiser's CSS.
---
## Tech Stack
| Tool | Version | Notes |
|---|---|---|
| React | 19.x | Function components, hooks only |
| Vite | latest | Dev server + build |
| TypeScript | strict | All components have typed props |
| Tailwind CSS | v4.3+ | CSS-first `@theme` config — no JS config file |
| Storybook | 10.x | Component dev, docs, MCP addon |
| ESLint + Prettier | latest | With `prettier-plugin-tailwindcss` for class sorting |
| clsx + tailwind-merge | latest | Combined as `cn()` utility |
---
## Steps
### 1. Scaffold Vite project
```bash
cd /home/richie/Nextcloud/Projects/Coding/
npm create vite@latest SDC-Frontend -- --template react-ts
cd SDC-Frontend
npm install
```
### 2. Install Tailwind CSS v4
```bash
npm install tailwindcss @tailwindcss/vite
```
Update `vite.config.ts` to add the Tailwind plugin alongside React.
### 3. Create design tokens and global styles
- `src/tokens/tokens.css``@theme` block with all design tokens ported from the synthesiser's CSS variables
- `src/styles/global.css` — imports Tailwind and tokens, sets base typography
- Update `src/main.tsx` to import `global.css`
- Delete Vite boilerplate (`App.css`, default `App.tsx` content, `src/assets/react.svg`)
### 4. Install utility dependencies
```bash
npm install clsx tailwind-merge
```
Create `src/lib/utils.ts` with `cn()` helper.
### 5. Configure path alias
Set up `@/``src/` in both `tsconfig.app.json` and `vite.config.ts` for clean imports.
### 6. Install and configure Storybook 10
```bash
npx storybook@latest init
npm install @storybook/addon-mcp
```
- Update `.storybook/main.ts` to add MCP addon
- Update `.storybook/preview.ts` to import `global.css` (so Tailwind works in stories)
### 7. Install and configure ESLint + Prettier
```bash
npm install -D prettier prettier-plugin-tailwindcss eslint-config-prettier
```
- Create `.prettierrc` with Tailwind plugin
- Update ESLint config to extend `prettier`
### 8. Create initial Button component
Validates the full pipeline: tokens → Tailwind → TypeScript → Storybook.
- `src/components/ui/Button/Button.tsx` — primary/secondary/danger variants, sm/md/lg sizes
- `src/components/ui/Button/Button.stories.tsx` — stories for each variant + disabled state
- `src/components/ui/Button/index.ts` — barrel export
### 9. Write CLAUDE.md
Carry over the five working principles from SDC-Synthesiser, adapted for React/TS/Tailwind/Storybook. Add:
- Component conventions (folder structure, typed props, forwardRef, stories required)
- Design token rules (single source of truth in `tokens.css`, never hardcode)
- Styling rules (Tailwind utilities via `cn()`, no inline styles, no CSS modules)
- Accessibility requirements (semantic HTML, keyboard nav, ARIA)
- MCP integrations (Penpot, Storybook)
- Reference to existing synthesiser
### 10. Write initial ARCHITECTURE.md
Lightweight living document covering:
- Token pipeline (Penpot → tokens.css → Tailwind → components)
- Component taxonomy (ui/composite/layout)
- Styling approach
- Storybook conventions
### 11. Create .claude/settings.json
**MCP servers:**
- `storybook``http://localhost:6006/mcp` (available when Storybook is running)
- `penpot``http://localhost:4401/mcp` (Penpot MCP server, runs via `npx @penpot/mcp@stable`)
Note: The Penpot UI is at `http://192.168.50.211:9001/`. The MCP server runs locally on the dev machine and connects to Penpot via the browser plugin.
**Permissions:** Pre-allow npm scripts, TypeScript checks, git operations, WebSearch, and WebFetch for documentation domains.
### 12. Create plans/ directory
Add this plan as the first entry.
### 13. Git init + initial commit
- `git init`
- Verify `.gitignore` covers `node_modules/`, `dist/`, `storybook-static/`, `.claude/settings.local.json`
- Stage all files, commit with descriptive message
- User provides Gitea remote URL to push
### 14. Verify end-to-end
1. `npm run dev` — Vite serves without errors
2. `npm run storybook` — Storybook loads, Button stories render with correct token colours
3. `npx tsc --noEmit` — TypeScript compiles clean
4. `npm run build` — produces `dist/` output
5. Storybook Controls work interactively on Button
6. Autodocs generate for Button
---
## Files created/modified
| File | Purpose |
|---|---|
| `vite.config.ts` | Add Tailwind plugin + path alias |
| `tsconfig.app.json` | Add `@/` path alias |
| `src/tokens/tokens.css` | Design tokens as `@theme` block |
| `src/styles/global.css` | Tailwind imports + base styles |
| `src/main.tsx` | Updated import path |
| `src/App.tsx` | Cleaned boilerplate |
| `src/lib/utils.ts` | `cn()` utility |
| `src/components/ui/Button/Button.tsx` | First component |
| `src/components/ui/Button/Button.stories.tsx` | First stories |
| `src/components/ui/Button/index.ts` | Barrel export |
| `.storybook/main.ts` | MCP addon added |
| `.storybook/preview.ts` | Global CSS import |
| `.prettierrc` | Tailwind class sorting |
| `CLAUDE.md` | Project principles + conventions |
| `ARCHITECTURE.md` | Living architecture doc |
| `.claude/settings.json` | MCP servers + permissions |
| `plans/project-setup.md` | This plan |
---
## Penpot MCP Setup (user action required)
After the project is scaffolded, the user needs to:
1. Install the Penpot MCP plugin inside their Penpot instance at `192.168.50.211:9001`
2. Run `npx @penpot/mcp@stable` on their dev machine
3. Open Penpot in the browser with the MCP plugin active
4. Claude Code can then use the `penpot` MCP tools
---
## Open items
- **Gitea remote URL** — user will provide when ready to push
- **Penpot MCP plugin installation** — user needs to install in their Penpot instance

View File

@@ -8,40 +8,179 @@ const meta: Meta<typeof Button> = {
argTypes: { argTypes: {
variant: { variant: {
control: 'select', control: 'select',
options: ['primary', 'secondary', 'danger'], options: ['primary', 'secondary', 'tertiary'],
},
color: {
control: 'select',
options: ['navy', 'red', 'light', 'surface'],
}, },
size: { size: {
control: 'select', control: 'select',
options: ['sm', 'md', 'lg'], options: ['default', 'comfortable', 'compact'],
}, },
disabled: { control: 'boolean' }, disabled: { control: 'boolean' },
children: { control: 'text' }, children: { control: 'text' },
}, },
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=10-20',
},
},
} }
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
export const Primary: Story = { export const Default: Story = {
args: { children: 'Primary Button', variant: 'primary' }, args: { children: 'Button' },
} }
export const Secondary: Story = { export const PrimaryNavy: Story = {
args: { children: 'Secondary Button', variant: 'secondary' }, args: { children: 'Button', variant: 'primary', color: 'navy' },
} }
export const Danger: Story = { export const PrimaryRed: Story = {
args: { children: 'Delete', variant: 'danger' }, args: { children: 'Delete', variant: 'primary', color: 'red' },
} }
export const Small: Story = { export const PrimaryLight: Story = {
args: { children: 'Small', size: 'sm' }, args: { children: 'Button', variant: 'primary', color: 'light' },
} }
export const Large: Story = { export const PrimarySurface: Story = {
args: { children: 'Large', size: 'lg' }, args: { children: 'Button', variant: 'primary', color: 'surface' },
}
export const SecondaryNavy: Story = {
args: { children: 'Button', variant: 'secondary', color: 'navy' },
}
export const SecondaryRed: Story = {
args: { children: 'Cancel', variant: 'secondary', color: 'red' },
}
export const SecondarySurface: Story = {
args: { children: 'Button', variant: 'secondary', color: 'surface' },
}
export const TertiaryNavy: Story = {
args: { children: 'Button', variant: 'tertiary', color: 'navy' },
}
export const TertiaryRed: Story = {
args: { children: 'Remove', variant: 'tertiary', color: 'red' },
}
export const TertiarySurface: Story = {
args: { children: 'Button', variant: 'tertiary', color: 'surface' },
}
const ArrowIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
)
const LockIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
)
export const WithLeftIcon: Story = {
args: { children: 'Button', leftIcon: <LockIcon /> },
}
export const WithRightIcon: Story = {
args: { children: 'Button', rightIcon: <ArrowIcon /> },
}
export const WithBothIcons: Story = {
args: {
children: 'Button',
leftIcon: <LockIcon />,
rightIcon: <ArrowIcon />,
},
}
export const Comfortable: Story = {
args: { children: 'Button', size: 'comfortable' },
}
export const Compact: Story = {
args: { children: 'Button', size: 'compact' },
}
export const AllSizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<Button size="default">Default</Button>
<Button size="comfortable">Comfortable</Button>
<Button size="compact">Compact</Button>
</div>
),
}
export const AllVariants: Story = {
render: () => (
<div className="flex items-center gap-4">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="tertiary">Tertiary</Button>
</div>
),
}
export const AllColours: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<Button color="navy">Navy</Button>
<Button color="red">Red</Button>
<Button color="light">Light</Button>
<Button color="surface">Surface</Button>
</div>
<div className="flex items-center gap-4">
<Button variant="secondary" color="navy">Navy</Button>
<Button variant="secondary" color="red">Red</Button>
<Button variant="secondary" color="light">Light</Button>
<Button variant="secondary" color="surface">Surface</Button>
</div>
<div className="flex items-center gap-4">
<Button variant="tertiary" color="navy">Navy</Button>
<Button variant="tertiary" color="red">Red</Button>
<Button variant="tertiary" color="light">Light</Button>
<Button variant="tertiary" color="surface">Surface</Button>
</div>
</div>
),
} }
export const Disabled: Story = { export const Disabled: Story = {
args: { children: 'Disabled', disabled: true }, render: () => (
<div className="flex items-center gap-4">
<Button disabled>Primary</Button>
<Button variant="secondary" disabled>Secondary</Button>
<Button variant="tertiary" disabled>Tertiary</Button>
</div>
),
} }

View File

@@ -2,30 +2,82 @@ import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' variant?: 'primary' | 'secondary' | 'tertiary'
size?: 'sm' | 'md' | 'lg' color?: 'navy' | 'red' | 'light' | 'surface'
size?: 'default' | 'comfortable' | 'compact'
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
const variantColorStyles: Record<string, Record<string, string>> = {
primary: {
navy: 'bg-blue-01 text-white hover:bg-blue-01/90 active:bg-blue-01/80',
red: 'bg-red-02 text-white hover:bg-red-02/90 active:bg-red-02/80',
light: 'bg-blue-04 text-blue-01 hover:bg-blue-04/80 active:bg-blue-04/60',
surface: 'bg-grey-01 text-white hover:bg-grey-01/90 active:bg-grey-01/80',
},
secondary: {
navy: 'border-2 border-blue-01 text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
red: 'border-2 border-red-02 text-red-02 hover:bg-red-02/5 active:bg-red-02/10',
light: 'border-2 border-blue-01 text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
surface: 'border-2 border-grey-01 text-grey-01 hover:bg-grey-01/5 active:bg-grey-01/10',
},
tertiary: {
navy: 'text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
red: 'text-red-02 hover:bg-red-02/5 active:bg-red-02/10',
light: 'text-blue-01 hover:bg-blue-01/5 active:bg-blue-01/10',
surface: 'text-grey-01 hover:bg-grey-01/5 active:bg-grey-01/10',
},
}
const sizeStyles: Record<string, string> = {
default: 'h-12 px-6 text-body gap-2',
comfortable: 'h-10 px-5 text-body gap-2',
compact: 'h-9 px-4 text-small gap-1.5',
}
const iconSizeStyles: Record<string, string> = {
default: 'size-6',
comfortable: 'size-5',
compact: 'size-5',
} }
export const Button = forwardRef<HTMLButtonElement, ButtonProps>( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', children, ...props }, ref) => { (
{
variant = 'primary',
color = 'navy',
size = 'default',
leftIcon,
rightIcon,
disabled,
className,
children,
...props
},
ref,
) => {
return ( return (
<button <button
ref={ref} ref={ref}
disabled={disabled}
className={cn( className={cn(
'inline-flex items-center justify-center font-medium rounded-default transition-colors', 'inline-flex items-center justify-center rounded-full font-bold transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-01',
'disabled:opacity-50 disabled:cursor-not-allowed', sizeStyles[size],
variant === 'primary' && 'bg-primary text-white hover:bg-primary-hover', variantColorStyles[variant][color],
variant === 'secondary' && 'bg-surface text-text border border-border hover:bg-bg', disabled && 'pointer-events-none opacity-50',
variant === 'danger' && 'bg-error text-white hover:bg-red-700',
size === 'sm' && 'px-3 py-1.5 text-sm',
size === 'md' && 'px-4 py-2 text-base',
size === 'lg' && 'px-6 py-3 text-lg',
className, className,
)} )}
{...props} {...props}
> >
{leftIcon && (
<span className={cn('shrink-0', iconSizeStyles[size])}>{leftIcon}</span>
)}
{children} {children}
{rightIcon && (
<span className={cn('shrink-0', iconSizeStyles[size])}>{rightIcon}</span>
)}
</button> </button>
) )
}, },

View File

@@ -0,0 +1,120 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Checkbox } from './Checkbox'
const meta: Meta<typeof Checkbox> = {
title: 'UI/Checkbox',
component: Checkbox,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
error: { control: 'text' },
disabled: { control: 'boolean' },
indeterminate: { control: 'boolean' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5043',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
label: 'Accept terms and conditions',
},
}
export const Checked: Story = {
args: {
label: 'Accept terms and conditions',
defaultChecked: true,
},
}
export const WithDescription: Story = {
args: {
label: 'Email notifications',
description: 'Receive updates about your research projects via email.',
},
}
export const WithError: Story = {
args: {
label: 'I agree to the privacy policy',
error: 'You must agree before continuing',
},
}
export const Disabled: Story = {
args: {
label: 'Unavailable option',
disabled: true,
},
}
export const DisabledChecked: Story = {
args: {
label: 'Locked setting',
disabled: true,
defaultChecked: true,
},
}
const IndeterminateExample = () => {
const [items, setItems] = useState([true, false, true])
const allChecked = items.every(Boolean)
const someChecked = items.some(Boolean) && !allChecked
return (
<div className="flex flex-col gap-3">
<Checkbox
label="Select all"
checked={allChecked}
indeterminate={someChecked}
onChange={() => setItems(allChecked ? [false, false, false] : [true, true, true])}
/>
<div className="ml-6 flex flex-col gap-2">
{['Survey responses', 'Interview transcripts', 'Field notes'].map((name, i) => (
<Checkbox
key={name}
label={name}
checked={items[i]}
onChange={() => {
const next = [...items]
next[i] = !next[i]
setItems(next)
}}
/>
))}
</div>
</div>
)
}
export const Indeterminate: Story = {
render: () => <IndeterminateExample />,
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-4">
<Checkbox label="Unchecked" />
<Checkbox label="Checked" defaultChecked />
<Checkbox label="With description" description="Additional context for this option." />
<Checkbox
label="Checked with description"
description="This option is currently enabled."
defaultChecked
/>
<Checkbox label="Error" error="This field is required" />
<Checkbox label="Disabled" disabled />
<Checkbox label="Disabled checked" disabled defaultChecked />
</div>
),
}

View File

@@ -0,0 +1,143 @@
import { forwardRef, useId, useRef, useEffect, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface CheckboxProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
label?: string
description?: string
error?: string
indeterminate?: boolean
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(
{
label,
description,
error,
indeterminate = false,
disabled,
className,
id: idProp,
...props
},
forwardedRef,
) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
const errorId = `${id}-error`
const internalRef = useRef<HTMLInputElement>(null)
const hasError = !!error
useEffect(() => {
const el = internalRef.current
if (el) el.indeterminate = indeterminate
}, [indeterminate])
const describedBy =
[description ? descriptionId : undefined, hasError ? errorId : undefined]
.filter(Boolean)
.join(' ') || undefined
return (
<div className={cn('flex gap-2', className)}>
<div className="flex h-6 items-center">
<input
ref={(node) => {
(internalRef as React.MutableRefObject<HTMLInputElement | null>).current = node
if (typeof forwardedRef === 'function') forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
}}
type="checkbox"
id={id}
disabled={disabled}
aria-invalid={hasError || undefined}
aria-describedby={describedBy}
className={cn(
'peer size-5 cursor-pointer appearance-none rounded-[3px] border-2 border-control-border bg-control-bg transition-colors',
'hover:border-control-border-hover',
'checked:border-control-checked checked:bg-control-checked',
'checked:hover:border-control-checked-hover checked:hover:bg-control-checked-hover',
'indeterminate:border-control-checked indeterminate:bg-control-checked',
'indeterminate:hover:border-control-checked-hover indeterminate:hover:bg-control-checked-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
'active:scale-95',
'disabled:pointer-events-none disabled:opacity-50',
hasError && 'border-control-error checked:border-control-error checked:bg-control-error',
)}
{...props}
/>
<svg
className="pointer-events-none absolute size-5 text-white opacity-0 peer-checked:opacity-100 peer-indeterminate:hidden"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="4.5 10.5 8 14 15.5 6.5" />
</svg>
<svg
className="pointer-events-none absolute size-5 text-white opacity-0 peer-indeterminate:opacity-100"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
aria-hidden="true"
>
<line x1="5" y1="10" x2="15" y2="10" />
</svg>
</div>
{(label || description || hasError) && (
<div className="flex flex-col gap-0.5 pt-px">
{label && (
<label
htmlFor={id}
className={cn(
'cursor-pointer text-body font-normal text-grey-01',
disabled && 'pointer-events-none opacity-50',
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
{hasError && (
<div id={errorId} className="flex items-center gap-1 text-small text-control-error">
<svg
className="size-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p>{error}</p>
</div>
)}
</div>
)}
</div>
)
},
)
Checkbox.displayName = 'Checkbox'

View File

@@ -0,0 +1,2 @@
export { Checkbox } from './Checkbox'
export type { CheckboxProps } from './Checkbox'

View File

@@ -0,0 +1,324 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Input } from './Input'
const meta: Meta<typeof Input> = {
title: 'UI/Input',
component: Input,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
hint: { control: 'text' },
error: { control: 'text' },
variant: {
control: 'select',
options: ['outlined', 'stacked'],
},
size: {
control: 'select',
options: ['default', 'compact'],
},
disabled: { control: 'boolean' },
readOnly: { control: 'boolean' },
placeholder: { control: 'text' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=22-3845',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
label: 'Label',
placeholder: 'Placeholder',
},
}
export const WithHint: Story = {
args: {
label: 'Email',
placeholder: 'you@example.com',
hint: 'We will never share your email',
},
}
export const WithValue: Story = {
args: {
label: 'Full name',
defaultValue: 'Jane Smith',
},
}
export const WithError: Story = {
args: {
label: 'Email',
defaultValue: 'not-an-email',
error: 'Please enter a valid email address',
},
}
const SearchIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
)
const MailIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
)
const ChevronDownIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
)
export const WithLeftIcon: Story = {
args: {
label: 'Search',
placeholder: 'Search...',
leftIcon: <SearchIcon />,
},
}
export const WithRightIcon: Story = {
args: {
label: 'Category',
placeholder: 'Select...',
rightIcon: <ChevronDownIcon />,
},
}
export const WithBothIcons: Story = {
args: {
label: 'Email',
placeholder: 'you@example.com',
leftIcon: <MailIcon />,
rightIcon: <ChevronDownIcon />,
},
}
export const Compact: Story = {
args: {
label: 'Label',
placeholder: 'Placeholder',
size: 'compact',
},
}
export const Disabled: Story = {
args: {
label: 'Label',
placeholder: 'Placeholder',
disabled: true,
},
}
export const DisabledWithValue: Story = {
args: {
label: 'Full name',
defaultValue: 'Jane Smith',
disabled: true,
},
}
export const ReadOnly: Story = {
args: {
label: 'Account ID',
defaultValue: 'ACC-2024-001',
readOnly: true,
},
}
const CharacterCountExample = () => {
const [value, setValue] = useState('')
return (
<Input
label="Bio"
placeholder="Tell us about yourself"
hint="Keep it brief"
maxLength={250}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)
}
export const WithCharacterCount: Story = {
render: () => <CharacterCountExample />,
}
export const AllStates: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input label="Default" placeholder="Placeholder" />
<Input label="With hint" placeholder="Placeholder" hint="Helpful hint text" />
<Input label="With value" defaultValue="Some value" />
<Input label="Error" defaultValue="Bad value" error="This field is required" />
<Input label="Disabled" placeholder="Placeholder" disabled />
<Input label="Disabled with value" defaultValue="Jane Smith" disabled />
<Input label="Read only" defaultValue="ACC-2024-001" readOnly />
</div>
),
}
export const AllSizes: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input label="Default size" placeholder="48px height" size="default" />
<Input label="Compact size" placeholder="40px height" size="compact" />
</div>
),
}
export const WithIcons: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input label="Search" placeholder="Search..." leftIcon={<SearchIcon />} />
<Input
label="Email"
placeholder="you@example.com"
leftIcon={<MailIcon />}
rightIcon={<ChevronDownIcon />}
/>
<Input
label="Search (compact)"
placeholder="Search..."
leftIcon={<SearchIcon />}
size="compact"
/>
</div>
),
}
// --- Stacked variant ---
export const Stacked: Story = {
args: {
label: 'Full name',
placeholder: 'Enter your full name',
variant: 'stacked',
},
}
export const StackedWithDescription: Story = {
args: {
label: 'Project title',
description: 'Choose a clear, descriptive name for your research project.',
placeholder: 'e.g. Student Wellbeing Survey 2026',
variant: 'stacked',
},
}
export const StackedWithHint: Story = {
args: {
label: 'Email address',
description: 'Your department email is preferred.',
placeholder: 'you@education.nsw.gov.au',
hint: 'We will use this for project notifications',
variant: 'stacked',
},
}
export const StackedWithError: Story = {
args: {
label: 'Email address',
description: 'Your department email is preferred.',
defaultValue: 'not-valid',
error: 'Please enter a valid email address',
variant: 'stacked',
},
}
export const StackedWithIcons: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input
variant="stacked"
label="Search participants"
description="Find by name or ID."
placeholder="Search..."
leftIcon={<SearchIcon />}
/>
<Input
variant="stacked"
label="Contact email"
placeholder="you@example.com"
leftIcon={<MailIcon />}
/>
</div>
),
}
export const StackedAllStates: Story = {
render: () => (
<div className="flex max-w-sm flex-col gap-6">
<Input variant="stacked" label="Default" placeholder="Placeholder" />
<Input
variant="stacked"
label="With description"
description="A short description of the field."
placeholder="Placeholder"
/>
<Input
variant="stacked"
label="With hint"
description="Description text here."
placeholder="Placeholder"
hint="Helpful hint text"
/>
<Input variant="stacked" label="With value" defaultValue="Some value" />
<Input
variant="stacked"
label="Error"
description="Description text here."
defaultValue="Bad value"
error="This field is required"
/>
<Input variant="stacked" label="Disabled" placeholder="Placeholder" disabled />
<Input
variant="stacked"
label="Disabled with description"
description="This field cannot be edited."
defaultValue="Jane Smith"
disabled
/>
<Input variant="stacked" label="Read only" defaultValue="ACC-2024-001" readOnly />
</div>
),
}

View File

@@ -0,0 +1,193 @@
import { forwardRef, useId, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string
description?: string
hint?: string
error?: string
variant?: 'outlined' | 'stacked'
size?: 'default' | 'compact'
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
const sizeStyles: Record<string, { container: string; input: string; icon: string }> = {
default: {
container: 'h-12 gap-2',
input: 'text-body',
icon: 'size-6',
},
compact: {
container: 'h-10 gap-2',
input: 'text-small',
icon: 'size-5',
},
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
label,
description,
hint,
error,
variant = 'outlined',
size = 'default',
leftIcon,
rightIcon,
disabled,
readOnly,
maxLength,
value,
defaultValue,
className,
id: idProp,
...props
},
ref,
) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
const hintId = `${id}-hint`
const hasError = !!error
const supportiveText = error || hint
const styles = sizeStyles[size]
const isStacked = variant === 'stacked'
const currentLength =
maxLength != null && typeof value === 'string' ? value.length : undefined
const describedBy = [
description && isStacked ? descriptionId : undefined,
supportiveText ? hintId : undefined,
]
.filter(Boolean)
.join(' ') || undefined
return (
<div className={cn('flex w-full flex-col', isStacked ? 'gap-1.5' : 'gap-1 pt-2', className)}>
{isStacked && (
<div className="flex flex-col gap-0.5">
<label
htmlFor={id}
className={cn(
'text-small font-bold',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'text-control-description',
)}
>
{label}
</label>
{description && (
<p
id={descriptionId}
className={cn('text-small text-grey-01', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
<div
className={cn(
'relative flex items-center rounded-[4px] border bg-control-bg px-3 transition-colors',
styles.container,
hasError
? 'border-control-error focus-within:border-2 focus-within:border-control-error focus-within:px-[11px]'
: 'border-control-border hover:border-control-border-hover focus-within:border-2 focus-within:border-control-checked focus-within:px-[11px]',
disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50',
readOnly && 'border-transparent bg-control-bg-readonly',
)}
>
{!isStacked && (
<label
htmlFor={id}
className={cn(
'absolute left-2 top-0 z-10 -translate-y-1/2 bg-control-bg px-1 text-small font-bold leading-none',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'text-control-description',
)}
>
{label}
</label>
)}
{leftIcon && (
<span
className={cn('inline-flex shrink-0 items-center justify-center text-grey-01 [&>svg]:size-full', styles.icon)}
>
{leftIcon}
</span>
)}
<input
ref={ref}
id={id}
disabled={disabled}
readOnly={readOnly}
maxLength={maxLength}
value={value}
defaultValue={defaultValue}
aria-invalid={hasError || undefined}
aria-describedby={describedBy}
className={cn(
'min-w-0 flex-1 bg-transparent font-normal text-grey-01 outline-none',
'placeholder:text-grey-01/50',
styles.input,
disabled && 'text-grey-01/50',
)}
{...props}
/>
{rightIcon && (
<span
className={cn('inline-flex shrink-0 items-center justify-center text-grey-01 [&>svg]:size-full', styles.icon)}
>
{rightIcon}
</span>
)}
</div>
{(supportiveText || currentLength != null) && (
<div
id={hintId}
className={cn(
'flex items-center gap-1 text-small',
hasError ? 'text-control-error' : 'text-control-description',
disabled && 'opacity-50',
)}
>
{hasError && (
<svg
className="size-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
)}
{supportiveText && <p className="flex-1">{supportiveText}</p>}
{currentLength != null && (
<p className="shrink-0 text-right">
{currentLength}/{maxLength}
</p>
)}
</div>
)}
</div>
)
},
)
Input.displayName = 'Input'

View File

@@ -0,0 +1,2 @@
export { Input } from './Input'
export type { InputProps } from './Input'

View File

@@ -0,0 +1,138 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Radio, RadioGroup } from './Radio'
const meta: Meta<typeof RadioGroup> = {
title: 'UI/Radio',
component: RadioGroup,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5188',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
const ControlledExample = () => {
const [value, setValue] = useState('email')
return (
<RadioGroup label="Notification method" value={value} onChange={setValue}>
<Radio value="email" label="Email" />
<Radio value="sms" label="SMS" />
<Radio value="push" label="Push notification" />
</RadioGroup>
)
}
export const Default: Story = {
render: () => <ControlledExample />,
}
export const WithDescriptions: Story = {
render: () => {
const [value, setValue] = useState('standard')
return (
<RadioGroup
label="Export format"
description="Choose how your data will be exported."
value={value}
onChange={setValue}
>
<Radio
value="standard"
label="Standard CSV"
description="Comma-separated values, compatible with most tools."
/>
<Radio
value="excel"
label="Excel workbook"
description="Formatted spreadsheet with multiple sheets."
/>
<Radio
value="json"
label="JSON"
description="Machine-readable format for programmatic access."
/>
</RadioGroup>
)
},
}
export const Horizontal: Story = {
render: () => {
const [value, setValue] = useState('all')
return (
<RadioGroup
label="Filter by status"
orientation="horizontal"
value={value}
onChange={setValue}
>
<Radio value="all" label="All" />
<Radio value="active" label="Active" />
<Radio value="archived" label="Archived" />
</RadioGroup>
)
},
}
export const WithError: Story = {
render: () => (
<RadioGroup label="Participant type" error="Please select a participant type">
<Radio value="student" label="Student" />
<Radio value="teacher" label="Teacher" />
<Radio value="parent" label="Parent/carer" />
</RadioGroup>
),
}
export const Disabled: Story = {
render: () => (
<RadioGroup label="Plan" disabled defaultValue="free">
<Radio value="free" label="Free" />
<Radio value="pro" label="Professional" />
<Radio value="enterprise" label="Enterprise" />
</RadioGroup>
),
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-8">
<RadioGroup label="Default group" defaultValue="a">
<Radio value="a" label="Option A" />
<Radio value="b" label="Option B" />
<Radio value="c" label="Option C" />
</RadioGroup>
<RadioGroup
label="With descriptions"
description="Pick one of the following."
defaultValue="x"
>
<Radio value="x" label="Option X" description="Description for option X." />
<Radio value="y" label="Option Y" description="Description for option Y." />
</RadioGroup>
<RadioGroup label="With error" error="Selection required">
<Radio value="1" label="Choice 1" />
<Radio value="2" label="Choice 2" />
</RadioGroup>
<RadioGroup label="Disabled" disabled defaultValue="on">
<Radio value="on" label="Enabled" />
<Radio value="off" label="Disabled" />
</RadioGroup>
<RadioGroup label="Horizontal" orientation="horizontal" defaultValue="left">
<Radio value="left" label="Left" />
<Radio value="center" label="Centre" />
<Radio value="right" label="Right" />
</RadioGroup>
</div>
),
}

View File

@@ -0,0 +1,205 @@
import {
createContext,
forwardRef,
useContext,
useId,
type InputHTMLAttributes,
} from 'react'
import { cn } from '@/lib/utils'
interface RadioGroupContextValue {
name: string
value?: string
disabled?: boolean
hasError?: boolean
onChange?: (value: string) => void
}
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
export interface RadioGroupProps {
label?: string
description?: string
error?: string
value?: string
defaultValue?: string
disabled?: boolean
orientation?: 'vertical' | 'horizontal'
name?: string
onChange?: (value: string) => void
children: React.ReactNode
className?: string
}
export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
(
{
label,
description,
error,
value,
defaultValue,
disabled,
orientation = 'vertical',
name: nameProp,
onChange,
children,
className,
},
ref,
) => {
const autoId = useId()
const name = nameProp ?? autoId
const descriptionId = `${name}-description`
const errorId = `${name}-error`
const hasError = !!error
return (
<RadioGroupContext.Provider value={{ name, value: value ?? defaultValue, disabled, hasError, onChange }}>
<fieldset
ref={ref}
disabled={disabled}
className={cn('flex flex-col gap-1.5', className)}
aria-describedby={
[description ? descriptionId : undefined, hasError ? errorId : undefined]
.filter(Boolean)
.join(' ') || undefined
}
>
{(label || description) && (
<div className="mb-1 flex flex-col gap-0.5">
{label && (
<legend
className={cn(
'text-small font-bold',
hasError ? 'text-control-error' : 'text-control-label',
disabled && 'opacity-50',
)}
>
{label}
</legend>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
<div
className={cn(
'flex gap-3',
orientation === 'vertical' ? 'flex-col' : 'flex-row flex-wrap',
)}
role="radiogroup"
>
{children}
</div>
{hasError && (
<div id={errorId} className="flex items-center gap-1 text-small text-control-error">
<svg
className="size-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p>{error}</p>
</div>
)}
</fieldset>
</RadioGroupContext.Provider>
)
},
)
RadioGroup.displayName = 'RadioGroup'
export interface RadioProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
label?: string
description?: string
value: string
}
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
({ label, description, value, disabled: disabledProp, className, id: idProp, ...props }, ref) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
const group = useContext(RadioGroupContext)
const name = group?.name
const isChecked = group?.value != null ? group.value === value : undefined
const disabled = disabledProp ?? group?.disabled
const hasError = group?.hasError
const handleChange = () => {
group?.onChange?.(value)
}
return (
<div className={cn('flex gap-2', className)}>
<div className="flex h-6 items-center">
<input
ref={ref}
type="radio"
id={id}
name={name}
value={value}
checked={isChecked}
disabled={disabled}
onChange={handleChange}
aria-describedby={description ? descriptionId : undefined}
className={cn(
'peer size-5 cursor-pointer appearance-none rounded-full border-2 border-control-border bg-control-bg transition-colors',
'hover:border-control-border-hover',
'checked:border-[6px] checked:border-control-checked',
'checked:hover:border-control-checked-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-1',
'active:scale-95',
'disabled:pointer-events-none disabled:opacity-50',
hasError && 'border-control-error checked:border-control-error',
)}
{...props}
/>
</div>
{(label || description) && (
<div className="flex flex-col gap-0.5 pt-px">
{label && (
<label
htmlFor={id}
className={cn(
'cursor-pointer text-body font-normal text-grey-01',
disabled && 'pointer-events-none opacity-50',
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
</div>
)
},
)
Radio.displayName = 'Radio'

View File

@@ -0,0 +1,2 @@
export { Radio, RadioGroup } from './Radio'
export type { RadioProps, RadioGroupProps } from './Radio'

View File

@@ -0,0 +1,85 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Switch } from './Switch'
const meta: Meta<typeof Switch> = {
title: 'UI/Switch',
component: Switch,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
description: { control: 'text' },
checked: { control: 'boolean' },
disabled: { control: 'boolean' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=33-5337',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
const ControlledSwitch = (props: React.ComponentProps<typeof Switch>) => {
const [checked, setChecked] = useState(props.checked ?? false)
return <Switch {...props} checked={checked} onChange={setChecked} />
}
export const Default: Story = {
render: () => <ControlledSwitch label="Enable notifications" />,
}
export const On: Story = {
render: () => <ControlledSwitch label="Enable notifications" checked />,
}
export const WithDescription: Story = {
render: () => (
<ControlledSwitch
label="Auto-save responses"
description="Automatically save participant responses as they are entered."
checked
/>
),
}
export const Disabled: Story = {
render: () => (
<div className="flex flex-col gap-4">
<Switch label="Disabled off" disabled />
<Switch label="Disabled on" disabled checked />
</div>
),
}
export const Standalone: Story = {
render: () => (
<div className="flex items-center gap-4">
<span className="text-body text-grey-01">Dark mode</span>
<ControlledSwitch aria-label="Toggle dark mode" />
</div>
),
}
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-4">
<ControlledSwitch label="Off" />
<ControlledSwitch label="On" checked />
<ControlledSwitch
label="With description"
description="Additional context about this setting."
/>
<ControlledSwitch
label="On with description"
description="This feature is currently enabled."
checked
/>
<Switch label="Disabled off" disabled />
<Switch label="Disabled on" disabled checked />
</div>
),
}

View File

@@ -0,0 +1,89 @@
import { forwardRef, useId, type ButtonHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export interface SwitchProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'role'> {
label?: string
description?: string
checked?: boolean
disabled?: boolean
onChange?: (checked: boolean) => void
}
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
(
{
label,
description,
checked = false,
disabled,
onChange,
className,
id: idProp,
...props
},
ref,
) => {
const autoId = useId()
const id = idProp ?? autoId
const descriptionId = `${id}-description`
return (
<div className={cn('flex items-start gap-3', className)}>
<button
ref={ref}
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-describedby={description ? descriptionId : undefined}
disabled={disabled}
onClick={() => onChange?.(!checked)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150',
checked ? 'bg-control-checked' : 'bg-control-border',
!disabled && checked && 'hover:bg-control-checked-hover',
!disabled && !checked && 'hover:bg-control-border-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-2',
'active:scale-[0.97]',
'disabled:pointer-events-none disabled:opacity-50',
)}
{...props}
>
<span
className={cn(
'pointer-events-none inline-block size-[18px] rounded-full bg-white shadow-default transition-transform duration-150',
checked ? 'translate-x-[22px]' : 'translate-x-[3px]',
)}
/>
</button>
{(label || description) && (
<div className="flex flex-col gap-0.5 pt-px">
{label && (
<label
htmlFor={id}
className={cn(
'cursor-pointer text-body font-normal text-grey-01',
disabled && 'pointer-events-none opacity-50',
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={cn('text-small text-text', disabled && 'opacity-50')}
>
{description}
</p>
)}
</div>
)}
</div>
)
},
)
Switch.displayName = 'Switch'

View File

@@ -0,0 +1,2 @@
export { Switch } from './Switch'
export type { SwitchProps } from './Switch'

View File

@@ -1,13 +1,12 @@
@import "@fontsource-variable/public-sans";
@import "@fontsource-variable/public-sans/wght-italic.css";
@import "tailwindcss"; @import "tailwindcss";
@import "../tokens/tokens.css"; @import "../tokens/tokens.css";
@import "./typography.css";
@layer base { @layer base {
html {
font-size: 15px;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-family: var(--font-sans);
line-height: 1.5; line-height: 1.5;
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);

41
src/styles/typography.css Normal file
View File

@@ -0,0 +1,41 @@
@utility text-body-strong {
font-size: var(--text-body);
line-height: var(--text-body--line-height);
font-weight: 700;
}
@utility text-small-strong {
font-size: var(--text-small);
line-height: var(--text-small--line-height);
font-weight: 700;
}
@utility text-label {
font-size: var(--text-h6);
line-height: var(--text-h6--line-height);
font-weight: 500;
}
@utility text-overline {
font-size: var(--text-caption);
line-height: var(--text-caption--line-height);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
@utility text-body-link {
font-size: var(--text-body);
line-height: var(--text-body--line-height);
font-weight: 700;
text-decoration-line: underline;
color: var(--color-blue-02);
}
@utility text-small-link {
font-size: var(--text-small);
line-height: var(--text-small--line-height);
font-weight: 700;
text-decoration-line: underline;
color: var(--color-blue-02);
}

View File

@@ -1,29 +1,86 @@
@theme { @theme {
/* Surface colors */ /* Typography - Font Family */
--color-bg: #f5f6f8; --font-sans: 'Public Sans Variable', system-ui, sans-serif;
--color-surface: #ffffff;
--color-border: #e2e5ea;
/* Text colors */ /* Typography - Font Sizes & Line Heights */
--color-text: #1a1d23; --text-h1: 48px;
--color-text-secondary: #5f6672; --text-h1--line-height: 1.25;
--text-h2: 32px;
--text-h2--line-height: 1.25;
--text-h3: 24px;
--text-h3--line-height: 1.333;
--text-h4: 20px;
--text-h4--line-height: 1.4;
--text-h5: 16px;
--text-h5--line-height: 1.5;
--text-h6: 14px;
--text-h6--line-height: 1.43;
--text-intro: 20px;
--text-intro--line-height: 1.4;
--text-body: 16px;
--text-body--line-height: 1.5;
--text-small: 14px;
--text-small--line-height: 1.357;
--text-caption: 12px;
--text-caption--line-height: 1.5;
/* Primary */ /* Blues */
--color-primary: #2563eb; --color-blue-01: #002664;
--color-primary-hover: #1d4ed8; --color-blue-02: #146CFD;
--color-primary-light: #eff4ff; --color-blue-03: #69B3E7;
--color-blue-04: #CBEDFD;
--color-blue-05: #EBF5FF; /* extrapolated: ultra-light background */
/* Semantic: Success */ /* Reds */
--color-success: #16a34a; --color-red-01: #3E0014;
--color-success-bg: #dcfce7; --color-red-02: #D7153A;
--color-red-03: #F5C5D0;
--color-red-04: #FDDDE5;
--color-red-05: #FFF5F8; /* extrapolated: ultra-light background */
/* Semantic: Warning */ /* Oranges */
--color-warning: #d97706; --color-orange-01: #7A3300; /* extrapolated: dark */
--color-warning-bg: #fef9c3; --color-orange-02: #EC6608;
--color-orange-03: #F5B98A;
--color-orange-04: #FEF0E4; /* extrapolated: light background */
/* Semantic: Error */ /* Greens */
--color-error: #dc2626; --color-green-01: #005C35; /* extrapolated: dark */
--color-error-bg: #fee2e2; --color-green-02: #00A651;
--color-green-03: #89E5B3;
--color-green-04: #E0F8EA; /* extrapolated: light background */
/* Greys */
--color-grey-01: #22272B;
--color-grey-02: #6D7278;
--color-grey-03: #C0C0C0;
--color-grey-04: #E0E0E0;
--color-off-white: #F4F4F4;
--color-white: #FFFFFF;
/* Semantic Aliases */
--color-primary: var(--color-blue-02);
--color-primary-dark: var(--color-blue-01);
--color-error: var(--color-red-02);
--color-success: var(--color-green-02);
--color-warning: var(--color-orange-02);
--color-text: var(--color-grey-01);
--color-text-secondary: var(--color-grey-02);
--color-border: var(--color-grey-04);
--color-bg: var(--color-off-white);
--color-surface: var(--color-white);
/* Form Controls */
--color-control-border: var(--color-grey-03);
--color-control-border-hover: var(--color-grey-01);
--color-control-checked: var(--color-blue-01);
--color-control-checked-hover: var(--color-blue-02);
--color-control-focus-ring: var(--color-blue-04);
--color-control-label: var(--color-blue-01);
--color-control-description: var(--color-grey-02);
--color-control-error: var(--color-red-02);
--color-control-bg: var(--color-white);
--color-control-bg-readonly: var(--color-off-white);
/* Radius */ /* Radius */
--radius-sm: 4px; --radius-sm: 4px;