Skip to content
Merged
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
57 changes: 57 additions & 0 deletions .github/workflows/storybook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: storybook
on:
pull_request:
paths:
- "packages/ui/**"
- ".github/workflows/storybook.yml"
workflow_dispatch:

concurrency:
group: storybook-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm -F @lockin/events gen:json-schema
- run: pnpm -F @lockin/ui build-storybook

# Deploy step is gated on the Storybook Vercel project being
# provisioned. Until VERCEL_STORYBOOK_PROJECT_ID exists as a repo
# secret, the deploy + comment steps skip and the job still passes
# on the build alone.
- name: Deploy preview to Vercel
id: deploy
if: ${{ secrets.VERCEL_STORYBOOK_PROJECT_ID != '' }}
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_STORYBOOK_PROJECT_ID }}
working-directory: packages/ui/storybook-static

- name: Comment preview URL on the PR
if: ${{ steps.deploy.outputs.preview-url != '' }}
uses: actions/github-script@v7
with:
script: |
const url = "${{ steps.deploy.outputs.preview-url }}";
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `📚 Storybook preview: ${url}`,
});
19 changes: 19 additions & 0 deletions packages/ui/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
// Storybook 9+ folds the former addon-essentials (controls, actions,
// viewport, backgrounds, docs) into core — no separate addon install.
stories: ["../src/**/*.stories.@(ts|tsx)"],
framework: { name: "@storybook/react-vite", options: {} },
typescript: { reactDocgen: "react-docgen-typescript" },
async viteFinal(viteConfig) {
// Tailwind v4 is a Vite plugin — Storybook doesn't pick it up from
// postcss.config like the Next app does, so we register it here.
const { default: tailwindcss } = await import("@tailwindcss/vite");
viteConfig.plugins = viteConfig.plugins ?? [];
viteConfig.plugins.push(tailwindcss());
return viteConfig;
},
};

export default config;
5 changes: 5 additions & 0 deletions packages/ui/.storybook/preview.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import "tailwindcss";
@import "../src/tokens.css";

/* Tell Tailwind v4 where to scan for class usage (components + stories). */
@source "../src/**/*.{ts,tsx}";
20 changes: 20 additions & 0 deletions packages/ui/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Preview } from "@storybook/react-vite";
import "./preview.css";

const preview: Preview = {
parameters: {
controls: {
matchers: { color: /(background|color)$/i, date: /Date$/i },
},
backgrounds: {
default: "surface",
values: [
{ name: "surface", value: "oklch(0.99 0 0)" },
{ name: "muted", value: "oklch(0.96 0 0)" },
{ name: "dark", value: "oklch(0.16 0 0)" },
],
},
},
};

export default preview;
7 changes: 6 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
"build": "tsc -p tsconfig.json",
"lint": "echo '(no lint configured yet)'",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
"test": "vitest run",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build --output-dir storybook-static"
},
"peerDependencies": {
"react": "^19.0.0",
Expand All @@ -28,6 +30,8 @@
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@storybook/react-vite": "^10.4.0",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/react": "^19",
Expand All @@ -36,6 +40,7 @@
"jsdom": "^25.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"storybook": "^10.4.0",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
Expand Down
38 changes: 38 additions & 0 deletions packages/ui/src/components/Button.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 { Button } from "./Button.js";

const meta = {
title: "Components/Button",
component: Button,
args: { children: "Press me" },
argTypes: {
variant: { control: "radio", options: ["primary", "secondary", "ghost", "danger"] },
size: { control: "radio", options: ["sm", "md", "lg"] },
disabled: { control: "boolean" },
},
} satisfies Meta<typeof Button>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Primary: Story = { args: { variant: "primary" } };
export const Secondary: Story = { args: { variant: "secondary" } };
export const Ghost: Story = { args: { variant: "ghost" } };
export const Danger: Story = { args: { variant: "danger" } };
export const Disabled: Story = { args: { disabled: true } };

export const Sizes: Story = {
render: (args) => (
<div className="flex items-center gap-3">
<Button {...args} size="sm">
Small
</Button>
<Button {...args} size="md">
Medium
</Button>
<Button {...args} size="lg">
Large
</Button>
</div>
),
};
21 changes: 21 additions & 0 deletions packages/ui/src/components/Card.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Card } from "./Card.js";

const meta = {
title: "Components/Card",
component: Card,
} satisfies Meta<typeof Card>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: () => (
<Card className="max-w-sm">
<h3 className="text-lg font-semibold text-text">Card title</h3>
<p className="text-text-muted">
Cards group related content with a border, padding, and a subtle shadow.
</p>
</Card>
),
};
42 changes: 42 additions & 0 deletions packages/ui/src/components/Icon.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 { Icon } from "./Icon.js";

// A plus glyph — enough to exercise sizing and the a11y label paths.
const PlusPaths = (
<>
<path d="M12 5v14" />
<path d="M5 12h14" />
</>
);

const meta = {
title: "Components/Icon",
component: Icon,
argTypes: {
size: { control: "radio", options: [16, 20, 24, 32] },
label: { control: "text" },
},
} satisfies Meta<typeof Icon>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Decorative: Story = {
args: { children: PlusPaths },
};

export const Labeled: Story = {
args: { label: "Add task", children: PlusPaths },
};

export const Sizes: Story = {
render: () => (
<div className="flex items-center gap-4">
{([16, 20, 24, 32] as const).map((size) => (
<Icon key={size} size={size} label={`size ${size}`}>
{PlusPaths}
</Icon>
))}
</div>
),
};
19 changes: 19 additions & 0 deletions packages/ui/src/components/Input.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Input } from "./Input.js";

const meta = {
title: "Components/Input",
component: Input,
args: { placeholder: "Type a task title…" },
argTypes: {
invalid: { control: "boolean" },
disabled: { control: "boolean" },
},
} satisfies Meta<typeof Input>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};
export const Invalid: Story = { args: { invalid: true, defaultValue: "bad value" } };
export const Disabled: Story = { args: { disabled: true, defaultValue: "locked" } };
32 changes: 32 additions & 0 deletions packages/ui/src/components/Stack.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack } from "./Stack.js";

const swatch = (label: string) => (
<div key={label} className="rounded-md bg-brand-500 px-4 py-2 text-white">
{label}
</div>
);

const meta = {
title: "Components/Stack",
component: Stack,
argTypes: {
direction: { control: "radio", options: ["row", "col"] },
gap: { control: "select", options: [1, 2, 3, 4, 6, 8, 12] },
align: { control: "radio", options: ["start", "center", "end", "stretch"] },
justify: { control: "radio", options: ["start", "center", "end", "between"] },
},
} satisfies Meta<typeof Stack>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Column: Story = {
args: { direction: "col", gap: 4 },
render: (args) => <Stack {...args}>{["One", "Two", "Three"].map(swatch)}</Stack>,
};

export const Row: Story = {
args: { direction: "row", gap: 3 },
render: (args) => <Stack {...args}>{["One", "Two", "Three"].map(swatch)}</Stack>,
};
31 changes: 31 additions & 0 deletions packages/ui/src/components/Text.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Text } from "./Text.js";

const meta = {
title: "Components/Text",
component: Text,
args: { children: "The quick brown fox" },
argTypes: {
size: { control: "select", options: ["xs", "sm", "base", "lg", "xl", "2xl", "3xl"] },
tone: { control: "radio", options: ["default", "muted", "danger", "success"] },
weight: { control: "radio", options: ["normal", "medium", "semibold", "bold"] },
},
} satisfies Meta<typeof Text>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};
export const Muted: Story = { args: { tone: "muted" } };

export const Scale: Story = {
render: () => (
<div className="flex flex-col gap-1">
{(["xs", "sm", "base", "lg", "xl", "2xl", "3xl"] as const).map((size) => (
<Text key={size} size={size}>
{size} — The quick brown fox
</Text>
))}
</div>
),
};
Loading
Loading