Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[mcp_servers.storybook]
url = "http://localhost:6006/mcp"
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ jobs:
"@types/react@$REACT_MAJOR" \
"@types/react-dom@$REACT_MAJOR"
- run: bun run typecheck
- name: Install Chromium for Storybook tests
run: bunx playwright install --with-deps chromium
- run: bun run test:coverage
- name: Enforce 100% diff coverage
if: matrix.react == '19' && github.event_name == 'pull_request'
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules/
.workflow-data/
dist/
coverage/
storybook-static/
.env
.env.local
.env.docker.local
Expand All @@ -22,3 +23,5 @@ supabase/.branches/_current_branch

# Dependency links used by isolated local worktrees
/node_modules

*storybook.log
2 changes: 2 additions & 0 deletions .storybook/fonts.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* Same linked font stylesheet as apps/dashboard/index.html. */
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
44 changes: 44 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { StorybookConfig } from '@storybook/react-vite'
import { fileURLToPath } from 'node:url'

const config: StorybookConfig = {
stories: [
'../stories/**/*.mdx',
'../stories/**/*.stories.@(ts|tsx)',
{ directory: '../apps/dashboard', files: '**/*.stories.tsx', titlePrefix: 'Dashboard' },
],
staticDirs: ['../public'],
addons: [
'@chromatic-com/storybook',
'@storybook/addon-vitest',
'@storybook/addon-a11y',
'@storybook/addon-docs',
'@storybook/addon-themes',
'@storybook/addon-mcp',
'msw-storybook-addon',
],
framework: {
name: '@storybook/react-vite',
options: {
builder: {
// Keep the widget library build and application environment out of Storybook.
viteConfigPath: fileURLToPath(new URL('./vite.config.ts', import.meta.url)),
},
},
},
core: { disableTelemetry: true },
typescript: {
// Union props (e.g. variant) and JSDoc come through only with the TypeScript docgen.
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
// The root tsconfig only covers the widget; docgen needs a project that includes apps/dashboard.
tsconfigPath: fileURLToPath(new URL('../tsconfig.check.json', import.meta.url)),
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
// Document the component's own props, not every inherited HTML attribute.
propFilter: (prop) => !prop.parent?.fileName.includes('node_modules'),
},
},
}

export default config
39 changes: 39 additions & 0 deletions .storybook/msw-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { http, HttpResponse } from 'msw'
import type { BillingSummary, Project } from '../apps/dashboard/api'

// Reserved test domain keeps stories separate from the deployed API.
export const apiBase = 'https://storybook.crrt.test/api'
export const project: Project = {
publicKey: 'storybook-project',
slug: 'storybook-project',
name: 'Storybook project',
allowedOrigins: [],
createdAt: '2026-10-01T12:00:00Z',
updatedAt: '2026-10-01T12:00:00Z',
role: 'owner',
widgetPrivate: false,
feedbackAccess: 'team',
}

export const billingSummary: BillingSummary = {
enabled: true,
testMode: true,
plan: 'pro',
lifecycle: 'active',
currentPeriodEnd: '2026-11-01T12:00:00Z',
allowed: { manageBilling: true },
usage: {
projects: { used: 2, limit: 5, resetAt: null },
internalUsers: { used: 3, limit: 5, resetAt: null },
comments: { used: 42, limit: 1000, resetAt: null },
productAudits: { used: 1, limit: 10, resetAt: null },
},
}

export const mswHandlers = [
http.get(`${apiBase}/v1/billing`, () => HttpResponse.json(billingSummary)),
http.patch(`${apiBase}/v1/projects/${project.publicKey}`, async ({ request }) => {
const settings = await request.json() as Pick<Project, 'widgetPrivate' | 'feedbackAccess'>
return HttpResponse.json({ ...project, ...settings })
}),
]
49 changes: 49 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Preview } from '@storybook/react-vite'
import { withThemeByDataAttribute } from '@storybook/addon-themes'
import { mswLoader } from 'msw-storybook-addon/csf3'
import { mswHandlers } from './msw-handlers'
// Same style stack as apps/dashboard/main.tsx: tokens first, then dashboard overrides and fonts.
import '../branding/crrt/tokens.css'
import '../apps/dashboard/globals.css'
import '@fontsource-variable/figtree/wght.css'
import '@fontsource/vt323/latin.css'
import './fonts.css'

const preview: Preview = {
tags: ['autodocs'],
loaders: [mswLoader()],
async beforeEach({ msw }) {
msw.use(...mswHandlers)
},
parameters: {
layout: 'centered',
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},

a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
},
decorators: [
// Paint the themed surface so docs previews show real light/dark contrast.
(Story) => (
<div className="bg-background p-6 font-sans text-foreground">
<Story />
</div>
),
withThemeByDataAttribute({
themes: { dark: 'dark', light: 'light' },
defaultTheme: 'dark',
attributeName: 'data-theme',
}),
],
}

export default preview
7 changes: 7 additions & 0 deletions .storybook/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.check.json",
"compilerOptions": {
"types": ["node", "vite/client"]
},
"include": ["./*.ts", "./*.tsx", "../stories/**/*.ts", "../stories/**/*.tsx", "../apps/dashboard/**/*.stories.tsx"]
}
8 changes: 8 additions & 0 deletions .storybook/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
plugins: [tailwindcss()],
// Stories should opt into fixtures instead of inheriting local API credentials.
envDir: false,
})
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,13 @@ Run the reviewer dashboard:
bun run dev:dashboard
```

### Storybook

Run `bun run storybook` to open the component development framework at
`http://127.0.0.1:6006`. The initial shell has no component stories; see
[the Storybook guide](stories/README.md) for configuration and adding them.
Use `bun run build-storybook` for a static build.

### Fully local development with Docker

Docker Desktop and the Supabase CLI can run Postgres, Auth, Storage, Realtime,
Expand Down
29 changes: 29 additions & 0 deletions apps/dashboard/components/AgentLauncher.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, fn } from 'storybook/test'
import { AgentLauncher } from './AgentLauncher'

const meta = {
component: AgentLauncher,
tags: ['ai-generated'],
args: { readyCount: 3, open: false, onOpen: fn() },
} satisfies Meta<typeof AgentLauncher>

export default meta
type Story = StoryObj<typeof meta>

export const Ready: Story = {
play: async ({ canvas, userEvent, args }) => {
const button = canvas.getByRole('button', { name: 'Agents, 3 ready' })
await expect(button).toHaveAttribute('aria-expanded', 'false')
await userEvent.click(button)
await expect(args.onOpen).toHaveBeenCalledOnce()
},
}
export const Empty: Story = { args: { readyCount: 0 } }
export const Open: Story = { args: { open: true } }
export const Unavailable: Story = {
args: { disabled: true, onOpen: undefined, unavailableReason: 'Select a project to use agents.' },
play: async ({ canvas }) => {
await expect(canvas.getByRole('button', { name: /Agents unavailable/ })).toBeDisabled()
},
}
38 changes: 38 additions & 0 deletions apps/dashboard/components/BillingPage.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect } from 'storybook/test'
import { http, HttpResponse } from 'msw'
import { apiBase } from '../../../.storybook/msw-handlers'
import { BillingPage } from './BillingPage'

const meta = {
component: BillingPage,
tags: ['ai-generated'],
parameters: { layout: 'fullscreen' },
args: { apiBase, accessToken: 'storybook-fixture', email: 'designer@example.test' },
} satisfies Meta<typeof BillingPage>

export default meta
type Story = StoryObj<typeof meta>

export const Active: Story = {
play: async ({ canvas }) => {
await expect(await canvas.findByRole('heading', { name: 'Cloud active' })).toBeVisible()
await expect(canvas.getByRole('progressbar', { name: 'Projects usage' })).toHaveAttribute('aria-valuenow', '2')
},
}
export const SelfHosted: Story = {
beforeEach: async ({ msw }) => {
msw.use(http.get(`${apiBase}/v1/billing`, () => HttpResponse.json({ enabled: false })))
},
play: async ({ canvas }) => {
await expect(await canvas.findByText(/Self-hosted CRRT remains free/)).toBeVisible()
},
}
export const Unavailable: Story = {
beforeEach: async ({ msw }) => {
msw.use(http.get(`${apiBase}/v1/billing`, () => HttpResponse.json({ error: 'Unavailable' }, { status: 503 })))
},
play: async ({ canvas }) => {
await expect(await canvas.findByRole('alert')).toHaveTextContent('Billing is temporarily unavailable.')
},
}
42 changes: 42 additions & 0 deletions apps/dashboard/components/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, fn, waitFor } from 'storybook/test'
import { Button } from './Button'
import { route } from '../lib/routes'

const meta = {
component: Button,
tags: ['ai-generated'],
args: { children: 'Create project', onClick: fn() },
} satisfies Meta<typeof Button>

export default meta
type Story = StoryObj<typeof meta>

export const Primary: Story = {
play: async ({ canvas, userEvent, args }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Create project' }))
await expect(args.onClick).toHaveBeenCalledOnce()
},
}

export const Secondary: Story = { args: { variant: 'secondary', children: 'Cancel' } }

export const DisabledLink: Story = {
args: { variant: 'link', href: route('/billing'), disabled: true, children: 'Billing' },
play: async ({ canvas }) => {
const link = canvas.getByRole('link', { name: 'Billing' })
await expect(link).toHaveAttribute('aria-disabled', 'true')
await expect(link).not.toHaveAttribute('href')
},
}

export const CssCheck: Story = {
globals: { theme: 'light' },
play: async ({ canvas }) => {
const button = canvas.getByRole('button', { name: 'Create project' })
// bg-primary resolves to --crrt-dashboard-orange (#c24c12) in light mode.
await waitFor(() => expect(getComputedStyle(button).backgroundColor).toBe('rgb(194, 76, 18)'))
// size=sm uses h-7 (28px), proving Tailwind utilities were generated too.
await expect(getComputedStyle(button).height).toBe('28px')
},
}
Loading
Loading