diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..8c8727a --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,146 @@ +# Migration Guide: SCL 0.2.x → 0.3.0 + +This guide documents breaking changes when upgrading from SCL 0.2.x to 0.3.0. + +## Overview + +SCL 0.3.0 is a complete rebuild using pure Radix primitives and vanilla CSS. All Tailwind CSS dependencies have been removed. + +## Breaking Changes + +### 1. Button Variant Rename + +The `default` variant has been renamed to `primary`. + +**Before (0.2.x):** +```tsx + +``` + +**After (0.3.0):** +```tsx + +``` + +**Migration:** Find and replace all instances of `variant="default"` with `variant="primary"` in your Button components. + +### 2. CSS Class Names + +All components now use BEM-like CSS class names prefixed with `scl-`. + +**Before (0.2.x):** Tailwind utility classes +```html + - - - - Are you absolutely sure? - This action cannot be undone. - - - Cancel - Continue - - - - ), -}; diff --git a/src/components/ui/alert-dialog/alert-dialog.test.tsx b/src/components/ui/alert-dialog/alert-dialog.test.tsx deleted file mode 100644 index 3d55047..0000000 --- a/src/components/ui/alert-dialog/alert-dialog.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from './alert-dialog'; - -describe('AlertDialog', () => { - it('renders trigger', () => { - render( - - Delete - - - Are you sure? - - - - ); - expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); - }); - - it('opens on trigger click', async () => { - const user = userEvent.setup(); - render( - - Delete - - - Confirm - - Continue - Cancel - - - ); - await user.click(screen.getByRole('button', { name: 'Delete' })); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - }); -}); diff --git a/src/components/ui/alert-dialog/alert-dialog.tsx b/src/components/ui/alert-dialog/alert-dialog.tsx deleted file mode 100644 index 690befe..0000000 --- a/src/components/ui/alert-dialog/alert-dialog.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; -import * as React from 'react'; -import { cn } from '@/lib/utils'; -import { buttonVariants } from '../button'; - -const AlertDialog = AlertDialogPrimitive.Root; -const AlertDialogTrigger = AlertDialogPrimitive.Trigger; -const AlertDialogPortal = AlertDialogPrimitive.Portal; - -const AlertDialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; - -const AlertDialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - -)); -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; - -const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-); -AlertDialogHeader.displayName = 'AlertDialogHeader'; - -const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
-); -AlertDialogFooter.displayName = 'AlertDialogFooter'; - -const AlertDialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; - -const AlertDialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; - -const AlertDialogAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; - -const AlertDialogCancel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; - -export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, -}; diff --git a/src/components/ui/alert-dialog/index.ts b/src/components/ui/alert-dialog/index.ts deleted file mode 100644 index afdb95f..0000000 --- a/src/components/ui/alert-dialog/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - AlertDialogPortal, - AlertDialogTitle, - AlertDialogTrigger, -} from './alert-dialog'; diff --git a/src/components/ui/alert/alert.css b/src/components/ui/alert/alert.css new file mode 100644 index 0000000..e913497 --- /dev/null +++ b/src/components/ui/alert/alert.css @@ -0,0 +1,71 @@ +/* Alert Component Styles */ + +.scl-alert { + position: relative; + width: 100%; + padding: var(--scl-space-3) var(--scl-space-4); + font-size: var(--scl-text-sm); + font-weight: 500; + font-family: var(--scl-font-mono); + border: 2px solid; +} + +.scl-alert svg { + position: absolute; + left: var(--scl-space-4); + top: var(--scl-space-4); +} + +.scl-alert svg + div { + transform: translateY(-3px); +} + +.scl-alert svg ~ * { + padding-left: var(--scl-space-7); +} + +/* Variants */ +.scl-alert--default { + background-color: var(--scl-primary); + color: var(--scl-primary-foreground); + border-color: var(--scl-primary); +} + +.scl-alert--default svg { + color: var(--scl-primary-foreground); +} + +.scl-alert--secondary { + background-color: var(--scl-secondary); + color: var(--scl-secondary-foreground); + border-color: var(--scl-secondary); +} + +.scl-alert--secondary svg { + color: var(--scl-secondary-foreground); +} + +.scl-alert--destructive { + background-color: var(--scl-destructive); + color: var(--scl-destructive-foreground); + border-color: var(--scl-destructive); +} + +.scl-alert--destructive svg { + color: var(--scl-destructive-foreground); +} + +.scl-alert__title { + margin-bottom: var(--scl-space-1); + font-weight: 500; + line-height: 1; + letter-spacing: -0.025em; +} + +.scl-alert__description { + font-size: var(--scl-text-sm); +} + +.scl-alert__description p { + line-height: 1.625; +} diff --git a/src/components/ui/alert/alert.test.tsx b/src/components/ui/alert/alert.test.tsx index 2fe14b4..c6632b9 100644 --- a/src/components/ui/alert/alert.test.tsx +++ b/src/components/ui/alert/alert.test.tsx @@ -18,12 +18,12 @@ describe('Alert', () => { it('applies default variant', () => { render(Default Alert); - expect(screen.getByRole('alert')).toHaveClass('bg-primary'); + expect(screen.getByRole('alert')).toHaveClass('scl-alert--default'); }); it('applies destructive variant', () => { render(Error Alert); - expect(screen.getByRole('alert')).toHaveClass('bg-destructive'); + expect(screen.getByRole('alert')).toHaveClass('scl-alert--destructive'); }); it('forwards ref to Alert', () => { diff --git a/src/components/ui/alert/alert.tsx b/src/components/ui/alert/alert.tsx index 5b3d3df..b67e35f 100644 --- a/src/components/ui/alert/alert.tsx +++ b/src/components/ui/alert/alert.tsx @@ -1,50 +1,37 @@ -import { cva, type VariantProps } from 'class-variance-authority'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cx } from '@/lib/utils'; +import './alert.css'; -const alertVariants = cva( - 'relative w-full px-4 py-3 text-sm font-medium [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7', - { - variants: { - variant: { - default: - 'bg-primary text-primary-foreground border-2 border-primary [&>svg]:text-primary-foreground', - secondary: - 'bg-secondary text-secondary-foreground border-2 border-secondary [&>svg]:text-secondary-foreground', - destructive: - 'bg-destructive text-destructive-foreground border-2 border-destructive [&>svg]:text-destructive-foreground', - }, - }, - defaultVariants: { - variant: 'default', - }, - } -); +export type AlertVariant = 'default' | 'secondary' | 'destructive'; -const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & VariantProps ->(({ className, variant, ...props }, ref) => ( -
-)); -Alert.displayName = 'Alert'; +export interface AlertProps extends React.HTMLAttributes { + variant?: AlertVariant; +} -const AlertTitle = React.forwardRef>( - ({ className, ...props }, ref) => ( -
( + ({ className, variant = 'default', ...props }, ref) => ( +
) ); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); AlertTitle.displayName = 'AlertTitle'; const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); AlertDescription.displayName = 'AlertDescription'; diff --git a/src/components/ui/alert/index.ts b/src/components/ui/alert/index.ts index ddf39c8..9e353ac 100644 --- a/src/components/ui/alert/index.ts +++ b/src/components/ui/alert/index.ts @@ -1 +1 @@ -export { Alert, AlertDescription, AlertTitle } from './alert'; +export { Alert, AlertDescription, AlertTitle, type AlertProps, type AlertVariant } from './alert'; diff --git a/src/components/ui/avatar/avatar.css b/src/components/ui/avatar/avatar.css new file mode 100644 index 0000000..d38e208 --- /dev/null +++ b/src/components/ui/avatar/avatar.css @@ -0,0 +1,32 @@ +/* Avatar Component Styles */ + +.scl-avatar { + position: relative; + display: flex; + height: 2.5rem; + width: 2.5rem; + flex-shrink: 0; + overflow: hidden; + border: 1px solid var(--scl-primary); +} + +.scl-avatar__image { + aspect-ratio: 1; + height: 100%; + width: 100%; + object-fit: cover; +} + +.scl-avatar__fallback { + display: flex; + height: 100%; + width: 100%; + align-items: center; + justify-content: center; + background-color: var(--scl-background); + border: 1px solid var(--scl-primary); + color: var(--scl-primary); + font-family: var(--scl-font-mono); + font-size: var(--scl-text-sm); + font-weight: 500; +} diff --git a/src/components/ui/avatar/avatar.tsx b/src/components/ui/avatar/avatar.tsx index 54a7cd2..5986b83 100644 --- a/src/components/ui/avatar/avatar.tsx +++ b/src/components/ui/avatar/avatar.tsx @@ -1,6 +1,7 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cx } from '@/lib/utils'; +import './avatar.css'; const Avatar = React.forwardRef< React.ElementRef, @@ -8,10 +9,7 @@ const Avatar = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -23,7 +21,7 @@ const AvatarImage = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -35,10 +33,7 @@ const AvatarFallback = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/components/ui/badge/badge.css b/src/components/ui/badge/badge.css new file mode 100644 index 0000000..65d25c2 --- /dev/null +++ b/src/components/ui/badge/badge.css @@ -0,0 +1,56 @@ +/* Badge Component Styles */ + +.scl-badge { + display: inline-flex; + align-items: center; + border: 2px solid; + padding: var(--scl-space-0-5) var(--scl-space-2-5); + font-size: var(--scl-text-xs); + font-weight: 600; + font-family: var(--scl-font-mono); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.scl-badge:focus { + outline: var(--scl-focus-ring-width) solid var(--scl-ring); + outline-offset: var(--scl-focus-ring-offset); +} + +/* Filled variants */ +.scl-badge--default { + border-color: var(--scl-primary); + background-color: var(--scl-primary); + color: var(--scl-primary-foreground); +} + +.scl-badge--secondary { + border-color: var(--scl-secondary); + background-color: var(--scl-secondary); + color: var(--scl-secondary-foreground); +} + +.scl-badge--destructive { + border-color: var(--scl-destructive); + background-color: var(--scl-destructive); + color: var(--scl-destructive-foreground); +} + +/* Outline variants */ +.scl-badge--outline { + border-color: var(--scl-primary); + background-color: transparent; + color: var(--scl-primary); +} + +.scl-badge--secondary-outline { + border-color: var(--scl-secondary); + background-color: transparent; + color: var(--scl-secondary); +} + +.scl-badge--destructive-outline { + border-color: var(--scl-destructive); + background-color: transparent; + color: var(--scl-destructive); +} diff --git a/src/components/ui/badge/badge.test.tsx b/src/components/ui/badge/badge.test.tsx index 833e305..4bbd09f 100644 --- a/src/components/ui/badge/badge.test.tsx +++ b/src/components/ui/badge/badge.test.tsx @@ -10,22 +10,22 @@ describe('Badge', () => { it('applies default variant', () => { render(Default); - expect(screen.getByText('Default')).toHaveClass('bg-primary'); + expect(screen.getByText('Default')).toHaveClass('scl-badge--default'); }); it('applies destructive variant', () => { render(Error); - expect(screen.getByText('Error')).toHaveClass('bg-destructive'); + expect(screen.getByText('Error')).toHaveClass('scl-badge--destructive'); }); it('applies secondary variant', () => { render(Secondary); - expect(screen.getByText('Secondary')).toHaveClass('bg-secondary'); + expect(screen.getByText('Secondary')).toHaveClass('scl-badge--secondary'); }); it('applies outline variant', () => { render(Outline); - expect(screen.getByText('Outline')).toHaveClass('text-primary'); + expect(screen.getByText('Outline')).toHaveClass('scl-badge--outline'); }); it('merges custom className', () => { diff --git a/src/components/ui/badge/badge.tsx b/src/components/ui/badge/badge.tsx index 82ba743..845b6da 100644 --- a/src/components/ui/badge/badge.tsx +++ b/src/components/ui/badge/badge.tsx @@ -1,34 +1,21 @@ -import { cva, type VariantProps } from 'class-variance-authority'; import type * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cx } from '@/lib/utils'; +import './badge.css'; -const badgeVariants = cva( - 'inline-flex items-center border-2 px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wider focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', - { - variants: { - variant: { - // Filled variants (color bg, black text) - default: 'border-primary bg-primary text-primary-foreground', - secondary: 'border-secondary bg-secondary text-secondary-foreground', - destructive: 'border-destructive bg-destructive text-destructive-foreground', - // Outline variants (transparent bg, color text/border) - outline: 'border-primary bg-transparent text-primary', - 'secondary-outline': 'border-secondary bg-transparent text-secondary', - 'destructive-outline': 'border-destructive bg-transparent text-destructive', - }, - }, - defaultVariants: { - variant: 'default', - }, - } -); +export type BadgeVariant = + | 'default' + | 'secondary' + | 'destructive' + | 'outline' + | 'secondary-outline' + | 'destructive-outline'; -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} +export interface BadgeProps extends React.HTMLAttributes { + variant?: BadgeVariant; +} -function Badge({ className, variant, ...props }: BadgeProps) { - return
; +function Badge({ className, variant = 'default', ...props }: BadgeProps) { + return
; } -export { Badge, badgeVariants }; +export { Badge }; diff --git a/src/components/ui/badge/index.ts b/src/components/ui/badge/index.ts index 846cd9a..3027ed9 100644 --- a/src/components/ui/badge/index.ts +++ b/src/components/ui/badge/index.ts @@ -1 +1 @@ -export { Badge, type BadgeProps, badgeVariants } from './badge'; +export { Badge, type BadgeProps, type BadgeVariant } from './badge'; diff --git a/src/components/ui/button/button.css b/src/components/ui/button/button.css new file mode 100644 index 0000000..4828ee4 --- /dev/null +++ b/src/components/ui/button/button.css @@ -0,0 +1,185 @@ +/* Button Component Styles */ + +.scl-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--scl-space-2); + white-space: nowrap; + font-family: var(--scl-font-mono); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + border: 2px solid transparent; + background: transparent; + transition: all var(--scl-duration-fast); +} + +.scl-button:focus-visible { + outline: var(--scl-focus-ring-width) solid var(--scl-ring); + outline-offset: var(--scl-focus-ring-offset); +} + +.scl-button:disabled { + pointer-events: none; + opacity: 0.3; +} + +.scl-button svg { + pointer-events: none; + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +/* Sizes */ +.scl-button--default { + height: 2.75rem; + padding: var(--scl-space-3) var(--scl-space-6); + font-size: var(--scl-text-sm); +} + +.scl-button--sm { + height: 2.25rem; + padding: var(--scl-space-2) var(--scl-space-4); + font-size: var(--scl-text-xs); +} + +.scl-button--lg { + height: 3.5rem; + padding: var(--scl-space-4) var(--scl-space-10); + font-size: var(--scl-text-base); +} + +.scl-button--icon { + height: 2.75rem; + width: 2.75rem; + padding: 0; +} + +/* Variants */ +.scl-button--primary { + background-color: var(--scl-primary); + color: var(--scl-primary-foreground); + border-color: var(--scl-primary); +} + +.scl-button--primary:hover { + background-color: transparent; + color: var(--scl-primary); +} + +.scl-button--primary:active { + background-color: var(--scl-primary); + color: var(--scl-primary-foreground); +} + +.scl-button--secondary { + background-color: var(--scl-secondary); + color: var(--scl-secondary-foreground); + border-color: var(--scl-secondary); +} + +.scl-button--secondary:hover { + background-color: transparent; + color: var(--scl-secondary); +} + +.scl-button--secondary:active { + background-color: var(--scl-secondary); + color: var(--scl-secondary-foreground); +} + +.scl-button--outline { + background-color: transparent; + color: var(--scl-primary); + border-color: var(--scl-primary); +} + +.scl-button--outline:hover { + background-color: var(--scl-primary); + color: var(--scl-primary-foreground); +} + +.scl-button--outline:active { + background-color: transparent; + color: var(--scl-primary); +} + +.scl-button--secondary-outline { + background-color: transparent; + color: var(--scl-secondary); + border-color: var(--scl-secondary); +} + +.scl-button--secondary-outline:hover { + background-color: var(--scl-secondary); + color: var(--scl-secondary-foreground); +} + +.scl-button--secondary-outline:active { + background-color: transparent; + color: var(--scl-secondary); +} + +.scl-button--ghost { + background-color: transparent; + color: var(--scl-primary); + border-color: transparent; +} + +.scl-button--ghost:hover { + border-color: var(--scl-primary); + background-color: var(--scl-primary); + color: var(--scl-primary-foreground); +} + +.scl-button--ghost:active { + background-color: transparent; + color: var(--scl-primary); +} + +.scl-button--link { + background-color: transparent; + color: var(--scl-primary); + border: none; + text-decoration-line: underline; + text-underline-offset: 4px; +} + +.scl-button--link:hover { + text-decoration-line: none; +} + +.scl-button--destructive { + background-color: var(--scl-destructive); + color: var(--scl-destructive-foreground); + border-color: var(--scl-destructive); +} + +.scl-button--destructive:hover { + background-color: transparent; + color: var(--scl-destructive); +} + +.scl-button--destructive:active { + background-color: var(--scl-destructive); + color: var(--scl-destructive-foreground); +} + +.scl-button--destructive-outline { + background-color: transparent; + color: var(--scl-destructive); + border-color: var(--scl-destructive); +} + +.scl-button--destructive-outline:hover { + background-color: var(--scl-destructive); + color: var(--scl-destructive-foreground); +} + +.scl-button--destructive-outline:active { + background-color: transparent; + color: var(--scl-destructive); +} diff --git a/src/components/ui/button/button.stories.tsx b/src/components/ui/button/button.stories.tsx index f189ea2..e7cf998 100644 --- a/src/components/ui/button/button.stories.tsx +++ b/src/components/ui/button/button.stories.tsx @@ -9,7 +9,7 @@ const meta: Meta = { variant: { control: 'select', options: [ - 'default', + 'primary', 'destructive', 'outline', 'secondary', @@ -43,7 +43,7 @@ export const Default: Story = { export const Primary: Story = { args: { - variant: 'default', + variant: 'primary', children: '[Start Quiz]', }, }; @@ -57,8 +57,8 @@ export const Secondary: Story = { export const AllVariants: Story = { render: () => ( -
- +
+ @@ -72,7 +72,7 @@ export const AllVariants: Story = { export const AllSizes: Story = { render: () => ( -
+
@@ -92,7 +92,7 @@ export const Disabled: Story = { export const WithIcon: Story = { render: () => ( -
+
), }; - -export const InteractiveDemo: Story = { - render: () => ( -
-
-

14 questions • ~3 minutes

- -

- Press{' '} - {' '} - to start -

-
-
- - -
-
- ), -}; - -export const WithThemeColors: Story = { - render: () => ( -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- ), -}; diff --git a/src/components/ui/button/button.test.tsx b/src/components/ui/button/button.test.tsx index eeb4b95..d1af49a 100644 --- a/src/components/ui/button/button.test.tsx +++ b/src/components/ui/button/button.test.tsx @@ -12,13 +12,13 @@ describe('Button', () => { it('applies variant classes correctly', () => { render(); const button = screen.getByRole('button'); - expect(button).toHaveClass('bg-destructive'); + expect(button).toHaveClass('scl-button--destructive'); }); it('applies size classes correctly', () => { render(); const button = screen.getByRole('button'); - expect(button).toHaveClass('h-9'); + expect(button).toHaveClass('scl-button--sm'); }); it('forwards ref correctly', () => { diff --git a/src/components/ui/button/button.tsx b/src/components/ui/button/button.tsx index 743d82b..db63365 100644 --- a/src/components/ui/button/button.tsx +++ b/src/components/ui/button/button.tsx @@ -1,57 +1,43 @@ import { Slot } from '@radix-ui/react-slot'; -import { cva, type VariantProps } from 'class-variance-authority'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cx } from '@/lib/utils'; +import './button.css'; -const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap font-mono font-bold uppercase tracking-wider cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-30 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', - { - variants: { - variant: { - default: - 'bg-primary text-primary-foreground border-2 border-primary hover:bg-transparent hover:text-primary active:bg-primary active:text-primary-foreground active:border-primary-foreground', - secondary: - 'bg-secondary text-secondary-foreground border-2 border-secondary hover:bg-transparent hover:text-secondary active:bg-secondary active:text-secondary-foreground active:border-secondary-foreground', - outline: - 'bg-transparent text-primary border-2 border-primary hover:bg-primary hover:text-primary-foreground active:bg-transparent active:text-primary active:border-primary-foreground', - 'secondary-outline': - 'bg-transparent text-secondary border-2 border-secondary hover:bg-secondary hover:text-secondary-foreground active:bg-transparent active:text-secondary active:border-secondary-foreground', - ghost: - 'bg-transparent text-primary border-2 border-transparent hover:border-primary hover:bg-primary hover:text-primary-foreground active:bg-transparent active:border-primary-foreground active:text-primary', - link: 'bg-transparent text-primary underline-offset-4 hover:underline border-none active:no-underline', - destructive: - 'bg-destructive text-destructive-foreground border-2 border-destructive hover:bg-transparent hover:text-destructive active:bg-destructive active:text-destructive-foreground active:border-destructive-foreground', - 'destructive-outline': - 'bg-transparent text-destructive border-2 border-destructive hover:bg-destructive hover:text-destructive-foreground active:bg-transparent active:text-destructive active:border-destructive-foreground', - }, - size: { - default: 'h-11 px-6 py-3 text-sm', - sm: 'h-9 px-4 py-2 text-xs', - lg: 'h-14 px-10 py-4 text-base', - icon: 'h-11 w-11', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, - } -); +export type ButtonVariant = + | 'primary' + | 'secondary' + | 'outline' + | 'secondary-outline' + | 'ghost' + | 'link' + | 'destructive' + | 'destructive-outline'; + +export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'; -export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; asChild?: boolean; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant = 'primary', size = 'default', asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; return ( - + ); } ); Button.displayName = 'Button'; -export { Button, buttonVariants }; +export { Button }; diff --git a/src/components/ui/button/index.ts b/src/components/ui/button/index.ts index e4d4fef..016cc80 100644 --- a/src/components/ui/button/index.ts +++ b/src/components/ui/button/index.ts @@ -1 +1 @@ -export { Button, type ButtonProps, buttonVariants } from './button'; +export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './button'; diff --git a/src/components/ui/card/card.css b/src/components/ui/card/card.css new file mode 100644 index 0000000..5abe13c --- /dev/null +++ b/src/components/ui/card/card.css @@ -0,0 +1,41 @@ +/* Card Component Styles */ + +.scl-card { + border: 2px solid var(--scl-primary); + background-color: var(--scl-card); + color: var(--scl-card-foreground); +} + +.scl-card__header { + display: flex; + flex-direction: column; + gap: var(--scl-space-1-5); +} + +.scl-card__title { + background-color: var(--scl-primary); + color: var(--scl-primary-foreground); + padding: var(--scl-space-1) var(--scl-space-2); + font-size: var(--scl-text-sm); + font-weight: 600; + font-family: var(--scl-font-mono); + line-height: 1; + letter-spacing: -0.025em; + width: 100%; +} + +.scl-card__description { + padding: var(--scl-space-2) var(--scl-space-6) 0; + font-size: var(--scl-text-sm); + color: var(--scl-muted-foreground); +} + +.scl-card__content { + padding: var(--scl-space-6); +} + +.scl-card__footer { + display: flex; + align-items: center; + padding: 0 var(--scl-space-6) var(--scl-space-6); +} diff --git a/src/components/ui/card/card.tsx b/src/components/ui/card/card.tsx index ad23e44..ed81755 100644 --- a/src/components/ui/card/card.tsx +++ b/src/components/ui/card/card.tsx @@ -1,57 +1,45 @@ import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cx } from '@/lib/utils'; +import './card.css'; const Card = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
) ); Card.displayName = 'Card'; const CardHeader = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
) ); CardHeader.displayName = 'CardHeader'; const CardTitle = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
) ); CardTitle.displayName = 'CardTitle'; const CardDescription = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
) ); CardDescription.displayName = 'CardDescription'; const CardContent = React.forwardRef>( - ({ className, ...props }, ref) =>
+ ({ className, ...props }, ref) => ( +
+ ) ); CardContent.displayName = 'CardContent'; const CardFooter = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
) ); CardFooter.displayName = 'CardFooter'; diff --git a/src/components/ui/checkbox/checkbox.css b/src/components/ui/checkbox/checkbox.css new file mode 100644 index 0000000..6d9281d --- /dev/null +++ b/src/components/ui/checkbox/checkbox.css @@ -0,0 +1,40 @@ +/* Checkbox Component Styles */ + +.scl-checkbox { + display: flex; + align-items: center; + justify-content: center; + height: 1.5rem; + width: 1.5rem; + flex-shrink: 0; + border: 2px solid var(--scl-primary); + background-color: var(--scl-background); + cursor: pointer; +} + +.scl-checkbox:focus-visible { + outline: none; + box-shadow: 0 0 0 1px var(--scl-ring) !important; +} + +.scl-checkbox:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.scl-checkbox[data-state="checked"] { + background-color: var(--scl-primary); + color: var(--scl-primary-foreground); +} + +.scl-checkbox__indicator { + display: flex; + align-items: center; + justify-content: center; + color: currentColor; +} + +.scl-checkbox__icon { + width: 1.25rem; + height: 1.25rem; +} diff --git a/src/components/ui/checkbox/checkbox.tsx b/src/components/ui/checkbox/checkbox.tsx index dca4892..0925607 100644 --- a/src/components/ui/checkbox/checkbox.tsx +++ b/src/components/ui/checkbox/checkbox.tsx @@ -1,7 +1,22 @@ import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; -import { Check } from 'lucide-react'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cx } from '@/lib/utils'; +import './checkbox.css'; + +const CheckIcon = () => ( + +); const Checkbox = React.forwardRef< React.ElementRef, @@ -9,14 +24,11 @@ const Checkbox = React.forwardRef< >(({ className, ...props }, ref) => ( - - + + )); diff --git a/src/components/ui/collapsible/collapsible.css b/src/components/ui/collapsible/collapsible.css new file mode 100644 index 0000000..ba06c01 --- /dev/null +++ b/src/components/ui/collapsible/collapsible.css @@ -0,0 +1,11 @@ +/* Collapsible Component Styles */ +/* Collapsible uses Radix primitives directly with minimal styling */ + +.scl-collapsible-content[data-state="closed"] { + height: 0; + overflow: hidden; +} + +.scl-collapsible-content[data-state="open"] { + animation: scl-collapsible-down var(--scl-duration-normal) ease-out; +} diff --git a/src/components/ui/collapsible/collapsible.tsx b/src/components/ui/collapsible/collapsible.tsx index c6a17d4..8c4b8d3 100644 --- a/src/components/ui/collapsible/collapsible.tsx +++ b/src/components/ui/collapsible/collapsible.tsx @@ -1,4 +1,5 @@ import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; +import './collapsible.css'; const Collapsible = CollapsiblePrimitive.Root; const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; diff --git a/src/components/ui/combobox/combobox.stories.tsx b/src/components/ui/combobox/combobox.stories.tsx deleted file mode 100644 index 135b241..0000000 --- a/src/components/ui/combobox/combobox.stories.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import * as React from 'react'; -import { Combobox, ComboboxMulti } from './combobox'; - -const meta: Meta = { - title: 'Components/Combobox', - component: Combobox, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -const frameworks = [ - { value: 'next', label: 'Next.js' }, - { value: 'sveltekit', label: 'SvelteKit' }, - { value: 'nuxt', label: 'Nuxt.js' }, - { value: 'remix', label: 'Remix' }, - { value: 'astro', label: 'Astro' }, -]; - -const vimPlugins = [ - { value: 'telescope', label: 'Telescope' }, - { value: 'treesitter', label: 'Treesitter' }, - { value: 'lspconfig', label: 'LSP Config' }, - { value: 'cmp', label: 'nvim-cmp' }, - { value: 'mason', label: 'Mason' }, - { value: 'lazy', label: 'lazy.nvim' }, -]; - -const colorschemes = [ - { value: 'green', label: 'Terminal Green' }, - { value: 'cyan', label: 'Cyan' }, - { value: 'hotpink', label: 'Hot Pink' }, - { value: 'yellow', label: 'Yellow' }, - { value: 'amber', label: 'Amber' }, - { value: 'white', label: 'White' }, - { value: 'blue', label: 'Blue' }, -]; - -export const Default: Story = { - render: function ControlledExample() { - const [value, setValue] = React.useState(''); - return ( - - ); - }, -}; - -export const WithPreselection: Story = { - render: function PreselectedExample() { - const [value, setValue] = React.useState('next'); - return ( - - ); - }, -}; - -export const VimPluginSelector: Story = { - render: function VimExample() { - const [value, setValue] = React.useState(''); - return ( - - ); - }, -}; - -export const ColorschemeSelector: Story = { - render: function ColorschemeExample() { - const [value, setValue] = React.useState('green'); - return ( -
- -

- Selected: {value || 'none'} -

-
- ); - }, -}; - -export const Disabled: Story = { - args: { - options: frameworks, - placeholder: 'Select framework...', - disabled: true, - }, -}; - -export const WithDisabledOptions: Story = { - render: function DisabledOptionsExample() { - const [value, setValue] = React.useState(''); - const optionsWithDisabled = [ - { value: 'next', label: 'Next.js' }, - { value: 'sveltekit', label: 'SvelteKit', disabled: true }, - { value: 'nuxt', label: 'Nuxt.js' }, - { value: 'remix', label: 'Remix', disabled: true }, - { value: 'astro', label: 'Astro' }, - ]; - return ( - - ); - }, -}; - -// Multi-select stories -export const MultiSelect: Story = { - render: function MultiExample() { - const [value, setValue] = React.useState([]); - return ( - - ); - }, -}; - -export const MultiSelectWithPreselection: Story = { - render: function MultiPreselectedExample() { - const [value, setValue] = React.useState(['telescope', 'treesitter', 'lspconfig']); - return ( - - ); - }, -}; - -export const MultiSelectWithMax: Story = { - render: function MultiMaxExample() { - const [value, setValue] = React.useState([]); - return ( -
- -

Selected {value.length}/3 colors

-
- ); - }, -}; diff --git a/src/components/ui/combobox/combobox.test.tsx b/src/components/ui/combobox/combobox.test.tsx deleted file mode 100644 index c685a6d..0000000 --- a/src/components/ui/combobox/combobox.test.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; -import { Combobox, ComboboxMulti } from './combobox'; - -const testOptions = [ - { value: 'apple', label: 'Apple' }, - { value: 'banana', label: 'Banana' }, - { value: 'cherry', label: 'Cherry' }, -]; - -describe('Combobox', () => { - it('renders with placeholder', () => { - render(); - - expect(screen.getByRole('combobox')).toHaveTextContent('Select fruit...'); - }); - - it('renders selected value', () => { - render(); - - expect(screen.getByRole('combobox')).toHaveTextContent('Apple'); - }); - - it('opens popover on click', async () => { - const user = userEvent.setup(); - - render(); - - await user.click(screen.getByRole('combobox')); - - await waitFor(() => { - expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); - }); - }); - - it('shows all options when opened', async () => { - const user = userEvent.setup(); - - render(); - - await user.click(screen.getByRole('combobox')); - - await waitFor(() => { - expect(screen.getByText('Apple')).toBeInTheDocument(); - expect(screen.getByText('Banana')).toBeInTheDocument(); - expect(screen.getByText('Cherry')).toBeInTheDocument(); - }); - }); - - it('calls onValueChange when option is selected', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - - render( - - ); - - await user.click(screen.getByRole('combobox')); - - await waitFor(() => { - expect(screen.getByText('Banana')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('Banana')); - - expect(onValueChange).toHaveBeenCalledWith('banana'); - }); - - it('shows empty state when no matches', async () => { - const user = userEvent.setup(); - - render( - - ); - - await user.click(screen.getByRole('combobox')); - - const searchInput = await screen.findByPlaceholderText('Search...'); - await user.type(searchInput, 'xyz'); - - await waitFor(() => { - expect(screen.getByText('No fruits found.')).toBeInTheDocument(); - }); - }); - - it('respects disabled state', () => { - render(); - - expect(screen.getByRole('combobox')).toBeDisabled(); - }); -}); - -describe('ComboboxMulti', () => { - it('renders with placeholder', () => { - render(); - - expect(screen.getByRole('combobox')).toHaveTextContent('Select fruits...'); - }); - - it('renders selected values', () => { - render( - - ); - - expect(screen.getByRole('combobox')).toHaveTextContent('Apple, Banana'); - }); - - it('toggles selection', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - - render( - - ); - - await user.click(screen.getByRole('combobox')); - - await waitFor(() => { - expect(screen.getByText('Banana')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('Banana')); - - expect(onValueChange).toHaveBeenCalledWith(['apple', 'banana']); - }); - - it('removes selection when clicked again', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - - render( - - ); - - await user.click(screen.getByRole('combobox')); - - await waitFor(() => { - expect(screen.getByText('Apple')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('Apple')); - - expect(onValueChange).toHaveBeenCalledWith(['banana']); - }); - - it('respects maxSelected limit', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - - render( - - ); - - await user.click(screen.getByRole('combobox')); - - await waitFor(() => { - expect(screen.getByText('Cherry')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('Cherry')); - - // Should not add third item when at max - expect(onValueChange).toHaveBeenCalledWith(['apple', 'banana']); - }); -}); diff --git a/src/components/ui/combobox/combobox.tsx b/src/components/ui/combobox/combobox.tsx deleted file mode 100644 index 2c32993..0000000 --- a/src/components/ui/combobox/combobox.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { Check, ChevronsUpDown } from 'lucide-react'; -import * as React from 'react'; -import { Button } from '@/components/ui/button'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { cn } from '@/lib/utils'; - -export interface ComboboxOption { - value: string; - label: string; - disabled?: boolean; -} - -export interface ComboboxProps { - options: ComboboxOption[]; - value?: string; - onValueChange?: (value: string) => void; - placeholder?: string; - searchPlaceholder?: string; - emptyText?: string; - className?: string; - disabled?: boolean; -} - -const Combobox = React.forwardRef( - ( - { - options, - value, - onValueChange, - placeholder = 'Select option...', - searchPlaceholder = 'Search...', - emptyText = 'No option found.', - className, - disabled = false, - }, - ref - ) => { - const [open, setOpen] = React.useState(false); - - const selectedOption = options.find((option) => option.value === value); - - return ( - - - - - - - - - {emptyText} - - {options.map((option) => ( - { - onValueChange?.(currentValue === value ? '' : currentValue); - setOpen(false); - }} - disabled={option.disabled} - > - - {option.label} - - ))} - - - - - - ); - } -); -Combobox.displayName = 'Combobox'; - -export interface ComboboxMultiProps { - options: ComboboxOption[]; - value?: string[]; - onValueChange?: (value: string[]) => void; - placeholder?: string; - searchPlaceholder?: string; - emptyText?: string; - className?: string; - disabled?: boolean; - maxSelected?: number; -} - -const ComboboxMulti = React.forwardRef( - ( - { - options, - value = [], - onValueChange, - placeholder = 'Select options...', - searchPlaceholder = 'Search...', - emptyText = 'No option found.', - className, - disabled = false, - maxSelected, - }, - ref - ) => { - const [open, setOpen] = React.useState(false); - - const selectedLabels = value - .map((v) => options.find((option) => option.value === v)?.label) - .filter(Boolean) - .join(', '); - - const toggleOption = (optionValue: string) => { - const newValue = value.includes(optionValue) - ? value.filter((v) => v !== optionValue) - : maxSelected && value.length >= maxSelected - ? value - : [...value, optionValue]; - onValueChange?.(newValue); - }; - - return ( - - - - - - - - - {emptyText} - - {options.map((option) => ( - toggleOption(option.value)} - disabled={option.disabled} - > - - {option.label} - - ))} - - - - - - ); - } -); -ComboboxMulti.displayName = 'ComboboxMulti'; - -export { Combobox, ComboboxMulti }; diff --git a/src/components/ui/combobox/index.ts b/src/components/ui/combobox/index.ts deleted file mode 100644 index c0848ac..0000000 --- a/src/components/ui/combobox/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { ComboboxMultiProps, ComboboxOption, ComboboxProps } from './combobox'; -export { Combobox, ComboboxMulti } from './combobox'; diff --git a/src/components/ui/command/command.stories.tsx b/src/components/ui/command/command.stories.tsx deleted file mode 100644 index 6e68b85..0000000 --- a/src/components/ui/command/command.stories.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { - Calculator, - Calendar, - CreditCard, - File, - Moon, - Settings, - Smile, - Sun, - Terminal, - User, -} from 'lucide-react'; -import * as React from 'react'; -import { - Command, - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, - CommandShortcut, -} from './command'; - -const meta: Meta = { - title: 'Components/Command', - component: Command, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => ( - - - - No results found. - - - - Calendar - - - - Search Emoji - - - - Calculator - - - - - - - Profile - :profile - - - - Billing - :billing - - - - Settings - :settings - - - - - ), -}; - -export const VimStyle: Story = { - render: () => ( - - - - No commands found. - - - - New file - :e [file] - - - 󰆓 - Save - :w - - - 󰈆 - Quit - :q - - - 󰈆 - Save and quit - :wq - - - - - - - Terminal - :term - - - 󰕭 - Split vertical - :vs - - - 󰕮 - Split horizontal - :sp - - - - - - - Set options - :set - - - - Colorscheme - :colorscheme - - - - - ), -}; - -export const WithDialog: Story = { - render: function DialogExample() { - const [open, setOpen] = React.useState(false); - - React.useEffect(() => { - const down = (e: KeyboardEvent) => { - if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setOpen((open) => !open); - } - }; - - document.addEventListener('keydown', down); - return () => document.removeEventListener('keydown', down); - }, []); - - return ( - <> -

- Press{' '} - - ⌘K - -

- - - - No results found. - - - - Calendar - - - - Search Emoji - - - - Calculator - - - - - - ); - }, -}; - -export const ThemeSwitcher: Story = { - render: () => ( - - - - No themes found. - - - - Green (Default) - - - - Cyan - - - - Hot Pink - - - - Yellow - - - - Amber - - - - White - - - - - ), -}; - -export const Empty: Story = { - render: () => ( - - - - No results found. - - - ), -}; diff --git a/src/components/ui/command/command.test.tsx b/src/components/ui/command/command.test.tsx deleted file mode 100644 index 6e8021a..0000000 --- a/src/components/ui/command/command.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, - CommandShortcut, -} from './command'; - -describe('Command', () => { - it('renders command component', () => { - render( - - - - No results. - - Item 1 - - - - ); - - expect(screen.getByTestId('command')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); - }); - - it('renders command items', () => { - render( - - - - Action 1 - Action 2 - Action 3 - - - - ); - - expect(screen.getByText('Action 1')).toBeInTheDocument(); - expect(screen.getByText('Action 2')).toBeInTheDocument(); - expect(screen.getByText('Action 3')).toBeInTheDocument(); - expect(screen.getByText('Actions')).toBeInTheDocument(); - }); - - it('renders command shortcut', () => { - render( - - - - - Save - :w - - - - - ); - - expect(screen.getByText('Save')).toBeInTheDocument(); - expect(screen.getByText(':w')).toBeInTheDocument(); - }); - - it('renders command separator', () => { - render( - - - - Item 1 - - - - Item 2 - - - - ); - - expect(screen.getByTestId('separator')).toBeInTheDocument(); - expect(screen.getByText('Group 1')).toBeInTheDocument(); - expect(screen.getByText('Group 2')).toBeInTheDocument(); - }); - - it('shows empty state when no results', async () => { - const user = userEvent.setup(); - - render( - - - - No results found. - - Apple - Banana - - - - ); - - const input = screen.getByPlaceholderText('Search...'); - await user.type(input, 'xyz'); - - expect(screen.getByText('No results found.')).toBeInTheDocument(); - }); - - it('filters items based on search', async () => { - const user = userEvent.setup(); - - render( - - - - No results found. - - Apple - Banana - Cherry - - - - ); - - const input = screen.getByPlaceholderText('Search...'); - await user.type(input, 'app'); - - expect(screen.getByText('Apple')).toBeInTheDocument(); - expect(screen.queryByText('Banana')).not.toBeInTheDocument(); - expect(screen.queryByText('Cherry')).not.toBeInTheDocument(); - }); - - it('supports keyboard navigation', async () => { - const user = userEvent.setup(); - - render( - - - - - Item 1 - Item 2 - Item 3 - - - - ); - - const input = screen.getByPlaceholderText('Search...'); - await user.click(input); - await user.keyboard('{ArrowDown}'); - await user.keyboard('{ArrowDown}'); - - // Check that navigation works (items should be selectable) - expect(screen.getByText('Item 1')).toBeInTheDocument(); - expect(screen.getByText('Item 2')).toBeInTheDocument(); - expect(screen.getByText('Item 3')).toBeInTheDocument(); - }); - - it('renders disabled items correctly', () => { - render( - - - - Disabled Item - - - - ); - - const item = screen.getByText('Disabled Item').closest('[cmdk-item]'); - expect(item).toHaveAttribute('data-disabled', 'true'); - }); - - it('applies custom className', () => { - render( - - - Test - - - ); - - expect(screen.getByTestId('command')).toHaveClass('custom-class'); - }); -}); diff --git a/src/components/ui/command/command.tsx b/src/components/ui/command/command.tsx deleted file mode 100644 index 3363dfe..0000000 --- a/src/components/ui/command/command.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { Command as CommandPrimitive } from 'cmdk'; -import { Search } from 'lucide-react'; -import * as React from 'react'; -import { Dialog, DialogContent } from '@/components/ui/dialog'; -import { cn } from '@/lib/utils'; - -const Command = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -Command.displayName = CommandPrimitive.displayName; - -interface CommandDialogProps { - children: React.ReactNode; - open?: boolean; - onOpenChange?: (open: boolean) => void; -} - -const CommandDialog = ({ children, ...props }: CommandDialogProps) => { - return ( - - - - {children} - - - - ); -}; - -const CommandInput = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
- - -
-)); -CommandInput.displayName = CommandPrimitive.Input.displayName; - -const CommandList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CommandList.displayName = CommandPrimitive.List.displayName; - -const CommandEmpty = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->((props, ref) => ( - -)); -CommandEmpty.displayName = CommandPrimitive.Empty.displayName; - -const CommandGroup = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CommandGroup.displayName = CommandPrimitive.Group.displayName; - -const CommandSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CommandSeparator.displayName = CommandPrimitive.Separator.displayName; - -const CommandItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CommandItem.displayName = CommandPrimitive.Item.displayName; - -const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { - return ( - - ); -}; -CommandShortcut.displayName = 'CommandShortcut'; - -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandSeparator, - CommandShortcut, -}; diff --git a/src/components/ui/command/index.ts b/src/components/ui/command/index.ts deleted file mode 100644 index e2a383a..0000000 --- a/src/components/ui/command/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - Command, - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, - CommandShortcut, -} from './command'; diff --git a/src/components/ui/dialog/dialog.css b/src/components/ui/dialog/dialog.css new file mode 100644 index 0000000..2ac65eb --- /dev/null +++ b/src/components/ui/dialog/dialog.css @@ -0,0 +1,108 @@ +/* Dialog Component Styles */ + +.scl-dialog-overlay { + position: fixed; + inset: 0; + z-index: 50; + background-color: rgba(0, 0, 0, 0.8); +} + +.scl-dialog-overlay[data-state="closed"] { + display: none; +} + +.scl-dialog-content { + position: fixed; + left: 50%; + top: 50%; + z-index: 50; + display: grid; + width: 100%; + max-width: 32rem; + transform: translate(-50%, -50%); + gap: var(--scl-space-4); + border: 2px solid var(--scl-primary); + background-color: var(--scl-background); + padding: var(--scl-space-6); +} + +.scl-dialog-close { + position: absolute; + right: var(--scl-space-4); + top: var(--scl-space-4); + background: none; + border: none; + color: var(--scl-primary); + cursor: pointer; + padding: 0; +} + +.scl-dialog-close:hover { + opacity: 0.8; +} + +.scl-dialog-close:focus { + outline: var(--scl-focus-ring-width) solid var(--scl-ring); + outline-offset: var(--scl-focus-ring-offset); +} + +.scl-dialog-close:disabled { + pointer-events: none; +} + +.scl-dialog-close__icon { + width: 1rem; + height: 1rem; +} + +.scl-dialog-header { + display: flex; + flex-direction: column; + gap: var(--scl-space-1-5); + text-align: center; +} + +@media (min-width: 640px) { + .scl-dialog-header { + text-align: left; + } +} + +.scl-dialog-footer { + display: flex; + flex-direction: column-reverse; + gap: var(--scl-space-2); +} + +@media (min-width: 640px) { + .scl-dialog-footer { + flex-direction: row; + justify-content: flex-end; + } +} + +.scl-dialog-title { + font-size: var(--scl-text-lg); + font-weight: 600; + font-family: var(--scl-font-mono); + line-height: 1; + letter-spacing: -0.025em; + color: var(--scl-primary); +} + +.scl-dialog-description { + font-size: var(--scl-text-sm); + color: var(--scl-muted-foreground); +} + +.scl-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} diff --git a/src/components/ui/dialog/dialog.tsx b/src/components/ui/dialog/dialog.tsx index 417e16b..98c2a9c 100644 --- a/src/components/ui/dialog/dialog.tsx +++ b/src/components/ui/dialog/dialog.tsx @@ -1,23 +1,35 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'; -import { X } from 'lucide-react'; import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cx } from '@/lib/utils'; +import './dialog.css'; const Dialog = DialogPrimitive.Root; - const DialogTrigger = DialogPrimitive.Trigger; - const DialogPortal = DialogPrimitive.Portal; - const DialogClose = DialogPrimitive.Close; +const CloseIcon = () => ( + +); + const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); @@ -31,16 +43,13 @@ const DialogContent = React.forwardRef< {children} - - - Close + + + Close @@ -48,15 +57,12 @@ const DialogContent = React.forwardRef< DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
+
); DialogHeader.displayName = 'DialogHeader'; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
+
); DialogFooter.displayName = 'DialogFooter'; @@ -66,7 +72,7 @@ const DialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -78,7 +84,7 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/components/ui/dithered-image/dithered-image.stories.tsx b/src/components/ui/dithered-image/dithered-image.stories.tsx deleted file mode 100644 index 2431832..0000000 --- a/src/components/ui/dithered-image/dithered-image.stories.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { DitheredImage } from './dithered-image'; - -const meta: Meta = { - title: 'Components/DitheredImage', - component: DitheredImage, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], - argTypes: { - ditherSize: { - control: { type: 'range', min: 1, max: 8, step: 1 }, - }, - threshold: { - control: { type: 'range', min: 0, max: 255, step: 1 }, - }, - algorithm: { - control: { type: 'select' }, - options: ['atkinson', 'floyd-steinberg', 'bayer'], - }, - color: { - control: false, - }, - }, -}; - -export default meta; -type Story = StoryObj; - -// Using picsum for a consistent sample portrait -const SAMPLE_IMAGE = 'https://picsum.photos/seed/scl-demo/400/400'; - -// Default now uses Atkinson (classic Mac) -export const Default: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'Atkinson dithered image (classic Mac)', - width: 400, - }, -}; - -// Classic Mac aesthetic - Atkinson with green -export const AtkinsonGreen: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'Atkinson dithered - classic Mac green', - width: 400, - algorithm: 'atkinson', - color: [0, 255, 0], - }, -}; - -// Atkinson with amber (like old monochrome monitors) -export const AtkinsonAmber: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'Atkinson dithered - amber monochrome', - width: 400, - algorithm: 'atkinson', - color: [255, 176, 0], - }, -}; - -// Floyd-Steinberg for comparison (smoother gradients) -export const FloydSteinberg: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'Floyd-Steinberg dithered', - width: 400, - algorithm: 'floyd-steinberg', - color: [0, 255, 0], - }, -}; - -// Bayer ordered dithering (the original pattern) -export const BayerOrdered: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'Bayer ordered dithering', - width: 400, - algorithm: 'bayer', - ditherSize: 1, - }, -}; - -// Large Bayer pattern -export const BayerLargePattern: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'Large Bayer dither pattern', - width: 400, - algorithm: 'bayer', - ditherSize: 2, - }, -}; - -// Cyan terminal color -export const CyanColor: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'Cyan dithered image', - width: 400, - algorithm: 'atkinson', - color: [0, 255, 255], - }, -}; - -// Hot pink -export const HotPinkColor: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'Hot pink dithered image', - width: 400, - algorithm: 'atkinson', - color: [255, 0, 255], - }, -}; - -// White (high contrast) -export const WhiteColor: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'White dithered image', - width: 400, - algorithm: 'atkinson', - color: [255, 255, 255], - }, -}; - -// Custom threshold (darker) -export const DarkThreshold: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'Dark threshold dithering', - width: 400, - algorithm: 'atkinson', - threshold: 100, - }, -}; - -// Custom threshold (lighter) -export const LightThreshold: Story = { - args: { - src: SAMPLE_IMAGE, - alt: 'Light threshold dithering', - width: 400, - algorithm: 'atkinson', - threshold: 180, - }, -}; - -// Comparison grid -export const AlgorithmComparison: Story = { - render: () => ( -
-
- -

Atkinson (Mac)

-
-
- -

Floyd-Steinberg

-
-
- -

Bayer (Ordered)

-
-
- ), -}; diff --git a/src/components/ui/dithered-image/dithered-image.tsx b/src/components/ui/dithered-image/dithered-image.tsx deleted file mode 100644 index 259bf12..0000000 --- a/src/components/ui/dithered-image/dithered-image.tsx +++ /dev/null @@ -1,452 +0,0 @@ -import * as React from 'react'; -import { cn } from '@/lib/utils'; - -// Simple colorization shader (used after CPU-based dithering) -const VERTEX_SHADER = ` - attribute vec2 a_position; - attribute vec2 a_texCoord; - varying vec2 v_texCoord; - - void main() { - gl_Position = vec4(a_position, 0.0, 1.0); - v_texCoord = a_texCoord; - } -`; - -const COLORIZE_SHADER = ` - precision mediump float; - - varying vec2 v_texCoord; - - uniform sampler2D u_image; - uniform vec3 u_color; - - void main() { - vec4 texColor = texture2D(u_image, v_texCoord); - // Dithered image is already black/white, just colorize the white pixels - vec3 finalColor = texColor.r * u_color; - gl_FragColor = vec4(finalColor, texColor.a); - } -`; - -// Bayer dithering shader (for ordered dithering mode) -const BAYER_SHADER = ` - precision mediump float; - - varying vec2 v_texCoord; - - uniform sampler2D u_image; - uniform sampler2D u_bayerTex; - uniform vec2 u_resolution; - uniform float u_ditherSize; - uniform vec3 u_color; - - void main() { - vec2 pixelPos = mod(floor(gl_FragCoord.xy / u_ditherSize), 8.0); - vec4 texColor = texture2D(u_image, v_texCoord); - - // Convert to grayscale using luminance - float gray = dot(texColor.rgb, vec3(0.299, 0.587, 0.114)); - - // Look up threshold from Bayer texture - float threshold = texture2D(u_bayerTex, (pixelPos + 0.5) / 8.0).r; - float dithered = step(threshold, gray); - - // Output color or black - vec3 finalColor = dithered * u_color; - gl_FragColor = vec4(finalColor, texColor.a); - } -`; - -// 8x8 Bayer matrix values (0-63, will be normalized to 0-1) -const BAYER_MATRIX = [ - 0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, - 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, - 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21, -]; - -export type DitherAlgorithm = 'atkinson' | 'bayer' | 'floyd-steinberg'; - -export interface DitheredImageProps extends Omit, 'color'> { - src: string; - alt?: string; - /** Width in pixels. If only width is set, height is calculated from aspect ratio */ - width?: number; - /** Height in pixels. If only height is set, width is calculated from aspect ratio */ - height?: number; - /** Size of dither pattern pixels (default: 1) */ - ditherSize?: number; - /** RGB color for lit pixels (default: terminal green [0, 255, 0]) */ - color?: [number, number, number]; - /** Dithering algorithm (default: 'atkinson' for classic Mac look) */ - algorithm?: DitherAlgorithm; - /** Threshold for black/white conversion (0-255, default: 128) */ - threshold?: number; -} - -/** - * Atkinson dithering algorithm (Bill Atkinson, Apple 1980s) - * Diffuses 6/8 (75%) of the error to create sharper contrast - * - * Error diffusion pattern: - * X 1/8 1/8 - * 1/8 1/8 1/8 - * 1/8 - */ -function atkinsonDither(imageData: ImageData, threshold: number = 128): ImageData { - const data = new Uint8ClampedArray(imageData.data); - const width = imageData.width; - const height = imageData.height; - - const getIndex = (x: number, y: number) => (y * width + x) * 4; - - const addError = (x: number, y: number, error: number) => { - if (x < 0 || x >= width || y < 0 || y >= height) return; - const idx = getIndex(x, y); - data[idx] = Math.max(0, Math.min(255, data[idx] + error)); - data[idx + 1] = Math.max(0, Math.min(255, data[idx + 1] + error)); - data[idx + 2] = Math.max(0, Math.min(255, data[idx + 2] + error)); - }; - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const idx = getIndex(x, y); - - // Get grayscale value using luminance - const gray = data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114; - const newVal = gray < threshold ? 0 : 255; - - // Calculate error (only diffuse 6/8 = 75%) - const error = Math.floor((gray - newVal) / 8); - - // Set pixel to black or white - data[idx] = newVal; - data[idx + 1] = newVal; - data[idx + 2] = newVal; - - // Diffuse error to neighbors (Atkinson pattern) - addError(x + 1, y, error); // right - addError(x + 2, y, error); // right+1 - addError(x - 1, y + 1, error); // bottom-left - addError(x, y + 1, error); // bottom - addError(x + 1, y + 1, error); // bottom-right - addError(x, y + 2, error); // bottom+1 - } - } - - return new ImageData(data, width, height); -} - -/** - * Floyd-Steinberg dithering algorithm - * Diffuses 100% of error, creates smoother gradients - * - * Error diffusion pattern: - * X 7/16 - * 3/16 5/16 1/16 - */ -function floydSteinbergDither(imageData: ImageData, threshold: number = 128): ImageData { - const data = new Uint8ClampedArray(imageData.data); - const width = imageData.width; - const height = imageData.height; - - const getIndex = (x: number, y: number) => (y * width + x) * 4; - - const addError = (x: number, y: number, error: number, factor: number) => { - if (x < 0 || x >= width || y < 0 || y >= height) return; - const idx = getIndex(x, y); - const e = error * factor; - data[idx] = Math.max(0, Math.min(255, data[idx] + e)); - data[idx + 1] = Math.max(0, Math.min(255, data[idx + 1] + e)); - data[idx + 2] = Math.max(0, Math.min(255, data[idx + 2] + e)); - }; - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const idx = getIndex(x, y); - - const gray = data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114; - const newVal = gray < threshold ? 0 : 255; - const error = gray - newVal; - - data[idx] = newVal; - data[idx + 1] = newVal; - data[idx + 2] = newVal; - - // Floyd-Steinberg error diffusion - addError(x + 1, y, error, 7 / 16); - addError(x - 1, y + 1, error, 3 / 16); - addError(x, y + 1, error, 5 / 16); - addError(x + 1, y + 1, error, 1 / 16); - } - } - - return new ImageData(data, width, height); -} - -function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null { - const shader = gl.createShader(type); - if (!shader) return null; - - gl.shaderSource(shader, source); - gl.compileShader(shader); - - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - console.error('Shader compile error:', gl.getShaderInfoLog(shader)); - gl.deleteShader(shader); - return null; - } - return shader; -} - -function createProgram( - gl: WebGLRenderingContext, - vertexShader: WebGLShader, - fragmentShader: WebGLShader -): WebGLProgram | null { - const program = gl.createProgram(); - if (!program) return null; - - gl.attachShader(program, vertexShader); - gl.attachShader(program, fragmentShader); - gl.linkProgram(program); - - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - console.error('Program link error:', gl.getProgramInfoLog(program)); - gl.deleteProgram(program); - return null; - } - return program; -} - -const DitheredImage = React.forwardRef( - ( - { - className, - src, - alt, - width, - height, - ditherSize = 1, - color = [0, 255, 0], - algorithm = 'atkinson', - threshold = 128, - ...props - }, - ref - ) => { - const canvasRef = React.useRef(null); - const glRef = React.useRef(null); - const programRef = React.useRef(null); - const bayerTexRef = React.useRef(null); - - React.useImperativeHandle(ref, () => canvasRef.current!); - - // Initialize WebGL - React.useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const gl = canvas.getContext('webgl', { preserveDrawingBuffer: true }); - if (!gl) { - console.error('WebGL not supported'); - return; - } - glRef.current = gl; - - // Choose shader based on algorithm - const fragmentShaderSource = algorithm === 'bayer' ? BAYER_SHADER : COLORIZE_SHADER; - - const vertexShader = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); - const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource); - - if (!vertexShader || !fragmentShader) { - console.error('Failed to create shaders'); - return; - } - - const program = createProgram(gl, vertexShader, fragmentShader); - if (!program) { - console.error('Failed to create program'); - return; - } - - // biome-ignore lint/correctness/useHookAtTopLevel: This is WebGL's useProgram, not a React hook - gl.useProgram(program); - programRef.current = program; - - // Create Bayer texture (only needed for bayer algorithm) - if (algorithm === 'bayer') { - const bayerTex = gl.createTexture(); - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, bayerTex); - const bayerData = new Uint8Array(BAYER_MATRIX.map((v) => (v / 64) * 255)); - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.LUMINANCE, - 8, - 8, - 0, - gl.LUMINANCE, - gl.UNSIGNED_BYTE, - bayerData - ); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); - bayerTexRef.current = bayerTex; - } - - // Set up geometry (full-screen quad) - const positions = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]); - const texCoords = new Float32Array([0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0]); - - const positionBuffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); - gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW); - const positionLoc = gl.getAttribLocation(program, 'a_position'); - gl.enableVertexAttribArray(positionLoc); - gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0); - - const texCoordBuffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); - gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW); - const texCoordLoc = gl.getAttribLocation(program, 'a_texCoord'); - gl.enableVertexAttribArray(texCoordLoc); - gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, 0, 0); - - // Set texture units - gl.uniform1i(gl.getUniformLocation(program, 'u_image'), 0); - if (algorithm === 'bayer') { - gl.uniform1i(gl.getUniformLocation(program, 'u_bayerTex'), 1); - } - - return () => { - gl.deleteProgram(program); - gl.deleteShader(vertexShader); - gl.deleteShader(fragmentShader); - if (bayerTexRef.current) { - gl.deleteTexture(bayerTexRef.current); - } - }; - }, [algorithm]); - - // Process image when src or settings change - React.useEffect(() => { - const canvas = canvasRef.current; - const gl = glRef.current; - const program = programRef.current; - if (!canvas || !gl || !program) return; - - const image = new Image(); - image.crossOrigin = 'anonymous'; - image.onload = () => { - const aspectRatio = image.naturalWidth / image.naturalHeight; - let w: number; - let h: number; - - if (width && height) { - w = width; - h = height; - } else if (width) { - w = width; - h = Math.round(width / aspectRatio); - } else if (height) { - h = height; - w = Math.round(height * aspectRatio); - } else { - w = image.naturalWidth; - h = image.naturalHeight; - } - - canvas.width = w; - canvas.height = h; - gl.viewport(0, 0, w, h); - - // biome-ignore lint/correctness/useHookAtTopLevel: This is WebGL's useProgram, not a React hook - gl.useProgram(program); - - let textureData: ImageData | HTMLImageElement = image; - - // For error diffusion algorithms, process on CPU first - if (algorithm === 'atkinson' || algorithm === 'floyd-steinberg') { - // Draw image to temp canvas to get pixel data - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = w; - tempCanvas.height = h; - const tempCtx = tempCanvas.getContext('2d'); - if (tempCtx) { - tempCtx.drawImage(image, 0, 0, w, h); - const imageData = tempCtx.getImageData(0, 0, w, h); - - // Apply dithering algorithm - const dithered = - algorithm === 'atkinson' - ? atkinsonDither(imageData, threshold) - : floydSteinbergDither(imageData, threshold); - - textureData = dithered; - } - } - - // Bind Bayer texture if needed - if (algorithm === 'bayer') { - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, bayerTexRef.current); - } - - // Create and bind image texture - gl.activeTexture(gl.TEXTURE0); - const texture = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, texture); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); - - if (textureData instanceof ImageData) { - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData); - } else { - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureData); - } - - // Set uniforms - if (algorithm === 'bayer') { - gl.uniform2f(gl.getUniformLocation(program, 'u_resolution'), w, h); - gl.uniform1f(gl.getUniformLocation(program, 'u_ditherSize'), ditherSize); - } - gl.uniform3f( - gl.getUniformLocation(program, 'u_color'), - color[0] / 255, - color[1] / 255, - color[2] / 255 - ); - - // Draw - gl.drawArrays(gl.TRIANGLES, 0, 6); - - // Clean up image texture - gl.deleteTexture(texture); - }; - image.onerror = () => { - console.error('Failed to load image:', src); - }; - image.src = src; - }, [src, width, height, ditherSize, color, algorithm, threshold]); - - return ( - - ); - } -); -DitheredImage.displayName = 'DitheredImage'; - -export { DitheredImage }; diff --git a/src/components/ui/dithered-image/index.ts b/src/components/ui/dithered-image/index.ts deleted file mode 100644 index 72991fc..0000000 --- a/src/components/ui/dithered-image/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { DitherAlgorithm, DitheredImageProps } from './dithered-image'; -export { DitheredImage } from './dithered-image'; diff --git a/src/components/ui/dropdown-menu/dropdown-menu.stories.tsx b/src/components/ui/dropdown-menu/dropdown-menu.stories.tsx deleted file mode 100644 index eb44525..0000000 --- a/src/components/ui/dropdown-menu/dropdown-menu.stories.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { Button } from '../button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from './dropdown-menu'; - -const meta: Meta = { - title: 'Components/DropdownMenu', - component: DropdownMenu, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - render: () => ( - - - - - - My Account - - - Profile - ⇧⌘P - - - Billing - ⌘B - - - Settings - ⌘S - - - - Log out - ⇧⌘Q - - - - ), -}; - -export const Simple: Story = { - render: () => ( - - - - - - Edit - Duplicate - Delete - - - ), -}; diff --git a/src/components/ui/dropdown-menu/dropdown-menu.test.tsx b/src/components/ui/dropdown-menu/dropdown-menu.test.tsx deleted file mode 100644 index 84243cb..0000000 --- a/src/components/ui/dropdown-menu/dropdown-menu.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from './dropdown-menu'; - -describe('DropdownMenu', () => { - it('renders dropdown trigger', () => { - render( - - Open - - Item 1 - - - ); - expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument(); - }); - - it('opens dropdown on click', async () => { - const user = userEvent.setup(); - render( - - Open - - Item 1 - Item 2 - - - ); - await user.click(screen.getByRole('button', { name: 'Open' })); - expect(screen.getByRole('menuitem', { name: 'Item 1' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Item 2' })).toBeInTheDocument(); - }); - - it('renders label and separator', async () => { - const user = userEvent.setup(); - render( - - Open - - My Account - - Profile - - - ); - await user.click(screen.getByRole('button', { name: 'Open' })); - expect(screen.getByText('My Account')).toBeInTheDocument(); - expect(screen.getByRole('separator')).toBeInTheDocument(); - }); -}); diff --git a/src/components/ui/dropdown-menu/dropdown-menu.tsx b/src/components/ui/dropdown-menu/dropdown-menu.tsx deleted file mode 100644 index 8ac31a0..0000000 --- a/src/components/ui/dropdown-menu/dropdown-menu.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import { Check, ChevronRight, Circle } from 'lucide-react'; -import * as React from 'react'; -import { cn } from '@/lib/utils'; - -const DropdownMenu = DropdownMenuPrimitive.Root; -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -const DropdownMenuGroup = DropdownMenuPrimitive.Group; -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub; -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - svg]:size-4 [&>svg]:shrink-0', - inset && 'pl-8', - className - )} - {...props} - /> -)); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { - return ( - - ); -}; -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -}; diff --git a/src/components/ui/dropdown-menu/index.ts b/src/components/ui/dropdown-menu/index.ts deleted file mode 100644 index 9c087d5..0000000 --- a/src/components/ui/dropdown-menu/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuPortal, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from './dropdown-menu'; diff --git a/src/components/ui/form/form.stories.tsx b/src/components/ui/form/form.stories.tsx deleted file mode 100644 index aca5b42..0000000 --- a/src/components/ui/form/form.stories.tsx +++ /dev/null @@ -1,342 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import type { Meta, StoryObj } from '@storybook/react'; -import { useForm } from 'react-hook-form'; -import * as z from 'zod'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from './form'; - -const meta: Meta = { - title: 'Components/Form', - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -const basicSchema = z.object({ - username: z.string().min(2, { - message: 'Username must be at least 2 characters.', - }), - email: z.string().email({ - message: 'Please enter a valid email address.', - }), -}); - -export const BasicForm: Story = { - render: function BasicFormExample() { - const form = useForm>({ - resolver: zodResolver(basicSchema), - defaultValues: { - username: '', - email: '', - }, - }); - - function onSubmit(values: z.infer) { - console.log(values); - alert(JSON.stringify(values, null, 2)); - } - - return ( -
- - ( - - Username - - - - Your public display name. - - - )} - /> - ( - - Email - - - - We'll never share your email. - - - )} - /> - - - - ); - }, -}; - -const vimConfigSchema = z.object({ - leader: z.string().min(1, 'Leader key is required'), - colorscheme: z.string().min(1, 'Select a colorscheme'), - relativenumber: z.boolean(), - tabstop: z.coerce.number().min(1).max(8), - notes: z.string().optional(), -}); - -export const VimConfigForm: Story = { - render: function VimConfigExample() { - const form = useForm>({ - resolver: zodResolver(vimConfigSchema), - defaultValues: { - leader: ' ', - colorscheme: '', - relativenumber: true, - tabstop: 2, - notes: '', - }, - }); - - function onSubmit(values: z.infer) { - console.log(values); - alert(`:set applied!\n${JSON.stringify(values, null, 2)}`); - } - - return ( -
- - ( - - Leader Key - - - - vim.g.mapleader = "{field.value}" - - - )} - /> - ( - - Colorscheme - - Select your preferred color theme. - - - )} - /> - ( - - - - -
- Relative numbers - :set relativenumber -
-
- )} - /> - ( - - Tab Stop - - - - :set tabstop={field.value} - - - )} - /> - ( - - Notes - -