diff --git a/entrypoints/popup/components/Button.tsx b/entrypoints/popup/components/Button.tsx
index e34179a..ca3c6c8 100644
--- a/entrypoints/popup/components/Button.tsx
+++ b/entrypoints/popup/components/Button.tsx
@@ -1,56 +1,2 @@
-import { ReactNode } from "react";
-
-interface ButtonProps {
- children: ReactNode;
- onClick?: () => void;
- variant?: "primary" | "secondary" | "danger" | "success";
- size?: "sm" | "md" | "lg";
- disabled?: boolean;
- fullWidth?: boolean;
- icon?: ReactNode;
-}
-
-/** Styled button with variant, size, and optional icon support. */
-export default function Button({
- children,
- onClick,
- variant = "primary",
- size = "md",
- disabled = false,
- fullWidth = false,
- icon,
-}: ButtonProps) {
- const baseClasses =
- "font-medium rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
-
- const variantClasses = {
- primary:
- "bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:from-blue-700 hover:to-purple-700 focus:ring-blue-500",
- secondary:
- "bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600",
- danger:
- "bg-red-50 text-red-700 hover:bg-red-100 focus:ring-red-500 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50",
- success:
- "bg-green-50 text-green-700 hover:bg-green-100 focus:ring-green-500 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50",
- };
-
- const sizeClasses = {
- sm: "px-3 py-1.5 text-xs",
- md: "px-4 py-2 text-sm",
- lg: "px-6 py-3 text-base",
- };
-
- const widthClass = fullWidth ? "w-full" : "";
- const disabledClass = disabled ? "opacity-50 cursor-not-allowed" : "";
-
- return (
-
- );
-}
+export { default } from "../../../src/components/ui/Button";
+export type { ButtonProps } from "../../../src/components/ui/Button";
diff --git a/entrypoints/popup/components/Input.tsx b/entrypoints/popup/components/Input.tsx
index 7e5b5b7..dbc8d89 100644
--- a/entrypoints/popup/components/Input.tsx
+++ b/entrypoints/popup/components/Input.tsx
@@ -1,39 +1,2 @@
-interface InputProps {
- type?: "text" | "email" | "number";
- value: string | number;
- onChange: (value: string) => void;
- placeholder?: string;
- label?: string;
- disabled?: boolean;
- onKeyPress?: (e: React.KeyboardEvent) => void;
-}
-
-/** Styled text input with optional label. */
-export default function Input({
- type = "text",
- value,
- onChange,
- placeholder,
- label,
- disabled = false,
- onKeyPress,
-}: InputProps) {
- return (
-
- {label && (
-
- )}
- onChange(e.target.value)}
- onKeyPress={onKeyPress}
- placeholder={placeholder}
- disabled={disabled}
- className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
- />
-
- );
-}
+export { default } from "../../../src/components/ui/Input";
+export type { InputProps } from "../../../src/components/ui/Input";
diff --git a/entrypoints/popup/components/Toggle.tsx b/entrypoints/popup/components/Toggle.tsx
index c73be47..7130bf6 100644
--- a/entrypoints/popup/components/Toggle.tsx
+++ b/entrypoints/popup/components/Toggle.tsx
@@ -1,43 +1,2 @@
-interface ToggleProps {
- enabled: boolean;
- onChange: (enabled: boolean) => void;
- label: string;
- description?: string;
-}
-
-/** Accessible on/off switch with a label and optional description. */
-export default function Toggle({
- enabled,
- onChange,
- label,
- description,
-}: ToggleProps) {
- return (
-
-
-
- {description && (
-
- {description}
-
- )}
-
-
-
- );
-}
+export { default } from "../../../src/components/ui/ToggleSwitch";
+export type { ToggleSwitchProps as ToggleProps } from "../../../src/components/ui/ToggleSwitch";
diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..4b5f0fe
--- /dev/null
+++ b/src/components/ui/Badge.tsx
@@ -0,0 +1,16 @@
+import type { HTMLAttributes } from "react";
+import { cn } from "./utils";
+export default function Badge({
+ className,
+ ...props
+}: HTMLAttributes
) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
new file mode 100644
index 0000000..b1540b7
--- /dev/null
+++ b/src/components/ui/Button.tsx
@@ -0,0 +1,57 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import { cn } from "./utils";
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
+ size?: "sm" | "md" | "lg" | "icon";
+ fullWidth?: boolean;
+ icon?: ReactNode;
+}
+
+export default function Button({
+ children,
+ className,
+ variant = "primary",
+ size = "md",
+ fullWidth = false,
+ icon,
+ type = "button",
+ ...props
+}: ButtonProps) {
+ const variants = {
+ primary:
+ "bg-gradient-to-r from-blue-600 to-violet-600 text-white shadow-soft hover:from-blue-700 hover:to-violet-700 focus-visible:ring-blue-500",
+ secondary:
+ "border border-gray-200/80 bg-white/80 text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-700/80 dark:bg-gray-800/80 dark:text-gray-200 dark:hover:bg-gray-700/80 focus-visible:ring-gray-400",
+ ghost:
+ "text-gray-600 hover:bg-gray-100/80 dark:text-gray-300 dark:hover:bg-gray-800/80 focus-visible:ring-gray-400",
+ danger:
+ "bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-900/50 focus-visible:ring-red-500",
+ success:
+ "bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:hover:bg-emerald-900/50 focus-visible:ring-emerald-500",
+ };
+ const sizes = {
+ sm: "px-3 py-1.5 text-xs",
+ md: "px-4 py-2.5 text-sm",
+ lg: "px-5 py-3 text-base",
+ icon: "h-10 w-10 p-0",
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx
new file mode 100644
index 0000000..d9b864a
--- /dev/null
+++ b/src/components/ui/Card.tsx
@@ -0,0 +1,16 @@
+import type { HTMLAttributes } from "react";
+import { cn } from "./utils";
+export default function Card({
+ className,
+ ...props
+}: HTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..edf9996
--- /dev/null
+++ b/src/components/ui/EmptyState.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from "react";
+export default function EmptyState({
+ icon,
+ title,
+ description,
+}: {
+ icon?: ReactNode;
+ title: string;
+ description?: string;
+}) {
+ return (
+
+
+ {icon}
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx
new file mode 100644
index 0000000..cce0e49
--- /dev/null
+++ b/src/components/ui/Input.tsx
@@ -0,0 +1,50 @@
+import type { InputHTMLAttributes, ReactNode } from "react";
+import { cn } from "./utils";
+
+export interface InputProps extends Omit<
+ InputHTMLAttributes,
+ "onChange"
+> {
+ label?: string;
+ leftIcon?: ReactNode;
+ onChange?: (value: string) => void;
+}
+
+export default function Input({
+ label,
+ leftIcon,
+ className,
+ onChange,
+ id,
+ ...props
+}: InputProps) {
+ return (
+
+ {label && (
+
+ )}
+
+ {leftIcon && (
+
+ {leftIcon}
+
+ )}
+ onChange?.(e.target.value)}
+ className={cn(
+ "w-full rounded-xl border border-gray-200/90 bg-white/80 px-3 py-2.5 text-sm text-gray-900 shadow-sm outline-none transition-all placeholder:text-gray-400 focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700/80 dark:bg-gray-800/80 dark:text-gray-100 dark:focus:border-blue-500",
+ leftIcon && "pl-10",
+ className,
+ )}
+ {...props}
+ />
+
+
+ );
+}
diff --git a/src/components/ui/SectionHeader.tsx b/src/components/ui/SectionHeader.tsx
new file mode 100644
index 0000000..94f4e18
--- /dev/null
+++ b/src/components/ui/SectionHeader.tsx
@@ -0,0 +1,27 @@
+import type { ReactNode } from "react";
+export interface SectionHeaderProps {
+ title: string;
+ description?: string;
+ action?: ReactNode;
+}
+export default function SectionHeader({
+ title,
+ description,
+ action,
+}: SectionHeaderProps) {
+ return (
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {action}
+
+ );
+}
diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx
new file mode 100644
index 0000000..0cdf893
--- /dev/null
+++ b/src/components/ui/Select.tsx
@@ -0,0 +1,35 @@
+import type { SelectHTMLAttributes } from "react";
+import { cn } from "./utils";
+export interface SelectProps extends SelectHTMLAttributes {
+ label?: string;
+}
+export default function Select({
+ label,
+ className,
+ id,
+ children,
+ ...props
+}: SelectProps) {
+ return (
+
+ {label && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ui/StatCard.tsx b/src/components/ui/StatCard.tsx
new file mode 100644
index 0000000..b9746a2
--- /dev/null
+++ b/src/components/ui/StatCard.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from "react";
+export default function StatCard({
+ icon,
+ value,
+ label,
+}: {
+ icon: ReactNode;
+ value: ReactNode;
+ label: string;
+}) {
+ return (
+
+
+ {icon}
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
diff --git a/src/components/ui/Tabs.tsx b/src/components/ui/Tabs.tsx
new file mode 100644
index 0000000..c05dda9
--- /dev/null
+++ b/src/components/ui/Tabs.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from "react";
+import { cn } from "./utils";
+export interface TabItem {
+ value: T;
+ label: ReactNode;
+ icon?: ReactNode;
+}
+export interface TabsProps {
+ items: TabItem[];
+ value: T;
+ onChange: (value: T) => void;
+}
+export default function Tabs({
+ items,
+ value,
+ onChange,
+}: TabsProps) {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx
new file mode 100644
index 0000000..ae99e4c
--- /dev/null
+++ b/src/components/ui/Toast.tsx
@@ -0,0 +1,7 @@
+export default function Toast({ message }: { message: string }) {
+ return (
+
+ {message}
+
+ );
+}
diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx
new file mode 100644
index 0000000..5bde9f2
--- /dev/null
+++ b/src/components/ui/ToggleSwitch.tsx
@@ -0,0 +1,49 @@
+import { cn } from "./utils";
+export interface ToggleSwitchProps {
+ enabled: boolean;
+ onChange: (enabled: boolean) => void;
+ label?: string;
+ description?: string;
+}
+export default function ToggleSwitch({
+ enabled,
+ onChange,
+ label,
+ description,
+}: ToggleSwitchProps) {
+ return (
+
+
+ {label && (
+
+ {label}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx
new file mode 100644
index 0000000..4e9ca4f
--- /dev/null
+++ b/src/components/ui/Tooltip.tsx
@@ -0,0 +1,17 @@
+import type { ReactNode } from "react";
+export default function Tooltip({
+ children,
+ label,
+}: {
+ children: ReactNode;
+ label: string;
+}) {
+ return (
+
+ {children}
+
+ {label}
+
+
+ );
+}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
new file mode 100644
index 0000000..abbb5ed
--- /dev/null
+++ b/src/components/ui/index.ts
@@ -0,0 +1,13 @@
+export { default as Badge } from "./Badge";
+export { default as Button } from "./Button";
+export { default as Card } from "./Card";
+export { default as EmptyState } from "./EmptyState";
+export { default as Input } from "./Input";
+export { default as SectionHeader } from "./SectionHeader";
+export { default as Select } from "./Select";
+export { default as StatCard } from "./StatCard";
+export { default as Tabs } from "./Tabs";
+export { default as Toast } from "./Toast";
+export { default as ToggleSwitch } from "./ToggleSwitch";
+export { default as Tooltip } from "./Tooltip";
+export { cn } from "./utils";
diff --git a/src/components/ui/utils.ts b/src/components/ui/utils.ts
new file mode 100644
index 0000000..3e882b1
--- /dev/null
+++ b/src/components/ui/utils.ts
@@ -0,0 +1,6 @@
+export type ClassValue = string | number | bigint | boolean | null | undefined;
+
+/** Lightweight class name joiner kept local to avoid popup bundle bloat. */
+export function cn(...classes: ClassValue[]) {
+ return classes.filter(Boolean).join(" ");
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index d50d125..65cb70c 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,10 +1,13 @@
import type { Config } from "tailwindcss";
export default {
- content: ["./entrypoints/**/*.{html,tsx,ts}"],
+ content: ["./entrypoints/**/*.{html,tsx,ts}", "./src/**/*.{tsx,ts}"],
darkMode: "class",
theme: {
extend: {
+ boxShadow: {
+ soft: "0 14px 35px -22px rgb(15 23 42 / 0.45)",
+ },
colors: {
primary: {
50: "#eff6ff",