diff --git a/.gitignore b/.gitignore
index 6b6bff22..719583e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,3 +59,6 @@ __pycache__/
resources/prebuilt/bin/
resources/prebuilt/venv/
resources/prebuilt/cache/
+
+*storybook.log
+storybook-static
diff --git a/.storybook/main.ts b/.storybook/main.ts
new file mode 100644
index 00000000..301640e5
--- /dev/null
+++ b/.storybook/main.ts
@@ -0,0 +1,13 @@
+import type { StorybookConfig } from '@storybook/react-vite'
+
+const config: StorybookConfig = {
+ stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
+ addons: ['@storybook/addon-docs', '@storybook/addon-a11y'],
+ framework: '@storybook/react-vite',
+ viteFinal: async (config) => {
+ // Reuse project's vite config for path aliases
+ return config
+ },
+}
+
+export default config
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
new file mode 100644
index 00000000..4142d6db
--- /dev/null
+++ b/.storybook/preview.tsx
@@ -0,0 +1,47 @@
+import type { Preview } from '@storybook/react-vite'
+import React from 'react'
+import '@fontsource/inter/400.css'
+import '@fontsource/inter/500.css'
+import '@fontsource/inter/600.css'
+import '@fontsource/inter/700.css'
+import '@fontsource/inter/800.css'
+import '../src/style/index.css'
+import './storybook.css' // Storybook-specific overrides
+import { Toaster } from 'sonner'
+
+// Apply theme immediately via script
+if (typeof document !== 'undefined') {
+ document.documentElement.setAttribute('data-theme', 'light')
+ document.documentElement.classList.add('root')
+}
+
+const preview: Preview = {
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ controls: {
+ expanded: true,
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ backgrounds: {
+ default: 'light',
+ values: [
+ { name: 'light', value: '#f5f5f5' },
+ { name: 'dark', value: '#1d1c1b' },
+ ],
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+
+ ),
+ ],
+}
+
+export default preview
diff --git a/.storybook/storybook.css b/.storybook/storybook.css
new file mode 100644
index 00000000..6e0e8f28
--- /dev/null
+++ b/.storybook/storybook.css
@@ -0,0 +1 @@
+/* Storybook-specific CSS overrides - currently empty as component handles styling */
diff --git a/package.json b/package.json
index 9cdd6453..dd7170c4 100644
--- a/package.json
+++ b/package.json
@@ -31,7 +31,9 @@
"test:watch": "vitest",
"test:e2e": "vitest run --config vitest.config.ts",
"test:coverage": "vitest run --coverage",
- "type-check": "tsc --noEmit"
+ "type-check": "tsc --noEmit",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build -o storybook-static"
},
"dependencies": {
"@electron/notarize": "^2.5.0",
@@ -131,7 +133,11 @@
"vite": "^5.4.11",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6",
- "vitest": "^2.1.5"
+ "vitest": "^2.1.5",
+ "storybook": "^10.1.11",
+ "@storybook/react-vite": "^10.1.11",
+ "@storybook/addon-a11y": "^10.1.11",
+ "@storybook/addon-docs": "^10.1.11"
},
"overrides": {
"glob": "^10.4.5"
diff --git a/src/components/ui/button.stories.tsx b/src/components/ui/button.stories.tsx
new file mode 100644
index 00000000..0ae56ba1
--- /dev/null
+++ b/src/components/ui/button.stories.tsx
@@ -0,0 +1,223 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { Button } from './button'
+import { Plus, Download, Trash2 } from 'lucide-react'
+import { expect, fn, userEvent, within } from 'storybook/test'
+
+const meta: Meta = {
+ title: 'UI/Button',
+ component: Button,
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: [
+ 'primary',
+ 'secondary',
+ 'outline',
+ 'ghost',
+ 'success',
+ 'cuation',
+ 'information',
+ 'warning',
+ ],
+ },
+ size: {
+ control: 'select',
+ options: ['xxs', 'xs', 'sm', 'md', 'lg', 'icon'],
+ },
+ disabled: {
+ control: 'boolean',
+ },
+ asChild: {
+ control: 'boolean',
+ },
+ children: {
+ control: 'text',
+ },
+ },
+ args: {
+ children: 'Button',
+ variant: 'primary',
+ size: 'md',
+ },
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Primary: Story = {
+ args: {
+ variant: 'primary',
+ children: 'Primary Button',
+ },
+}
+
+export const Secondary: Story = {
+ args: {
+ variant: 'secondary',
+ children: 'Secondary Button',
+ },
+}
+
+export const Outline: Story = {
+ args: {
+ variant: 'outline',
+ children: 'Outline Button',
+ },
+}
+
+export const Ghost: Story = {
+ args: {
+ variant: 'ghost',
+ children: 'Ghost Button',
+ },
+}
+
+export const Success: Story = {
+ args: {
+ variant: 'success',
+ children: 'Success Button',
+ },
+}
+
+export const Warning: Story = {
+ args: {
+ variant: 'warning',
+ children: 'Warning Button',
+ },
+}
+
+export const Disabled: Story = {
+ args: {
+ variant: 'primary',
+ children: 'Disabled Button',
+ disabled: true,
+ },
+}
+
+export const WithIcon: Story = {
+ render: (args) => (
+
+ ),
+ args: {
+ variant: 'primary',
+ },
+}
+
+export const IconOnly: Story = {
+ render: (args) => (
+
+ ),
+ args: {
+ variant: 'ghost',
+ size: 'icon',
+ },
+}
+
+export const AllVariants: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+ ),
+}
+
+export const AllSizes: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+ ),
+}
+
+// Interaction test stories
+export const ClickInteraction: Story = {
+ args: {
+ variant: 'primary',
+ children: 'Click Me',
+ onClick: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ const button = canvas.getByRole('button', { name: /click me/i })
+
+ // Test that button is visible and enabled
+ await expect(button).toBeVisible()
+ await expect(button).toBeEnabled()
+
+ // Click the button
+ await userEvent.click(button)
+
+ // Verify the onClick handler was called
+ await expect(args.onClick).toHaveBeenCalledTimes(1)
+ },
+}
+
+export const DisabledInteraction: Story = {
+ args: {
+ variant: 'primary',
+ children: 'Disabled Button',
+ disabled: true,
+ onClick: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ const button = canvas.getByRole('button', { name: /disabled button/i })
+
+ // Test that button is visible but disabled
+ await expect(button).toBeVisible()
+ await expect(button).toBeDisabled()
+
+ // Verify the onClick handler was NOT called (disabled buttons block pointer events)
+ await expect(args.onClick).not.toHaveBeenCalled()
+ },
+}
+
+export const HoverInteraction: Story = {
+ args: {
+ variant: 'outline',
+ children: 'Hover Over Me',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ const button = canvas.getByRole('button', { name: /hover over me/i })
+
+ // Test initial state
+ await expect(button).toBeVisible()
+
+ // Hover over the button
+ await userEvent.hover(button)
+
+ // The button should still be visible after hover
+ await expect(button).toBeVisible()
+
+ // Unhover
+ await userEvent.unhover(button)
+ },
+}
diff --git a/src/components/ui/dialog.stories.tsx b/src/components/ui/dialog.stories.tsx
new file mode 100644
index 00000000..0a151d4e
--- /dev/null
+++ b/src/components/ui/dialog.stories.tsx
@@ -0,0 +1,512 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { useState } from 'react'
+import {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogContentSection,
+ DialogFooter,
+} from './dialog'
+import { Button } from './button'
+import { Input } from './input'
+import { expect, userEvent, within } from 'storybook/test'
+
+const meta: Meta = {
+ title: 'UI/Dialog',
+ component: Dialog,
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {
+ render: function DefaultDialog() {
+ const [open, setOpen] = useState(false)
+ return (
+
+ )
+ },
+}
+
+export const SmallSize: Story = {
+ render: function SmallDialog() {
+ const [open, setOpen] = useState(false)
+ return (
+
+ )
+ },
+}
+
+export const LargeSize: Story = {
+ render: function LargeDialog() {
+ const [open, setOpen] = useState(false)
+ return (
+
+ )
+ },
+}
+
+export const WithForm: Story = {
+ render: function FormDialog() {
+ const [open, setOpen] = useState(false)
+ return (
+
+ )
+ },
+}
+
+export const WithTooltip: Story = {
+ render: function TooltipDialog() {
+ const [open, setOpen] = useState(false)
+ return (
+
+ )
+ },
+}
+
+export const WithBackButton: Story = {
+ render: function BackButtonDialog() {
+ const [open, setOpen] = useState(false)
+ return (
+
+ )
+ },
+}
+
+export const DestructiveAction: Story = {
+ render: function DestructiveDialog() {
+ const [open, setOpen] = useState(false)
+ return (
+
+ )
+ },
+}
+
+export const NoCloseButton: Story = {
+ render: function NoCloseDialog() {
+ const [open, setOpen] = useState(false)
+ return (
+
+ )
+ },
+}
+
+export const AllSizes: Story = {
+ render: function AllSizesDialog() {
+ const [openSm, setOpenSm] = useState(false)
+ const [openMd, setOpenMd] = useState(false)
+ const [openLg, setOpenLg] = useState(false)
+ return (
+
+
+
+
+
+
+
+ )
+ },
+}
+
+// Interaction test stories
+export const OpenCloseInteraction: Story = {
+ render: function OpenCloseDialog() {
+ const [open, setOpen] = useState(false)
+ return (
+
+ )
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ const body = within(document.body)
+
+ // Open the dialog
+ await userEvent.click(canvas.getByRole('button', { name: /open dialog/i }))
+
+ // Verify dialog is visible (renders in portal)
+ await expect(await body.findByText('Interactive Dialog')).toBeVisible()
+
+ // Close the dialog
+ await userEvent.click(body.getByRole('button', { name: /cancel/i }))
+
+ // Verify dialog is closed
+ await expect(body.queryByText('Interactive Dialog')).not.toBeInTheDocument()
+ },
+}
+
+export const FormInteraction: Story = {
+ render: function FormInteractionDialog() {
+ const [open, setOpen] = useState(false)
+ const [submitted, setSubmitted] = useState(false)
+ return (
+
+ )
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ const body = within(document.body)
+
+ // Open the dialog
+ await userEvent.click(canvas.getByRole('button', { name: /open form/i }))
+
+ // Wait for dialog to appear (renders in portal)
+ await expect(await body.findByText('Sign Up')).toBeVisible()
+
+ // Fill in the form
+ const nameInput = body.getByPlaceholderText('Enter your name')
+ const emailInput = body.getByPlaceholderText('Enter your email')
+
+ await userEvent.type(nameInput, 'John Doe')
+ await userEvent.type(emailInput, 'john@example.com')
+
+ // Verify inputs have values
+ await expect(nameInput).toHaveValue('John Doe')
+ await expect(emailInput).toHaveValue('john@example.com')
+
+ // Submit the form
+ await userEvent.click(body.getByRole('button', { name: /sign up/i }))
+
+ // Verify success message appears
+ await expect(await canvas.findByTestId('success-message')).toBeVisible()
+ },
+}
+
+export const ConfirmDialogInteraction: Story = {
+ render: function ConfirmInteractionDialog() {
+ const [open, setOpen] = useState(false)
+ const [confirmed, setConfirmed] = useState(false)
+ return (
+
+
+ {confirmed &&
Item deleted!
}
+
+ )
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ const body = within(document.body)
+
+ // Open the confirmation dialog
+ await userEvent.click(canvas.getByRole('button', { name: /delete item/i }))
+
+ // Verify dialog is visible (renders in portal)
+ await expect(await body.findByText('Confirm Delete')).toBeVisible()
+
+ // Click the confirm/delete button
+ await userEvent.click(body.getByRole('button', { name: /^delete$/i }))
+
+ // Verify the deleted message appears
+ const deletedMessage = await canvas.findByTestId('deleted-message')
+ await expect(deletedMessage).toHaveTextContent('Item deleted!')
+ },
+}
diff --git a/src/components/ui/input.stories.tsx b/src/components/ui/input.stories.tsx
new file mode 100644
index 00000000..047affa4
--- /dev/null
+++ b/src/components/ui/input.stories.tsx
@@ -0,0 +1,291 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { Input } from './input'
+import { Search, Eye, EyeOff } from 'lucide-react'
+import { useState } from 'react'
+import { expect, fn, userEvent, within } from 'storybook/test'
+
+const meta: Meta = {
+ title: 'UI/Input',
+ component: Input,
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['default', 'sm'],
+ },
+ state: {
+ control: 'select',
+ options: ['default', 'hover', 'input', 'error', 'success', 'disabled'],
+ },
+ disabled: {
+ control: 'boolean',
+ },
+ required: {
+ control: 'boolean',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ placeholder: 'Enter text...',
+ },
+}
+
+export const WithTitle: Story = {
+ args: {
+ title: 'Email Address',
+ placeholder: 'name@example.com',
+ type: 'email',
+ },
+}
+
+export const Required: Story = {
+ args: {
+ title: 'Username',
+ placeholder: 'Enter username',
+ required: true,
+ },
+}
+
+export const WithTooltip: Story = {
+ args: {
+ title: 'API Key',
+ placeholder: 'Enter your API key',
+ tooltip: 'Your API key can be found in your account settings',
+ },
+}
+
+export const WithNote: Story = {
+ args: {
+ title: 'Password',
+ type: 'password',
+ placeholder: 'Enter password',
+ note: 'Must be at least 8 characters',
+ },
+}
+
+export const ErrorState: Story = {
+ args: {
+ title: 'Email',
+ placeholder: 'name@example.com',
+ state: 'error',
+ note: 'Please enter a valid email address',
+ defaultValue: 'invalid-email',
+ },
+}
+
+export const SuccessState: Story = {
+ args: {
+ title: 'Username',
+ placeholder: 'Enter username',
+ state: 'success',
+ note: 'Username is available',
+ defaultValue: 'johndoe',
+ },
+}
+
+export const Disabled: Story = {
+ args: {
+ title: 'Locked Field',
+ placeholder: 'This field is disabled',
+ disabled: true,
+ defaultValue: 'Cannot edit',
+ },
+}
+
+export const SmallSize: Story = {
+ args: {
+ size: 'sm',
+ placeholder: 'Small input',
+ },
+}
+
+export const WithLeadingIcon: Story = {
+ args: {
+ placeholder: 'Search...',
+ leadingIcon: ,
+ },
+}
+
+export const WithBackIcon: Story = {
+ render: function PasswordInput() {
+ const [showPassword, setShowPassword] = useState(false)
+ return (
+ : }
+ onBackIconClick={() => setShowPassword(!showPassword)}
+ />
+ )
+ },
+}
+
+export const AllStates: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+ ),
+}
+
+export const FormExample: Story = {
+ render: () => (
+
+
Contact Form
+
+
+
+
+
+ ),
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+// Interaction test stories
+export const TypeInteraction: Story = {
+ args: {
+ placeholder: 'Type something...',
+ onChange: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ const input = canvas.getByPlaceholderText('Type something...')
+
+ // Test that input is visible and enabled
+ await expect(input).toBeVisible()
+ await expect(input).toBeEnabled()
+
+ // Clear any existing value and type new text
+ await userEvent.clear(input)
+ await userEvent.type(input, 'Hello World')
+
+ // Verify the input value
+ await expect(input).toHaveValue('Hello World')
+
+ // Verify onChange was called
+ await expect(args.onChange).toHaveBeenCalled()
+ },
+}
+
+export const FocusInteraction: Story = {
+ args: {
+ title: 'Focus Test',
+ placeholder: 'Click to focus...',
+ onFocus: fn(),
+ onBlur: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ const input = canvas.getByPlaceholderText('Click to focus...')
+
+ // Click to focus the input
+ await userEvent.click(input)
+ await expect(args.onFocus).toHaveBeenCalled()
+
+ // Tab away to blur
+ await userEvent.tab()
+ await expect(args.onBlur).toHaveBeenCalled()
+ },
+}
+
+export const ClearAndTypeInteraction: Story = {
+ args: {
+ title: 'Edit Field',
+ placeholder: 'Edit this text',
+ defaultValue: 'Initial value',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ const input = canvas.getByPlaceholderText('Edit this text')
+
+ // Verify initial value
+ await expect(input).toHaveValue('Initial value')
+
+ // Select all and replace
+ await userEvent.tripleClick(input)
+ await userEvent.type(input, 'Replaced text')
+
+ // Verify the new value
+ await expect(input).toHaveValue('Replaced text')
+ },
+}
+
+export const PasswordToggleInteraction: Story = {
+ render: function PasswordToggle() {
+ const [showPassword, setShowPassword] = useState(false)
+ return (
+ : }
+ onBackIconClick={() => setShowPassword(!showPassword)}
+ />
+ )
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ const input = canvas.getByPlaceholderText('Enter password')
+ const toggleButton = canvas.getByRole('button')
+
+ // Initially password should be hidden (type="password")
+ await expect(input).toHaveAttribute('type', 'password')
+
+ // Click toggle to show password
+ await userEvent.click(toggleButton)
+ await expect(input).toHaveAttribute('type', 'text')
+
+ // Click toggle again to hide password
+ await userEvent.click(toggleButton)
+ await expect(input).toHaveAttribute('type', 'password')
+ },
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index a8534db9..7efc37c9 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -31,47 +31,47 @@ function resolveStateClasses(state: InputState | undefined) {
if (state === "disabled") {
return {
container: "opacity-50 cursor-not-allowed",
- field:
- "border-input-border-default bg-input-bg-default text-input-text-default",
+ field: "border-input-border-default bg-input-bg-default",
+ input: "text-text-heading",
placeholder: "placeholder-input-label-default",
}
}
if (state === "hover") {
return {
container: "",
- field:
- "border-input-border-hover bg-input-bg-default text-input-text-default",
+ field: "border-input-border-hover bg-input-bg-default",
+ input: "text-text-heading",
placeholder: "placeholder-input-label-default",
}
}
if (state === "input") {
return {
container: "",
- field:
- "border-input-border-focus bg-input-bg-input text-input-text-focus",
+ field: "border-input-border-focus bg-input-bg-input",
+ input: "text-text-heading",
placeholder: "placeholder-input-label-default",
}
}
if (state === "error") {
return {
container: "",
- field:
- "border-input-border-cuation bg-input-bg-default text-text-body",
+ field: "border-input-border-cuation bg-input-bg-default",
+ input: "text-text-heading",
placeholder: "placeholder-input-label-default",
}
}
if (state === "success") {
return {
container: "",
- field:
- "border-input-border-success bg-input-bg-confirm text-text-body",
+ field: "border-input-border-success bg-input-bg-confirm",
+ input: "text-text-heading",
placeholder: "placeholder-input-label-default",
}
}
return {
container: "",
- field:
- "border-input-border-default bg-input-bg-default text-input-text-default",
+ field: "border-input-border-default bg-input-bg-default",
+ input: "text-text-heading",
placeholder: "placeholder-input-label-default/10",
}
}
@@ -140,6 +140,7 @@ const Input = React.forwardRef(
placeholder={placeholder}
className={cn(
"peer w-full bg-transparent outline-none placeholder:transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium",
+ stateCls.input,
stateCls.placeholder,
hasLeft ? "pl-9" : "pl-3",
hasRight ? "pr-9" : "pr-3",
diff --git a/tailwind.config.js b/tailwind.config.js
index cb0e2014..29f90933 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,7 +1,7 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
- content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "./.storybook/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {