a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
textarea]:h-auto",
+
+ // Variants based on alignment.
+ "has-[>[data-align=inline-start]]:[&_input]:pl-2",
+ "has-[>[data-align=inline-end]]:[&_input]:pr-2",
+ "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&_input]:pb-3",
+ "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&_input]:pt-3",
+
+ // Focus state.
+ "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
+
+ // Error state.
+ "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
+
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const inputGroupAddonVariants = cva(
+ "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ align: {
+ "inline-start": "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
+ "inline-end": "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
+ "block-start":
+ "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
+ "block-end": "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ },
+);
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ // The addon behaves like a label: clicking it moves focus into the control. Keyboard
+ // users reach the control directly with Tab, so a key handler here would add nothing.
+ return (
+ // oxlint-disable-next-line jsx-a11y/click-events-have-key-events
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return;
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus();
+ }}
+ {...props}
+ />
+ );
+}
+
+const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
+ "icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+});
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size"> & VariantProps) {
+ return (
+
+ );
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function InputGroupInput({ className, inputClassName, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
+
+export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea };
diff --git a/apps/admin/src/components/ui/item.tsx b/apps/admin/src/components/ui/item.tsx
new file mode 100644
index 0000000..b382a8a
--- /dev/null
+++ b/apps/admin/src/components/ui/item.tsx
@@ -0,0 +1,158 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemSeparator({ className, ...props }: React.ComponentProps) {
+ return ;
+}
+
+const itemVariants = cva(
+ "group/item focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors [a]:hover:bg-accent/50",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline: "border-border",
+ muted: "bg-muted/50",
+ },
+ size: {
+ default: "gap-4 p-4",
+ sm: "gap-2.5 px-4 py-3",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Item({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "div";
+ return (
+
+ );
+}
+
+const itemMediaVariants = cva(
+ "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4",
+ image: "size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function ItemMedia({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemFooter,
+ ItemGroup,
+ ItemHeader,
+ ItemMedia,
+ ItemSeparator,
+ ItemTitle,
+};
diff --git a/apps/admin/src/components/ui/kbd.tsx b/apps/admin/src/components/ui/kbd.tsx
new file mode 100644
index 0000000..3076797
--- /dev/null
+++ b/apps/admin/src/components/ui/kbd.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
+ return (
+
+ );
+}
+
+function KbdGroup({ className, ...props }: React.ComponentProps<"kbd">) {
+ return ;
+}
+
+export { Kbd, KbdGroup };
diff --git a/apps/admin/src/components/ui/native-select.tsx b/apps/admin/src/components/ui/native-select.tsx
new file mode 100644
index 0000000..f184f36
--- /dev/null
+++ b/apps/admin/src/components/ui/native-select.tsx
@@ -0,0 +1,92 @@
+import { ChevronDownIcon } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * Styled `` for the cases where a native picker beats a Radix popover: short
+ * option lists, mobile browsers, dense tables. The browser arrow is removed with
+ * `appearance-none` and replaced by a chevron that sits in the same spot as the
+ * Select trigger's, so both components look identical side by side.
+ *
+ * Sizes mirror Input and SelectTrigger: xs h-6, sm h-8, default h-10, lg h-12. A native
+ * select centers its text on its own, so vertical padding stays small; `py-2` on a short
+ * control clips the glyphs.
+ */
+const nativeSelectSizes = {
+ xs: {
+ select: "h-6 py-0 pl-1.5 pr-7 text-xs",
+ icon: "right-1.5 size-3.5",
+ },
+ sm: {
+ select: "h-8 py-0 pl-3 pr-9",
+ icon: "right-3 size-4",
+ },
+ default: {
+ select: "h-10 py-1 pl-4 pr-10",
+ icon: "right-4 size-4",
+ },
+ lg: {
+ select: "h-12 py-2 pl-6 pr-12",
+ icon: "right-6 size-4",
+ },
+} as const;
+
+type NativeSelectSize = keyof typeof nativeSelectSizes;
+
+function NativeSelect({
+ className,
+ wrapperClassName,
+ size = "default",
+ ...props
+}: Omit, "size"> & {
+ size?: NativeSelectSize;
+ wrapperClassName?: string;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+function NativeSelectOption({ className, ...props }: React.ComponentProps<"option">) {
+ return (
+
+ );
+}
+
+function NativeSelectOptGroup({ className, ...props }: React.ComponentProps<"optgroup">) {
+ return (
+
+ );
+}
+
+export { NativeSelect, NativeSelectOptGroup, NativeSelectOption, nativeSelectSizes, type NativeSelectSize };
diff --git a/apps/admin/src/components/ui/select.tsx b/apps/admin/src/components/ui/select.tsx
index 3f3cc81..dfc5fe8 100644
--- a/apps/admin/src/components/ui/select.tsx
+++ b/apps/admin/src/components/ui/select.tsx
@@ -4,6 +4,21 @@ import * as React from "react";
import { cn } from "@/lib/utils";
+/**
+ * Trigger sizes mirror the Input scale (xs h-6, sm h-8, default h-10, lg h-12) so a
+ * Select next to an Input in a form shares its height and horizontal padding. The
+ * chevron sits inside that padding, which keeps it the same distance from the border
+ * as the value text is from the left edge.
+ */
+const selectTriggerSizes = {
+ xs: "h-6 gap-1 px-1.5 text-xs [&_svg:not([class*='size-'])]:size-3.5",
+ sm: "h-8 gap-2 px-3",
+ default: "h-10 gap-2 px-4",
+ lg: "h-12 gap-2 px-6",
+} as const;
+
+type SelectTriggerSize = keyof typeof selectTriggerSizes;
+
function Select({ ...props }: React.ComponentProps) {
return ;
}
@@ -22,21 +37,22 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps & {
- size?: "sm" | "default";
+ size?: SelectTriggerSize;
}) {
return (
{children}
-
+
);
@@ -97,7 +113,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps
-
+
@@ -155,4 +171,6 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
+ selectTriggerSizes,
+ type SelectTriggerSize,
};
diff --git a/apps/admin/src/components/ui/spinner.tsx b/apps/admin/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..2c6ff6d
--- /dev/null
+++ b/apps/admin/src/components/ui/spinner.tsx
@@ -0,0 +1,10 @@
+import { Loader2Icon } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return ;
+}
+
+export { Spinner };
diff --git a/apps/extension/src/components/ui/button-group.tsx b/apps/extension/src/components/ui/button-group.tsx
new file mode 100644
index 0000000..03b3380
--- /dev/null
+++ b/apps/extension/src/components/ui/button-group.tsx
@@ -0,0 +1,81 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+/**
+ * The template's default SelectTrigger is h-10 to match Input, while the default Button
+ * is h-9. Inside a group they must share a height, so default-size triggers are pulled
+ * down to the button height here.
+ */
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>[data-slot=select-trigger][data-size=default]]:h-9 has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ },
+);
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "div";
+
+ return (
+
+ );
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
diff --git a/apps/extension/src/components/ui/empty.tsx b/apps/extension/src/components/ui/empty.tsx
new file mode 100644
index 0000000..0a8b73c
--- /dev/null
+++ b/apps/extension/src/components/ui/empty.tsx
@@ -0,0 +1,86 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Empty({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+const emptyMediaVariants = cva(
+ "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function EmptyMedia({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle };
diff --git a/apps/extension/src/components/ui/input-group.tsx b/apps/extension/src/components/ui/input-group.tsx
new file mode 100644
index 0000000..51b5f47
--- /dev/null
+++ b/apps/extension/src/components/ui/input-group.tsx
@@ -0,0 +1,156 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+
+/**
+ * Ported from shadcn. The template's Input wraps its ` ` in a div, so the
+ * selectors below target descendant inputs (`[&_input]`) instead of direct children,
+ * and InputGroupInput styles the control through `inputClassName`.
+ */
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ textarea]:h-auto",
+
+ // Variants based on alignment.
+ "has-[>[data-align=inline-start]]:[&_input]:pl-2",
+ "has-[>[data-align=inline-end]]:[&_input]:pr-2",
+ "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&_input]:pb-3",
+ "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&_input]:pt-3",
+
+ // Focus state.
+ "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
+
+ // Error state.
+ "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
+
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const inputGroupAddonVariants = cva(
+ "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ align: {
+ "inline-start": "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
+ "inline-end": "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
+ "block-start":
+ "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
+ "block-end": "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ },
+);
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ // The addon behaves like a label: clicking it moves focus into the control. Keyboard
+ // users reach the control directly with Tab, so a key handler here would add nothing.
+ return (
+ // oxlint-disable-next-line jsx-a11y/click-events-have-key-events
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return;
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus();
+ }}
+ {...props}
+ />
+ );
+}
+
+const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
+ "icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+});
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size"> & VariantProps) {
+ return (
+
+ );
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function InputGroupInput({ className, inputClassName, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
+
+export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea };
diff --git a/apps/extension/src/components/ui/item.tsx b/apps/extension/src/components/ui/item.tsx
new file mode 100644
index 0000000..b382a8a
--- /dev/null
+++ b/apps/extension/src/components/ui/item.tsx
@@ -0,0 +1,158 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemSeparator({ className, ...props }: React.ComponentProps) {
+ return ;
+}
+
+const itemVariants = cva(
+ "group/item focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors [a]:hover:bg-accent/50",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline: "border-border",
+ muted: "bg-muted/50",
+ },
+ size: {
+ default: "gap-4 p-4",
+ sm: "gap-2.5 px-4 py-3",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Item({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "div";
+ return (
+
+ );
+}
+
+const itemMediaVariants = cva(
+ "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4",
+ image: "size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function ItemMedia({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemFooter,
+ ItemGroup,
+ ItemHeader,
+ ItemMedia,
+ ItemSeparator,
+ ItemTitle,
+};
diff --git a/apps/extension/src/components/ui/kbd.tsx b/apps/extension/src/components/ui/kbd.tsx
new file mode 100644
index 0000000..3076797
--- /dev/null
+++ b/apps/extension/src/components/ui/kbd.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
+ return (
+
+ );
+}
+
+function KbdGroup({ className, ...props }: React.ComponentProps<"kbd">) {
+ return ;
+}
+
+export { Kbd, KbdGroup };
diff --git a/apps/extension/src/components/ui/native-select.tsx b/apps/extension/src/components/ui/native-select.tsx
new file mode 100644
index 0000000..f184f36
--- /dev/null
+++ b/apps/extension/src/components/ui/native-select.tsx
@@ -0,0 +1,92 @@
+import { ChevronDownIcon } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * Styled `` for the cases where a native picker beats a Radix popover: short
+ * option lists, mobile browsers, dense tables. The browser arrow is removed with
+ * `appearance-none` and replaced by a chevron that sits in the same spot as the
+ * Select trigger's, so both components look identical side by side.
+ *
+ * Sizes mirror Input and SelectTrigger: xs h-6, sm h-8, default h-10, lg h-12. A native
+ * select centers its text on its own, so vertical padding stays small; `py-2` on a short
+ * control clips the glyphs.
+ */
+const nativeSelectSizes = {
+ xs: {
+ select: "h-6 py-0 pl-1.5 pr-7 text-xs",
+ icon: "right-1.5 size-3.5",
+ },
+ sm: {
+ select: "h-8 py-0 pl-3 pr-9",
+ icon: "right-3 size-4",
+ },
+ default: {
+ select: "h-10 py-1 pl-4 pr-10",
+ icon: "right-4 size-4",
+ },
+ lg: {
+ select: "h-12 py-2 pl-6 pr-12",
+ icon: "right-6 size-4",
+ },
+} as const;
+
+type NativeSelectSize = keyof typeof nativeSelectSizes;
+
+function NativeSelect({
+ className,
+ wrapperClassName,
+ size = "default",
+ ...props
+}: Omit, "size"> & {
+ size?: NativeSelectSize;
+ wrapperClassName?: string;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+function NativeSelectOption({ className, ...props }: React.ComponentProps<"option">) {
+ return (
+
+ );
+}
+
+function NativeSelectOptGroup({ className, ...props }: React.ComponentProps<"optgroup">) {
+ return (
+
+ );
+}
+
+export { NativeSelect, NativeSelectOptGroup, NativeSelectOption, nativeSelectSizes, type NativeSelectSize };
diff --git a/apps/extension/src/components/ui/select.tsx b/apps/extension/src/components/ui/select.tsx
index 3f3cc81..dfc5fe8 100644
--- a/apps/extension/src/components/ui/select.tsx
+++ b/apps/extension/src/components/ui/select.tsx
@@ -4,6 +4,21 @@ import * as React from "react";
import { cn } from "@/lib/utils";
+/**
+ * Trigger sizes mirror the Input scale (xs h-6, sm h-8, default h-10, lg h-12) so a
+ * Select next to an Input in a form shares its height and horizontal padding. The
+ * chevron sits inside that padding, which keeps it the same distance from the border
+ * as the value text is from the left edge.
+ */
+const selectTriggerSizes = {
+ xs: "h-6 gap-1 px-1.5 text-xs [&_svg:not([class*='size-'])]:size-3.5",
+ sm: "h-8 gap-2 px-3",
+ default: "h-10 gap-2 px-4",
+ lg: "h-12 gap-2 px-6",
+} as const;
+
+type SelectTriggerSize = keyof typeof selectTriggerSizes;
+
function Select({ ...props }: React.ComponentProps) {
return ;
}
@@ -22,21 +37,22 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps & {
- size?: "sm" | "default";
+ size?: SelectTriggerSize;
}) {
return (
{children}
-
+
);
@@ -97,7 +113,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps
-
+
@@ -155,4 +171,6 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
+ selectTriggerSizes,
+ type SelectTriggerSize,
};
diff --git a/apps/extension/src/components/ui/spinner.tsx b/apps/extension/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..2c6ff6d
--- /dev/null
+++ b/apps/extension/src/components/ui/spinner.tsx
@@ -0,0 +1,10 @@
+import { Loader2Icon } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return ;
+}
+
+export { Spinner };
diff --git a/apps/frontend/src/components/ui/button-group.tsx b/apps/frontend/src/components/ui/button-group.tsx
new file mode 100644
index 0000000..03b3380
--- /dev/null
+++ b/apps/frontend/src/components/ui/button-group.tsx
@@ -0,0 +1,81 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+/**
+ * The template's default SelectTrigger is h-10 to match Input, while the default Button
+ * is h-9. Inside a group they must share a height, so default-size triggers are pulled
+ * down to the button height here.
+ */
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>[data-slot=select-trigger][data-size=default]]:h-9 has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ },
+);
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "div";
+
+ return (
+
+ );
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
diff --git a/apps/frontend/src/components/ui/empty.tsx b/apps/frontend/src/components/ui/empty.tsx
new file mode 100644
index 0000000..0a8b73c
--- /dev/null
+++ b/apps/frontend/src/components/ui/empty.tsx
@@ -0,0 +1,86 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Empty({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+const emptyMediaVariants = cva(
+ "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function EmptyMedia({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle };
diff --git a/apps/frontend/src/components/ui/input-group.tsx b/apps/frontend/src/components/ui/input-group.tsx
new file mode 100644
index 0000000..51b5f47
--- /dev/null
+++ b/apps/frontend/src/components/ui/input-group.tsx
@@ -0,0 +1,156 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+
+/**
+ * Ported from shadcn. The template's Input wraps its ` ` in a div, so the
+ * selectors below target descendant inputs (`[&_input]`) instead of direct children,
+ * and InputGroupInput styles the control through `inputClassName`.
+ */
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ textarea]:h-auto",
+
+ // Variants based on alignment.
+ "has-[>[data-align=inline-start]]:[&_input]:pl-2",
+ "has-[>[data-align=inline-end]]:[&_input]:pr-2",
+ "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&_input]:pb-3",
+ "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&_input]:pt-3",
+
+ // Focus state.
+ "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
+
+ // Error state.
+ "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
+
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const inputGroupAddonVariants = cva(
+ "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ align: {
+ "inline-start": "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
+ "inline-end": "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
+ "block-start":
+ "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
+ "block-end": "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ },
+);
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ // The addon behaves like a label: clicking it moves focus into the control. Keyboard
+ // users reach the control directly with Tab, so a key handler here would add nothing.
+ return (
+ // oxlint-disable-next-line jsx-a11y/click-events-have-key-events
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return;
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus();
+ }}
+ {...props}
+ />
+ );
+}
+
+const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
+ "icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+});
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size"> & VariantProps) {
+ return (
+
+ );
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function InputGroupInput({ className, inputClassName, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
+
+export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea };
diff --git a/apps/frontend/src/components/ui/item.tsx b/apps/frontend/src/components/ui/item.tsx
new file mode 100644
index 0000000..b382a8a
--- /dev/null
+++ b/apps/frontend/src/components/ui/item.tsx
@@ -0,0 +1,158 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemSeparator({ className, ...props }: React.ComponentProps) {
+ return ;
+}
+
+const itemVariants = cva(
+ "group/item focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors [a]:hover:bg-accent/50",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline: "border-border",
+ muted: "bg-muted/50",
+ },
+ size: {
+ default: "gap-4 p-4",
+ sm: "gap-2.5 px-4 py-3",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Item({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "div";
+ return (
+
+ );
+}
+
+const itemMediaVariants = cva(
+ "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4",
+ image: "size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function ItemMedia({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemFooter,
+ ItemGroup,
+ ItemHeader,
+ ItemMedia,
+ ItemSeparator,
+ ItemTitle,
+};
diff --git a/apps/frontend/src/components/ui/kbd.tsx b/apps/frontend/src/components/ui/kbd.tsx
new file mode 100644
index 0000000..3076797
--- /dev/null
+++ b/apps/frontend/src/components/ui/kbd.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
+ return (
+
+ );
+}
+
+function KbdGroup({ className, ...props }: React.ComponentProps<"kbd">) {
+ return ;
+}
+
+export { Kbd, KbdGroup };
diff --git a/apps/frontend/src/components/ui/native-select.tsx b/apps/frontend/src/components/ui/native-select.tsx
new file mode 100644
index 0000000..f184f36
--- /dev/null
+++ b/apps/frontend/src/components/ui/native-select.tsx
@@ -0,0 +1,92 @@
+import { ChevronDownIcon } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * Styled `` for the cases where a native picker beats a Radix popover: short
+ * option lists, mobile browsers, dense tables. The browser arrow is removed with
+ * `appearance-none` and replaced by a chevron that sits in the same spot as the
+ * Select trigger's, so both components look identical side by side.
+ *
+ * Sizes mirror Input and SelectTrigger: xs h-6, sm h-8, default h-10, lg h-12. A native
+ * select centers its text on its own, so vertical padding stays small; `py-2` on a short
+ * control clips the glyphs.
+ */
+const nativeSelectSizes = {
+ xs: {
+ select: "h-6 py-0 pl-1.5 pr-7 text-xs",
+ icon: "right-1.5 size-3.5",
+ },
+ sm: {
+ select: "h-8 py-0 pl-3 pr-9",
+ icon: "right-3 size-4",
+ },
+ default: {
+ select: "h-10 py-1 pl-4 pr-10",
+ icon: "right-4 size-4",
+ },
+ lg: {
+ select: "h-12 py-2 pl-6 pr-12",
+ icon: "right-6 size-4",
+ },
+} as const;
+
+type NativeSelectSize = keyof typeof nativeSelectSizes;
+
+function NativeSelect({
+ className,
+ wrapperClassName,
+ size = "default",
+ ...props
+}: Omit, "size"> & {
+ size?: NativeSelectSize;
+ wrapperClassName?: string;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+function NativeSelectOption({ className, ...props }: React.ComponentProps<"option">) {
+ return (
+
+ );
+}
+
+function NativeSelectOptGroup({ className, ...props }: React.ComponentProps<"optgroup">) {
+ return (
+
+ );
+}
+
+export { NativeSelect, NativeSelectOptGroup, NativeSelectOption, nativeSelectSizes, type NativeSelectSize };
diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx
index 3f3cc81..dfc5fe8 100644
--- a/apps/frontend/src/components/ui/select.tsx
+++ b/apps/frontend/src/components/ui/select.tsx
@@ -4,6 +4,21 @@ import * as React from "react";
import { cn } from "@/lib/utils";
+/**
+ * Trigger sizes mirror the Input scale (xs h-6, sm h-8, default h-10, lg h-12) so a
+ * Select next to an Input in a form shares its height and horizontal padding. The
+ * chevron sits inside that padding, which keeps it the same distance from the border
+ * as the value text is from the left edge.
+ */
+const selectTriggerSizes = {
+ xs: "h-6 gap-1 px-1.5 text-xs [&_svg:not([class*='size-'])]:size-3.5",
+ sm: "h-8 gap-2 px-3",
+ default: "h-10 gap-2 px-4",
+ lg: "h-12 gap-2 px-6",
+} as const;
+
+type SelectTriggerSize = keyof typeof selectTriggerSizes;
+
function Select({ ...props }: React.ComponentProps) {
return ;
}
@@ -22,21 +37,22 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps & {
- size?: "sm" | "default";
+ size?: SelectTriggerSize;
}) {
return (
{children}
-
+
);
@@ -97,7 +113,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps
-
+
@@ -155,4 +171,6 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
+ selectTriggerSizes,
+ type SelectTriggerSize,
};
diff --git a/apps/frontend/src/components/ui/spinner.tsx b/apps/frontend/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..2c6ff6d
--- /dev/null
+++ b/apps/frontend/src/components/ui/spinner.tsx
@@ -0,0 +1,10 @@
+import { Loader2Icon } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return ;
+}
+
+export { Spinner };