From b13ff7b316024eca19107ffa7876f256150b5731 Mon Sep 17 00:00:00 2001 From: Matei Date: Tue, 9 Jun 2026 00:16:18 -0400 Subject: [PATCH] feat(mapgen-studio): canonical token-driven shadcn ui primitives Add the canonical primitive library at src/components/ui/ built ON the existing design tokens + cn: button, input, textarea, label, select, switch, checkbox, tooltip, dialog, dropdown-menu, popover, tabs, separator, scroll-area, sonner. Token-driven only (no hex palettes, no lightMode prop, no prefers-color-scheme); dense as-built dimensions (Button h-8/h-7, Input h-7 11px, Switch 36x20) and the contour-luminance focus signature. Add the named type scale tokens (--text-data/--text-label) and register them in cn's tailwind-merge font-size group so a color utility can't clobber the size. Additive: no existing call site is swapped. OpenSpec: mapgen-studio-ui-primitives. Co-Authored-By: Claude Opus 4.8 --- apps/mapgen-studio/package.json | 17 +- .../src/components/ui/button.tsx | 65 ++++++ .../src/components/ui/checkbox.tsx | 35 +++ .../src/components/ui/dialog.tsx | 121 +++++++++++ .../src/components/ui/dropdown-menu.tsx | 202 ++++++++++++++++++ apps/mapgen-studio/src/components/ui/index.ts | 70 ++++++ .../mapgen-studio/src/components/ui/input.tsx | 31 +++ .../mapgen-studio/src/components/ui/label.tsx | 25 +++ .../src/components/ui/popover.tsx | 35 +++ .../src/components/ui/scroll-area.tsx | 49 +++++ .../src/components/ui/select.tsx | 170 +++++++++++++++ .../src/components/ui/separator.tsx | 33 +++ .../src/components/ui/sonner.tsx | 66 ++++++ .../src/components/ui/switch.tsx | 36 ++++ apps/mapgen-studio/src/components/ui/tabs.tsx | 63 ++++++ .../src/components/ui/textarea.tsx | 29 +++ .../src/components/ui/tooltip.tsx | 35 +++ apps/mapgen-studio/src/index.css | 7 + apps/mapgen-studio/src/lib/utils.ts | 18 +- bun.lock | 93 ++++++-- .../mapgen-studio-ui-primitives/design.md | 63 ++++++ .../mapgen-studio-ui-primitives/proposal.md | 45 ++++ .../specs/mapgen-studio/spec.md | 84 ++++++++ .../mapgen-studio-ui-primitives/tasks.md | 39 ++++ 24 files changed, 1406 insertions(+), 25 deletions(-) create mode 100644 apps/mapgen-studio/src/components/ui/button.tsx create mode 100644 apps/mapgen-studio/src/components/ui/checkbox.tsx create mode 100644 apps/mapgen-studio/src/components/ui/dialog.tsx create mode 100644 apps/mapgen-studio/src/components/ui/dropdown-menu.tsx create mode 100644 apps/mapgen-studio/src/components/ui/index.ts create mode 100644 apps/mapgen-studio/src/components/ui/input.tsx create mode 100644 apps/mapgen-studio/src/components/ui/label.tsx create mode 100644 apps/mapgen-studio/src/components/ui/popover.tsx create mode 100644 apps/mapgen-studio/src/components/ui/scroll-area.tsx create mode 100644 apps/mapgen-studio/src/components/ui/select.tsx create mode 100644 apps/mapgen-studio/src/components/ui/separator.tsx create mode 100644 apps/mapgen-studio/src/components/ui/sonner.tsx create mode 100644 apps/mapgen-studio/src/components/ui/switch.tsx create mode 100644 apps/mapgen-studio/src/components/ui/tabs.tsx create mode 100644 apps/mapgen-studio/src/components/ui/textarea.tsx create mode 100644 apps/mapgen-studio/src/components/ui/tooltip.tsx create mode 100644 openspec/changes/mapgen-studio-ui-primitives/design.md create mode 100644 openspec/changes/mapgen-studio-ui-primitives/proposal.md create mode 100644 openspec/changes/mapgen-studio-ui-primitives/specs/mapgen-studio/spec.md create mode 100644 openspec/changes/mapgen-studio-ui-primitives/tasks.md diff --git a/apps/mapgen-studio/package.json b/apps/mapgen-studio/package.json index 605341481c..8a4387c24d 100644 --- a/apps/mapgen-studio/package.json +++ b/apps/mapgen-studio/package.json @@ -21,13 +21,25 @@ "@civ7/plugin-mods": "workspace:*", "@civ7/studio-server": "workspace:*", "@deck.gl/core": "^9.0.43", - "@fontsource/inter": "^5.1.1", - "@fontsource/jetbrains-mono": "^5.1.1", "@deck.gl/layers": "^9.0.43", "@deck.gl/react": "^9.0.43", + "@fontsource/inter": "^5.1.1", + "@fontsource/jetbrains-mono": "^5.1.1", "@orpc/client": "^1.14.4", "@orpc/contract": "^1.14.4", "@orpc/server": "^1.14.4", + "@radix-ui/react-checkbox": "1.3.4", + "@radix-ui/react-dialog": "1.1.16", + "@radix-ui/react-dropdown-menu": "2.1.17", + "@radix-ui/react-label": "2.1.9", + "@radix-ui/react-popover": "1.1.16", + "@radix-ui/react-scroll-area": "1.2.11", + "@radix-ui/react-select": "2.3.0", + "@radix-ui/react-separator": "1.1.9", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-switch": "1.3.0", + "@radix-ui/react-tabs": "1.1.14", + "@radix-ui/react-tooltip": "1.2.9", "@standard-schema/spec": "^1.1.0", "@rjsf/core": "^6.2.5", "@rjsf/utils": "^6.2.5", @@ -42,6 +54,7 @@ "mod-swooper-maps": "workspace:*", "react": "^19.2.4", "react-dom": "^19.2.4", + "sonner": "2.0.7", "tailwind-merge": "latest", "typebox": "^1.0.80" }, diff --git a/apps/mapgen-studio/src/components/ui/button.tsx b/apps/mapgen-studio/src/components/ui/button.tsx new file mode 100644 index 0000000000..eaa74ba618 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/button.tsx @@ -0,0 +1,65 @@ +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"; + +/** + * Button — the one filled action in a borders-only instrument. + * + * `default` is the single restrained primary slate; everything else is a + * contour (outline / ghost / link). Dense as-built dimensions are preserved: + * h-8 default, h-7 sm, 4px radius. Focus is the luminance contour ring. + */ +const buttonVariants = cva( + "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-sm text-data font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground border border-primary hover:bg-primary/90 active:bg-primary/80", + destructive: + "bg-destructive text-destructive-foreground border border-destructive hover:bg-destructive/90", + outline: + "border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground active:bg-muted", + secondary: + "bg-secondary text-secondary-foreground border border-border hover:bg-muted", + ghost: "text-foreground hover:bg-accent hover:text-accent-foreground", + link: "text-foreground underline-offset-4 hover:underline", + }, + size: { + default: "h-8 px-3", + sm: "h-7 px-2.5", + lg: "h-9 px-4", + icon: "h-7 w-7", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + /** Render as the child element (Radix `asChild` slot pattern). */ + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/apps/mapgen-studio/src/components/ui/checkbox.tsx b/apps/mapgen-studio/src/components/ui/checkbox.tsx new file mode 100644 index 0000000000..d9fd931c75 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/checkbox.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * Checkbox — dense 14px box, 4px radius. Checked fills with the one primary + * slate; focus is the luminance contour ring. + */ +const Checkbox = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/apps/mapgen-studio/src/components/ui/dialog.tsx b/apps/mapgen-studio/src/components/ui/dialog.tsx new file mode 100644 index 0000000000..b45221187f --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * Dialog — modal on the floating tier. The overlay is a dimmed scrim with a + * quiet backdrop blur; the panel rests on `popover` with an 8px radius and a + * shadow (floating depth). Titles use 14px (`text-sm`). + */ +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/mapgen-studio/src/components/ui/dropdown-menu.tsx b/apps/mapgen-studio/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000000..ed9fb789a1 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,202 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * DropdownMenu — Radix menu on the `popover` floating tier (8px radius, + * shadow). Items are dense (11px, py-1) with the accent surface as the + * highlight; the active mark is a contour, not a slab. + */ +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.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-3.5 [&>svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = + DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => ( + +); +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/mapgen-studio/src/components/ui/index.ts b/apps/mapgen-studio/src/components/ui/index.ts new file mode 100644 index 0000000000..895628a61b --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/index.ts @@ -0,0 +1,70 @@ +/** + * Canonical shadcn primitive library for MapGen Studio. + * + * Built ON the token system in `src/index.css` + the `cn` helper. Dense, + * dark-first, borders-only; floating layers (dialog/popover/dropdown/tooltip/ + * toast) are the only surfaces that carry a shadow. Focus is the luminance + * contour ring (`--ring`). No hardcoded palettes, no `lightMode` prop. + * + * Additive only: this barrel introduces the primitives; existing call sites + * are migrated in a later slice. + */ +export { Button, buttonVariants, type ButtonProps } from "./button"; +export { Input } from "./input"; +export { Textarea } from "./textarea"; +export { Label } from "./label"; +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} from "./select"; +export { Switch } from "./switch"; +export { Checkbox } from "./checkbox"; +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, +} from "./tooltip"; +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from "./dialog"; +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} from "./dropdown-menu"; +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } from "./popover"; +export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs"; +export { Separator } from "./separator"; +export { ScrollArea, ScrollBar } from "./scroll-area"; +export { Toaster } from "./sonner"; +export { toast } from "sonner"; diff --git a/apps/mapgen-studio/src/components/ui/input.tsx b/apps/mapgen-studio/src/components/ui/input.tsx new file mode 100644 index 0000000000..0bc5fc03bc --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/input.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +/** + * Input — dense inset field. h-7, 11px data type, 4px radius. Sits on the + * inset `input-background` substrate with the `input` control border; focus + * draws the luminance contour ring. + */ +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/apps/mapgen-studio/src/components/ui/label.tsx b/apps/mapgen-studio/src/components/ui/label.tsx new file mode 100644 index 0000000000..bb9f02d3f1 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/label.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; + +import { cn } from "@/lib/utils"; + +/** + * Label — the field eyebrow. 11px data type by default; pair with + * `text-label` for the 10px uppercase eyebrow when a control needs it. + */ +const Label = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/mapgen-studio/src/components/ui/popover.tsx b/apps/mapgen-studio/src/components/ui/popover.tsx new file mode 100644 index 0000000000..3256c553d6 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/popover.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "@/lib/utils"; + +/** + * Popover — Radix floating surface on the `popover` tier (8px radius, shadow). + * Generic container for inspector pickers and overflow content. + */ +const Popover = PopoverPrimitive.Root; +const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/apps/mapgen-studio/src/components/ui/scroll-area.tsx b/apps/mapgen-studio/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000000..ad06b20d50 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/scroll-area.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +/** + * ScrollArea — Radix custom scroller. The thumb is a quiet hairline that stays + * findable on hover, matching the bespoke `.custom-scrollbar` treatment. + */ +const ScrollArea = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/apps/mapgen-studio/src/components/ui/select.tsx b/apps/mapgen-studio/src/components/ui/select.tsx new file mode 100644 index 0000000000..08f71e6983 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/select.tsx @@ -0,0 +1,170 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * Select — Radix listbox. Trigger matches the dense Input footprint (h-7, + * 11px). Content floats on the `popover` tier with a shadow (the only place + * depth-by-shadow is allowed — floating layers). + */ +const Select = SelectPrimitive.Root; +const SelectGroup = SelectPrimitive.Group; +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/apps/mapgen-studio/src/components/ui/separator.tsx b/apps/mapgen-studio/src/components/ui/separator.tsx new file mode 100644 index 0000000000..075b19f526 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/separator.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +/** + * Separator — a hairline divider within a surface. Uses the `border-subtle` + * tier so intra-surface dividers sit quieter than panel edges. + */ +const Separator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref, + ) => ( + + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/mapgen-studio/src/components/ui/sonner.tsx b/apps/mapgen-studio/src/components/ui/sonner.tsx new file mode 100644 index 0000000000..fb0a5d8a82 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/sonner.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +/** + * Toaster — sonner bound to the design tokens. Theme follows the single + * `.dark` class strategy (no next-themes, no prefers-color-scheme): we read + * the class off `` so toasts re-theme with the rest of the chrome. + * + * Toasts are a floating layer, so they carry a shadow and ride the `popover` + * tier; styling is token-driven via CSS variables. + */ +const useThemeFromClass = (): "light" | "dark" => { + const getTheme = React.useCallback( + (): "light" | "dark" => + typeof document !== "undefined" && + document.documentElement.classList.contains("dark") + ? "dark" + : "light", + [], + ); + + const [theme, setTheme] = React.useState<"light" | "dark">(getTheme); + + React.useEffect(() => { + if (typeof document === "undefined") return; + const root = document.documentElement; + const observer = new MutationObserver(() => setTheme(getTheme())); + observer.observe(root, { attributes: true, attributeFilter: ["class"] }); + setTheme(getTheme()); + return () => observer.disconnect(); + }, [getTheme]); + + return theme; +}; + +const Toaster = ({ ...props }: ToasterProps) => { + const theme = useThemeFromClass(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/apps/mapgen-studio/src/components/ui/switch.tsx b/apps/mapgen-studio/src/components/ui/switch.tsx new file mode 100644 index 0000000000..8a8ac13060 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/switch.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import * as SwitchPrimitive from "@radix-ui/react-switch"; + +import { cn } from "@/lib/utils"; + +/** + * Switch — 36x20 (w-9 h-5) as-built dimensions, full radius. Checked fills + * with the one primary slate; unchecked rests on the muted surface. Focus is + * the luminance contour ring. + */ +const Switch = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitive.Root.displayName; + +export { Switch }; diff --git a/apps/mapgen-studio/src/components/ui/tabs.tsx b/apps/mapgen-studio/src/components/ui/tabs.tsx new file mode 100644 index 0000000000..c021f6ab1b --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/tabs.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +/** + * Tabs — the active tab is marked by a thin steel contour rule (underline), + * not a filled slab, per the signature. List rides on the muted surface; the + * active trigger lifts to the panel tier with a primary underline. + */ +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/mapgen-studio/src/components/ui/textarea.tsx b/apps/mapgen-studio/src/components/ui/textarea.tsx new file mode 100644 index 0000000000..e7c7a45ab8 --- /dev/null +++ b/apps/mapgen-studio/src/components/ui/textarea.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +/** + * Textarea — multi-line sibling of Input. Same inset substrate, control + * border, 11px data type, and luminance focus ring. + */ +const Textarea = React.forwardRef< + HTMLTextAreaElement, + React.ComponentProps<"textarea"> +>(({ className, ...props }, ref) => { + return ( +