diff --git a/.husky/pre-commit b/.husky/pre-commit index 56bd4d28..b6c9553b 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,8 @@ # Run validation before commit (typecheck + lint + format + tests) bun run validate + +# Validate UI if changes detected +if git diff --cached --name-only | grep -q "^ui/"; then + echo "[i] UI changes detected, running ui:validate..." + bun run ui:validate +fi diff --git a/CLAUDE.md b/CLAUDE.md index 6063af47..462bee76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,49 @@ bun run format # Auto-fix formatting - `src/` - TypeScript source (55 modules) - `dist/` - Compiled JavaScript (npm package) - `lib/` - Native shell scripts (bash, PowerShell) +- `ui/` - React dashboard (Vite + React 19 + shadcn/ui) + +## UI Quality Gates (React Dashboard) + +**The ui/ directory has IDENTICAL quality gates to the main project.** + +**Package Manager: bun** (same as root) +```bash +cd ui +bun install # Install dependencies +bun run build # TypeScript + Vite build +bun run validate # Full validation: typecheck + lint:fix + format:check +``` + +**Fix issues before committing:** +```bash +cd ui +bun run typecheck # Type check only +bun run lint:fix # Auto-fix lint issues +bun run format # Auto-fix formatting +bun run format:check # Verify formatting (no changes) +``` + +**Linting rules (ui/eslint.config.js) - ALL errors:** +- `@typescript-eslint/no-unused-vars` - error (ignore `_` prefix) +- `@typescript-eslint/no-explicit-any` - error +- `@typescript-eslint/no-non-null-assertion` - error +- `prefer-const`, `no-var`, `eqeqeq` - error +- `react-hooks/exhaustive-deps` - warning +- `react-refresh/only-export-components` - error + +**Type safety (ui/tsconfig.app.json):** +- `strict: true` with `verbatimModuleSyntax` enabled +- `noUnusedLocals`, `noUnusedParameters` - enabled +- Type-only imports required: `import type { X }` for types + +**UI file structure:** +- `ui/src/` - React components, hooks, pages +- `ui/src/components/ui/` - shadcn/ui components +- `ui/src/hooks/` - Custom React hooks +- `ui/src/pages/` - Route pages +- `ui/src/providers/` - Context providers +- `dist/ui/` - Built bundle (served by Express) **Linting rules (eslint.config.mjs) - ALL errors:** - `@typescript-eslint/no-unused-vars` - error (ignore `_` prefix) diff --git a/package.json b/package.json index dbebdfe6..773a63f2 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "ui:dev": "cd ui && bun run dev", "ui:build": "cd ui && bun run build", "ui:preview": "cd ui && bun run preview", + "ui:validate": "cd ui && bun run validate", "prepublishOnly": "bun run validate && node scripts/sync-version.js", "prepack": "bun run validate && node scripts/sync-version.js", "prepare": "husky", diff --git a/ui/.prettierrc b/ui/.prettierrc new file mode 100644 index 00000000..2d30e11b --- /dev/null +++ b/ui/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always" +} diff --git a/ui/@/components/ui/alert-dialog.tsx b/ui/@/components/ui/alert-dialog.tsx deleted file mode 100644 index 935eecf3..00000000 --- a/ui/@/components/ui/alert-dialog.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import * as React from "react" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" - -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" - -function AlertDialog({ - ...props -}: React.ComponentProps) { - return -} - -function AlertDialogTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogPortal({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - - ) -} - -function AlertDialogHeader({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AlertDialogFooter({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AlertDialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogAction({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogCancel({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, -} diff --git a/ui/@/components/ui/button.tsx b/ui/@/components/ui/button.tsx deleted file mode 100644 index 21409a06..00000000 --- a/ui/@/components/ui/button.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-sm": "size-8", - "icon-lg": "size-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot : "button" - - return ( - - ) -} - -export { Button, buttonVariants } diff --git a/ui/@/components/ui/card.tsx b/ui/@/components/ui/card.tsx deleted file mode 100644 index 681ad980..00000000 --- a/ui/@/components/ui/card.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -function Card({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, -} diff --git a/ui/@/components/ui/dialog.tsx b/ui/@/components/ui/dialog.tsx deleted file mode 100644 index 6cb123b3..00000000 --- a/ui/@/components/ui/dialog.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" - -import { cn } from "@/lib/utils" - -function Dialog({ - ...props -}: React.ComponentProps) { - return -} - -function DialogTrigger({ - ...props -}: React.ComponentProps) { - return -} - -function DialogPortal({ - ...props -}: React.ComponentProps) { - return -} - -function DialogClose({ - ...props -}: React.ComponentProps) { - return -} - -function DialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DialogContent({ - className, - children, - showCloseButton = true, - ...props -}: React.ComponentProps & { - showCloseButton?: boolean -}) { - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ) -} - -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - DialogTrigger, -} diff --git a/ui/@/components/ui/dropdown-menu.tsx b/ui/@/components/ui/dropdown-menu.tsx deleted file mode 100644 index eaed9baf..00000000 --- a/ui/@/components/ui/dropdown-menu.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" - -import { cn } from "@/lib/utils" - -function DropdownMenu({ - ...props -}: React.ComponentProps) { - return -} - -function DropdownMenuPortal({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuContent({ - className, - sideOffset = 4, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -function DropdownMenuGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props -}: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" -}) { - return ( - - ) -} - -function DropdownMenuCheckboxItem({ - className, - children, - checked, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuRadioGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuRadioItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuLabel({ - className, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - ) -} - -function DropdownMenuSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ) -} - -function DropdownMenuSub({ - ...props -}: React.ComponentProps) { - return -} - -function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - {children} - - - ) -} - -function DropdownMenuSubContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - DropdownMenu, - DropdownMenuPortal, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -} diff --git a/ui/@/components/ui/input.tsx b/ui/@/components/ui/input.tsx deleted file mode 100644 index 89169058..00000000 --- a/ui/@/components/ui/input.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - - ) -} - -export { Input } diff --git a/ui/@/components/ui/label.tsx b/ui/@/components/ui/label.tsx deleted file mode 100644 index fb5fbc3e..00000000 --- a/ui/@/components/ui/label.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client" - -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" - -import { cn } from "@/lib/utils" - -function Label({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { Label } diff --git a/ui/@/components/ui/separator.tsx b/ui/@/components/ui/separator.tsx deleted file mode 100644 index 275381ca..00000000 --- a/ui/@/components/ui/separator.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client" - -import * as React from "react" -import * as SeparatorPrimitive from "@radix-ui/react-separator" - -import { cn } from "@/lib/utils" - -function Separator({ - className, - orientation = "horizontal", - decorative = true, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { Separator } diff --git a/ui/@/components/ui/sheet.tsx b/ui/@/components/ui/sheet.tsx deleted file mode 100644 index 6906f5b2..00000000 --- a/ui/@/components/ui/sheet.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import * as React from "react" -import * as SheetPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" - -import { cn } from "@/lib/utils" - -function Sheet({ ...props }: React.ComponentProps) { - return -} - -function SheetTrigger({ - ...props -}: React.ComponentProps) { - return -} - -function SheetClose({ - ...props -}: React.ComponentProps) { - return -} - -function SheetPortal({ - ...props -}: React.ComponentProps) { - return -} - -function SheetOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SheetContent({ - className, - children, - side = "right", - ...props -}: React.ComponentProps & { - side?: "top" | "right" | "bottom" | "left" -}) { - return ( - - - - {children} - - - Close - - - - ) -} - -function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SheetTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SheetDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - Sheet, - SheetTrigger, - SheetClose, - SheetContent, - SheetHeader, - SheetFooter, - SheetTitle, - SheetDescription, -} diff --git a/ui/@/components/ui/sidebar.tsx b/ui/@/components/ui/sidebar.tsx deleted file mode 100644 index 9c706c57..00000000 --- a/ui/@/components/ui/sidebar.tsx +++ /dev/null @@ -1,724 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" -import { PanelLeftIcon } from "lucide-react" - -import { useIsMobile } from "@/hooks/use-mobile" -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Skeleton } from "@/components/ui/skeleton" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" - -const SIDEBAR_COOKIE_NAME = "sidebar_state" -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_WIDTH = "16rem" -const SIDEBAR_WIDTH_MOBILE = "18rem" -const SIDEBAR_WIDTH_ICON = "3rem" -const SIDEBAR_KEYBOARD_SHORTCUT = "b" - -type SidebarContextProps = { - state: "expanded" | "collapsed" - open: boolean - setOpen: (open: boolean) => void - openMobile: boolean - setOpenMobile: (open: boolean) => void - isMobile: boolean - toggleSidebar: () => void -} - -const SidebarContext = React.createContext(null) - -function useSidebar() { - const context = React.useContext(SidebarContext) - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider.") - } - - return context -} - -function SidebarProvider({ - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props -}: React.ComponentProps<"div"> & { - defaultOpen?: boolean - open?: boolean - onOpenChange?: (open: boolean) => void -}) { - const isMobile = useIsMobile() - const [openMobile, setOpenMobile] = React.useState(false) - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen) - const open = openProp ?? _open - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value - if (setOpenProp) { - setOpenProp(openState) - } else { - _setOpen(openState) - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` - }, - [setOpenProp, open] - ) - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) - }, [isMobile, setOpen, setOpenMobile]) - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault() - toggleSidebar() - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [toggleSidebar]) - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed" - - const contextValue = React.useMemo( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] - ) - - return ( - - -
- {children} -
-
-
- ) -} - -function Sidebar({ - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - ...props -}: React.ComponentProps<"div"> & { - side?: "left" | "right" - variant?: "sidebar" | "floating" | "inset" - collapsible?: "offcanvas" | "icon" | "none" -}) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() - - if (collapsible === "none") { - return ( -
- {children} -
- ) - } - - if (isMobile) { - return ( - - - - Sidebar - Displays the mobile sidebar. - -
{children}
-
-
- ) - } - - return ( -
- {/* This is what handles the sidebar gap on desktop */} -
- -
- ) -} - -function SidebarTrigger({ - className, - onClick, - ...props -}: React.ComponentProps) { - const { toggleSidebar } = useSidebar() - - return ( - - ) -} - -function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar() - - return ( -