From 69b49b2d2af9eb76f45a2d8e665ab8fb129249e5 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 2 Jul 2026 22:31:18 +0700 Subject: [PATCH 01/33] Integrate beUI motion primitives --- entrypoints/popup/App.tsx | 344 ++++---------------- entrypoints/popup/components/Button.tsx | 58 +--- entrypoints/popup/components/Input.tsx | 41 +-- entrypoints/popup/components/Statistics.tsx | 129 +------- entrypoints/popup/components/Toggle.tsx | 45 +-- package.json | 6 +- src/components/alias/AccountSwitcher.tsx | 16 + src/components/alias/PopupHeader.tsx | 10 + src/components/ui/AnimatedBadge.tsx | 13 + src/components/ui/AnimatedToastStack.tsx | 10 + src/components/ui/Badge.tsx | 1 + src/components/ui/Button.tsx | 75 +++++ src/components/ui/Card.tsx | 5 + src/components/ui/EmptyState.tsx | 4 + src/components/ui/Input.tsx | 24 ++ src/components/ui/NumberAnimation.tsx | 10 + src/components/ui/SectionHeader.tsx | 5 + src/components/ui/Select.tsx | 11 + src/components/ui/StatCard.tsx | 4 + src/components/ui/Tabs.tsx | 23 ++ src/components/ui/Toast.tsx | 4 + src/components/ui/ToggleSwitch.tsx | 9 + src/components/ui/Tooltip.tsx | 9 + src/components/ui/index.ts | 16 + src/components/ui/utils.ts | 1 + src/lib/ease.ts | 7 + src/lib/hooks/use-hover-capable.ts | 15 + src/lib/utils.ts | 6 + tailwind.config.ts | 5 +- 29 files changed, 358 insertions(+), 548 deletions(-) create mode 100644 src/components/alias/AccountSwitcher.tsx create mode 100644 src/components/alias/PopupHeader.tsx create mode 100644 src/components/ui/AnimatedBadge.tsx create mode 100644 src/components/ui/AnimatedToastStack.tsx create mode 100644 src/components/ui/Badge.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/Card.tsx create mode 100644 src/components/ui/EmptyState.tsx create mode 100644 src/components/ui/Input.tsx create mode 100644 src/components/ui/NumberAnimation.tsx create mode 100644 src/components/ui/SectionHeader.tsx create mode 100644 src/components/ui/Select.tsx create mode 100644 src/components/ui/StatCard.tsx create mode 100644 src/components/ui/Tabs.tsx create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/components/ui/ToggleSwitch.tsx create mode 100644 src/components/ui/Tooltip.tsx create mode 100644 src/components/ui/index.ts create mode 100644 src/components/ui/utils.ts create mode 100644 src/lib/ease.ts create mode 100644 src/lib/hooks/use-hover-capable.ts create mode 100644 src/lib/utils.ts diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index 4de933a..7294c68 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -1,4 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import { Button, Card, Toast } from "../../src/components/ui"; +import PopupHeader from "../../src/components/alias/PopupHeader"; +import AccountSwitcher from "../../src/components/alias/AccountSwitcher"; import QRCode from "qrcode"; import "./App.css"; import Settings from "./components/Settings"; @@ -709,7 +712,7 @@ function App() { // skipcq: JS-0415 return ( -
+
{/* Show Welcome Screen for first-time users */} {!hasEmailAccounts ? (
@@ -724,285 +727,56 @@ function App() { ) : ( // skipcq: JS-0415 <> - {/* Header */} -
-
-
- -
-

- {t("extensionName")} -

-

- {t("headerSubtitle")} -

-
-
- -
-
+ setIsSettingsOpen(true)} /> {/* Main Content */} -
-
- {/* Base Email Selector - Dropdown */} -
- -
-
-
- - - - - - -
- -
- - - -
-
- -
- - {/* Quick Add Account Form */} - {showAddAccount && ( -
-
-
- - - -
- { - setNewAccountEmail(e.target.value); - setAddAccountError(""); - }} - onBlur={() => { - if ( - newAccountEmail && - !newAccountEmail.includes("@") - ) { - setNewAccountEmail(`${newAccountEmail}@gmail.com`); - } - }} - placeholder={t("emailPlaceholder")} - className="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" - ref={focusOnMount} - /> - {newAccountEmail && !newAccountEmail.includes("@") && ( -
- @gmail.com -
- )} -
- {addAccountError && ( -
-

- {addAccountError} -

-
- )} - {newAccountEmail && !newAccountEmail.includes("@") && ( -

- {t("pressTabToAddGmail", "Tab").split("Tab")[0]} - - Tab - - {t("pressTabToAddGmail", "Tab").split("Tab")[1]} -

- )} -
-
- - - -
- setNewAccountLabel(e.target.value)} - placeholder={t("accountLabelPlaceholder")} - className="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" - /> -
-
- - -
-
- )} - - {baseEmail && - !baseEmail.includes("@gmail.com") && - baseEmail.includes("@") && ( -

- {t("gmailWarning")} -

- )} -
+
+ + setShowAddAccount(!showAddAccount)} + onSelectAccount={async (selectedEmail) => { + setBaseEmail(selectedEmail); + setIsSelectMode(false); + setSelectedAliases(new Set()); + setSearchQuery(""); + setFilterTag("all"); + setCurrentPage(1); + + const updated = emailAccounts.map((acc) => ({ + ...acc, + isActive: acc.email === selectedEmail, + })); + + await browser.storage.local.set({ + email_accounts: updated, + base_email: selectedEmail, + }); + }} + onNewAccountEmailChange={(value) => { + setNewAccountEmail(value); + setAddAccountError(""); + }} + onNewAccountLabelChange={setNewAccountLabel} + onNewAccountBlur={() => { + if (newAccountEmail && !newAccountEmail.includes("@")) { + setNewAccountEmail(`${newAccountEmail}@gmail.com`); + } + }} + onAddAccount={handleAddAccount} + onCancelAddAccount={() => { + setShowAddAccount(false); + setNewAccountEmail(""); + setNewAccountLabel(""); + setAddAccountError(""); + }} + /> {/* Unified Email Alias Generator */} -
+ {/* Toast Notification */} - {toastMessage && ( -
- {toastMessage} -
- )} + {toastMessage && }
)} @@ -1077,7 +847,7 @@ function App() { onClick={() => setQrAlias(null)} >
e.stopPropagation()} >

@@ -1090,13 +860,13 @@ function App() {
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/Statistics.tsx b/entrypoints/popup/components/Statistics.tsx index a005e5d..90b7e25 100644 --- a/entrypoints/popup/components/Statistics.tsx +++ b/entrypoints/popup/components/Statistics.tsx @@ -1,4 +1,6 @@ import { useState, useEffect } from "react"; +import { BarChart3, Check, Clock, Mail, X } from "lucide-react"; +import { NumberAnimation, StatCard } from "../../../src/components/ui"; import { getAccountStorageKey } from "../utils"; interface Stats { @@ -127,19 +129,7 @@ export default function Statistics() { View Statistics - - - + ); } @@ -156,118 +146,15 @@ export default function Statistics() { onClick={() => setIsOpen(false)} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300" > - - - +
-
-
- - - -
-
- {stats.totalGenerated} -
-
- Total Generated -
-
- -
-
- - - -
-
- {stats.createdToday} -
-
- Created Today -
-
- -
-
- - - -
-
- {stats.createdThisWeek} -
-
- This Week -
-
- -
-
- - - -
-
- {stats.mostUsedTag} -
-
- Most Used Tag -
-
+ } value={} label="Total Generated" /> + } value={} label="Created Today" /> + } value={} label="This Week" /> + } value={stats.mostUsedTag} label="Top Tag" />

); 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/package.json b/package.json index a2f4b09..3c9dd45 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,11 @@ "@wxt-dev/module-react": "^1.1.5", "qrcode": "^1.5.4", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-dom": "^19.2.3", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "motion": "^12.0.0", + "tailwind-merge": "^2.6.0" }, "devDependencies": { "@testing-library/dom": "^10.4.1", diff --git a/src/components/alias/AccountSwitcher.tsx b/src/components/alias/AccountSwitcher.tsx new file mode 100644 index 0000000..fd362c7 --- /dev/null +++ b/src/components/alias/AccountSwitcher.tsx @@ -0,0 +1,16 @@ +import { Mail, Plus, Tag, UserRound } from "lucide-react"; +import { Button, Input, Select } from "../ui"; +import { t } from "../../../lib/i18n"; + +export interface EmailAccount { id: string; email: string; label?: string; isActive: boolean; } +export interface AccountSwitcherProps { + baseEmail: string; emailAccounts: EmailAccount[]; showAddAccount: boolean; newAccountEmail: string; newAccountLabel: string; addAccountError: string; + focusOnMount: (el: HTMLInputElement | null) => void; + onToggleAddAccount: () => void; onSelectAccount: (email: string) => Promise; onNewAccountEmailChange: (value: string) => void; onNewAccountLabelChange: (value: string) => void; onNewAccountBlur: () => void; onAddAccount: () => void; onCancelAddAccount: () => void; +} + +/** Multi-account selector and quick-add form; delegates all behavior to App. */ +export default function AccountSwitcher(props: AccountSwitcherProps) { + const { baseEmail, emailAccounts, showAddAccount, newAccountEmail, newAccountLabel, addAccountError, focusOnMount, onToggleAddAccount, onSelectAccount, onNewAccountEmailChange, onNewAccountLabelChange, onNewAccountBlur, onAddAccount, onCancelAddAccount } = props; + return
{showAddAccount &&
} error={addAccountError || undefined} ref={focusOnMount} />{newAccountEmail && !newAccountEmail.includes("@") &&

{t("pressTabToAddGmail", "Tab").split("Tab")[0]}Tab{t("pressTabToAddGmail", "Tab").split("Tab")[1]}

}} />
}{baseEmail && !baseEmail.includes("@gmail.com") && baseEmail.includes("@") &&

{t("gmailWarning")}

}
; +} diff --git a/src/components/alias/PopupHeader.tsx b/src/components/alias/PopupHeader.tsx new file mode 100644 index 0000000..9e57eab --- /dev/null +++ b/src/components/alias/PopupHeader.tsx @@ -0,0 +1,10 @@ +import { Settings, Sparkles } from "lucide-react"; +import { Button } from "../ui"; +import { t } from "../../../lib/i18n"; + +export interface PopupHeaderProps { onOpenSettings: () => void; } + +/** beUI Motion header for the extension popup; business logic stays in App. */ +export default function PopupHeader({ onOpenSettings }: PopupHeaderProps) { + return

{t("extensionName")}

{t("headerSubtitle")}

; +} diff --git a/src/components/ui/AnimatedBadge.tsx b/src/components/ui/AnimatedBadge.tsx new file mode 100644 index 0000000..c6878a2 --- /dev/null +++ b/src/components/ui/AnimatedBadge.tsx @@ -0,0 +1,13 @@ +// Source adapted from beUI Motion Animated Badge: https://beui.dev/components/motion/animated-badge +import { motion, useReducedMotion } from "motion/react"; +import { Check, Info, AlertCircle } from "lucide-react"; +import type { ReactNode } from "react"; +import { SPRING_PRESS } from "../../lib/ease"; +import { cn } from "../../lib/utils"; +export type AnimatedBadgeStatus = "info" | "success" | "warning" | "danger"; +export default function AnimatedBadge({ children, status = "info", className }: { children: ReactNode; status?: AnimatedBadgeStatus; className?: string }) { + const reduce = useReducedMotion(); + const Icon = status === "success" ? Check : status === "danger" || status === "warning" ? AlertCircle : Info; + const styles = { info: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/50 dark:text-blue-300", success: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300", warning: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300", danger: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/50 dark:text-red-300" }[status]; + return {children}; +} diff --git a/src/components/ui/AnimatedToastStack.tsx b/src/components/ui/AnimatedToastStack.tsx new file mode 100644 index 0000000..b34c4c9 --- /dev/null +++ b/src/components/ui/AnimatedToastStack.tsx @@ -0,0 +1,10 @@ +// Source adapted from beUI Motion Animated Toast Stack: https://beui.dev/components/motion/animated-toast-stack +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { Check, AlertCircle, Info, X } from "lucide-react"; +import { SPRING_PANEL } from "../../lib/ease"; +import { cn } from "../../lib/utils"; +export interface ToastItem { id: string; message: string; status?: "success" | "error" | "info"; } +export default function AnimatedToastStack({ toasts, onDismiss }: { toasts: ToastItem[]; onDismiss?: (id: string) => void }) { + const reduce = useReducedMotion(); + return
{toasts.map((toast) => { const Icon = toast.status === "error" ? AlertCircle : toast.status === "info" ? Info : Check; return {toast.message}{onDismiss && }; })}
; +} diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..bd2c750 --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1 @@ +export { default } from "./AnimatedBadge"; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..fae6734 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,75 @@ +// Source adapted from beUI Motion Button: https://beui.dev/components/motion/button +import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps } from "motion/react"; +import { forwardRef, type ButtonHTMLAttributes, type PointerEvent, type ReactNode, useCallback, useRef, useState } from "react"; +import { EASE_OUT, SPRING_PRESS } from "../../lib/ease"; +import { cn } from "../../lib/utils"; +import { useHoverCapable } from "../../lib/hooks/use-hover-capable"; + +export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline" | "danger" | "success"; +export type ButtonSize = "sm" | "md" | "lg" | "icon"; + +export interface ButtonProps extends Omit, "children"> { + variant?: ButtonVariant; + size?: ButtonSize; + pressScale?: number; + ripple?: boolean; + fullWidth?: boolean; + icon?: ReactNode; + children?: ReactNode; +} + +type Ripple = { id: number; x: number; y: number; size: number }; + +const variants: Record = { + primary: "bg-gradient-to-r from-blue-600 to-violet-600 text-white shadow-soft hover:from-blue-700 hover:to-violet-700", + secondary: "border border-gray-200/80 bg-white/85 text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-700/80 dark:bg-gray-800/85 dark:text-gray-200 dark:hover:bg-gray-700/80", + ghost: "text-gray-600 hover:bg-gray-100/80 dark:text-gray-300 dark:hover:bg-gray-800/80", + outline: "border border-gray-200/80 bg-transparent text-gray-700 hover:bg-blue-50/70 dark:border-gray-700/80 dark:text-gray-200 dark:hover:bg-blue-950/30", + 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", + 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", +}; + +const sizes: Record = { + sm: "h-8 rounded-xl px-3 text-xs gap-1.5", + md: "h-10 rounded-xl px-4 text-sm gap-2", + lg: "h-12 rounded-2xl px-5 text-base gap-2", + icon: "h-10 w-10 rounded-xl p-0", +}; + +export const Button = forwardRef(function Button( + { variant = "primary", size = "md", pressScale = 0.93, ripple = false, fullWidth = false, icon, className, children, onPointerDown, type = "button", disabled, ...rest }, + ref, +) { + const reduce = useReducedMotion(); + const canHover = useHoverCapable(); + const [ripples, setRipples] = useState([]); + const nextId = useRef(0); + const handlePointerDown = useCallback((event: PointerEvent) => { + if (ripple && !reduce) { + const rect = event.currentTarget.getBoundingClientRect(); + const size = Math.max(rect.width, rect.height) * 2; + setRipples((prev) => [...prev, { id: nextId.current++, x: event.clientX - rect.left, y: event.clientY - rect.top, size }]); + } + onPointerDown?.(event); + }, [onPointerDown, reduce, ripple]); + + return ( + + {ripple && !reduce ? {ripples.map((r) => setRipples((prev) => prev.filter((x) => x.id !== r.id))} />)} : null} + {icon && {icon}} + {children} + + ); +}); + +export default Button; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..08f76f7 --- /dev/null +++ b/src/components/ui/Card.tsx @@ -0,0 +1,5 @@ +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..6f433d4 --- /dev/null +++ b/src/components/ui/EmptyState.tsx @@ -0,0 +1,4 @@ +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..51fbf26 --- /dev/null +++ b/src/components/ui/Input.tsx @@ -0,0 +1,24 @@ +// Source adapted from beUI Motion Input: https://beui.dev/components/motion/input +import { motion, useReducedMotion, type HTMLMotionProps } from "motion/react"; +import { forwardRef, type ReactNode, useState } from "react"; +import { Check, AlertCircle } from "lucide-react"; +import { SPRING_PRESS } from "../../lib/ease"; +import { cn } from "../../lib/utils"; + +export interface InputProps extends Omit, "onChange"> { label?: string; leftIcon?: ReactNode; rightIcon?: ReactNode; error?: string; success?: boolean; onChange?: (value: string) => void; } + +export const Input = forwardRef(function Input({ label, leftIcon, rightIcon, error, success, className, id, onChange, onFocus, onBlur, ...props }, ref) { + const reduce = useReducedMotion(); + const [focused, setFocused] = useState(false); + return
+ {label && } + + {leftIcon && {leftIcon}} + onChange?.(e.target.value)} onFocus={(e) => { setFocused(true); onFocus?.(e); }} onBlur={(e) => { setFocused(false); onBlur?.(e); }} className={cn("w-full rounded-xl border border-gray-200/90 bg-white/85 px-3 py-2.5 text-sm text-gray-900 shadow-sm outline-none transition-all placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700/80 dark:bg-gray-800/85 dark:text-gray-100", leftIcon && "pl-10", (rightIcon || error || success) && "pr-10", focused && "border-blue-400 ring-2 ring-blue-500/20", error && "border-red-400 ring-2 ring-red-500/10", success && "border-emerald-400", className)} {...props} /> + {success ? : error ? : rightIcon ? {rightIcon} : null} + {focused && !reduce && } + + {error &&

{error}

} +
; +}); +export default Input; diff --git a/src/components/ui/NumberAnimation.tsx b/src/components/ui/NumberAnimation.tsx new file mode 100644 index 0000000..b4d672a --- /dev/null +++ b/src/components/ui/NumberAnimation.tsx @@ -0,0 +1,10 @@ +// Source adapted from beUI Motion Number Animation: https://beui.dev/components/motion/number +import { motion, useReducedMotion } from "motion/react"; +import { useEffect, useState } from "react"; +import { SPRING_SWAP } from "../../lib/ease"; +export default function NumberAnimation({ value, className }: { value: number | string; className?: string }) { + const reduce = useReducedMotion(); + const [display, setDisplay] = useState(value); + useEffect(() => setDisplay(value), [value]); + return {display}; +} diff --git a/src/components/ui/SectionHeader.tsx b/src/components/ui/SectionHeader.tsx new file mode 100644 index 0000000..c24e64b --- /dev/null +++ b/src/components/ui/SectionHeader.tsx @@ -0,0 +1,5 @@ +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..87a994c --- /dev/null +++ b/src/components/ui/Select.tsx @@ -0,0 +1,11 @@ +// Source adapted from beUI Motion Select: https://beui.dev/components/motion/select +import { motion, useReducedMotion, type HTMLMotionProps } from "motion/react"; +import { ChevronDown } from "lucide-react"; +import type { ReactNode, SelectHTMLAttributes } from "react"; +import { SPRING_PANEL } from "../../lib/ease"; +import { cn } from "../../lib/utils"; +export interface SelectProps extends SelectHTMLAttributes { label?: string; leftIcon?: ReactNode; containerClassName?: string; } +export default function Select({ label, leftIcon, className, containerClassName, id, children, ...props }: SelectProps) { + const reduce = useReducedMotion(); + return
{label && }{leftIcon && {leftIcon}}
; +} diff --git a/src/components/ui/StatCard.tsx b/src/components/ui/StatCard.tsx new file mode 100644 index 0000000..fb1f0d8 --- /dev/null +++ b/src/components/ui/StatCard.tsx @@ -0,0 +1,4 @@ +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..6e84a19 --- /dev/null +++ b/src/components/ui/Tabs.tsx @@ -0,0 +1,23 @@ +// Source adapted from beUI Motion Tabs: https://beui.dev/components/motion/tabs +import { motion, useReducedMotion } from "motion/react"; +import type { ReactNode } from "react"; +import { SPRING_LAYOUT } from "../../lib/ease"; +import { cn } from "../../lib/utils"; + +export interface TabItem { value: T; label: ReactNode; icon?: ReactNode; } +export interface TabsProps { items: TabItem[]; value: T; onChange: (value: T) => void; variant?: "pill" | "segment" | "underline"; className?: string; } + +export default function Tabs({ items, value, onChange, variant = "segment", className }: TabsProps) { + const reduce = useReducedMotion(); + return ( +
+ {items.map((item) => { + const active = item.value === value; + return ; + })} +
+ ); +} diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 0000000..535a346 --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,4 @@ +import AnimatedToastStack from "./AnimatedToastStack"; +export default function Toast({ message }: { message: string }) { + return ; +} diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx new file mode 100644 index 0000000..e1b0f40 --- /dev/null +++ b/src/components/ui/ToggleSwitch.tsx @@ -0,0 +1,9 @@ +// Source adapted from beUI Motion Switch: https://beui.dev/components/motion/switch +import { motion, useReducedMotion } from "motion/react"; +import { SPRING_PRESS } from "../../lib/ease"; +import { cn } from "../../lib/utils"; +export interface ToggleSwitchProps { enabled: boolean; onChange: (enabled: boolean) => void; label?: string; description?: string; className?: string; } +export default function ToggleSwitch({ enabled, onChange, label, description, className }: ToggleSwitchProps) { + const reduce = useReducedMotion(); + return
{label &&

{label}

}{description &&

{description}

}
onChange(!enabled)} whileTap={reduce ? undefined : { scale: 0.96 }} className={cn("relative h-6 w-11 rounded-full transition-colors", enabled ? "bg-blue-600 bg-gradient-to-r from-blue-600 to-violet-600" : "bg-gray-300 dark:bg-gray-700")}>
; +} diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx new file mode 100644 index 0000000..12a9e51 --- /dev/null +++ b/src/components/ui/Tooltip.tsx @@ -0,0 +1,9 @@ +// Source adapted from beUI Motion Tooltip: https://beui.dev/components/motion/tooltip +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useState, type ReactNode } from "react"; +import { SPRING_PANEL } from "../../lib/ease"; +export default function Tooltip({ children, label }: { children: ReactNode; label: string }) { + const [open, setOpen] = useState(false); + const reduce = useReducedMotion(); + return setOpen(true)} onMouseLeave={() => setOpen(false)} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)}>{children}{open && {label}}; +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..0425ee0 --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,16 @@ +export { default as AnimatedBadge } from "./AnimatedBadge"; +export { default as AnimatedToastStack } from "./AnimatedToastStack"; +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 NumberAnimation } from "./NumberAnimation"; +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 "../../lib/utils"; diff --git a/src/components/ui/utils.ts b/src/components/ui/utils.ts new file mode 100644 index 0000000..d7e9602 --- /dev/null +++ b/src/components/ui/utils.ts @@ -0,0 +1 @@ +export { cn } from "../../lib/utils"; diff --git a/src/lib/ease.ts b/src/lib/ease.ts new file mode 100644 index 0000000..32615fa --- /dev/null +++ b/src/lib/ease.ts @@ -0,0 +1,7 @@ +// Shared motion tokens copied from beUI Motion docs and kept tiny for popup usage. +export const EASE_OUT = [0.16, 1, 0.3, 1] as const; +export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const; +export const SPRING_PRESS = { type: "spring", stiffness: 500, damping: 30, mass: 0.6 } as const; +export const SPRING_LAYOUT = { type: "spring", stiffness: 360, damping: 32, mass: 0.6 } as const; +export const SPRING_PANEL = { type: "spring", stiffness: 420, damping: 40, mass: 0.5 } as const; +export const SPRING_SWAP = { type: "spring", stiffness: 460, damping: 30, mass: 0.55 } as const; diff --git a/src/lib/hooks/use-hover-capable.ts b/src/lib/hooks/use-hover-capable.ts new file mode 100644 index 0000000..63ab5f9 --- /dev/null +++ b/src/lib/hooks/use-hover-capable.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +/** Returns true only when the device has real hover support. */ +export function useHoverCapable() { + const [canHover, setCanHover] = useState(false); + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const query = window.matchMedia("(hover: hover) and (pointer: fine)"); + const update = () => setCanHover(query.matches); + update(); + query.addEventListener?.("change", update); + return () => query.removeEventListener?.("change", update); + }, []); + return canHover; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/tailwind.config.ts b/tailwind.config.ts index d50d125..f13fd17 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/**/*.{html,tsx,ts}"], darkMode: "class", theme: { extend: { + boxShadow: { + soft: "0 14px 35px -22px rgb(15 23 42 / 0.45)", + }, colors: { primary: { 50: "#eff6ff", From c68e3de233c51d67d8043d13010cd5c456a10aa0 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:31:59 +0000 Subject: [PATCH 02/33] style: format code with Prettier This commit fixes the style issues introduced in 69b49b2 according to the output from Prettier. Details: https://github.com/ePlus-DEV/gmail-alias-toolkit/pull/23 --- entrypoints/popup/components/Statistics.tsx | 24 ++- src/components/alias/AccountSwitcher.tsx | 127 +++++++++++++- src/components/alias/PopupHeader.tsx | 36 +++- src/components/ui/AnimatedBadge.tsx | 43 ++++- src/components/ui/AnimatedToastStack.tsx | 67 +++++++- src/components/ui/Button.tsx | 180 +++++++++++++++----- src/components/ui/Card.tsx | 15 +- src/components/ui/EmptyState.tsx | 26 ++- src/components/ui/Input.tsx | 103 +++++++++-- src/components/ui/NumberAnimation.tsx | 23 ++- src/components/ui/SectionHeader.tsx | 28 ++- src/components/ui/Select.tsx | 52 +++++- src/components/ui/StatCard.tsx | 24 ++- src/components/ui/Tabs.tsx | 63 ++++++- src/components/ui/Toast.tsx | 6 +- src/components/ui/ToggleSwitch.tsx | 53 +++++- src/components/ui/Tooltip.tsx | 42 ++++- src/lib/ease.ts | 28 ++- 18 files changed, 834 insertions(+), 106 deletions(-) diff --git a/entrypoints/popup/components/Statistics.tsx b/entrypoints/popup/components/Statistics.tsx index 90b7e25..3e2d4f0 100644 --- a/entrypoints/popup/components/Statistics.tsx +++ b/entrypoints/popup/components/Statistics.tsx @@ -151,10 +151,26 @@ export default function Statistics() {
- } value={} label="Total Generated" /> - } value={} label="Created Today" /> - } value={} label="This Week" /> - } value={stats.mostUsedTag} label="Top Tag" /> + } + value={} + label="Total Generated" + /> + } + value={} + label="Created Today" + /> + } + value={} + label="This Week" + /> + } + value={stats.mostUsedTag} + label="Top Tag" + />
); diff --git a/src/components/alias/AccountSwitcher.tsx b/src/components/alias/AccountSwitcher.tsx index fd362c7..9fcc9e0 100644 --- a/src/components/alias/AccountSwitcher.tsx +++ b/src/components/alias/AccountSwitcher.tsx @@ -2,15 +2,132 @@ import { Mail, Plus, Tag, UserRound } from "lucide-react"; import { Button, Input, Select } from "../ui"; import { t } from "../../../lib/i18n"; -export interface EmailAccount { id: string; email: string; label?: string; isActive: boolean; } +export interface EmailAccount { + id: string; + email: string; + label?: string; + isActive: boolean; +} export interface AccountSwitcherProps { - baseEmail: string; emailAccounts: EmailAccount[]; showAddAccount: boolean; newAccountEmail: string; newAccountLabel: string; addAccountError: string; + baseEmail: string; + emailAccounts: EmailAccount[]; + showAddAccount: boolean; + newAccountEmail: string; + newAccountLabel: string; + addAccountError: string; focusOnMount: (el: HTMLInputElement | null) => void; - onToggleAddAccount: () => void; onSelectAccount: (email: string) => Promise; onNewAccountEmailChange: (value: string) => void; onNewAccountLabelChange: (value: string) => void; onNewAccountBlur: () => void; onAddAccount: () => void; onCancelAddAccount: () => void; + onToggleAddAccount: () => void; + onSelectAccount: (email: string) => Promise; + onNewAccountEmailChange: (value: string) => void; + onNewAccountLabelChange: (value: string) => void; + onNewAccountBlur: () => void; + onAddAccount: () => void; + onCancelAddAccount: () => void; } /** Multi-account selector and quick-add form; delegates all behavior to App. */ export default function AccountSwitcher(props: AccountSwitcherProps) { - const { baseEmail, emailAccounts, showAddAccount, newAccountEmail, newAccountLabel, addAccountError, focusOnMount, onToggleAddAccount, onSelectAccount, onNewAccountEmailChange, onNewAccountLabelChange, onNewAccountBlur, onAddAccount, onCancelAddAccount } = props; - return
{showAddAccount &&
} error={addAccountError || undefined} ref={focusOnMount} />{newAccountEmail && !newAccountEmail.includes("@") &&

{t("pressTabToAddGmail", "Tab").split("Tab")[0]}Tab{t("pressTabToAddGmail", "Tab").split("Tab")[1]}

}} />
}{baseEmail && !baseEmail.includes("@gmail.com") && baseEmail.includes("@") &&

{t("gmailWarning")}

}
; + const { + baseEmail, + emailAccounts, + showAddAccount, + newAccountEmail, + newAccountLabel, + addAccountError, + focusOnMount, + onToggleAddAccount, + onSelectAccount, + onNewAccountEmailChange, + onNewAccountLabelChange, + onNewAccountBlur, + onAddAccount, + onCancelAddAccount, + } = props; + return ( +
+
+ + +
+ {showAddAccount && ( +
+ } + error={addAccountError || undefined} + ref={focusOnMount} + /> + {newAccountEmail && !newAccountEmail.includes("@") && ( +

+ {t("pressTabToAddGmail", "Tab").split("Tab")[0]} + + Tab + + {t("pressTabToAddGmail", "Tab").split("Tab")[1]} +

+ )} + } + /> +
+ + +
+
+ )} + {baseEmail && + !baseEmail.includes("@gmail.com") && + baseEmail.includes("@") && ( +

+ {t("gmailWarning")} +

+ )} +
+ ); } diff --git a/src/components/alias/PopupHeader.tsx b/src/components/alias/PopupHeader.tsx index 9e57eab..d6403bc 100644 --- a/src/components/alias/PopupHeader.tsx +++ b/src/components/alias/PopupHeader.tsx @@ -2,9 +2,41 @@ import { Settings, Sparkles } from "lucide-react"; import { Button } from "../ui"; import { t } from "../../../lib/i18n"; -export interface PopupHeaderProps { onOpenSettings: () => void; } +export interface PopupHeaderProps { + onOpenSettings: () => void; +} /** beUI Motion header for the extension popup; business logic stays in App. */ export default function PopupHeader({ onOpenSettings }: PopupHeaderProps) { - return

{t("extensionName")}

{t("headerSubtitle")}

; + return ( +
+
+
+
+
+ + +
+
+

+ {t("extensionName")} +

+

+ {t("headerSubtitle")} +

+
+
+ +
+
+
+ ); } diff --git a/src/components/ui/AnimatedBadge.tsx b/src/components/ui/AnimatedBadge.tsx index c6878a2..be76d58 100644 --- a/src/components/ui/AnimatedBadge.tsx +++ b/src/components/ui/AnimatedBadge.tsx @@ -5,9 +5,44 @@ import type { ReactNode } from "react"; import { SPRING_PRESS } from "../../lib/ease"; import { cn } from "../../lib/utils"; export type AnimatedBadgeStatus = "info" | "success" | "warning" | "danger"; -export default function AnimatedBadge({ children, status = "info", className }: { children: ReactNode; status?: AnimatedBadgeStatus; className?: string }) { +export default function AnimatedBadge({ + children, + status = "info", + className, +}: { + children: ReactNode; + status?: AnimatedBadgeStatus; + className?: string; +}) { const reduce = useReducedMotion(); - const Icon = status === "success" ? Check : status === "danger" || status === "warning" ? AlertCircle : Info; - const styles = { info: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/50 dark:text-blue-300", success: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300", warning: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300", danger: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/50 dark:text-red-300" }[status]; - return {children}; + const Icon = + status === "success" + ? Check + : status === "danger" || status === "warning" + ? AlertCircle + : Info; + const styles = { + info: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/50 dark:text-blue-300", + success: + "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300", + warning: + "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300", + danger: + "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/50 dark:text-red-300", + }[status]; + return ( + + + {children} + + ); } diff --git a/src/components/ui/AnimatedToastStack.tsx b/src/components/ui/AnimatedToastStack.tsx index b34c4c9..da50c2c 100644 --- a/src/components/ui/AnimatedToastStack.tsx +++ b/src/components/ui/AnimatedToastStack.tsx @@ -3,8 +3,69 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Check, AlertCircle, Info, X } from "lucide-react"; import { SPRING_PANEL } from "../../lib/ease"; import { cn } from "../../lib/utils"; -export interface ToastItem { id: string; message: string; status?: "success" | "error" | "info"; } -export default function AnimatedToastStack({ toasts, onDismiss }: { toasts: ToastItem[]; onDismiss?: (id: string) => void }) { +export interface ToastItem { + id: string; + message: string; + status?: "success" | "error" | "info"; +} +export default function AnimatedToastStack({ + toasts, + onDismiss, +}: { + toasts: ToastItem[]; + onDismiss?: (id: string) => void; +}) { const reduce = useReducedMotion(); - return
{toasts.map((toast) => { const Icon = toast.status === "error" ? AlertCircle : toast.status === "info" ? Info : Check; return {toast.message}{onDismiss && }; })}
; + return ( +
+ + {toasts.map((toast) => { + const Icon = + toast.status === "error" + ? AlertCircle + : toast.status === "info" + ? Info + : Check; + return ( + + + {toast.message} + {onDismiss && ( + + )} + + ); + })} + +
+ ); } diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index fae6734..74bcffb 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1,14 +1,36 @@ // Source adapted from beUI Motion Button: https://beui.dev/components/motion/button -import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps } from "motion/react"; -import { forwardRef, type ButtonHTMLAttributes, type PointerEvent, type ReactNode, useCallback, useRef, useState } from "react"; +import { + AnimatePresence, + motion, + useReducedMotion, + type HTMLMotionProps, +} from "motion/react"; +import { + forwardRef, + type ButtonHTMLAttributes, + type PointerEvent, + type ReactNode, + useCallback, + useRef, + useState, +} from "react"; import { EASE_OUT, SPRING_PRESS } from "../../lib/ease"; import { cn } from "../../lib/utils"; import { useHoverCapable } from "../../lib/hooks/use-hover-capable"; -export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline" | "danger" | "success"; +export type ButtonVariant = + | "primary" + | "secondary" + | "ghost" + | "outline" + | "danger" + | "success"; export type ButtonSize = "sm" | "md" | "lg" | "icon"; -export interface ButtonProps extends Omit, "children"> { +export interface ButtonProps extends Omit< + ButtonHTMLAttributes, + "children" +> { variant?: ButtonVariant; size?: ButtonSize; pressScale?: number; @@ -21,12 +43,18 @@ export interface ButtonProps extends Omit = { - primary: "bg-gradient-to-r from-blue-600 to-violet-600 text-white shadow-soft hover:from-blue-700 hover:to-violet-700", - secondary: "border border-gray-200/80 bg-white/85 text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-700/80 dark:bg-gray-800/85 dark:text-gray-200 dark:hover:bg-gray-700/80", - ghost: "text-gray-600 hover:bg-gray-100/80 dark:text-gray-300 dark:hover:bg-gray-800/80", - outline: "border border-gray-200/80 bg-transparent text-gray-700 hover:bg-blue-50/70 dark:border-gray-700/80 dark:text-gray-200 dark:hover:bg-blue-950/30", - 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", - 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", + primary: + "bg-gradient-to-r from-blue-600 to-violet-600 text-white shadow-soft hover:from-blue-700 hover:to-violet-700", + secondary: + "border border-gray-200/80 bg-white/85 text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-700/80 dark:bg-gray-800/85 dark:text-gray-200 dark:hover:bg-gray-700/80", + ghost: + "text-gray-600 hover:bg-gray-100/80 dark:text-gray-300 dark:hover:bg-gray-800/80", + outline: + "border border-gray-200/80 bg-transparent text-gray-700 hover:bg-blue-50/70 dark:border-gray-700/80 dark:text-gray-200 dark:hover:bg-blue-950/30", + 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", + 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", }; const sizes: Record = { @@ -36,40 +64,104 @@ const sizes: Record = { icon: "h-10 w-10 rounded-xl p-0", }; -export const Button = forwardRef(function Button( - { variant = "primary", size = "md", pressScale = 0.93, ripple = false, fullWidth = false, icon, className, children, onPointerDown, type = "button", disabled, ...rest }, - ref, -) { - const reduce = useReducedMotion(); - const canHover = useHoverCapable(); - const [ripples, setRipples] = useState([]); - const nextId = useRef(0); - const handlePointerDown = useCallback((event: PointerEvent) => { - if (ripple && !reduce) { - const rect = event.currentTarget.getBoundingClientRect(); - const size = Math.max(rect.width, rect.height) * 2; - setRipples((prev) => [...prev, { id: nextId.current++, x: event.clientX - rect.left, y: event.clientY - rect.top, size }]); - } - onPointerDown?.(event); - }, [onPointerDown, reduce, ripple]); +export const Button = forwardRef( + function Button( + { + variant = "primary", + size = "md", + pressScale = 0.93, + ripple = false, + fullWidth = false, + icon, + className, + children, + onPointerDown, + type = "button", + disabled, + ...rest + }, + ref, + ) { + const reduce = useReducedMotion(); + const canHover = useHoverCapable(); + const [ripples, setRipples] = useState([]); + const nextId = useRef(0); + const handlePointerDown = useCallback( + (event: PointerEvent) => { + if (ripple && !reduce) { + const rect = event.currentTarget.getBoundingClientRect(); + const size = Math.max(rect.width, rect.height) * 2; + setRipples((prev) => [ + ...prev, + { + id: nextId.current++, + x: event.clientX - rect.left, + y: event.clientY - rect.top, + size, + }, + ]); + } + onPointerDown?.(event); + }, + [onPointerDown, reduce, ripple], + ); - return ( - - {ripple && !reduce ? {ripples.map((r) => setRipples((prev) => prev.filter((x) => x.id !== r.id))} />)} : null} - {icon && {icon}} - {children} - - ); -}); + return ( + + {ripple && !reduce ? ( + + + {ripples.map((r) => ( + + setRipples((prev) => prev.filter((x) => x.id !== r.id)) + } + /> + ))} + + + ) : null} + {icon && ( + + {icon} + + )} + {children} + + ); + }, +); export default Button; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index 08f76f7..d9b864a 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -1,5 +1,16 @@ import type { HTMLAttributes } from "react"; import { cn } from "./utils"; -export default function Card({ className, ...props }: HTMLAttributes) { - return
; +export default function Card({ + className, + ...props +}: HTMLAttributes) { + return ( +
+ ); } diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx index 6f433d4..edf9996 100644 --- a/src/components/ui/EmptyState.tsx +++ b/src/components/ui/EmptyState.tsx @@ -1,4 +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}

}
; +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 index 51fbf26..10bb898 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -5,20 +5,99 @@ import { Check, AlertCircle } from "lucide-react"; import { SPRING_PRESS } from "../../lib/ease"; import { cn } from "../../lib/utils"; -export interface InputProps extends Omit, "onChange"> { label?: string; leftIcon?: ReactNode; rightIcon?: ReactNode; error?: string; success?: boolean; onChange?: (value: string) => void; } +export interface InputProps extends Omit, "onChange"> { + label?: string; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + error?: string; + success?: boolean; + onChange?: (value: string) => void; +} -export const Input = forwardRef(function Input({ label, leftIcon, rightIcon, error, success, className, id, onChange, onFocus, onBlur, ...props }, ref) { +export const Input = forwardRef(function Input( + { + label, + leftIcon, + rightIcon, + error, + success, + className, + id, + onChange, + onFocus, + onBlur, + ...props + }, + ref, +) { const reduce = useReducedMotion(); const [focused, setFocused] = useState(false); - return
- {label && } - - {leftIcon && {leftIcon}} - onChange?.(e.target.value)} onFocus={(e) => { setFocused(true); onFocus?.(e); }} onBlur={(e) => { setFocused(false); onBlur?.(e); }} className={cn("w-full rounded-xl border border-gray-200/90 bg-white/85 px-3 py-2.5 text-sm text-gray-900 shadow-sm outline-none transition-all placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700/80 dark:bg-gray-800/85 dark:text-gray-100", leftIcon && "pl-10", (rightIcon || error || success) && "pr-10", focused && "border-blue-400 ring-2 ring-blue-500/20", error && "border-red-400 ring-2 ring-red-500/10", success && "border-emerald-400", className)} {...props} /> - {success ? : error ? : rightIcon ? {rightIcon} : null} - {focused && !reduce && } - - {error &&

{error}

} -
; + return ( +
+ {label && ( + + )} + + {leftIcon && ( + + {leftIcon} + + )} + onChange?.(e.target.value)} + onFocus={(e) => { + setFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setFocused(false); + onBlur?.(e); + }} + className={cn( + "w-full rounded-xl border border-gray-200/90 bg-white/85 px-3 py-2.5 text-sm text-gray-900 shadow-sm outline-none transition-all placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700/80 dark:bg-gray-800/85 dark:text-gray-100", + leftIcon && "pl-10", + (rightIcon || error || success) && "pr-10", + focused && "border-blue-400 ring-2 ring-blue-500/20", + error && "border-red-400 ring-2 ring-red-500/10", + success && "border-emerald-400", + className, + )} + {...props} + /> + {success ? ( + + ) : error ? ( + + ) : rightIcon ? ( + + {rightIcon} + + ) : null} + {focused && !reduce && ( + + )} + + {error && ( +

+ {error} +

+ )} +
+ ); }); export default Input; diff --git a/src/components/ui/NumberAnimation.tsx b/src/components/ui/NumberAnimation.tsx index b4d672a..9ac4ffe 100644 --- a/src/components/ui/NumberAnimation.tsx +++ b/src/components/ui/NumberAnimation.tsx @@ -2,9 +2,28 @@ import { motion, useReducedMotion } from "motion/react"; import { useEffect, useState } from "react"; import { SPRING_SWAP } from "../../lib/ease"; -export default function NumberAnimation({ value, className }: { value: number | string; className?: string }) { +export default function NumberAnimation({ + value, + className, +}: { + value: number | string; + className?: string; +}) { const reduce = useReducedMotion(); const [display, setDisplay] = useState(value); useEffect(() => setDisplay(value), [value]); - return {display}; + return ( + + {display} + + ); } diff --git a/src/components/ui/SectionHeader.tsx b/src/components/ui/SectionHeader.tsx index c24e64b..94f4e18 100644 --- a/src/components/ui/SectionHeader.tsx +++ b/src/components/ui/SectionHeader.tsx @@ -1,5 +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}
; +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 index 87a994c..2afef08 100644 --- a/src/components/ui/Select.tsx +++ b/src/components/ui/Select.tsx @@ -4,8 +4,54 @@ import { ChevronDown } from "lucide-react"; import type { ReactNode, SelectHTMLAttributes } from "react"; import { SPRING_PANEL } from "../../lib/ease"; import { cn } from "../../lib/utils"; -export interface SelectProps extends SelectHTMLAttributes { label?: string; leftIcon?: ReactNode; containerClassName?: string; } -export default function Select({ label, leftIcon, className, containerClassName, id, children, ...props }: SelectProps) { +export interface SelectProps extends SelectHTMLAttributes { + label?: string; + leftIcon?: ReactNode; + containerClassName?: string; +} +export default function Select({ + label, + leftIcon, + className, + containerClassName, + id, + children, + ...props +}: SelectProps) { const reduce = useReducedMotion(); - return
{label && }{leftIcon && {leftIcon}}
; + return ( +
+ {label && ( + + )} + + {leftIcon && ( + + {leftIcon} + + )} + + + +
+ ); } diff --git a/src/components/ui/StatCard.tsx b/src/components/ui/StatCard.tsx index fb1f0d8..b9746a2 100644 --- a/src/components/ui/StatCard.tsx +++ b/src/components/ui/StatCard.tsx @@ -1,4 +1,24 @@ import type { ReactNode } from "react"; -export default function StatCard({ icon, value, label }: { icon: ReactNode; value: ReactNode; label: string }) { - return
{icon}
{value}
{label}
; +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 index 6e84a19..2ee2680 100644 --- a/src/components/ui/Tabs.tsx +++ b/src/components/ui/Tabs.tsx @@ -4,19 +4,66 @@ import type { ReactNode } from "react"; import { SPRING_LAYOUT } from "../../lib/ease"; import { cn } from "../../lib/utils"; -export interface TabItem { value: T; label: ReactNode; icon?: ReactNode; } -export interface TabsProps { items: TabItem[]; value: T; onChange: (value: T) => void; variant?: "pill" | "segment" | "underline"; className?: string; } +export interface TabItem { + value: T; + label: ReactNode; + icon?: ReactNode; +} +export interface TabsProps { + items: TabItem[]; + value: T; + onChange: (value: T) => void; + variant?: "pill" | "segment" | "underline"; + className?: string; +} -export default function Tabs({ items, value, onChange, variant = "segment", className }: TabsProps) { +export default function Tabs({ + items, + value, + onChange, + variant = "segment", + className, +}: TabsProps) { const reduce = useReducedMotion(); return ( -
+
{items.map((item) => { const active = item.value === value; - return ; + return ( + + ); })}
); diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index 535a346..465768b 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -1,4 +1,8 @@ import AnimatedToastStack from "./AnimatedToastStack"; export default function Toast({ message }: { message: string }) { - return ; + return ( + + ); } diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx index e1b0f40..2977cb5 100644 --- a/src/components/ui/ToggleSwitch.tsx +++ b/src/components/ui/ToggleSwitch.tsx @@ -2,8 +2,55 @@ import { motion, useReducedMotion } from "motion/react"; import { SPRING_PRESS } from "../../lib/ease"; import { cn } from "../../lib/utils"; -export interface ToggleSwitchProps { enabled: boolean; onChange: (enabled: boolean) => void; label?: string; description?: string; className?: string; } -export default function ToggleSwitch({ enabled, onChange, label, description, className }: ToggleSwitchProps) { +export interface ToggleSwitchProps { + enabled: boolean; + onChange: (enabled: boolean) => void; + label?: string; + description?: string; + className?: string; +} +export default function ToggleSwitch({ + enabled, + onChange, + label, + description, + className, +}: ToggleSwitchProps) { const reduce = useReducedMotion(); - return
{label &&

{label}

}{description &&

{description}

}
onChange(!enabled)} whileTap={reduce ? undefined : { scale: 0.96 }} className={cn("relative h-6 w-11 rounded-full transition-colors", enabled ? "bg-blue-600 bg-gradient-to-r from-blue-600 to-violet-600" : "bg-gray-300 dark:bg-gray-700")}>
; + return ( +
+
+ {label && ( +

+ {label} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ onChange(!enabled)} + whileTap={reduce ? undefined : { scale: 0.96 }} + className={cn( + "relative h-6 w-11 rounded-full transition-colors", + enabled + ? "bg-blue-600 bg-gradient-to-r from-blue-600 to-violet-600" + : "bg-gray-300 dark:bg-gray-700", + )} + > + + +
+ ); } diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx index 12a9e51..f6b282d 100644 --- a/src/components/ui/Tooltip.tsx +++ b/src/components/ui/Tooltip.tsx @@ -2,8 +2,46 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useState, type ReactNode } from "react"; import { SPRING_PANEL } from "../../lib/ease"; -export default function Tooltip({ children, label }: { children: ReactNode; label: string }) { +export default function Tooltip({ + children, + label, +}: { + children: ReactNode; + label: string; +}) { const [open, setOpen] = useState(false); const reduce = useReducedMotion(); - return setOpen(true)} onMouseLeave={() => setOpen(false)} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)}>{children}{open && {label}}; + return ( + setOpen(true)} + onMouseLeave={() => setOpen(false)} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + > + {children} + + {open && ( + + {label} + + )} + + + ); } diff --git a/src/lib/ease.ts b/src/lib/ease.ts index 32615fa..3f6ab9d 100644 --- a/src/lib/ease.ts +++ b/src/lib/ease.ts @@ -1,7 +1,27 @@ // Shared motion tokens copied from beUI Motion docs and kept tiny for popup usage. export const EASE_OUT = [0.16, 1, 0.3, 1] as const; export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const; -export const SPRING_PRESS = { type: "spring", stiffness: 500, damping: 30, mass: 0.6 } as const; -export const SPRING_LAYOUT = { type: "spring", stiffness: 360, damping: 32, mass: 0.6 } as const; -export const SPRING_PANEL = { type: "spring", stiffness: 420, damping: 40, mass: 0.5 } as const; -export const SPRING_SWAP = { type: "spring", stiffness: 460, damping: 30, mass: 0.55 } as const; +export const SPRING_PRESS = { + type: "spring", + stiffness: 500, + damping: 30, + mass: 0.6, +} as const; +export const SPRING_LAYOUT = { + type: "spring", + stiffness: 360, + damping: 32, + mass: 0.6, +} as const; +export const SPRING_PANEL = { + type: "spring", + stiffness: 420, + damping: 40, + mass: 0.5, +} as const; +export const SPRING_SWAP = { + type: "spring", + stiffness: 460, + damping: 30, + mass: 0.55, +} as const; From 8a96bec5c88363a9e0c64becad235be92c582ddf Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 3 Jul 2026 08:20:11 +0700 Subject: [PATCH 03/33] fix: reorder dependencies in package.json for clarity --- package.json | 6 ++-- yarn.lock | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 3c9dd45..f55c78f 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,12 @@ "dependencies": { "@wxt-dev/auto-icons": "^1.1.0", "@wxt-dev/module-react": "^1.1.5", - "qrcode": "^1.5.4", - "react": "^19.2.3", - "react-dom": "^19.2.3", "clsx": "^2.1.1", "lucide-react": "^0.468.0", "motion": "^12.0.0", + "qrcode": "^1.5.4", + "react": "^19.2.3", + "react-dom": "^19.2.3", "tailwind-merge": "^2.6.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index dda3a9f..b1e69f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2162,6 +2162,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -2922,6 +2929,28 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^12.42.2": + version: 12.42.2 + resolution: "framer-motion@npm:12.42.2" + dependencies: + motion-dom: "npm:^12.42.2" + motion-utils: "npm:^12.39.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/2e514e0e303c0c01bc7d6a1028f7722ec17fc307922c6770a8dc5f7782521c60254f9c265329b887a29b32e2c730742328ecae468be626406afa5db1e6c0aa01 + languageName: node + linkType: hard + "fs-extra@npm:^11.2.0, fs-extra@npm:^11.3.0": version: 11.3.3 resolution: "fs-extra@npm:11.3.3" @@ -3088,11 +3117,15 @@ __metadata: "@wxt-dev/auto-icons": "npm:^1.1.0" "@wxt-dev/module-react": "npm:^1.1.5" autoprefixer: "npm:^10.4.20" + clsx: "npm:^2.1.1" jsdom: "npm:^29.1.1" + lucide-react: "npm:^0.468.0" + motion: "npm:^12.0.0" postcss: "npm:^8.5.10" qrcode: "npm:^1.5.4" react: "npm:^19.2.3" react-dom: "npm:^19.2.3" + tailwind-merge: "npm:^2.6.0" tailwindcss: "npm:^3.4.17" typescript: "npm:^5.9.3" vite: "npm:^8.1.1" @@ -3980,6 +4013,15 @@ __metadata: languageName: node linkType: hard +"lucide-react@npm:^0.468.0": + version: 0.468.0 + resolution: "lucide-react@npm:0.468.0" + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + checksum: 10c0/da7640e4ad09d8987716e9124fd85c4a0c49b7658dbbf45db64547fc0f155f7e8b2d7f68115ada3e24012f3e1a4f209d7a87762ba4de5ca3dd753f6b68d8e622 + languageName: node + linkType: hard + "lz-string@npm:^1.5.0": version: 1.5.0 resolution: "lz-string@npm:1.5.0" @@ -4213,6 +4255,43 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^12.42.2": + version: 12.42.2 + resolution: "motion-dom@npm:12.42.2" + dependencies: + motion-utils: "npm:^12.39.0" + checksum: 10c0/1bcee88df706e3aaa4e5b8e7d2ba0efd8e2304c142e7541411bbd6f03b0fbe8d25883ec157905fdebf284f51db02008245f6d4653d20a97de9acf44bbb7be79f + languageName: node + linkType: hard + +"motion-utils@npm:^12.39.0": + version: 12.39.0 + resolution: "motion-utils@npm:12.39.0" + checksum: 10c0/6d7a2a2cc0797b72410a666a9cc1c201c8e39bf9669670464e433fe1e72af5f0217154c869867b34fbadf3664cf222c0d022bbc4eed7927f201ae971918e7440 + languageName: node + linkType: hard + +"motion@npm:^12.0.0": + version: 12.42.2 + resolution: "motion@npm:12.42.2" + dependencies: + framer-motion: "npm:^12.42.2" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/44c333efe50fadb8ede589e237694e05dd73bed09c66f26ebcf89ca8a7449e77e55a0d3f2df6c1bdc00ee8de7f4e6fe957ef219d8002008472d978f4979ddab5 + languageName: node + linkType: hard + "ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -5723,6 +5802,13 @@ __metadata: languageName: node linkType: hard +"tailwind-merge@npm:^2.6.0": + version: 2.6.1 + resolution: "tailwind-merge@npm:2.6.1" + checksum: 10c0/f9b5d7ba37f6c6dc7bb7a090f08252e8d827b5abfc1031bf468c5274ce104409e7952a0075a3e009aab53adda8c6d133bc1dd9d3427e2ae5bc00306f9ce1fbff + languageName: node + linkType: hard + "tailwindcss@npm:^3.4.17": version: 3.4.19 resolution: "tailwindcss@npm:3.4.19" From a2431fb3556218a6b461966fdbe0077146436cb5 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 3 Jul 2026 08:29:41 +0700 Subject: [PATCH 04/33] feat: integrate beUI motion into remaining UI components Add motion animations (hover, entrance effects) to Card, StatCard, EmptyState, and SectionHeader using beUI motion patterns. Respects prefers-reduced-motion for accessibility. - Card: 1.01x hover scale - StatCard: 1.02x hover scale with icon pulse - EmptyState: slide-in entrance with icon hover - SectionHeader: slide-in entrance with action hover Co-Authored-By: Claude Haiku 4.5 --- src/components/ui/Card.tsx | 15 +++++++++++---- src/components/ui/EmptyState.tsx | 22 ++++++++++++++++++---- src/components/ui/SectionHeader.tsx | 19 ++++++++++++++++--- src/components/ui/StatCard.tsx | 21 +++++++++++++++++---- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index d9b864a..8418c18 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -1,16 +1,23 @@ +// Source adapted from beUI Motion Card: https://beui.dev/components/motion/card +import { motion, useReducedMotion } from "motion/react"; import type { HTMLAttributes } from "react"; +import { SPRING_PRESS } from "../../lib/ease"; import { cn } from "./utils"; export default function Card({ className, - ...props + children, }: HTMLAttributes) { + const reduce = useReducedMotion(); return ( -
+ > + {children} + ); } diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx index edf9996..b02f780 100644 --- a/src/components/ui/EmptyState.tsx +++ b/src/components/ui/EmptyState.tsx @@ -1,4 +1,8 @@ +// Source adapted from beUI Motion Empty State: https://beui.dev/components/motion/empty-state +import { motion, useReducedMotion } from "motion/react"; import type { ReactNode } from "react"; +import { SPRING_PRESS } from "../../lib/ease"; + export default function EmptyState({ icon, title, @@ -8,11 +12,21 @@ export default function EmptyState({ title: string; description?: string; }) { + const reduce = useReducedMotion(); return ( -
-
+ + {icon} -
+

{title}

@@ -21,6 +35,6 @@ export default function EmptyState({ {description}

)} -
+ ); } diff --git a/src/components/ui/SectionHeader.tsx b/src/components/ui/SectionHeader.tsx index 94f4e18..c370a24 100644 --- a/src/components/ui/SectionHeader.tsx +++ b/src/components/ui/SectionHeader.tsx @@ -1,4 +1,8 @@ +// Source adapted from beUI Motion Section Header: https://beui.dev/components/motion/section +import { motion, useReducedMotion } from "motion/react"; import type { ReactNode } from "react"; +import { SPRING_PRESS } from "../../lib/ease"; + export interface SectionHeaderProps { title: string; description?: string; @@ -9,9 +13,14 @@ export default function SectionHeader({ description, action, }: SectionHeaderProps) { + const reduce = useReducedMotion(); return (
-
+

{title}

@@ -20,8 +29,12 @@ export default function SectionHeader({ {description}

)} -
- {action} + + {action && ( + + {action} + + )}
); } diff --git a/src/components/ui/StatCard.tsx b/src/components/ui/StatCard.tsx index b9746a2..3869846 100644 --- a/src/components/ui/StatCard.tsx +++ b/src/components/ui/StatCard.tsx @@ -1,4 +1,8 @@ +// Source adapted from beUI Motion Stat Card: https://beui.dev/components/motion/card +import { motion, useReducedMotion } from "motion/react"; import type { ReactNode } from "react"; +import { SPRING_PRESS } from "../../lib/ease"; + export default function StatCard({ icon, value, @@ -8,17 +12,26 @@ export default function StatCard({ value: ReactNode; label: string; }) { + const reduce = useReducedMotion(); return ( -
-
+ + {icon} -
+
{value}
{label}
-
+ ); } From af65fb8924a5bf362e557c3bd523c017d4804566 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 3 Jul 2026 09:06:33 +0700 Subject: [PATCH 05/33] refactor: align UI components with beUI design patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update core components to match beUI's visual design and motion patterns: **AnimatedToastStack:** - Add 6 statuses (success, error, warning, info, neutral, loading) - Change opacity: 95% → 10% tint (semi-transparent instead of opaque) - Add action button support with callbacks - Add loading status with spinning icon animation - Improve styling: border-opacity, padding, font-weight alignment - Better accessibility with hover states **AnimatedBadge:** - Add 6 status types (success, error, warning, info, neutral, loading) - Add size variants (sm, md) for flexible layouts - Add loading status with rotating icon - Add showIcon prop for icon toggle - Add tabular-nums for numeric alignment - Update colors to match tinted design tokens **Button:** - Replace gradient (from-blue-600 to-violet-600) with solid color (blue-600) - Adjust border radius: rounded-xl → rounded-lg/rounded-xl - Maintain accessibility and ripple effects **Tabs:** - Increase text size: text-xs → text-sm - Adjust padding: px-2 py-2 → px-2.5 py-1.5 - Use font-medium instead of font-semibold - Improve spacing consistency All changes respect prefers-reduced-motion. Tests pass ✓ Co-Authored-By: Claude Haiku 4.5 --- src/components/ui/AnimatedBadge.tsx | 75 ++++++++++++---- src/components/ui/AnimatedToastStack.tsx | 107 +++++++++++++++++++---- src/components/ui/Button.tsx | 10 +-- src/components/ui/Tabs.tsx | 8 +- 4 files changed, 156 insertions(+), 44 deletions(-) diff --git a/src/components/ui/AnimatedBadge.tsx b/src/components/ui/AnimatedBadge.tsx index be76d58..de3e17a 100644 --- a/src/components/ui/AnimatedBadge.tsx +++ b/src/components/ui/AnimatedBadge.tsx @@ -1,48 +1,91 @@ // Source adapted from beUI Motion Animated Badge: https://beui.dev/components/motion/animated-badge import { motion, useReducedMotion } from "motion/react"; -import { Check, Info, AlertCircle } from "lucide-react"; +import { Check, Info, AlertCircle, AlertTriangle, Loader } from "lucide-react"; import type { ReactNode } from "react"; import { SPRING_PRESS } from "../../lib/ease"; import { cn } from "../../lib/utils"; -export type AnimatedBadgeStatus = "info" | "success" | "warning" | "danger"; + +export type AnimatedBadgeStatus = "info" | "success" | "warning" | "danger" | "neutral" | "loading"; +export type AnimatedBadgeSize = "sm" | "md"; + +const statusStyles = { + info: "border-blue-200/80 bg-blue-50 text-blue-700 dark:border-blue-800/50 dark:bg-blue-950/40 dark:text-blue-300", + success: + "border-emerald-200/80 bg-emerald-50 text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/40 dark:text-emerald-300", + warning: + "border-amber-200/80 bg-amber-50 text-amber-700 dark:border-amber-800/50 dark:bg-amber-950/40 dark:text-amber-300", + danger: + "border-red-200/80 bg-red-50 text-red-700 dark:border-red-800/50 dark:bg-red-950/40 dark:text-red-300", + neutral: + "border-gray-200/80 bg-gray-50 text-gray-700 dark:border-gray-800/50 dark:bg-gray-950/40 dark:text-gray-300", + loading: + "border-blue-200/80 bg-blue-50 text-blue-700 dark:border-blue-800/50 dark:bg-blue-950/40 dark:text-blue-300", +}; + +const sizeStyles = { + sm: "rounded-full border px-1.5 py-0.5 text-[10px] font-medium gap-0.5", + md: "rounded-full border px-2 py-0.5 text-xs font-semibold gap-1", +}; + +const iconSizes = { + sm: "h-3 w-3", + md: "h-3.5 w-3.5", +}; + export default function AnimatedBadge({ children, status = "info", + size = "md", + showIcon = true, className, }: { children: ReactNode; status?: AnimatedBadgeStatus; + size?: AnimatedBadgeSize; + showIcon?: boolean; className?: string; }) { const reduce = useReducedMotion(); + const isLoading = status === "loading"; + const Icon = status === "success" ? Check : status === "danger" || status === "warning" ? AlertCircle - : Info; - const styles = { - info: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/50 dark:text-blue-300", - success: - "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300", - warning: - "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300", - danger: - "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/50 dark:text-red-300", - }[status]; + : status === "loading" + ? Loader + : status === "neutral" + ? Info + : Info; + return ( - - {children} + {showIcon && ( + <> + {isLoading ? ( + + + + ) : ( + + )} + + )} + {children} ); } diff --git a/src/components/ui/AnimatedToastStack.tsx b/src/components/ui/AnimatedToastStack.tsx index da50c2c..8b7512c 100644 --- a/src/components/ui/AnimatedToastStack.tsx +++ b/src/components/ui/AnimatedToastStack.tsx @@ -1,13 +1,60 @@ // Source adapted from beUI Motion Animated Toast Stack: https://beui.dev/components/motion/animated-toast-stack import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Check, AlertCircle, Info, X } from "lucide-react"; +import { Check, AlertCircle, Info, X, AlertTriangle, Loader } from "lucide-react"; import { SPRING_PANEL } from "../../lib/ease"; import { cn } from "../../lib/utils"; + +export type ToastStatus = "success" | "error" | "info" | "warning" | "neutral" | "loading"; + export interface ToastItem { id: string; message: string; - status?: "success" | "error" | "info"; + status?: ToastStatus; + action?: { + label: string; + onClick: () => void; + }; } + +const statusConfig = { + success: { + icon: Check, + border: "border-emerald-500/30", + bg: "bg-emerald-500/10", + text: "text-emerald-600 dark:text-emerald-400", + }, + error: { + icon: AlertCircle, + border: "border-red-500/30", + bg: "bg-red-500/10", + text: "text-red-600 dark:text-red-400", + }, + warning: { + icon: AlertTriangle, + border: "border-amber-500/30", + bg: "bg-amber-500/10", + text: "text-amber-600 dark:text-amber-400", + }, + info: { + icon: Info, + border: "border-blue-500/30", + bg: "bg-blue-500/10", + text: "text-blue-600 dark:text-blue-400", + }, + neutral: { + icon: Info, + border: "border-gray-500/30", + bg: "bg-gray-500/10", + text: "text-gray-600 dark:text-gray-400", + }, + loading: { + icon: Loader, + border: "border-blue-500/30", + bg: "bg-blue-500/10", + text: "text-blue-600 dark:text-blue-400", + }, +}; + export default function AnimatedToastStack({ toasts, onDismiss, @@ -16,16 +63,16 @@ export default function AnimatedToastStack({ onDismiss?: (id: string) => void; }) { const reduce = useReducedMotion(); + return ( -
+
{toasts.map((toast) => { - const Icon = - toast.status === "error" - ? AlertCircle - : toast.status === "info" - ? Info - : Check; + const status = (toast.status || "info") as ToastStatus; + const config = statusConfig[status]; + const Icon = config.icon; + const isLoading = status === "loading"; + return ( - - {toast.message} +
+ {isLoading ? ( + + + + ) : ( + + )} +
+ {toast.message} + {toast.action && ( + + )} {onDismiss && ( )}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 74bcffb..f0691b5 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -44,7 +44,7 @@ type Ripple = { id: number; x: number; y: number; size: number }; const variants: Record = { primary: - "bg-gradient-to-r from-blue-600 to-violet-600 text-white shadow-soft hover:from-blue-700 hover:to-violet-700", + "bg-blue-600 text-white shadow-soft hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700", secondary: "border border-gray-200/80 bg-white/85 text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-700/80 dark:bg-gray-800/85 dark:text-gray-200 dark:hover:bg-gray-700/80", ghost: @@ -58,10 +58,10 @@ const variants: Record = { }; const sizes: Record = { - sm: "h-8 rounded-xl px-3 text-xs gap-1.5", - md: "h-10 rounded-xl px-4 text-sm gap-2", - lg: "h-12 rounded-2xl px-5 text-base gap-2", - icon: "h-10 w-10 rounded-xl p-0", + sm: "h-8 rounded-lg px-3 text-xs gap-1.5", + md: "h-10 rounded-lg px-4 text-sm gap-2", + lg: "h-12 rounded-xl px-5 text-base gap-2", + icon: "h-10 w-10 rounded-lg p-0", }; export const Button = forwardRef( diff --git a/src/components/ui/Tabs.tsx b/src/components/ui/Tabs.tsx index 2ee2680..a22f63d 100644 --- a/src/components/ui/Tabs.tsx +++ b/src/components/ui/Tabs.tsx @@ -28,7 +28,7 @@ export default function Tabs({ return (
({ key={item.value} onClick={() => onChange(item.value)} className={cn( - "relative flex flex-1 items-center justify-center gap-1.5 rounded-xl px-2 py-2 text-xs font-semibold transition-colors", + "relative flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors", active ? "text-blue-700 dark:text-blue-300" - : "text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200", + : "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300", )} > {active && ( Date: Fri, 3 Jul 2026 09:08:32 +0700 Subject: [PATCH 06/33] refactor: enhance Select component styling and error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align Select component with beUI design patterns: - Adjust border radius: rounded-xl → rounded-lg - Add error state support with visual feedback - Add animated chevron icon response to error state - Improve styling consistency with other form inputs - Better visual hierarchy and accessibility Co-Authored-By: Claude Haiku 4.5 --- src/components/ui/Select.tsx | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx index 2afef08..a71c429 100644 --- a/src/components/ui/Select.tsx +++ b/src/components/ui/Select.tsx @@ -1,14 +1,17 @@ // Source adapted from beUI Motion Select: https://beui.dev/components/motion/select -import { motion, useReducedMotion, type HTMLMotionProps } from "motion/react"; +import { motion, useReducedMotion } from "motion/react"; import { ChevronDown } from "lucide-react"; import type { ReactNode, SelectHTMLAttributes } from "react"; import { SPRING_PANEL } from "../../lib/ease"; import { cn } from "../../lib/utils"; + export interface SelectProps extends SelectHTMLAttributes { label?: string; leftIcon?: ReactNode; containerClassName?: string; + error?: string; } + export default function Select({ label, leftIcon, @@ -16,9 +19,11 @@ export default function Select({ containerClassName, id, children, + error, ...props }: SelectProps) { const reduce = useReducedMotion(); + return (
{label && ( @@ -42,7 +47,9 @@ export default function Select({ - + + + + {error && ( +

+ {error} +

+ )}
); } From 177ac9b3d84b1a6f9639401438c9fd385c76bfa5 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 3 Jul 2026 09:28:03 +0700 Subject: [PATCH 07/33] refactor: refine component styling for beUI consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor style refinements across components: **Card:** - Update border color: white/70 → gray-200/80 - Update background: white/80 → white/85 - Align with beUI's color palette **Input:** - Change border radius: rounded-xl → rounded-lg - Improve dark mode ring colors - Better visual hierarchy **ToggleSwitch:** - Remove gradient: from-blue-600 to-violet-600 → solid blue-600 - Match beUI's simpler color approach Co-Authored-By: Claude Haiku 4.5 --- src/components/ui/Card.tsx | 2 +- src/components/ui/Input.tsx | 9 +++++---- src/components/ui/ToggleSwitch.tsx | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index 8418c18..c464e2f 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -13,7 +13,7 @@ export default function Card({ whileHover={reduce ? undefined : { scale: 1.01 }} transition={SPRING_PRESS} className={cn( - "rounded-2xl border border-white/70 bg-white/80 shadow-soft backdrop-blur dark:border-gray-700/70 dark:bg-gray-900/70", + "rounded-xl border border-gray-200/80 bg-white/85 shadow-soft backdrop-blur dark:border-gray-700/80 dark:bg-gray-900/70", className, )} > diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index 10bb898..71f291f 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -65,12 +65,13 @@ export const Input = forwardRef(function Input( onBlur?.(e); }} className={cn( - "w-full rounded-xl border border-gray-200/90 bg-white/85 px-3 py-2.5 text-sm text-gray-900 shadow-sm outline-none transition-all placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700/80 dark:bg-gray-800/85 dark:text-gray-100", + "w-full rounded-lg border bg-white/85 px-3 py-2.5 text-sm text-gray-900 shadow-sm outline-none transition-all placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800/85 dark:text-gray-100", + "border-gray-200/90 dark:border-gray-700/80", leftIcon && "pl-10", (rightIcon || error || success) && "pr-10", - focused && "border-blue-400 ring-2 ring-blue-500/20", - error && "border-red-400 ring-2 ring-red-500/10", - success && "border-emerald-400", + focused && "border-blue-400 ring-2 ring-blue-500/20 dark:border-blue-500/40", + error && "border-red-400 ring-2 ring-red-500/10 dark:border-red-500/30", + success && "border-emerald-400 dark:border-emerald-500/40", className, )} {...props} diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx index 2977cb5..d5263d1 100644 --- a/src/components/ui/ToggleSwitch.tsx +++ b/src/components/ui/ToggleSwitch.tsx @@ -40,7 +40,7 @@ export default function ToggleSwitch({ className={cn( "relative h-6 w-11 rounded-full transition-colors", enabled - ? "bg-blue-600 bg-gradient-to-r from-blue-600 to-violet-600" + ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-700", )} > From 2efd78aa27dff9c161cba76ee9287f3c7d8653ae Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 3 Jul 2026 09:30:12 +0700 Subject: [PATCH 08/33] refactor: improve Tooltip styling for beUI consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update Tooltip component: - Change border radius: rounded-xl → rounded-lg - Update font: text-[11px] font-semibold → text-xs font-medium - Add dark mode background variant Co-Authored-By: Claude Haiku 4.5 --- src/components/ui/Tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx index f6b282d..3574421 100644 --- a/src/components/ui/Tooltip.tsx +++ b/src/components/ui/Tooltip.tsx @@ -36,7 +36,7 @@ export default function Tooltip({ : { opacity: 0, y: 4, scale: 0.96, filter: "blur(4px)" } } transition={SPRING_PANEL} - className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded-xl bg-gray-950 px-2.5 py-1.5 text-[11px] font-semibold text-white shadow-xl" + className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded-lg bg-gray-950 px-2.5 py-1.5 text-xs font-medium text-white shadow-xl dark:bg-gray-900" > {label}
From ddd9a6d4d140cc446f7ee5a3b40db1604d181235 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 3 Jul 2026 09:32:54 +0700 Subject: [PATCH 09/33] refactor: standardize border radius and opacity across components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final styling consistency pass: **EmptyState:** - Border radius: rounded-2xl → rounded-xl - Border opacity: gray-200 → gray-200/80 **StatCard:** - Border radius: rounded-2xl → rounded-xl - Background: white/70 → white/85 - Dark mode: bg-gray-800/60 → bg-gray-800/70 Ensures consistent visual language across all UI components aligned with beUI design system. Co-Authored-By: Claude Haiku 4.5 --- src/components/ui/EmptyState.tsx | 2 +- src/components/ui/StatCard.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx index b02f780..7a30505 100644 --- a/src/components/ui/EmptyState.tsx +++ b/src/components/ui/EmptyState.tsx @@ -18,7 +18,7 @@ export default function EmptyState({ initial={reduce ? undefined : { opacity: 0, y: 10 }} animate={reduce ? undefined : { opacity: 1, y: 0 }} transition={SPRING_PRESS} - className="rounded-2xl border border-dashed border-gray-200 bg-gray-50/70 px-4 py-6 text-center dark:border-gray-700 dark:bg-gray-900/50" + className="rounded-xl border border-dashed border-gray-200/80 bg-gray-50/70 px-4 py-6 text-center dark:border-gray-700/80 dark:bg-gray-900/50" > Date: Fri, 3 Jul 2026 09:35:14 +0700 Subject: [PATCH 10/33] feat: implement beUI design token system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete beUI design token system for theme consistency: **Tailwind Config:** - Add semantic color tokens (background, foreground, card, primary, destructive, etc.) - Use CSS custom properties for dynamic theming - Support light and dark mode variants **Global Styles:** - Define CSS variables for light mode (light background, dark foreground) - Define CSS variables for dark mode (dark background, light foreground) - All tokens properly scoped with HSL values **Updated Components:** - Button: Use primary, destructive, accent, muted tokens - Card: Use card, border tokens - Input: Use input, border, ring, destructive tokens - Select: Use input, border, ring, destructive tokens Benefits: ✓ Single source of truth for colors (CSS variables) ✓ Easy theme switching (just toggle dark class) ✓ Consistent design system across all components ✓ Future-proof for light/dark mode and custom themes All tests pass ✓ Co-Authored-By: Claude Haiku 4.5 --- entrypoints/popup/style.css | 42 ++++++++++++++++++++++++++++++++++++ src/components/ui/Button.tsx | 12 +++++------ src/components/ui/Card.tsx | 2 +- src/components/ui/Input.tsx | 10 ++++----- src/components/ui/Select.tsx | 6 +++--- tailwind.config.ts | 30 +++++++++++++++----------- 6 files changed, 75 insertions(+), 27 deletions(-) diff --git a/entrypoints/popup/style.css b/entrypoints/popup/style.css index 926b4ae..34957f1 100644 --- a/entrypoints/popup/style.css +++ b/entrypoints/popup/style.css @@ -2,6 +2,48 @@ @tailwind components; @tailwind utilities; +/* beUI Design Tokens - Light Mode */ +:root { + --background: 0 0% 100%; + --foreground: 0 0% 3.6%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.6%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 210 40% 98%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 217.2 91.2% 59.8%; + --accent-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 217.2 91.2% 59.8%; +} + +/* beUI Design Tokens - Dark Mode */ +.dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 217.2 32.6% 17.5%; + --card-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 91.2% 59.8%; + --secondary-foreground: 222.2 47.4% 11.2%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 91.2% 59.8%; + --accent-foreground: 222.2 47.4% 11.2%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 217.2 91.2% 59.8%; +} + * { margin: 0; padding: 0; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index f0691b5..5db0073 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -44,17 +44,17 @@ type Ripple = { id: number; x: number; y: number; size: number }; const variants: Record = { primary: - "bg-blue-600 text-white shadow-soft hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700", + "bg-primary text-primary-foreground shadow-soft hover:bg-primary/90 dark:hover:bg-primary/80", secondary: - "border border-gray-200/80 bg-white/85 text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-700/80 dark:bg-gray-800/85 dark:text-gray-200 dark:hover:bg-gray-700/80", + "border border-border bg-card text-foreground shadow-sm hover:bg-card/80", ghost: - "text-gray-600 hover:bg-gray-100/80 dark:text-gray-300 dark:hover:bg-gray-800/80", + "text-foreground hover:bg-muted", outline: - "border border-gray-200/80 bg-transparent text-gray-700 hover:bg-blue-50/70 dark:border-gray-700/80 dark:text-gray-200 dark:hover:bg-blue-950/30", + "border border-border bg-transparent text-foreground hover:bg-muted", 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", + "bg-destructive/10 text-destructive hover:bg-destructive/20 dark:hover:bg-destructive/15", 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", + "bg-accent/10 text-accent hover:bg-accent/20 dark:hover:bg-accent/15", }; const sizes: Record = { diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index c464e2f..facfca4 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -13,7 +13,7 @@ export default function Card({ whileHover={reduce ? undefined : { scale: 1.01 }} transition={SPRING_PRESS} className={cn( - "rounded-xl border border-gray-200/80 bg-white/85 shadow-soft backdrop-blur dark:border-gray-700/80 dark:bg-gray-900/70", + "rounded-xl border border-border bg-card shadow-soft backdrop-blur", className, )} > diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index 71f291f..820efde 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -65,13 +65,13 @@ export const Input = forwardRef(function Input( onBlur?.(e); }} className={cn( - "w-full rounded-lg border bg-white/85 px-3 py-2.5 text-sm text-gray-900 shadow-sm outline-none transition-all placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800/85 dark:text-gray-100", - "border-gray-200/90 dark:border-gray-700/80", + "w-full rounded-lg border bg-input px-3 py-2.5 text-sm text-foreground shadow-sm outline-none transition-all placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", + "border-border", leftIcon && "pl-10", (rightIcon || error || success) && "pr-10", - focused && "border-blue-400 ring-2 ring-blue-500/20 dark:border-blue-500/40", - error && "border-red-400 ring-2 ring-red-500/10 dark:border-red-500/30", - success && "border-emerald-400 dark:border-emerald-500/40", + focused && "border-ring ring-2 ring-ring/20", + error && "border-destructive ring-2 ring-destructive/10", + success && "border-accent ring-2 ring-accent/20", className, )} {...props} diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx index a71c429..bbcbff5 100644 --- a/src/components/ui/Select.tsx +++ b/src/components/ui/Select.tsx @@ -47,9 +47,9 @@ export default function Select({ onSelectAccount(event.target.value)} - leftIcon={} - containerClassName="min-w-0 flex-1" - className="truncate" - > - {emailAccounts.length > 0 ? ( - emailAccounts.map((account) => ( - - )) - ) : ( - - )} - +
+ + +
+ + ); +} + +export interface SelectItemProps { + value: string; + disabled?: boolean; + className?: string; + children: ReactNode; +} + +export function SelectItem({ + value, + disabled = false, + className, + children, +}: SelectItemProps) { + const ctx = useSelectContext("SelectItem"); + const selected = ctx.value === value; + const label = typeof children === "string" ? children : value; + + useLayoutEffect(() => { + ctx.register(value, label); + return () => ctx.unregister(value); + }, [ctx.register, ctx.unregister, value, label]); + + return ( + + + ); } diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 0425ee0..0744048 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -7,7 +7,13 @@ export { default as EmptyState } from "./EmptyState"; export { default as Input } from "./Input"; export { default as NumberAnimation } from "./NumberAnimation"; export { default as SectionHeader } from "./SectionHeader"; -export { default as Select } from "./Select"; +export { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "./Select"; export { default as StatCard } from "./StatCard"; export { default as Tabs } from "./Tabs"; export { default as Toast } from "./Toast"; From 06393fdc7e8a035a60d57f0683599e8cecd0460f Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:58:28 +0000 Subject: [PATCH 18/33] style: format code with Prettier This commit fixes the style issues introduced in cafdfcd according to the output from Prettier. Details: https://github.com/ePlus-DEV/gmail-alias-toolkit/pull/23 --- src/components/ui/Select.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx index 5c65bf2..4060229 100644 --- a/src/components/ui/Select.tsx +++ b/src/components/ui/Select.tsx @@ -174,10 +174,7 @@ export interface SelectTriggerProps { children: ReactNode; } -export function SelectTrigger({ - className, - children, -}: SelectTriggerProps) { +export function SelectTrigger({ className, children }: SelectTriggerProps) { const ctx = useSelectContext("SelectTrigger"); const isTop = ctx.placement === "top"; const kf = ctx.open ? [0, 0, 12] : [12, 0, 12]; @@ -235,10 +232,7 @@ export interface SelectValueProps { className?: string; } -export function SelectValue({ - placeholder, - className, -}: SelectValueProps) { +export function SelectValue({ placeholder, className }: SelectValueProps) { const ctx = useSelectContext("SelectValue"); const label = ctx.labelFor(ctx.value); return ( @@ -258,10 +252,7 @@ export interface SelectContentProps { children: ReactNode; } -export function SelectContent({ - className, - children, -}: SelectContentProps) { +export function SelectContent({ className, children }: SelectContentProps) { const ctx = useSelectContext("SelectContent"); const innerRef = useRef(null); const [height, setHeight] = useState(0); From 1831af41a6e17c60fd3c1fa20a0189c8aa55baa7 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 3 Jul 2026 10:25:49 +0700 Subject: [PATCH 19/33] config: add beUI MCP server configuration --- .codex/config.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .codex/config.toml diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..3e4859e --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +[mcp_servers.beui] +url = "https://mcp.beui.dev/mcp" From ace2955c4ae1e86d0c4ed845cd052898c6132880 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 3 Jul 2026 10:32:51 +0700 Subject: [PATCH 20/33] Refactor code structure for improved readability and maintainability --- components.json | 24 + entrypoints/popup/style.css | 133 ++- package.json | 4 +- postcss.config.js | 3 +- src/components/motion/action-swap.tsx | 332 +++++++ src/components/motion/animated-badge.tsx | 214 +++++ src/components/motion/animated-number.tsx | 51 ++ .../motion/animated-toast-stack.tsx | 504 +++++++++++ src/components/motion/button/base.tsx | 142 +++ src/components/motion/button/magnetic.tsx | 26 + src/components/motion/button/stateful.tsx | 227 +++++ src/components/motion/checkbox.tsx | 120 +++ src/components/motion/input.tsx | 271 ++++++ src/components/motion/magnetic.tsx | 50 ++ src/components/motion/marquee.tsx | 63 ++ src/components/motion/number-ticker.tsx | 186 ++++ src/components/motion/radio.tsx | 140 +++ src/components/motion/select.tsx | 412 +++++++++ src/components/motion/switch.tsx | 88 ++ src/components/motion/tabs.tsx | 190 ++++ src/components/motion/text-cascade.tsx | 23 + src/components/motion/text-reveal.tsx | 106 +++ src/components/motion/text-shimmer.tsx | 29 + src/components/motion/theme-toggle.tsx | 171 ++++ src/components/motion/tilt-card.tsx | 69 ++ src/components/motion/tooltip.tsx | 164 ++++ src/components/ui/Button.tsx | 6 +- src/components/ui/Input.tsx | 12 +- src/lib/ease.ts | 36 +- src/lib/hooks/use-hover-capable.ts | 18 +- src/lib/utils.ts | 6 +- tailwind.config.ts | 34 +- tsconfig.json | 1 + yarn.lock | 816 ++++++------------ 34 files changed, 3996 insertions(+), 675 deletions(-) create mode 100644 components.json create mode 100644 src/components/motion/action-swap.tsx create mode 100644 src/components/motion/animated-badge.tsx create mode 100644 src/components/motion/animated-number.tsx create mode 100644 src/components/motion/animated-toast-stack.tsx create mode 100644 src/components/motion/button/base.tsx create mode 100644 src/components/motion/button/magnetic.tsx create mode 100644 src/components/motion/button/stateful.tsx create mode 100644 src/components/motion/checkbox.tsx create mode 100644 src/components/motion/input.tsx create mode 100644 src/components/motion/magnetic.tsx create mode 100644 src/components/motion/marquee.tsx create mode 100644 src/components/motion/number-ticker.tsx create mode 100644 src/components/motion/radio.tsx create mode 100644 src/components/motion/select.tsx create mode 100644 src/components/motion/switch.tsx create mode 100644 src/components/motion/tabs.tsx create mode 100644 src/components/motion/text-cascade.tsx create mode 100644 src/components/motion/text-reveal.tsx create mode 100644 src/components/motion/text-shimmer.tsx create mode 100644 src/components/motion/theme-toggle.tsx create mode 100644 src/components/motion/tilt-card.tsx create mode 100644 src/components/motion/tooltip.tsx diff --git a/components.json b/components.json new file mode 100644 index 0000000..02c372b --- /dev/null +++ b/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "iconLibrary": "lucide", + "tailwind": { + "config": "tailwind.config.ts", + "css": "entrypoints/popup/style.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "src/components", + "ui": "src/components/ui", + "utils": "src/lib/utils", + "lib": "src/lib", + "hooks": "src/lib/hooks" + }, + "registries": { + "@beui": "https://beui.dev/r/{name}.json" + } +} diff --git a/entrypoints/popup/style.css b/entrypoints/popup/style.css index eafaa5a..50e1f4e 100644 --- a/entrypoints/popup/style.css +++ b/entrypoints/popup/style.css @@ -1,47 +1,85 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; + +@source "."; +@source "../../src"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --shadow-soft: 0 14px 35px -22px rgb(15 23 42 / 0.45); +} -/* beUI Design Tokens - Light Mode */ :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.6%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.6%; - --primary: 217 92% 59%; - --primary-foreground: 210 40% 98%; - --secondary: 217 32% 17%; - --secondary-foreground: 210 40% 98%; - --destructive: 0 84% 60%; - --destructive-foreground: 210 40% 98%; - --muted: 210 40% 96%; - --muted-foreground: 215 16% 47%; - --accent: 16 86% 67%; - --accent-foreground: 210 40% 98%; - --border: 214 32% 91%; - --input: 214 32% 91%; - --ring: 217 92% 59%; + color-scheme: light; + --background: oklch(99% 0 0); + --foreground: oklch(15% 0 0); + --card: oklch(97% 0 0); + --card-foreground: oklch(15% 0 0); + --popover: oklch(97% 0 0); + --popover-foreground: oklch(15% 0 0); + --primary: oklch(55% 0.18 255); + --primary-foreground: oklch(99% 0 0); + --secondary: oklch(97% 0 0); + --secondary-foreground: oklch(15% 0 0); + --muted: oklch(97% 0 0); + --muted-foreground: oklch(50% 0 0); + --accent: oklch(55% 0.18 255); + --accent-foreground: oklch(99% 0 0); + --destructive: oklch(62% 0.22 25); + --destructive-foreground: oklch(99% 0 0); + --border: oklch(15% 0 0 / 0.06); + --input: oklch(15% 0 0 / 0.06); + --ring: oklch(55% 0.18 255 / 0.5); + --glass-bg: rgb(255 255 255 / 0.72); + --glass-strong-bg: rgb(255 255 255 / 0.86); + --glass-thin-bg: rgb(255 255 255 / 0.52); + --glass-border: rgb(15 23 42 / 0.08); } -/* beUI Design Tokens - Dark Mode */ .dark { - --background: 222 84% 5%; - --foreground: 210 40% 98%; - --card: 0 0% 13%; - --card-foreground: 210 40% 98%; - --primary: 217 92% 59%; - --primary-foreground: 222 47% 11%; - --secondary: 217 92% 59%; - --secondary-foreground: 222 47% 11%; - --destructive: 0 63% 31%; - --destructive-foreground: 210 40% 98%; - --muted: 0 0% 20%; - --muted-foreground: 215 20% 65%; - --accent: 16 86% 67%; - --accent-foreground: 222 47% 11%; - --border: 0 0% 20%; - --input: 0 0% 13%; - --ring: 217 92% 59%; + color-scheme: dark; + --background: #151515; + --foreground: oklch(96% 0 0); + --card: #1c1c1c; + --card-foreground: oklch(96% 0 0); + --popover: #1c1c1c; + --popover-foreground: oklch(96% 0 0); + --primary: oklch(70% 0.15 255); + --primary-foreground: oklch(15% 0 0); + --secondary: #1c1c1c; + --secondary-foreground: oklch(96% 0 0); + --muted: #1c1c1c; + --muted-foreground: oklch(62% 0 0); + --accent: oklch(70% 0.15 255); + --accent-foreground: oklch(15% 0 0); + --destructive: oklch(62% 0.22 25); + --destructive-foreground: oklch(96% 0 0); + --border: rgb(255 255 255 / 0.05); + --input: rgb(255 255 255 / 0.05); + --ring: oklch(70% 0.15 255 / 0.55); + --glass-bg: rgb(28 28 28 / 0.72); + --glass-strong-bg: rgb(28 28 28 / 0.86); + --glass-thin-bg: rgb(28 28 28 / 0.52); + --glass-border: rgb(255 255 255 / 0.08); } * { @@ -65,3 +103,20 @@ body { width: 100%; height: 100%; } + +@utility glass { + background: var(--glass-bg); + backdrop-filter: blur(16px); + border: 1px solid var(--glass-border); +} + +@utility glass-strong { + background: var(--glass-strong-bg); + backdrop-filter: blur(16px); +} + +@utility glass-thin { + background: var(--glass-thin-bg); + backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); +} diff --git a/package.json b/package.json index f55c78f..0c3a9aa 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,14 @@ "clsx": "^2.1.1", "lucide-react": "^0.468.0", "motion": "^12.0.0", + "next-themes": "^0.4.6", "qrcode": "^1.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", "tailwind-merge": "^2.6.0" }, "devDependencies": { + "@tailwindcss/postcss": "^4.3.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -46,7 +48,7 @@ "autoprefixer": "^10.4.20", "jsdom": "^29.1.1", "postcss": "^8.5.10", - "tailwindcss": "^3.4.17", + "tailwindcss": "^4", "typescript": "^5.9.3", "vite": "^8.1.1", "vitest": "^4.1.9", diff --git a/postcss.config.js b/postcss.config.js index 2aa7205..c2ddf74 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,6 +1,5 @@ export default { plugins: { - tailwindcss: {}, - autoprefixer: {}, + "@tailwindcss/postcss": {}, }, }; diff --git a/src/components/motion/action-swap.tsx b/src/components/motion/action-swap.tsx new file mode 100644 index 0000000..e969ee8 --- /dev/null +++ b/src/components/motion/action-swap.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react"; +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from "src/lib/ease"; +import { cn } from "src/lib/utils"; + +export type ActionSwapItem = { + id: string; + label: ReactNode; + icon?: ReactNode; + ariaLabel?: string; +}; + +export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost"; +export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon"; +export type ActionSwapAnimation = "blur" | "roll" | "cascade"; + +/** Animations with a single-element variant set (cascade animates per letter). */ +type CoreAnimation = "blur" | "roll"; + +export interface ActionSwapButtonProps extends Omit< + HTMLMotionProps<"button">, + "children" | "onChange" +> { + items: ActionSwapItem[]; + value?: string; + defaultValue?: string; + onValueChange?: (value: string, item: ActionSwapItem) => void; + variant?: ActionSwapButtonVariant; + size?: ActionSwapButtonSize; + animation?: ActionSwapAnimation; + iconOnly?: boolean; + cycle?: boolean; +} + +export interface ActionSwapTextProps { + value: string; + children: ReactNode; + animation?: ActionSwapAnimation; + className?: string; +} + +export interface ActionSwapIconProps { + value: string; + children: ReactNode; + animation?: ActionSwapAnimation; + className?: string; +} + +const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const; +const ROLL_TRANSITION = { duration: 0.24, ease: EASE_OUT } as const; +const SWAP_BLUR = "blur(8px)"; +const ROLL_BLUR = "blur(6px)"; + +// Cascade rolls the label one letter at a time, left to right. The leaving +// and landing strings overlap as independent layers (no shared cells), so +// proportional glyph widths never jitter. Exits cascade at half the enter +// stagger so the tail of the old label lingers briefly. +const CASCADE_STAGGER = 0.025; + +const CASCADE_LETTER_VARIANTS: Variants = { + initial: { opacity: 0, y: "105%", filter: ROLL_BLUR }, + animate: (delay: number = 0) => ({ + opacity: 1, + y: "0%", + filter: "blur(0px)", + transition: { ...SPRING_SWAP, delay }, + }), + exit: (delay: number = 0) => ({ + opacity: 0, + y: "-105%", + filter: ROLL_BLUR, + transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 }, + }), +}; + +const TEXT_VARIANTS: Record = { + blur: { + initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR }, + animate: { + opacity: 1, + scale: 1, + filter: "blur(0px)", + transition: BLUR_TRANSITION, + }, + exit: { + opacity: 0, + scale: 0.94, + filter: SWAP_BLUR, + transition: BLUR_TRANSITION, + }, + }, + roll: { + initial: { opacity: 0, y: "115%", filter: ROLL_BLUR }, + animate: { + opacity: 1, + y: "0%", + filter: "blur(0px)", + transition: ROLL_TRANSITION, + }, + exit: { + opacity: 0, + y: "-115%", + filter: ROLL_BLUR, + transition: { duration: 0.18, ease: "easeInOut" }, + }, + }, +}; + +const ICON_VARIANTS: Record = { + blur: { + initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR }, + animate: { + opacity: 1, + scale: 1, + filter: "blur(0px)", + transition: BLUR_TRANSITION, + }, + exit: { + opacity: 0, + scale: 0.25, + filter: SWAP_BLUR, + transition: BLUR_TRANSITION, + }, + }, + roll: { + initial: { opacity: 0, y: 16, filter: ROLL_BLUR }, + animate: { + opacity: 1, + y: 0, + filter: "blur(0px)", + transition: ROLL_TRANSITION, + }, + exit: { + opacity: 0, + y: -16, + filter: ROLL_BLUR, + transition: { duration: 0.18, ease: "easeInOut" }, + }, + }, +}; + +const VARIANT_CLASS: Record = { + primary: "bg-primary text-primary-foreground hover:bg-primary/90", + secondary: "border border-border bg-card text-foreground hover:border-border", + outline: "border border-border bg-transparent text-foreground hover:bg-primary/5", + ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground", +}; + +const SIZE_CLASS: Record = { + sm: "h-8 gap-1.5 rounded-full px-3 text-xs", + md: "h-10 gap-2 rounded-full px-4 text-sm", + lg: "h-12 gap-2.5 rounded-full px-5 text-base", + icon: "h-10 w-10 rounded-full", +}; + +export function ActionSwapText({ + value, + children, + animation = "blur", + className, +}: ActionSwapTextProps) { + const reduce = useReducedMotion(); + const measureRef = useRef(null); + const [width, setWidth] = useState(); + + useLayoutEffect(() => { + const nextWidth = measureRef.current?.offsetWidth; + if (!nextWidth) return; + setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth)); + }); + + // Cascade needs a plain string to split into letters; non-string content + // and reduced motion fall back to the closest single-element animation. + const label = typeof children === "string" ? children : null; + const cascade = animation === "cascade" && label !== null && !reduce; + const coreAnimation: CoreAnimation = + animation === "cascade" ? "roll" : animation; + + return ( + + + {children} + + {cascade ? ( + <> + {/* Letters are decorative fragments; readers get the whole label. */} + {label} + + + {label.split("").map((char, i) => ( + + {char} + + ))} + + + + ) : ( + + + {children} + + + )} + + ); +} + +export function ActionSwapIcon({ + value, + children, + animation = "blur", + className, +}: ActionSwapIconProps) { + const reduce = useReducedMotion(); + // Icons are single elements — cascade maps to its closest motion, roll. + const coreAnimation: CoreAnimation = + animation === "cascade" ? "roll" : animation; + + return ( + + + + {children} + + + + ); +} + +export function ActionSwapButton({ + items, + value, + defaultValue, + onValueChange, + variant = "secondary", + size = "md", + animation = "blur", + iconOnly = size === "icon", + cycle = true, + className, + disabled, + onClick, + ...rest +}: ActionSwapButtonProps) { + const reduce = useReducedMotion(); + const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id); + const currentValue = value ?? internalValue; + const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue)); + const activeItem = items[activeIndex] ?? items[0]; + const hasIcon = items.some((item) => item.icon); + const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined; + + if (!activeItem) return null; + + const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined); + + return ( + { + onClick?.(event); + if (event.defaultPrevented || disabled || !cycle || !nextItem) return; + if (value === undefined) setInternalValue(nextItem.id); + onValueChange?.(nextItem.id, nextItem); + }} + {...rest} + > + {hasIcon ? ( + + {activeItem.icon ?? null} + + ) : null} + {!iconOnly ? ( + + {activeItem.label} + + ) : null} + + ); +} diff --git a/src/components/motion/animated-badge.tsx b/src/components/motion/animated-badge.tsx new file mode 100644 index 0000000..68f2987 --- /dev/null +++ b/src/components/motion/animated-badge.tsx @@ -0,0 +1,214 @@ +"use client"; +// beui.dev/components/motion/animated-badge + +import { + AlertTriangle, + Check, + Circle, + Info, + LoaderCircle, + X, + type LucideIcon, +} from "lucide-react"; +import { + AnimatePresence, + motion, + useReducedMotion, + type HTMLMotionProps, + type Variants, +} from "motion/react"; +import type { ReactNode } from "react"; +import { EASE_OUT } from "src/lib/ease"; +import { cn } from "src/lib/utils"; + +export type AnimatedBadgeStatus = + | "neutral" + | "info" + | "success" + | "warning" + | "danger" + | "loading"; + +export type AnimatedBadgeSize = "sm" | "md"; + +export interface AnimatedBadgeProps extends Omit< + HTMLMotionProps<"span">, + "children" +> { + status?: AnimatedBadgeStatus; + size?: AnimatedBadgeSize; + children?: ReactNode; + icon?: ReactNode; + showIcon?: boolean; + pulse?: boolean; + contentKey?: string | number; +} + +const STATUS_CLASS: Record = { + neutral: "border-border bg-card text-muted-foreground", + info: "border-primary/30 bg-primary/10 text-primary", + success: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + warning: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400", + danger: "border-destructive/30 bg-destructive/10 text-destructive", + loading: "border-primary/30 bg-primary/10 text-primary", +}; + +const SIZE_CLASS: Record = { + sm: "h-6 gap-1.5 px-2 text-[11px]", + md: "h-8 gap-2 px-3 text-xs", +}; + +const ICON_CLASS: Record = { + sm: "h-3 w-3", + md: "h-3.5 w-3.5", +}; + +const ICONS: Record = { + neutral: Circle, + info: Info, + success: Check, + warning: AlertTriangle, + danger: X, + loading: LoaderCircle, +}; + +const ICON_ROLL_VARIANTS: Variants = { + initial: { + opacity: 0.72, + y: "80%", + scale: 0.92, + rotate: -8, + filter: "blur(6px)", + }, + animate: { + opacity: 1, + y: "0%", + scale: 1, + rotate: 0, + filter: "blur(0px)", + transition: { + y: { type: "spring", stiffness: 210, damping: 24, mass: 0.85 }, + scale: { type: "spring", stiffness: 250, damping: 24, mass: 0.75 }, + rotate: { duration: 0.28, ease: EASE_OUT }, + opacity: { duration: 0.28, ease: EASE_OUT }, + filter: { duration: 0.42, ease: EASE_OUT }, + }, + }, + exit: { + opacity: 0.5, + y: "-80%", + scale: 0.96, + rotate: 8, + filter: "blur(6px)", + transition: { duration: 0.22, ease: EASE_OUT }, + }, +}; + +const TEXT_ROLL_VARIANTS: Variants = { + initial: { opacity: 0.76, y: "85%", filter: "blur(6px)" }, + animate: { + opacity: 1, + y: "0%", + filter: "blur(0px)", + transition: { + y: { type: "spring", stiffness: 210, damping: 24, mass: 0.85 }, + opacity: { duration: 0.3, ease: EASE_OUT }, + filter: { duration: 0.42, ease: EASE_OUT }, + }, + }, + exit: { + opacity: 0.5, + y: "-85%", + filter: "blur(6px)", + transition: { duration: 0.2, ease: EASE_OUT }, + }, +}; + +export function AnimatedBadge({ + status = "neutral", + size = "md", + children, + icon, + showIcon = true, + pulse = status === "loading", + contentKey, + className, + ...rest +}: AnimatedBadgeProps) { + const reduce = useReducedMotion(); + const Icon = ICONS[status]; + const resolvedContentKey = + contentKey ?? + (typeof children === "string" || typeof children === "number" + ? children + : status); + + return ( + + {pulse && !reduce ? ( + + ) : null} + {showIcon ? ( + + + + {status === "loading" && !reduce && !icon ? ( + + + + ) : ( + (icon ?? ) + )} + + + + ) : null} + {children != null ? ( + + + + {children} + + + + ) : null} + + ); +} diff --git a/src/components/motion/animated-number.tsx b/src/components/motion/animated-number.tsx new file mode 100644 index 0000000..9b6b3c7 --- /dev/null +++ b/src/components/motion/animated-number.tsx @@ -0,0 +1,51 @@ +"use client"; +// beui.dev/components/motion/number + +import { animate, useInView, useReducedMotion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import { EASE_OUT } from "src/lib/ease"; +import { cn } from "src/lib/utils"; + +export interface AnimatedNumberProps { + value: number; + duration?: number; + format?: (n: number) => string; + className?: string; + startOnView?: boolean; +} + +export function AnimatedNumber({ + value, + duration = 1.2, + format = (n) => Math.round(n).toLocaleString(), + className, + startOnView = true, +}: AnimatedNumberProps) { + const ref = useRef(null); + const inView = useInView(ref, { once: true, amount: 0.6 }); + const reduce = useReducedMotion(); + const [display, setDisplay] = useState(0); + const fromRef = useRef(0); + + useEffect(() => { + if (startOnView && !inView) return; + if (reduce) { + fromRef.current = value; + setDisplay(value); + return; + } + const controls = animate(fromRef.current, value, { + duration, + ease: EASE_OUT, + onUpdate: (v) => setDisplay(v), + }); + fromRef.current = value; + return () => controls.stop(); + }, [value, duration, inView, startOnView, reduce]); + + return ( + + {format(display)} + + ); +} diff --git a/src/components/motion/animated-toast-stack.tsx b/src/components/motion/animated-toast-stack.tsx new file mode 100644 index 0000000..55478a5 --- /dev/null +++ b/src/components/motion/animated-toast-stack.tsx @@ -0,0 +1,504 @@ +"use client"; +// beui.dev/components/motion/animated-toast-stack + +import { + AlertCircle, + Bell, + Check, + Info, + LoaderCircle, + X, + type LucideIcon, +} from "lucide-react"; +import { + AnimatePresence, + motion, + useReducedMotion, + type Transition, +} from "motion/react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { EASE_OUT } from "src/lib/ease"; +import { cn } from "src/lib/utils"; + +export type ToastStatus = "neutral" | "info" | "loading" | "success" | "error"; +export type ToastPosition = + | "top-left" + | "top-center" + | "top-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + +export type AnimatedToastAction = { + label: ReactNode; + onClick: (toast: AnimatedToast) => void; +}; + +export type AnimatedToast = { + id: string; + title: ReactNode; + description?: ReactNode; + status?: ToastStatus; + icon?: ReactNode; + action?: AnimatedToastAction; + duration?: number; + dismissible?: boolean; + createdAt?: number; +}; + +export type ToastInput = Omit & { + id?: string; +}; + +export type ToastClassNames = { + root?: string; + item?: string; + surface?: string; + iconWrap?: string; + content?: string; + title?: string; + description?: string; + action?: string; + close?: string; + progress?: string; +}; + +export interface AnimatedToastStackProps { + toasts: AnimatedToast[]; + onDismiss?: (id: string) => void; + position?: ToastPosition; + placement?: "static" | "fixed" | "absolute"; + fixed?: boolean; + portal?: boolean; + portalRoot?: Element | null; + maxVisible?: number; + className?: string; + classNames?: ToastClassNames; + icons?: Partial>; + renderToast?: (toast: AnimatedToast) => ReactNode; +} + +export interface UseAnimatedToastStackOptions { + initialToasts?: ToastInput[]; + defaultDuration?: number; + limit?: number; +} + +const STACK_SPRING: Transition = { + type: "spring", + stiffness: 420, + damping: 34, + mass: 0.75, +}; + +const CONTENT_TRANSITION = { + duration: 0.28, + ease: EASE_OUT, +} as const; + +const STATUS_ICON: Record = { + neutral: Bell, + info: Info, + loading: LoaderCircle, + success: Check, + error: AlertCircle, +}; + +const STATUS_CLASS: Record = { + neutral: "text-muted-foreground bg-primary/[0.05]", + info: "text-primary bg-primary/10", + loading: "text-primary bg-primary/10", + success: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400", + error: "text-destructive bg-destructive/10", +}; + +const POSITION_CLASS: Record = { + "top-left": "left-4 top-4", + "top-center": "left-1/2 top-4 -translate-x-1/2", + "top-right": "right-4 top-4", + "bottom-left": "bottom-6 left-4", + "bottom-center": "bottom-6 left-1/2 -translate-x-1/2", + "bottom-right": "bottom-6 right-4", +}; + +let idSeed = 0; + +function createToast(input: ToastInput, defaultDuration: number): AnimatedToast { + return { + duration: defaultDuration, + dismissible: true, + ...input, + id: input.id ?? `toast-${Date.now()}-${idSeed++}`, + createdAt: Date.now(), + }; +} + +export function useAnimatedToastStack({ + initialToasts = [], + defaultDuration = 4200, + limit, +}: UseAnimatedToastStackOptions = {}) { + const toastTimers = useRef>(new Map()); + const [toasts, setToasts] = useState(() => + initialToasts.map((toast) => createToast(toast, defaultDuration)), + ); + + const dismissToast = useCallback((id: string) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const clearToasts = useCallback(() => { + setToasts([]); + }, []); + + const showToast = useCallback( + (input: ToastInput) => { + const toast = createToast(input, defaultDuration); + setToasts((current) => { + const next = [...current, toast]; + return typeof limit === "number" ? next.slice(-limit) : next; + }); + return toast.id; + }, + [defaultDuration, limit], + ); + + const updateToast = useCallback((id: string, patch: Partial) => { + setToasts((current) => + current.map((toast) => + toast.id === id + ? { + ...toast, + ...patch, + id, + createdAt: patch.duration === undefined ? toast.createdAt : Date.now(), + } + : toast, + ), + ); + }, []); + + useEffect(() => { + const activeIds = new Set(toasts.map((toast) => toast.id)); + + toastTimers.current.forEach((entry, id) => { + if (!activeIds.has(id)) { + window.clearTimeout(entry.timer); + toastTimers.current.delete(id); + } + }); + + toasts.forEach((toast) => { + const duration = toast.duration ?? defaultDuration; + const existing = toastTimers.current.get(toast.id); + + if (duration <= 0) { + if (existing) { + window.clearTimeout(existing.timer); + toastTimers.current.delete(toast.id); + } + return; + } + + const createdAt = toast.createdAt ?? Date.now(); + const signature = `${createdAt}:${duration}`; + + if (existing?.signature === signature) { + return; + } + + if (existing) { + window.clearTimeout(existing.timer); + } + + const elapsed = Date.now() - createdAt; + const remaining = Math.max(duration - elapsed, 0); + const timer = window.setTimeout(() => { + toastTimers.current.delete(toast.id); + dismissToast(toast.id); + }, remaining); + + toastTimers.current.set(toast.id, { timer, signature }); + }); + }, [defaultDuration, dismissToast, toasts]); + + useEffect(() => { + const timers = toastTimers.current; + + return () => { + timers.forEach((entry) => { + window.clearTimeout(entry.timer); + }); + timers.clear(); + }; + }, []); + + return useMemo( + () => ({ + toasts, + showToast, + updateToast, + dismissToast, + clearToasts, + setToasts, + }), + [clearToasts, dismissToast, showToast, toasts, updateToast], + ); +} + +export function AnimatedToastStack({ + toasts, + onDismiss, + position = "bottom-right", + placement, + fixed = false, + portal, + portalRoot, + maxVisible = 4, + className, + classNames, + icons, + renderToast, +}: AnimatedToastStackProps) { + const [mounted, setMounted] = useState(false); + const visibleToasts = toasts.slice(-maxVisible); + const isBottom = position.startsWith("bottom"); + const resolvedPlacement = placement ?? (fixed ? "fixed" : "static"); + const shouldPortal = portal ?? resolvedPlacement === "fixed"; + + useEffect(() => { + setMounted(true); + }, []); + + const stack = ( +
    + + {visibleToasts.map((toast, index) => ( + + ))} + +
+ ); + + if (shouldPortal && !mounted) { + return null; + } + + if (shouldPortal) { + return createPortal(stack, portalRoot ?? document.body); + } + + return stack; +} + +const ToastItem = memo(function ToastItem({ + toast, + index, + onDismiss, + classNames, + icons, + renderToast, +}: { + toast: AnimatedToast; + index: number; + onDismiss?: (id: string) => void; + classNames?: ToastClassNames; + icons?: Partial>; + renderToast?: (toast: AnimatedToast) => ReactNode; +}) { + const reduce = useReducedMotion(); + const status = toast.status ?? "neutral"; + const Icon = STATUS_ICON[status]; + const iconNode = icons?.[status] ?? toast.icon ?? ; + const canDismiss = toast.dismissible !== false && Boolean(onDismiss); + + return ( + { + if (!canDismiss || !onDismiss) return; + if (Math.abs(info.offset.x) > 72 || Math.abs(info.velocity.x) > 520) { + onDismiss(toast.id); + } + }} + className={cn("pointer-events-auto relative will-change-transform", classNames?.item)} + style={{ zIndex: 20 - index }} + > +
+ {renderToast ? ( + renderToast(toast) + ) : ( +
+ + + + {status === "loading" ? ( + {iconNode} + ) : ( + iconNode + )} + + + + +
+ + +

+ {toast.title} +

+ {toast.description ? ( +

+ {toast.description} +

+ ) : null} +
+
+ + {toast.action ? ( + + ) : null} +
+ + {canDismiss ? ( + + ) : null} +
+ )} + +
+
+ ); +}); diff --git a/src/components/motion/button/base.tsx b/src/components/motion/button/base.tsx new file mode 100644 index 0000000..3a0bf69 --- /dev/null +++ b/src/components/motion/button/base.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { + AnimatePresence, + motion, + useReducedMotion, + type HTMLMotionProps, +} from "motion/react"; +import { + forwardRef, + type PointerEvent, + type ReactNode, + useCallback, + useRef, + useState, +} from "react"; +import { EASE_OUT, SPRING_PRESS } from "src/lib/ease"; +import { cn } from "src/lib/utils"; +import { useHoverCapable } from "src/lib/hooks/use-hover-capable"; + +export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline"; +export type ButtonSize = "sm" | "md" | "lg" | "icon"; + +export interface ButtonProps extends Omit< + HTMLMotionProps<"button">, + "children" +> { + variant?: ButtonVariant; + size?: ButtonSize; + pressScale?: number; + /** Spawn a Material-style ripple from the press point. Off by default. */ + ripple?: boolean; + children?: ReactNode; +} + +type Ripple = { id: number; x: number; y: number; size: number }; + +const VARIANT_CLASS: Record = { + primary: "bg-primary text-primary-foreground hover:bg-primary/90", + secondary: "border border-border bg-card text-foreground hover:border-border", + ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5", + outline: + "border border-border bg-transparent text-foreground hover:bg-primary/5", +}; + +const SIZE_CLASS: Record = { + sm: "h-8 px-3 text-xs gap-1.5 rounded-full", + md: "h-10 px-5 text-sm gap-2 rounded-full", + lg: "h-12 px-6 text-base gap-2 rounded-full", + icon: "h-8 w-8 rounded-lg", +}; + +export const Button = forwardRef( + function Button( + { + variant = "primary", + size = "md", + pressScale = 0.93, + ripple = false, + className, + children, + onPointerDown, + ...rest + }, + ref, + ) { + const reduce = useReducedMotion(); + const canHover = useHoverCapable(); + const [ripples, setRipples] = useState([]); + const nextId = useRef(0); + + const handlePointerDown = useCallback( + (event: PointerEvent) => { + if (ripple && !reduce) { + const rect = event.currentTarget.getBoundingClientRect(); + const size = Math.max(rect.width, rect.height) * 2; + setRipples((prev) => [ + ...prev, + { + id: nextId.current++, + x: event.clientX - rect.left, + y: event.clientY - rect.top, + size, + }, + ]); + } + onPointerDown?.(event); + }, + [ripple, reduce, onPointerDown], + ); + + return ( + + {ripple && !reduce ? ( + + + {ripples.map((r) => ( + + setRipples((prev) => prev.filter((x) => x.id !== r.id)) + } + /> + ))} + + + ) : null} + {children} + + ); + }, +); diff --git a/src/components/motion/button/magnetic.tsx b/src/components/motion/button/magnetic.tsx new file mode 100644 index 0000000..a4970af --- /dev/null +++ b/src/components/motion/button/magnetic.tsx @@ -0,0 +1,26 @@ +"use client"; +// beui.dev/components/motion/button + +import { forwardRef } from "react"; +import { Magnetic } from "../magnetic"; +import { Button, type ButtonProps } from "./base"; + +export interface MagneticButtonProps extends ButtonProps { + /** Magnetic pull strength. Default 0.25. */ + strength?: number; + /** Class applied to the magnetic wrapper. */ + magneticClassName?: string; +} + +export const MagneticButton = forwardRef(function MagneticButton( + { strength = 0.25, magneticClassName, children, ...rest }, + ref, +) { + return ( + + + + ); +}); diff --git a/src/components/motion/button/stateful.tsx b/src/components/motion/button/stateful.tsx new file mode 100644 index 0000000..4cc2af4 --- /dev/null +++ b/src/components/motion/button/stateful.tsx @@ -0,0 +1,227 @@ +"use client"; +// beui.dev/components/motion/button + +import { + AnimatePresence, + motion, + useReducedMotion, + type Variants, +} from "motion/react"; +import { Check, Loader2, X } from "lucide-react"; +import { + forwardRef, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { EASE_OUT, SPRING_SWAP } from "src/lib/ease"; +import { Button, type ButtonProps } from "./base"; + +export type ButtonState = "idle" | "loading" | "success" | "error"; + +export interface StatefulButtonProps extends Omit { + state?: ButtonState; + children: ReactNode; + loadingText?: ReactNode; + successText?: ReactNode; + errorText?: ReactNode; + icon?: ReactNode; +} + +const CASCADE_STAGGER = 0.025; +const ROLL_BLUR = "blur(6px)"; + +const CASCADE_LETTER_VARIANTS: Variants = { + initial: { opacity: 0, y: "105%", filter: ROLL_BLUR }, + animate: (delay: number = 0) => ({ + opacity: 1, + y: "0%", + filter: "blur(0px)", + transition: { ...SPRING_SWAP, delay }, + }), + exit: (delay: number = 0) => ({ + opacity: 0, + y: "-105%", + filter: ROLL_BLUR, + transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 }, + }), +}; + +const ICON_VARIANTS: Variants = { + // Width collapses too, so the icon adds/removes its own space smoothly + // instead of popping the row width in a single frame. + initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR }, + animate: { + opacity: 1, + width: "1.5rem", + scale: 1, + filter: "blur(0px)", + transition: SPRING_SWAP, + }, + exit: { + opacity: 0, + width: 0, + scale: 0.7, + filter: ROLL_BLUR, + transition: { duration: 0.16, ease: EASE_OUT }, + }, +}; + +function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) { + const reduce = useReducedMotion(); + return ( + + {children} + + ); +} + +function TextSlot({ + value, + children, +}: { + value: string; + children: ReactNode; +}) { + const reduce = useReducedMotion(); + const measureRef = useRef(null); + const [width, setWidth] = useState(); + const label = typeof children === "string" ? children : null; + const cascade = label !== null && !reduce; + + // Width is set instantly from the measurer; the parent's single `layout` + // animation smooths the resize (text + icons together) so nothing competes. + useLayoutEffect(() => { + const nextWidth = measureRef.current?.offsetWidth; + if (!nextWidth) return; + setWidth((current) => (current === nextWidth ? current : nextWidth)); + }); + + return ( + + + {children} + + + {cascade ? ( + <> + {label} + + + {label.split("").map((char, index) => ( + + {char} + + ))} + + + + ) : ( + + + {children} + + + )} + + ); +} + +export const StatefulButton = forwardRef(function StatefulButton( + { + state = "idle", + children, + loadingText = "Loading", + successText = "Done", + errorText = "Try again", + icon, + disabled, + ...rest + }, + ref, +) { + const isBusy = state === "loading"; + const stateText = + state === "loading" + ? loadingText + : state === "success" + ? successText + : state === "error" + ? errorText + : children; + const textKey = + typeof stateText === "string" ? `${state}-${stateText}` : state; + + return ( + + ); +}); diff --git a/src/components/motion/checkbox.tsx b/src/components/motion/checkbox.tsx new file mode 100644 index 0000000..b87b138 --- /dev/null +++ b/src/components/motion/checkbox.tsx @@ -0,0 +1,120 @@ +"use client"; +// beui.dev/components/motion/checkbox + +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useId } from "react"; +import { EASE_OUT, SPRING_PRESS } from "src/lib/ease"; +import { cn } from "src/lib/utils"; + +const CHECK_PATH = "M5 13l4 4L19 7"; +const INDETERMINATE_PATH = "M6 12h12"; + +export interface CheckboxProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + indeterminate?: boolean; + label?: string; + className?: string; + id?: string; + "aria-label"?: string; +} + +export function Checkbox({ + checked, + onCheckedChange, + disabled, + indeterminate, + label, + className, + id: idProp, + "aria-label": ariaLabel, +}: CheckboxProps) { + const autoId = useId(); + const id = idProp ?? autoId; + const reduce = useReducedMotion(); + const showMark = checked || indeterminate; + const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH; + + return ( + + ); +} diff --git a/src/components/motion/input.tsx b/src/components/motion/input.tsx new file mode 100644 index 0000000..11836bd --- /dev/null +++ b/src/components/motion/input.tsx @@ -0,0 +1,271 @@ +"use client"; +// beui.dev/components/motion/input + +import { + AnimatePresence, + animate, + motion, + useReducedMotion, +} from "motion/react"; +import { + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type InputHTMLAttributes, + type ReactNode, +} from "react"; +import { cn } from "src/lib/utils"; + +// Caret glide — snappy enough to feel attached to the keystroke, soft enough to read. +const CARET_SPRING = { + type: "spring", + stiffness: 700, + damping: 40, + mass: 0.5, +} as const; + +// Horizontal padding inside the field, in px. Wider when an icon occupies the edge. +const EDGE_PAD = 14; +const ICON_PAD = 40; + +export interface InputProps extends Omit< + InputHTMLAttributes, + "value" | "defaultValue" | "onChange" +> { + label?: string; + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; + /** Truthy error triggers a shake, red border and (if a string) a message. */ + error?: string | boolean; + success?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + className?: string; +} + +export function Input({ + label, + value: valueProp, + defaultValue, + onChange, + error, + success, + leftIcon, + rightIcon, + className, + disabled, + id: idProp, + type, + ...rest +}: InputProps) { + // Password masks characters as dots, so a plain-text mirror can't measure the + // caret position. Use the native caret there; the gliding caret is for text. + const customCaret = type !== "password"; + const reactId = useId(); + const id = idProp ?? reactId; + const reduce = useReducedMotion(); + + const controlled = valueProp !== undefined; + const [internal, setInternal] = useState(defaultValue ?? ""); + const value = controlled ? (valueProp ?? "") : internal; + + const [focused, setFocused] = useState(false); + const [caretIndex, setCaretIndex] = useState(null); + const [caretX, setCaretX] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + + const fieldRef = useRef(null); + const inputRef = useRef(null); + const mirrorRef = useRef(null); + + const hasError = Boolean(error); + const errorMessage = typeof error === "string" ? error : null; + const index = caretIndex ?? value.length; + + // Right edge shows the success check, otherwise the caller's right icon. + const rightSlot = success ? null : rightIcon; + const leftPad = leftIcon ? ICON_PAD : EDGE_PAD; + const rightPad = rightSlot || success ? ICON_PAD : EDGE_PAD; + + // Measure text width up to the caret, and read the input's scroll so the + // caret stays aligned once the value overflows and the field scrolls. + useLayoutEffect(() => { + if (mirrorRef.current) { + mirrorRef.current.textContent = value.slice(0, index); + setCaretX(mirrorRef.current.offsetWidth); + } + if (inputRef.current) setScrollLeft(inputRef.current.scrollLeft); + }, [value, index]); + + // Shake the field when an error appears. + useEffect(() => { + if (!fieldRef.current || reduce || !hasError) return; + animate( + fieldRef.current, + { x: [0, -6, 6, -4, 4, -2, 0] }, + { duration: 0.45 }, + ); + }, [hasError, reduce]); + + const handleChange = (next: string) => { + if (!controlled) setInternal(next); + onChange?.(next); + }; + + return ( +
+ {label ? ( + + ) : null} + +
+ {leftIcon ? ( + + {leftIcon} + + ) : null} + + { + handleChange(e.target.value); + setCaretIndex(e.target.selectionStart); + }} + onFocus={(e) => { + setFocused(true); + setCaretIndex(e.target.selectionStart); + }} + onBlur={() => setFocused(false)} + onSelect={(e) => setCaretIndex(e.currentTarget.selectionStart)} + onScroll={(e) => setScrollLeft(e.currentTarget.scrollLeft)} + className={cn( + "peer h-full w-full bg-transparent text-base leading-6 text-foreground outline-none", + customCaret ? "caret-transparent" : "caret-foreground", + "placeholder:text-muted-foreground/60", + disabled && "cursor-not-allowed", + )} + {...rest} + /> + + {customCaret ? ( + <> + {/* Hidden mirror measures caret x for the value up to the caret index. */} + + + {/* Custom caret glides between positions and blinks while focused. */} + + + ) : null} + + {success ? ( + + + + ) : rightSlot ? ( + + {rightSlot} + + ) : null} +
+ + + {errorMessage ? ( + + {errorMessage} + + ) : null} + +
+ ); +} diff --git a/src/components/motion/magnetic.tsx b/src/components/motion/magnetic.tsx new file mode 100644 index 0000000..67f52f8 --- /dev/null +++ b/src/components/motion/magnetic.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react"; +import { useRef, type ReactNode } from "react"; +import { SPRING_MOUSE } from "src/lib/ease"; +import { useHoverCapable } from "src/lib/hooks/use-hover-capable"; +import { cn } from "src/lib/utils"; + +export interface MagneticProps { + children: ReactNode; + strength?: number; + className?: string; +} + +export function Magnetic({ children, strength = 0.35, className }: MagneticProps) { + const ref = useRef(null); + const reduce = useReducedMotion(); + const canHover = useHoverCapable(); + // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion. + const enabled = !reduce && canHover; + const x = useMotionValue(0); + const y = useMotionValue(0); + const sx = useSpring(x, SPRING_MOUSE); + const sy = useSpring(y, SPRING_MOUSE); + + const onMove = (e: React.MouseEvent) => { + const el = ref.current; + if (!el || !enabled) return; + const rect = el.getBoundingClientRect(); + x.set((e.clientX - rect.left - rect.width / 2) * strength); + y.set((e.clientY - rect.top - rect.height / 2) * strength); + }; + + const onLeave = () => { + x.set(0); + y.set(0); + }; + + return ( + + {children} + + ); +} diff --git a/src/components/motion/marquee.tsx b/src/components/motion/marquee.tsx new file mode 100644 index 0000000..29f022e --- /dev/null +++ b/src/components/motion/marquee.tsx @@ -0,0 +1,63 @@ +import { Children, type ReactNode } from "react"; +import { cn } from "src/lib/utils"; + +export interface MarqueeProps { + children: ReactNode; + direction?: "left" | "right" | "up" | "down"; + speed?: number; + pauseOnHover?: boolean; + gap?: string; + className?: string; + fade?: boolean; +} + +export function Marquee({ + children, + direction = "left", + speed = 30, + pauseOnHover = true, + gap = "1rem", + className, + fade = true, +}: MarqueeProps) { + const vertical = direction === "up" || direction === "down"; + const reverse = direction === "right" || direction === "down"; + const items = Children.toArray(children); + + return ( +
+ {[0, 1].map((dup) => ( +
+ {items.map((child, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Marquee duplicates static child slots; item order is not mutated. +
+ {child} +
+ ))} +
+ ))} +
+ ); +} diff --git a/src/components/motion/number-ticker.tsx b/src/components/motion/number-ticker.tsx new file mode 100644 index 0000000..311c99d --- /dev/null +++ b/src/components/motion/number-ticker.tsx @@ -0,0 +1,186 @@ +"use client"; +// beui.dev/components/motion/number + +import { animate, motion, useInView, useReducedMotion } from "motion/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { EASE_OUT } from "src/lib/ease"; +import { cn } from "src/lib/utils"; + +export interface NumberTickerProps { + value: number; + /** Digits to pad to (left). */ + pad?: number; + /** Per-digit roll duration in seconds. */ + duration?: number; + /** Stagger between digits. */ + stagger?: number; + /** Render only after the element enters the viewport. */ + startOnView?: boolean; + prefix?: string; + suffix?: string; + /** Add a small blur during digit rolls. */ + blur?: boolean; + className?: string; + digitClassName?: string; + /** Insert locale group separators (commas). Server-component safe. */ + locale?: boolean; + /** Custom formatter. Client-only — server components must use `locale` instead. */ + format?: (value: number) => string; +} + +const DIGIT_HEIGHT_EM = 1.1; +const DIGITS = Array.from({ length: 10 }, (_, n) => n); + +export function NumberTicker({ + value, + pad, + duration = 0.9, + stagger = 0.04, + startOnView = true, + prefix, + suffix, + blur = false, + className, + digitClassName, + locale, + format, +}: NumberTickerProps) { + const containerRef = useRef(null); + const inView = useInView(containerRef, { once: true, amount: 0.6 }); + const [armed, setArmed] = useState(!startOnView); + + useEffect(() => { + if (startOnView && inView) setArmed(true); + }, [startOnView, inView]); + + const text = useMemo(() => { + const rounded = Math.round(value); + const formatted = format + ? format(rounded) + : locale + ? rounded.toLocaleString() + : rounded.toString(); + return pad ? formatted.padStart(pad, "0") : formatted; + }, [value, pad, format, locale]); + const glyphs = useMemo(() => { + const chars = text.split(""); + // Key by place value (position from the right): a changing digit keeps its + // identity and rolls to the new value instead of remounting and replaying + // from 0. Growing numbers add glyphs on the left without re-keying the + // ones, tens, hundreds already on screen. + return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` })); + }, [text]); + const readableText = `${prefix ?? ""}${text}${suffix ?? ""}`; + + // Stagger is an entrance flourish. Once the reveal has played, value + // changes roll every digit immediately — a per-digit delay on live updates + // reads as lag. + const [entered, setEntered] = useState(false); + useEffect(() => { + if (!armed || entered) return; + const total = (duration + glyphs.length * stagger) * 1000; + const t = window.setTimeout(() => setEntered(true), total); + return () => window.clearTimeout(t); + }, [armed, entered, duration, stagger, glyphs.length]); + + return ( + + {readableText} + + + ); +} + +function Digit({ + digit, + delay, + duration, + blur, + className, +}: { + digit: number; + delay: number; + duration: number; + blur: boolean; + className?: string; +}) { + const reduce = useReducedMotion(); + const columnRef = useRef(null); + + useEffect(() => { + if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) { + return; + } + + const node = columnRef.current; + const controls = animate( + node, + { filter: ["blur(10px)", "blur(0px)"] }, + { + duration: Math.min(duration * 0.75, 0.32), + delay, + ease: EASE_OUT, + }, + ); + + return () => { + controls.stop(); + node.style.filter = "blur(0px)"; + }; + }, [blur, delay, digit, duration, reduce]); + + return ( + + + {DIGITS.map((n) => ( + + {n} + + ))} + + + ); +} diff --git a/src/components/motion/radio.tsx b/src/components/motion/radio.tsx new file mode 100644 index 0000000..11e302f --- /dev/null +++ b/src/components/motion/radio.tsx @@ -0,0 +1,140 @@ +"use client"; +// beui.dev/components/motion/radio + +import { motion, MotionConfig, useReducedMotion } from "motion/react"; +import { + createContext, + useContext, + useId, + useState, + type ReactNode, +} from "react"; +import { SPRING_LAYOUT, SPRING_PRESS } from "src/lib/ease"; +import { cn } from "src/lib/utils"; + +type RadioCtx = { + value: string; + setValue: (value: string) => void; + layoutId: string; +}; + +const RadioCtx = createContext(null); + +function useRadioGroup() { + const ctx = useContext(RadioCtx); + if (!ctx) { + throw new Error("RadioGroupItem must be used inside "); + } + return ctx; +} + +export interface RadioGroupProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + children: ReactNode; + className?: string; + orientation?: "vertical" | "horizontal"; +} + +export function RadioGroup({ + value, + defaultValue = "", + onValueChange, + children, + className, + orientation = "vertical", +}: RadioGroupProps) { + const [internal, setInternal] = useState(defaultValue); + const layoutId = useId(); + const reduce = useReducedMotion(); + const controlled = value !== undefined; + const current = controlled ? value : internal; + const setValue = (next: string) => { + if (!controlled) setInternal(next); + onValueChange?.(next); + }; + + return ( + + +
+ {children} +
+
+
+ ); +} + +export interface RadioGroupItemProps { + value: string; + label?: string; + disabled?: boolean; + className?: string; + id?: string; +} + +export function RadioGroupItem({ + value, + label, + disabled, + className, + id: idProp, +}: RadioGroupItemProps) { + const { value: groupValue, setValue, layoutId } = useRadioGroup(); + const autoId = useId(); + const id = idProp ?? autoId; + const reduce = useReducedMotion(); + const selected = groupValue === value; + + return ( + + ); +} diff --git a/src/components/motion/select.tsx b/src/components/motion/select.tsx new file mode 100644 index 0000000..09cda6a --- /dev/null +++ b/src/components/motion/select.tsx @@ -0,0 +1,412 @@ +"use client"; +// beui.dev/components/motion/select + +import { Check, ChevronDown } from "lucide-react"; +import { + motion, + type Transition, + useReducedMotion, + type Variants, +} from "motion/react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { EASE_OUT } from "src/lib/ease"; +import { cn } from "src/lib/utils"; + +// Spring with bounce powers the unfold/separation; per-property timings in the +// content choreograph it (see SelectContent). Mirrors bouncy-accordion's feel. +const CHEVRON_TRANSITION: Transition = { type: "spring", duration: 0.4, bounce: 0.3 }; + +const LIST_VARIANTS: Variants = { + hidden: {}, + show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } }, +}; +const ITEM_VARIANTS: Variants = { + hidden: { opacity: 0, y: -6, filter: "blur(3px)" }, + show: { opacity: 1, y: 0, filter: "blur(0px)" }, +}; + +type Placement = "bottom" | "top"; + +interface SelectContextValue { + value: string | undefined; + open: boolean; + setOpen: (open: boolean) => void; + select: (value: string) => void; + register: (value: string, label: string) => void; + unregister: (value: string) => void; + labelFor: (value: string | undefined) => string | undefined; + reduce: boolean; + triggerId: string; + listId: string; + disabled: boolean; + placement: Placement; + setPlacement: (p: Placement) => void; +} + +const SelectContext = createContext(null); + +function useSelectContext(component: string) { + const ctx = useContext(SelectContext); + if (!ctx) throw new Error(`${component} must be used within onChange?.(e.target.value)} - onFocus={(e) => { - setFocused(true); - onFocus?.(e); - }} - onBlur={(e) => { - setFocused(false); - onBlur?.(e); - }} - className={cn( - "w-full rounded-lg border bg-input px-3 py-2.5 text-sm text-foreground shadow-sm outline-none transition-all placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", - "border-border", - leftIcon && "pl-10", - (rightIcon || error || success) && "pr-10", - focused && "border-ring ring-2 ring-ring/20", - error && "border-destructive ring-2 ring-destructive/10", - success && "border-accent ring-2 ring-accent/20", - className, - )} - {...props} - /> - {success ? ( - - ) : error ? ( - - ) : rightIcon ? ( - - {rightIcon} - - ) : null} - {focused && !reduce && ( - - )} - - {error && ( -

- {error} -

- )} -
- ); -}); -export default Input; diff --git a/src/components/ui/NumberAnimation.tsx b/src/components/ui/NumberAnimation.tsx deleted file mode 100644 index 9ac4ffe..0000000 --- a/src/components/ui/NumberAnimation.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// Source adapted from beUI Motion Number Animation: https://beui.dev/components/motion/number -import { motion, useReducedMotion } from "motion/react"; -import { useEffect, useState } from "react"; -import { SPRING_SWAP } from "../../lib/ease"; -export default function NumberAnimation({ - value, - className, -}: { - value: number | string; - className?: string; -}) { - const reduce = useReducedMotion(); - const [display, setDisplay] = useState(value); - useEffect(() => setDisplay(value), [value]); - return ( - - {display} - - ); -} diff --git a/src/components/ui/SectionHeader.tsx b/src/components/ui/SectionHeader.tsx deleted file mode 100644 index 7cd2184..0000000 --- a/src/components/ui/SectionHeader.tsx +++ /dev/null @@ -1,43 +0,0 @@ -// Source adapted from beUI Motion Section Header: https://beui.dev/components/motion/section -import { motion, useReducedMotion } from "motion/react"; -import type { ReactNode } from "react"; -import { SPRING_PRESS } from "../../lib/ease"; - -export interface SectionHeaderProps { - title: string; - description?: string; - action?: ReactNode; -} -export default function SectionHeader({ - title, - description, - action, -}: SectionHeaderProps) { - const reduce = useReducedMotion(); - return ( -
- -

- {title} -

- {description && ( -

- {description} -

- )} -
- {action && ( - - {action} - - )} -
- ); -} diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx deleted file mode 100644 index 4060229..0000000 --- a/src/components/ui/Select.tsx +++ /dev/null @@ -1,403 +0,0 @@ -// Source: beUI Motion Select: https://beui.dev/components/motion/select -"use client"; - -import { Check, ChevronDown } from "lucide-react"; -import { - motion, - type Transition, - useReducedMotion, - type Variants, -} from "motion/react"; -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useId, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { EASE_OUT, SPRING_PANEL } from "../../lib/ease"; -import { cn } from "../../lib/utils"; - -const CHEVRON_TRANSITION: Transition = { - type: "spring", - duration: 0.4, - bounce: 0.3, -}; - -const LIST_VARIANTS: Variants = { - hidden: {}, - show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } }, -}; - -const ITEM_VARIANTS: Variants = { - hidden: { opacity: 0, y: -6, filter: "blur(3px)" }, - show: { opacity: 1, y: 0, filter: "blur(0px)" }, -}; - -type Placement = "bottom" | "top"; - -interface SelectContextValue { - value: string | undefined; - open: boolean; - setOpen: (open: boolean) => void; - select: (value: string) => void; - register: (value: string, label: string) => void; - unregister: (value: string) => void; - labelFor: (value: string | undefined) => string | undefined; - reduce: boolean; - triggerId: string; - listId: string; - disabled: boolean; - placement: Placement; - setPlacement: (p: Placement) => void; -} - -const SelectContext = createContext(null); - -function useSelectContext(component: string) { - const ctx = useContext(SelectContext); - if (!ctx) throw new Error(`${component} must be used within - handleFormatChange(e.target.value as RandomFormat) + onValueChange={(value) => + handleFormatChange(value as RandomFormat) } - className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" > - - - - - + + + + + + {t("privateMailFormat")} + + + {t("randomCharactersFormat")} + + {t("randomWordsFormat")} + + {t("timestampFormat")} + + +
{/* Number of Emails */}
-
{/* Generate Button */} - + {/* Generated Emails List */} {generatedRandomList.length > 0 && ( // skipcq: JS-0415 -
-
+
+
- + {t("generatedAliases")}
- + {t("totalCount", String(generatedRandomList.length))} - +
@@ -284,14 +307,16 @@ export default function GeneratorTabs({ {generatedRandomList.map((email) => (
-
+
{email}
- +
))}
)} -
+
{randomFormat === "private-mail" ? t("formatPrivateMail") : randomFormat === "alphanumeric" @@ -332,7 +357,7 @@ export default function GeneratorTabs({
-
+
- setCustomTag(e.target.value)} + onChange={setCustomTag} onKeyDown={handleKeyPress} - className="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + className="w-full" placeholder={t("tagPlaceholder")} />
- +
{/* Custom Presets - Quick Access */} {customPresets.length > 0 && (
-
+
{t("yourPresets")}
{customPresets.map((preset) => ( - + ))}
)} -
+
{t("example")} {baseEmail.split("@")[0]}+ - + your-tag @{baseEmail.split("@")[1]} - +
)} @@ -430,3 +460,6 @@ export default function GeneratorTabs({
); } + + + diff --git a/entrypoints/popup/components/GmailTricks.tsx b/entrypoints/popup/components/GmailTricks.tsx index 9d8bf13..15388cf 100644 --- a/entrypoints/popup/components/GmailTricks.tsx +++ b/entrypoints/popup/components/GmailTricks.tsx @@ -1,4 +1,7 @@ import { useState } from "react"; +import Button from "./Button"; +import Input from "./Input"; +import { Checkbox } from "src/components/motion/checkbox"; import { getDotVariationCandidates } from "../utils"; interface GmailTricksProps { @@ -205,82 +208,88 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) {
{/* Trick Type Selector */}
- - - - - - +
{/* Options */}
-
@@ -289,21 +298,19 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) { selectedTrick === "googlemail" || selectedTrick === "dotplus" || selectedTrick === "combo") && ( -
- + setRandomizeDots(e.target.checked)} - className="w-4 h-4 text-blue-600 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500" + onCheckedChange={setRandomizeDots} /> - + {randomizeDots ? "Random" : "Sequential"}
@@ -311,9 +318,10 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) {
{/* Generate Button */} - + {/* Generated Tricks List */} {generatedTricks.length > 0 && ( -
-
+
+
- + Generated Variations - + {generatedTricks.length} total
@@ -350,14 +358,16 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) { {generatedTricks.map((email) => (
-
+
{email}
- +
))}
@@ -381,10 +391,10 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) { )} {/* Info */} -
+
@@ -394,7 +404,7 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) { clipRule="evenodd" /> -

+

Gmail trick: Dots are ignored & everything after + goes to same inbox

@@ -403,3 +413,8 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) {
); } + + + + + diff --git a/entrypoints/popup/components/HistorySection.tsx b/entrypoints/popup/components/HistorySection.tsx index a7c0c23..f3df773 100644 --- a/entrypoints/popup/components/HistorySection.tsx +++ b/entrypoints/popup/components/HistorySection.tsx @@ -1,4 +1,14 @@ /** Recent aliases list with search, filter, pagination, and bulk selection. */ +import Button from "./Button"; +import Input from "./Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "src/components/motion/select"; +import { Checkbox } from "src/components/motion/checkbox"; import { t } from "../../../lib/i18n"; interface Alias { @@ -72,43 +82,49 @@ export default function HistorySection({
{/* Header with title and action buttons */}
-

+

{viewMode === "all" ? t("recentAliases") : t("favorites")}

{viewMode === "all" && recentAliases.length > 0 && ( <> - - - + )} - + {viewMode === "all" ? t("totalCount", String(recentAliases.length)) : t("starredCount", String(favorites.length))} @@ -118,8 +134,8 @@ export default function HistorySection({ {/* Bulk delete bar */} {isSelectMode && ( -
- - + + {t("selectedCount", String(selectedAliases.size))} - +
)} {/* View mode tabs */} -
- - +
{/* Search and filters */}
- setSearchQuery(e.target.value)} + onChange={setSearchQuery} placeholder={t("searchAliases")} - className="w-full pl-3 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + className="w-full" /> {searchQuery && ( - + )}
- + + - + + + + + {t("mostRecent")} + {t("az")} + +
@@ -334,9 +368,9 @@ function HistoryList({ if (filteredAliases.length === 0 && viewMode === "favorites") { return ( -
+
+
void; }) { return ( -
+
{isSelectMode && ( - )} - + {alias.email} - - - +
); } @@ -549,50 +589,59 @@ function Pagination({ // skipcq: JS-0415 return ( // skipcq: JS-0415 -
+
{/* Page info */}
-
+
{t("showingRange", [ String(startIndex + 1), String(Math.min(endIndex, totalItems)), String(totalItems), ])}
- { + setItemsPerPage(Number(value)); setCurrentPage(1); }} - className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + className="w-24" > - - - - - + + + + + {t("perPage", "5")} + {t("perPage", "10")} + {t("perPage", "20")} + {t("perPage", "50")} + +
{/* Navigation buttons */}
- - +
{Array.from({ length: totalPages }, (_, i) => i + 1) .filter( @@ -608,42 +657,51 @@ function Pagination({ return (
{showEllipsis && ( - + ... )} - +
); })}
- - +
); } + + + diff --git a/entrypoints/popup/components/Input.tsx b/entrypoints/popup/components/Input.tsx index a234d31..48bcc0e 100644 --- a/entrypoints/popup/components/Input.tsx +++ b/entrypoints/popup/components/Input.tsx @@ -1,2 +1,5 @@ export { Input as default } from "../../../src/components/motion/input"; export type { InputProps } from "../../../src/components/motion/input"; + + + diff --git a/entrypoints/popup/components/KeyboardShortcuts.tsx b/entrypoints/popup/components/KeyboardShortcuts.tsx index 4d154c7..6377fde 100644 --- a/entrypoints/popup/components/KeyboardShortcuts.tsx +++ b/entrypoints/popup/components/KeyboardShortcuts.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import Button from "./Button"; /** Button that opens a modal listing available keyboard shortcuts. */ export default function KeyboardShortcuts() { @@ -16,9 +17,10 @@ export default function KeyboardShortcuts() { return ( <> - + {isOpen && ( // skipcq: JS-0415 @@ -43,17 +45,19 @@ export default function KeyboardShortcuts() { onClick={() => setIsOpen(false)} >
e.stopPropagation()} > -
+
-

+

Keyboard Shortcuts

- +
@@ -78,14 +82,14 @@ export default function KeyboardShortcuts() { className="flex items-center justify-between" >
-
+
{shortcut.description}
-
+
{shortcut.context}
- + {shortcut.key}
@@ -97,3 +101,6 @@ export default function KeyboardShortcuts() { ); } + + + diff --git a/entrypoints/popup/components/Settings.tsx b/entrypoints/popup/components/Settings.tsx index 18c232b..af9feaf 100644 --- a/entrypoints/popup/components/Settings.tsx +++ b/entrypoints/popup/components/Settings.tsx @@ -2,6 +2,14 @@ import { useState, useEffect, useCallback } from "react"; import Toggle from "./Toggle"; import Button from "./Button"; import Input from "./Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "src/components/motion/select"; +import { RadioGroup, RadioGroupItem } from "src/components/motion/radio"; import { getAccountStorageKey } from "../utils"; import { t } from "../../../lib/i18n"; @@ -517,13 +525,13 @@ export default function Settings({ return ( // skipcq: JS-0415
-
+
{/* Header */} -
+
- +

{t("settings")}

- +
{/* Tabs */} -
- - - +
{/* Content */} -
+
{/* General Tab */} {activeTab === "general" && ( // skipcq: JS-0415
-
+
{/* Appearance Section */}
-

- +

+
-
-
-
-
))}
@@ -894,10 +824,10 @@ export default function Settings({ {/* Data Management Section */}
-

- +

+

- +

@@ -953,16 +883,16 @@ export default function Settings({ {activeTab === "accounts" && (
-

+

{t("emailAccounts")}

-

+

{t("manageAccountsDescription")}

{emailAccounts.length === 0 ? ( -
+
{t("noAccountsFound")}
) : ( @@ -972,37 +902,24 @@ export default function Settings({ key={account.id} className={`rounded-lg border-2 transition-all ${ account.isActive - ? "border-blue-500 bg-blue-50 dark:bg-blue-950/40" - : "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600" + ? "border-primary bg-primary/10" + : "border-border bg-card hover:border-border dark:hover:border-border" }`} > {editingAccountId === account.id ? ( // Edit mode
-
-
- - +
) : ( @@ -1030,25 +947,19 @@ export default function Settings({
{/* Radio button to select active account */}