diff --git a/.claude.json b/.claude.json new file mode 100644 index 0000000..dca3674 --- /dev/null +++ b/.claude.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "beui": { + "type": "http", + "url": "https://mcp.beui.dev/mcp" + } + } +} 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" 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/App.tsx b/entrypoints/popup/App.tsx index 4de933a..60ca043 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -1,4 +1,8 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import { AnimatedToastStack } from "src/components/motion/animated-toast-stack"; +import Button from "./components/Button"; +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"; @@ -104,7 +108,32 @@ function App() { const [qrAlias, setQrAlias] = useState(null); const qrCanvasRef = useRef(null); // Theme - const [, setTheme] = useState<"light" | "dark" | "auto">("light"); + const [theme, setTheme] = useState<"light" | "dark" | "auto">("light"); + + const applyTheme = useCallback((nextTheme: "light" | "dark" | "auto") => { + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + document.documentElement.classList.toggle( + "dark", + nextTheme === "dark" || (nextTheme === "auto" && prefersDark), + ); + }, []); + + const handleThemeChange = useCallback( + async (nextTheme: "light" | "dark") => { + setTheme(nextTheme); + applyTheme(nextTheme); + const result = await browser.storage.local.get("app_settings"); + await browser.storage.local.set({ + app_settings: { + ...(result.app_settings || {}), + theme: nextTheme, + }, + }); + }, + [applyTheme], + ); // Load recent aliases, base email, and settings from storage useEffect(() => { @@ -209,13 +238,7 @@ function App() { setShowNotifications(result.app_settings.showNotifications ?? true); const savedTheme = result.app_settings.theme || "light"; setTheme(savedTheme); - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - document.documentElement.classList.toggle( - "dark", - savedTheme === "dark" || (savedTheme === "auto" && prefersDark), - ); + applyTheme(savedTheme); } // Load email accounts list @@ -249,13 +272,7 @@ function App() { setShowNotifications(newSettings.showNotifications ?? true); const newTheme = newSettings.theme || "light"; setTheme(newTheme); - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - document.documentElement.classList.toggle( - "dark", - newTheme === "dark" || (newTheme === "auto" && prefersDark), - ); + applyTheme(newTheme); } } if (changes.email_accounts) { @@ -709,7 +726,7 @@ function App() { // skipcq: JS-0415 return ( -
+
{/* Show Welcome Screen for first-time users */} {!hasEmailAccounts ? (
@@ -724,285 +741,60 @@ 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 ? ( + + ) : null}
)} @@ -1077,29 +880,30 @@ function App() { onClick={() => setQrAlias(null)} >
e.stopPropagation()} > -

+

{t("scanToCopyAlias")}

-

+

{qrAlias}

- - +
diff --git a/entrypoints/popup/components/Button.tsx b/entrypoints/popup/components/Button.tsx index e34179a..2124cd0 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 { Button as default } from "../../../src/components/motion/button/base"; +export type { ButtonProps } from "../../../src/components/motion/button/base"; diff --git a/entrypoints/popup/components/Favorites.tsx b/entrypoints/popup/components/Favorites.tsx index f85d22a..ae5ee17 100644 --- a/entrypoints/popup/components/Favorites.tsx +++ b/entrypoints/popup/components/Favorites.tsx @@ -1,4 +1,6 @@ import { useState, useEffect } from "react"; +import Button from "./Button"; +import { Tooltip } from "src/components/motion/tooltip"; import { getAccountStorageKey } from "../utils"; import { t } from "../../../lib/i18n"; @@ -61,13 +63,15 @@ export default function Favorites({ baseEmail, onCopy }: FavoritesProps) { if (favorites.length === 0) { return ( -
+
-

⭐ Favorites

+

+ ⭐ Favorites +

-

{t("noFavoritesYet")}

-

+

+ {t("noFavoritesYet")} +

+

Click ⭐ on any alias in history to add it here

@@ -89,10 +95,10 @@ export default function Favorites({ baseEmail, onCopy }: FavoritesProps) { } return ( -
+
-

⭐ Favorites

- +

⭐ Favorites

+ {t("favoritesSaved", String(favorites.length))}
@@ -105,35 +111,40 @@ export default function Favorites({ baseEmail, onCopy }: FavoritesProps) { return (
- + - + + + + +
); })} diff --git a/entrypoints/popup/components/GeneratorTabs.tsx b/entrypoints/popup/components/GeneratorTabs.tsx index 7cc9fb5..4f9264f 100644 --- a/entrypoints/popup/components/GeneratorTabs.tsx +++ b/entrypoints/popup/components/GeneratorTabs.tsx @@ -1,4 +1,25 @@ import GmailTricks from "./GmailTricks"; +import Button from "./Button"; +import Input from "./Input"; +import { + AtSign, + Check, + Copy, + LoaderCircle, + Shuffle, + Tag, + Zap, +} from "lucide-react"; +import { useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "src/components/motion/select"; +import { ActionSwapButton } from "src/components/motion/action-swap"; +import { Tooltip } from "src/components/motion/tooltip"; import { generateAlias, generateRandomString, @@ -56,6 +77,10 @@ export default function GeneratorTabs({ saveRecentAliases, setToastMessage, }: GeneratorTabsProps) { + const [randomActionState, setRandomActionState] = useState< + "idle" | "generating" | "done" + >("idle"); + /** Updates random format setting and persists to storage. */ const handleFormatChange = async (newFormat: RandomFormat) => { setRandomFormat(newFormat); @@ -72,125 +97,117 @@ export default function GeneratorTabs({ return (
{/* Main Tabs */} -
- - + + - setActiveTab("tags")} + variant="ghost" + size="sm" + className={`h-10 w-full min-w-0 rounded-xl border px-2 text-xs transition-colors ${ + activeTab === "tags" + ? "border-primary/40 bg-primary/10 text-primary" + : "border-border bg-background text-muted-foreground hover:bg-muted/70" + }`} > - - - {t("customTags")} - - + + - setActiveTab("tricks")} + variant="ghost" + size="sm" + className={`h-10 w-full min-w-0 rounded-xl border px-2 text-xs transition-colors ${ + activeTab === "tricks" + ? "border-primary/40 bg-primary/10 text-primary" + : "border-border bg-background text-muted-foreground hover:bg-muted/70" + }`} > - - - {t("gmailTricks")} - + + {t("tabTricksShort")} + +
{/* Tab Content */} -
+
{/* Random Tab */} {activeTab === "random" && (
- {/* Format Selector */} -
- - -
- - {/* Number of Emails */} -
- - - setRandomEmailCount( - Math.max(1, parseInt(e.target.value) || 10), - ) - } - className="w-20 px-3 py-1.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" - /> +
+
+ + +
+
+ + + setRandomEmailCount(Math.max(1, parseInt(value) || 10)) + } + className="w-full" + /> +
{/* Generate Button */} - + disabled={randomActionState === "generating"} + items={[ + { + id: "idle", + icon: , + label: t("generateRandomAliases", [ + String(randomEmailCount), + randomEmailCount > 1 ? "es" : "", + ]), + }, + { + id: "generating", + icon: , + label: t("generating"), + }, + { + id: "done", + icon: , + label: t("copied"), + }, + ]} + value={randomActionState} + cycle={false} + variant="primary" + size="md" + animation="roll" + className="mb-2.5 h-10 w-full rounded-xl px-4 text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-ring" + /> {/* Generated Emails List */} {generatedRandomList.length > 0 && ( // skipcq: JS-0415 -
-
+
+
- + {t("generatedAliases")}
- + {t("totalCount", String(generatedRandomList.length))} - + setTimeout( + () => setToastMessage(null), + showNotifications ? 2000 : 0, + ); + }} + variant="ghost" + size="sm" + className="text-xs text-primary hover:text-primary font-medium" + aria-label={t("copyToClipboard")} + > + {t("copyAll")} + +
@@ -284,37 +315,41 @@ export default function GeneratorTabs({ {generatedRandomList.map((email) => (
-
+
{email}
- + + + + +
))}
)} -
+
{randomFormat === "private-mail" ? t("formatPrivateMail") : randomFormat === "alphanumeric" @@ -329,93 +364,73 @@ export default function GeneratorTabs({ {/* Custom Tags Tab */} {activeTab === "tags" && ( // skipcq: JS-0415 -
-
-
-
- - - -
- +
+
+ 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")} + leftIcon={} /> +
-
{/* Custom Presets - Quick Access */} {customPresets.length > 0 && ( -
-
+
+
{t("yourPresets")}
{customPresets.map((preset) => ( - + ))}
)} -
- - {t("example")} {baseEmail.split("@")[0]}+ - - your-tag - - @{baseEmail.split("@")[1]} +
+ + {t("example")}{" "} + + {baseEmail.split("@")[0]}+your-tag@{baseEmail.split("@")[1]} + - + + +
)} diff --git a/entrypoints/popup/components/GmailTricks.tsx b/entrypoints/popup/components/GmailTricks.tsx index 9d8bf13..15dc294 100644 --- a/entrypoints/popup/components/GmailTricks.tsx +++ b/entrypoints/popup/components/GmailTricks.tsx @@ -1,5 +1,11 @@ import { useState } from "react"; +import Button from "./Button"; +import Input from "./Input"; +import { Checkbox } from "src/components/motion/checkbox"; +import { Tooltip } from "src/components/motion/tooltip"; +import { Copy, Dices, Info, Zap } from "lucide-react"; import { getDotVariationCandidates } from "../utils"; +import { t } from "../../../lib/i18n"; interface GmailTricksProps { baseEmail: string; @@ -199,88 +205,77 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) { }, 0); }; + const trickButtonClass = (active: boolean) => + `h-10 min-w-0 rounded-xl border px-2 text-xs font-medium transition-colors ${ + active + ? "border-primary/35 bg-primary/10 text-foreground" + : "border-border bg-background text-muted-foreground hover:bg-muted/70 hover:text-foreground" + }`; + // skipcq: JS-0415 return ( // skipcq: JS-0415
{/* Trick Type Selector */}
- - - - - - + {t("allCombos")} +
{/* Options */}
-
@@ -289,60 +284,48 @@ 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"} + + {randomizeDots ? t("random") : t("sequential")}
)}
{/* Generate Button */} - + {/* Generated Tricks List */} {generatedTricks.length > 0 && ( -
-
+
+
- - Generated Variations + + {t("generatedVariations")} - - {generatedTricks.length} total + + {t("totalCount", String(generatedTricks.length))}
@@ -350,30 +333,22 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) { {generatedTricks.map((email) => (
-
+
{email}
- + + +
))}
@@ -381,22 +356,14 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) { )} {/* Info */} -
+
- - - -

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

+ + {t("gmailTrickInfoLabel")} + {" "} + {t("gmailTrickInfo")}

diff --git a/entrypoints/popup/components/HistorySection.tsx b/entrypoints/popup/components/HistorySection.tsx index a7c0c23..2bf1721 100644 --- a/entrypoints/popup/components/HistorySection.tsx +++ b/entrypoints/popup/components/HistorySection.tsx @@ -1,4 +1,28 @@ /** Recent aliases list with search, filter, pagination, and bulk selection. */ +import { useMemo } from "react"; +import Button from "./Button"; +import Input from "./Input"; +import { + Check, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Copy, + QrCode, + Star, +} from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "src/components/motion/select"; +import { Checkbox } from "src/components/motion/checkbox"; +import { Tooltip } from "src/components/motion/tooltip"; +import { Table, type TableColumn } from "src/components/motion/table"; +import { AnimatedBadge } from "src/components/motion/animated-badge"; import { t } from "../../../lib/i18n"; interface Alias { @@ -6,6 +30,14 @@ interface Alias { timestamp: number; } +function shortenEmail(email: string) { + const [local, domain] = email.split("@"); + if (!local || !domain || email.length <= 30) return email; + const visibleLocal = + local.length > 20 ? `${local.slice(0, 14)}...${local.slice(-4)}` : local; + return `${visibleLocal}@${domain}`; +} + interface HistorySectionProps { recentAliases: Alias[]; favorites: string[]; @@ -69,57 +101,77 @@ export default function HistorySection({ // skipcq: JS-0415 return ( // skipcq: JS-0415 -
+
{/* 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))} - -
+ + +
+ )}
{/* 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 && ( - + )}
-
- setFilterTag(e.target.value)} - className="flex-1 px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-xs focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + onValueChange={setFilterTag} + className="flex-1" > - - {Array.from( - new Set( - recentAliases - .map((a) => { - const match = a.email.match(/\+([^@]+)@/); - return match ? match[1] : null; - }) - .filter((t): t is string => t !== null), - ), - ).map((tag) => ( - - ))} - + + + + + {t("allTags")} + {Array.from( + new Set( + recentAliases + .map((a) => { + const match = a.email.match(/\+([^@]+)@/); + return match ? match[1] : null; + }) + .filter((t): t is string => t !== null), + ), + ).map((tag) => ( + + {tag} + + ))} + + - + + + + + {t("mostRecent")} + {t("az")} + +
@@ -283,7 +353,6 @@ export default function HistorySection({ isSelectMode={isSelectMode} selectedAliases={selectedAliases} toggleSelectAlias={toggleSelectAlias} - copiedEmail={copiedEmail} favorites={favorites} toggleFavorite={toggleFavorite} copyToClipboard={copyToClipboard} @@ -304,7 +373,6 @@ function HistoryList({ isSelectMode, selectedAliases, toggleSelectAlias, - copiedEmail, favorites, toggleFavorite, copyToClipboard, @@ -319,7 +387,6 @@ function HistoryList({ isSelectMode: boolean; selectedAliases: Set; toggleSelectAlias: (email: string) => void; - copiedEmail: string | null; favorites: string[]; toggleFavorite: (email: string) => void; copyToClipboard: (email: string) => Promise; @@ -331,12 +398,109 @@ function HistoryList({ const startIndex = (currentPage - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; const paginatedAliases = filteredAliases.slice(startIndex, endIndex); + const columns = useMemo[]>( + () => [ + ...(isSelectMode + ? [ + { + key: "select", + header: "", + width: "36px", + align: "center" as const, + cell: (alias: Alias) => ( + toggleSelectAlias(alias.email)} + aria-label={alias.email} + /> + ), + }, + ] + : []), + { + key: "email", + header: t("aliasColumn"), + width: isSelectMode ? "142px" : "180px", + cell: (alias) => ( + + + + ), + sortValue: (alias) => alias.email, + }, + { + key: "actions", + header: "", + width: "58px", + align: "left", + cell: (alias) => { + const isFavorited = favorites.includes(alias.email); + + return ( +
+ + + + + + +
+ ); + }, + }, + ], + [ + copyToClipboard, + favorites, + isSelectMode, + selectedAliases, + setQrAlias, + toggleFavorite, + toggleSelectAlias, + ], + ); if (filteredAliases.length === 0 && viewMode === "favorites") { return ( -
+
+
- {paginatedAliases.map((alias) => ( - toggleSelectAlias(alias.email)} - isCopied={copiedEmail === alias.email} - isFavorited={favorites.includes(alias.email)} - onToggleFavorite={() => toggleFavorite(alias.email)} - onCopy={() => copyToClipboard(alias.email)} - onShowQR={() => setQrAlias(alias.email)} - /> - ))} +
+ alias.email} + rowHeight={44} + height="auto" + defaultSort={null} + emptyState={t("noResultsFound")} + className="rounded-xl bg-card shadow-sm [&>div]:overflow-x-hidden [&_td]:min-w-0 [&_td]:px-2 [&_th]:bg-muted/80 [&_th]:px-2 [&_th]:text-foreground" + /> {totalPages > 1 && ( void; }) { return ( -
+
{isSelectMode && ( - )} - - {alias.email} - - -
+
+ + + + - - - - + {isCopied ? ( + + ) : ( + + )} + + +
); } @@ -549,50 +693,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,40 +761,45 @@ function Pagination({ return (
{showEllipsis && ( - - ... - + ... )} - +
); })}
- - + + ⟫ +
diff --git a/entrypoints/popup/components/Input.tsx b/entrypoints/popup/components/Input.tsx index 7e5b5b7..a234d31 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 { 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..af5a9f3 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}
diff --git a/entrypoints/popup/components/Settings.tsx b/entrypoints/popup/components/Settings.tsx index 18c232b..afc65d6 100644 --- a/entrypoints/popup/components/Settings.tsx +++ b/entrypoints/popup/components/Settings.tsx @@ -2,6 +2,17 @@ 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 { Tooltip } from "src/components/motion/tooltip"; +import { BouncyAccordion } from "src/components/motion/bouncy-accordion"; +import { AnimatedBadge } from "src/components/motion/animated-badge"; import { getAccountStorageKey } from "../utils"; import { t } from "../../../lib/i18n"; @@ -516,18 +527,54 @@ export default function Settings({ // skipcq: JS-0415 return ( // skipcq: JS-0415 -
-
+
+
{/* Header */} -
+
- + +

{t("settings")}

+
+ + v{version} + +
+ + {/* Tabs */} +
+
+ -

{t("settings")}

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

- + - {t("appearanceDisplay")} -

-
-
- - -
- -
- - -
- -
- - - saveSettings({ - ...settings, - showNotifications: enabled, - }) - } - label="" - /> + ), + description: ( +
+
+ + +
+
+ + + saveSettings({ + ...settings, + showNotifications: enabled, + }) + } + label="" + /> +
-
-
- - {/* Alias Generation Section */} -
-

- + ), + }, + { + id: "alias-generation", + title: t("aliasGeneration"), + icon: ( + - {t("aliasGeneration")} -

-
-
- - -
- -
- - + ), + description: ( +
+
+ + +
+
+ + +
-
-
- - {/* Custom Presets Section */} -
-

- + ), + }, + { + id: "custom-presets", + title: t("customPresets"), + icon: ( + - {t("customPresets")} -

-
-
- - -
- -
- - {settings.customPresets.length > 0 && ( -
- {settings.customPresets.map((preset) => ( -
-
- - {preset.label} - - - +{preset.tag} - -
- + {settings.customPresets.length > 0 && ( +
+ {settings.customPresets.map((preset) => ( +
- - - +
+ + {preset.label} + + + +{preset.tag} + +
+ + + +
+ ))}
- ))} + )}
- )} -
- - {/* Data Management Section */} -
-

- + ), + }, + { + id: "data-management", + title: t("dataManagement"), + icon: ( + - {t("dataManagement")} -

-
- - - -
- -
-
-
+ ), + description: ( +
+
+ + + + + + + + + +
+ + + +
+ ), + }, + ]} + /> )} {/* Accounts Tab */} {activeTab === "accounts" && (
-

+

{t("emailAccounts")}

-

+

{t("manageAccountsDescription")}

{emailAccounts.length === 0 ? ( -
+
{t("noAccountsFound")}
) : ( @@ -970,37 +1016,35 @@ export default function Settings({ {emailAccounts.map((account) => (
{editingAccountId === account.id ? ( // Edit mode
-
-
- - +
) : ( @@ -1030,25 +1076,26 @@ export default function Settings({
{/* Radio button to select active account */}
+ + {selectable ? : null} + {orderedColumns.map((column) => { + const override = widths[column.key]; + const width = override ? `${override}px` : column.width; + return ( + + ); + })} + {/* Empty filler owns the leftover space — no gap, content unpinned. */} + + + + + + + {sortedRows.length === 0 ? ( + loading ? ( + + ) : ( + + + + ) + ) : ( + <> + {paddingTop > 0 ? ( + + + ) : null} + {visibleIndexes.map((rowIndex) => { + const entry = sortedRows[rowIndex]; + const isSelected = selected.has(entry.id); + return ( + { + rowRefs.current[entry.id] = el; + }} + data-selected={isSelected} + style={{ height: rowHeight }} + onPointerEnter={ + hasRowMenu + ? () => activateRow(entry.id, rowIndex) + : undefined + } + onPointerLeave={hasRowMenu ? deactivateRow : undefined} + className={cn( + "border-border/60 border-b transition-colors", + "data-[selected=true]:bg-primary/5", + "hover:bg-muted/50", + )} + > + {selectable ? ( + + ) : null} + {orderedColumns.map((column) => ( + + ))} + + ); + })} + {paddingBottom > 0 ? ( + + + ) : null} + {loading ? ( + + ) : null} + + )} + +
+ {emptyState} +
+
+
+ toggleRow(entry.id)} + aria-label={`Select row ${rowIndex + 1}`} + /> +
+
+ {!column.cell && column.editable ? ( + + onCellEdit?.(entry.id, column.key, next) + } + /> + ) : ( + readCell(entry.row, column) + )} + +
+
+
+ {hasRowMenu && activeRow ? ( + activateRow(activeRow.id, activeRow.index)} + onLeave={deactivateRow} + /> + ) : null} +
+ ); +} diff --git a/src/components/motion/table/row-handle.tsx b/src/components/motion/table/row-handle.tsx new file mode 100644 index 0000000..4955c56 --- /dev/null +++ b/src/components/motion/table/row-handle.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { + ArrowDownToLine, + ArrowUpToLine, + MoreVertical, + Trash2, +} from "lucide-react"; +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { TableMenu } from "./table-menu"; + +/** The row handle, portaled so it can sit on the row's left border without the + * scroll container clipping it. Straddles the border to bridge hover. */ +export function RowHandle({ + rowEl, + id, + index, + onInsertRow, + onDeleteRow, + onEnter, + onLeave, +}: { + rowEl: HTMLTableRowElement | null; + id: string; + index: number; + onInsertRow?: (index: number, position: "before" | "after") => void; + onDeleteRow?: (rowId: string, index: number) => void; + onEnter: () => void; + onLeave: () => void; +}) { + useEffect(() => { + window.addEventListener("scroll", onLeave, true); + return () => window.removeEventListener("scroll", onLeave, true); + }, [onLeave]); + + if (!rowEl || typeof document === "undefined") return null; + const rect = rowEl.getBoundingClientRect(); + + return createPortal( +
+ } + items={[ + ...(onInsertRow + ? [ + { + label: "Insert before", + icon: , + onSelect: () => onInsertRow(index, "before"), + }, + { + label: "Insert after", + icon: , + onSelect: () => onInsertRow(index, "after"), + }, + ] + : []), + ...(onDeleteRow + ? [ + { + label: "Delete row", + icon: , + destructive: true, + onSelect: () => onDeleteRow(id, index), + }, + ] + : []), + ]} + /> +
, + document.body, + ); +} diff --git a/src/components/motion/table/skeleton-rows.tsx b/src/components/motion/table/skeleton-rows.tsx new file mode 100644 index 0000000..8384677 --- /dev/null +++ b/src/components/motion/table/skeleton-rows.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { cn } from "src/lib/utils"; +import type { TableColumn } from "./types"; +import { alignText } from "./utils"; + +export function SkeletonRows({ + count, + columns, + selectable, + rowHeight, +}: { + count: number; + columns: TableColumn[]; + selectable: boolean; + rowHeight: number; +}) { + return ( + <> + {Array.from({ length: count }, (_, r) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows + + {selectable ? : null} + {columns.map((column) => ( + +
+ + ))} + + + ))} + + ); +} diff --git a/src/components/motion/table/table-header.tsx b/src/components/motion/table/table-header.tsx new file mode 100644 index 0000000..b8fbeff --- /dev/null +++ b/src/components/motion/table/table-header.tsx @@ -0,0 +1,337 @@ +"use client"; + +import { + ArrowLeftToLine, + ArrowRightToLine, + ChevronUp, + GripVertical, + MoreHorizontal, + Trash2, +} from "lucide-react"; +import { motion } from "motion/react"; +import { type PointerEvent as ReactPointerEvent, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { Checkbox } from "src/components/motion/checkbox"; +import { EASE_OUT, SPRING_PRESS } from "src/lib/ease"; +import { cn } from "src/lib/utils"; +import { TableMenu } from "./table-menu"; +import type { + HeaderCellRefs, + InsertPosition, + SortState, + TableColumn, +} from "./types"; +import { alignFlex, alignText, COLUMN_ACTIVE_SHADOW } from "./utils"; + +export interface TableHeaderProps { + columns: TableColumn[]; + rowHeight: number; + reduce: boolean; + thRefs: HeaderCellRefs; + selectable: boolean; + allSelected: boolean; + someSelected: boolean; + onToggleAll: () => void; + sort: SortState | null; + onToggleSort: (key: string) => void; + resizable: boolean; + onResizeStart: (key: string, e: ReactPointerEvent) => void; + onResizeMove: (e: ReactPointerEvent) => void; + onResizeEnd: (e: ReactPointerEvent) => void; + reorderable: boolean; + dragKey: string | null; + dropIndex: number | null; + onReorderStart: (key: string, e: ReactPointerEvent) => void; + onReorderMove: (e: ReactPointerEvent) => void; + onReorderEnd: (e: ReactPointerEvent) => void; + onInsertColumn?: (index: number, position: InsertPosition) => void; + onDeleteColumn?: (columnKey: string, index: number) => void; + onColumnRename?: (columnKey: string, value: string) => void; + activeColumn: string | null; + onColumnActivate?: (key: string) => void; + onColumnDeactivate?: () => void; +} + +/** Column insert / delete menu items shared by the header cell and the portal handle. */ +function columnMenuItems( + column: TableColumn, + index: number, + onInsertColumn?: (index: number, position: InsertPosition) => void, + onDeleteColumn?: (columnKey: string, index: number) => void, +) { + return [ + ...(onInsertColumn + ? [ + { + label: "Insert before", + icon: , + onSelect: () => onInsertColumn(index, "before"), + }, + { + label: "Insert after", + icon: , + onSelect: () => onInsertColumn(index, "after"), + }, + ] + : []), + ...(onDeleteColumn + ? [ + { + label: "Delete column", + icon: , + destructive: true, + onSelect: () => onDeleteColumn(column.key, index), + }, + ] + : []), + ]; +} + +/** The ellipse handle, portaled so it can sit on the column's top border without + * the scroll container clipping it. Straddles the border to bridge hover. */ +function ColumnHandle({ + column, + index, + thRefs, + onInsertColumn, + onDeleteColumn, + onEnter, + onLeave, +}: { + column: TableColumn; + index: number; + thRefs: HeaderCellRefs; + onInsertColumn?: (index: number, position: InsertPosition) => void; + onDeleteColumn?: (columnKey: string, index: number) => void; + onEnter: () => void; + onLeave: () => void; +}) { + useEffect(() => { + window.addEventListener("scroll", onLeave, true); + return () => window.removeEventListener("scroll", onLeave, true); + }, [onLeave]); + + const el = thRefs.current[column.key]; + if (!el || typeof document === "undefined") return null; + const rect = el.getBoundingClientRect(); + + return createPortal( +
+ } + items={columnMenuItems(column, index, onInsertColumn, onDeleteColumn)} + /> +
, + document.body, + ); +} + +export function TableHeader({ + columns, + rowHeight, + reduce, + thRefs, + selectable, + allSelected, + someSelected, + onToggleAll, + sort, + onToggleSort, + resizable, + onResizeStart, + onResizeMove, + onResizeEnd, + reorderable, + dragKey, + dropIndex, + onReorderStart, + onReorderMove, + onReorderEnd, + onInsertColumn, + onDeleteColumn, + onColumnRename, + activeColumn, + onColumnActivate, + onColumnDeactivate, +}: TableHeaderProps) { + const hasColumnMenu = !!(onInsertColumn || onDeleteColumn); + const activeIndex = columns.findIndex((c) => c.key === activeColumn); + return ( + <> + {hasColumnMenu && activeColumn && activeIndex >= 0 ? ( + onColumnActivate?.(activeColumn)} + onLeave={() => onColumnDeactivate?.()} + /> + ) : null} + + + {selectable ? ( + +
+ +
+ + ) : null} + {columns.map((column, index) => { + const active = sort?.key === column.key; + const isDragging = dragKey === column.key; + const isActive = activeColumn === column.key; + return ( + { + thRefs.current[column.key] = el; + }} + onPointerEnter={() => onColumnActivate?.(column.key)} + onPointerLeave={() => onColumnDeactivate?.()} + style={ + isActive ? { boxShadow: COLUMN_ACTIVE_SHADOW } : undefined + } + aria-sort={ + active + ? sort?.direction === "asc" + ? "ascending" + : "descending" + : undefined + } + data-drop={dragKey ? dropIndex === index : undefined} + data-dropend={ + dragKey + ? dropIndex === columns.length && + index === columns.length - 1 + : undefined + } + className={cn( + "group sticky top-0 z-10 border-border border-b bg-muted p-0 font-medium text-muted-foreground", + "data-[drop=true]:before:absolute data-[drop=true]:before:inset-y-0 data-[drop=true]:before:left-0 data-[drop=true]:before:w-0.5 data-[drop=true]:before:bg-primary", + "data-[dropend=true]:after:absolute data-[dropend=true]:after:inset-y-0 data-[dropend=true]:after:right-0 data-[dropend=true]:after:w-0.5 data-[dropend=true]:after:bg-primary", + )} + > + + {reorderable ? ( + + ) : null} + {column.sortable ? ( + + ) : onColumnRename ? ( + + onColumnRename(column.key, e.target.value) + } + className={cn( + "min-w-0 flex-1 truncate appearance-none rounded-md border-0 bg-transparent px-4 font-medium text-muted-foreground outline-none transition-colors focus:bg-muted focus:text-foreground", + alignText(column.align), + )} + /> + ) : ( + + {column.header} + + )} + + {resizable ? ( + + {open && typeof document !== "undefined" + ? createPortal( + <> +
setCoords(null)} + /> + + {items.map((item) => ( + + ))} + + , + document.body, + ) + : null} + + ); +} diff --git a/src/components/motion/table/types.ts b/src/components/motion/table/types.ts new file mode 100644 index 0000000..30772a7 --- /dev/null +++ b/src/components/motion/table/types.ts @@ -0,0 +1,86 @@ +import type { ReactNode } from "react"; + +export type SortDirection = "asc" | "desc"; + +export type SortState = { + key: string; + direction: SortDirection; +}; + +export type TableColumn = { + /** Stable key; also the default object property read for the cell + sort value. */ + key: string; + /** Header content. */ + header: ReactNode; + /** Allow clicking the header to sort by this column. */ + sortable?: boolean; + /** Cell text alignment. */ + align?: "left" | "center" | "right"; + /** Column width as a CSS length, e.g. "160px" or "20%". Omit to share remaining space equally. */ + width?: string; + /** Custom cell renderer. Falls back to `row[key]`. */ + cell?: (row: T) => ReactNode; + /** Render an inline text input for this column's cells (ignored when `cell` is set). */ + editable?: boolean; + /** Value used for sorting. Falls back to `row[key]`. */ + sortValue?: (row: T) => string | number; +}; + +export type InsertPosition = "before" | "after"; + +export interface TableProps { + data: T[]; + columns: TableColumn[]; + /** Stable id per row, required for correct selection across sorts. Defaults to row index. */ + getRowId?: (row: T, index: number) => string; + /** Render a leading checkbox column with select-all in the header. */ + selectable?: boolean; + selectedRowIds?: string[]; + defaultSelectedRowIds?: string[]; + onSelectionChange?: (ids: string[]) => void; + sort?: SortState | null; + defaultSort?: SortState | null; + onSortChange?: (sort: SortState | null) => void; + /** Allow dragging the right edge of a header to resize that column. */ + resizable?: boolean; + /** Minimum column width in px when resizing. */ + minColumnWidth?: number; + onColumnResize?: (key: string, width: number) => void; + /** Allow dragging a header grip to reorder columns. */ + reorderable?: boolean; + onColumnOrderChange?: (keys: string[]) => void; + /** Called when an `editable` cell changes. */ + onCellEdit?: (rowId: string, columnKey: string, value: string) => void; + /** When set, non-sortable headers become editable inputs for the column name. */ + onColumnRename?: (columnKey: string, value: string) => void; + /** Enables the row menu (Insert before / after). Receives the target index. */ + onInsertRow?: (index: number, position: InsertPosition) => void; + /** Enables Delete in the row menu. */ + onDeleteRow?: (rowId: string, index: number) => void; + /** Enables the column menu (Insert before / after). Receives the target column index. */ + onInsertColumn?: (index: number, position: InsertPosition) => void; + /** Enables Delete in the column menu. */ + onDeleteColumn?: (columnKey: string, index: number) => void; + /** Fixed row height in px — required for virtualization. */ + rowHeight?: number; + /** Scroll viewport height in px, or "auto" to render without an internal scroller. */ + height?: number | "auto"; + /** Rows rendered above/below the viewport. */ + overscan?: number; + /** Fires when the viewport scrolls near the bottom — load the next page. */ + onEndReached?: () => void; + /** Currently fetching — shows skeleton rows and pauses `onEndReached`. */ + loading?: boolean; + /** How many skeleton rows to show while loading more (default 3). */ + skeletonRows?: number; + emptyState?: ReactNode; + className?: string; +} + +/** A data row paired with its stable id. */ +export type TableRow = { row: T; id: string }; + +/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */ +export type HeaderCellRefs = { + current: Record; +}; diff --git a/src/components/motion/table/use-column-reorder.ts b/src/components/motion/table/use-column-reorder.ts new file mode 100644 index 0000000..325462e --- /dev/null +++ b/src/components/motion/table/use-column-reorder.ts @@ -0,0 +1,104 @@ +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useMemo, + useState, +} from "react"; +import type { HeaderCellRefs, TableColumn } from "./types"; + +export function useColumnReorder({ + columns, + thRefs, + onColumnOrderChange, +}: { + columns: TableColumn[]; + thRefs: HeaderCellRefs; + onColumnOrderChange?: (keys: string[]) => void; +}) { + const [order, setOrder] = useState(() => columns.map((c) => c.key)); + const [dragKey, setDragKey] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + + // Apply the current order, tolerating columns added/removed at runtime. New + // columns are placed at their position in `columns` (after their left + // neighbor), not appended — so an inserted column lands where it was added. + const orderedColumns = useMemo(() => { + const byKey = new Map(columns.map((c) => [c.key, c])); + const resultKeys = order.filter((k) => byKey.has(k)); + const present = new Set(resultKeys); + columns.forEach((column, i) => { + if (present.has(column.key)) return; + let at = resultKeys.length; + if (i === 0) { + at = 0; + } else { + const idx = resultKeys.indexOf(columns[i - 1].key); + at = idx === -1 ? i : idx + 1; + } + resultKeys.splice(at, 0, column.key); + present.add(column.key); + }); + return resultKeys + .map((k) => byKey.get(k)) + .filter((c): c is TableColumn => c !== undefined); + }, [order, columns]); + + const dropIndexFor = useCallback( + (clientX: number) => { + for (let i = 0; i < orderedColumns.length; i++) { + const rect = + thRefs.current[orderedColumns[i].key]?.getBoundingClientRect(); + if (rect && clientX < rect.left + rect.width / 2) return i; + } + return orderedColumns.length; + }, + [orderedColumns, thRefs], + ); + + const startReorder = useCallback((key: string, e: ReactPointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragKey(key); + e.currentTarget.setPointerCapture(e.pointerId); + }, []); + + const moveReorder = useCallback( + (e: ReactPointerEvent) => { + if (!dragKey) return; + setDropIndex(dropIndexFor(e.clientX)); + }, + [dragKey, dropIndexFor], + ); + + const endReorder = useCallback( + (e: ReactPointerEvent) => { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + if (dragKey && dropIndex !== null) { + const keys = orderedColumns.map((c) => c.key); + const from = keys.indexOf(dragKey); + if (from !== -1) { + const without = keys.filter((_, i) => i !== from); + let to = dropIndex; + if (from < to) to--; + without.splice(to, 0, dragKey); + setOrder(without); + onColumnOrderChange?.(without); + } + } + setDragKey(null); + setDropIndex(null); + }, + [dragKey, dropIndex, orderedColumns, onColumnOrderChange], + ); + + return { + orderedColumns, + dragKey, + dropIndex, + startReorder, + moveReorder, + endReorder, + }; +} diff --git a/src/components/motion/table/use-column-resize.ts b/src/components/motion/table/use-column-resize.ts new file mode 100644 index 0000000..43b5ee9 --- /dev/null +++ b/src/components/motion/table/use-column-resize.ts @@ -0,0 +1,84 @@ +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useRef, + useState, +} from "react"; +import type { HeaderCellRefs, TableColumn } from "./types"; + +export function useColumnResize({ + orderedColumns, + thRefs, + minColumnWidth, + onColumnResize, +}: { + orderedColumns: TableColumn[]; + thRefs: HeaderCellRefs; + minColumnWidth: number; + onColumnResize?: (key: string, width: number) => void; +}) { + const resizeRef = useRef<{ + key: string; + startX: number; + startWidth: number; + } | null>(null); + const [widths, setWidths] = useState>({}); + + const startResize = useCallback( + (key: string, e: ReactPointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Freeze every column to its current pixel width so resizing one only + // moves the trailing spacer, never the other columns. + setWidths((prev) => { + const snapshot = { ...prev }; + for (const column of orderedColumns) { + if (snapshot[column.key] == null) { + const measured = + thRefs.current[column.key]?.getBoundingClientRect().width; + snapshot[column.key] = measured + ? Math.round(measured) + : minColumnWidth; + } + } + resizeRef.current = { + key, + startX: e.clientX, + startWidth: snapshot[key], + }; + return snapshot; + }); + e.currentTarget.setPointerCapture(e.pointerId); + }, + [minColumnWidth, orderedColumns, thRefs], + ); + + const moveResize = useCallback( + (e: ReactPointerEvent) => { + const state = resizeRef.current; + if (!state) return; + const width = Math.max( + minColumnWidth, + state.startWidth + (e.clientX - state.startX), + ); + setWidths((prev) => ({ ...prev, [state.key]: width })); + }, + [minColumnWidth], + ); + + const endResize = useCallback( + (e: ReactPointerEvent) => { + const state = resizeRef.current; + resizeRef.current = null; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + if (state) { + onColumnResize?.(state.key, widths[state.key] ?? state.startWidth); + } + }, + [onColumnResize, widths], + ); + + return { widths, startResize, moveResize, endResize }; +} diff --git a/src/components/motion/table/use-column-sort.ts b/src/components/motion/table/use-column-sort.ts new file mode 100644 index 0000000..ac182cd --- /dev/null +++ b/src/components/motion/table/use-column-sort.ts @@ -0,0 +1,64 @@ +import { useCallback, useMemo, useState } from "react"; +import type { SortState, TableColumn, TableRow } from "./types"; +import { readSortValue } from "./utils"; + +export function useColumnSort({ + rows, + columns, + sort: sortProp, + defaultSort = null, + onSortChange, +}: { + rows: TableRow[]; + columns: TableColumn[]; + sort?: SortState | null; + defaultSort?: SortState | null; + onSortChange?: (sort: SortState | null) => void; +}) { + const [internalSort, setInternalSort] = useState( + defaultSort, + ); + const sort = sortProp !== undefined ? sortProp : internalSort; + + const commit = useCallback( + (next: SortState | null) => { + if (sortProp === undefined) setInternalSort(next); + onSortChange?.(next); + }, + [sortProp, onSortChange], + ); + + const toggleSort = useCallback( + (key: string) => { + if (!sort || sort.key !== key) { + commit({ key, direction: "asc" }); + } else if (sort.direction === "asc") { + commit({ key, direction: "desc" }); + } else { + commit(null); + } + }, + [sort, commit], + ); + + const sortedRows = useMemo(() => { + if (!sort) return rows; + const column = columns.find((c) => c.key === sort.key); + if (!column) return rows; + const copy = [...rows]; + copy.sort((a, b) => { + const av = readSortValue(a.row, column); + const bv = readSortValue(b.row, column); + let cmp: number; + if (typeof av === "number" && typeof bv === "number") { + cmp = av - bv; + } else { + cmp = String(av).localeCompare(String(bv)); + } + return sort.direction === "asc" ? cmp : -cmp; + }); + return copy; + }, [rows, sort, columns]); + + return { sort, sortedRows, toggleSort }; +} diff --git a/src/components/motion/table/use-row-selection.ts b/src/components/motion/table/use-row-selection.ts new file mode 100644 index 0000000..c1ac8cf --- /dev/null +++ b/src/components/motion/table/use-row-selection.ts @@ -0,0 +1,57 @@ +import { useCallback, useMemo, useState } from "react"; +import type { TableRow } from "./types"; + +export function useRowSelection({ + sortedRows, + selectedRowIds, + defaultSelectedRowIds, + onSelectionChange, +}: { + sortedRows: TableRow[]; + selectedRowIds?: string[]; + defaultSelectedRowIds?: string[]; + onSelectionChange?: (ids: string[]) => void; +}) { + const [internalSelected, setInternalSelected] = useState>( + () => new Set(defaultSelectedRowIds), + ); + const selected = useMemo( + () => + selectedRowIds !== undefined ? new Set(selectedRowIds) : internalSelected, + [selectedRowIds, internalSelected], + ); + + const commit = useCallback( + (next: Set) => { + if (selectedRowIds === undefined) setInternalSelected(next); + onSelectionChange?.([...next]); + }, + [selectedRowIds, onSelectionChange], + ); + + const allSelected = + sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.id)); + const someSelected = sortedRows.some((r) => selected.has(r.id)); + + const toggleAll = useCallback(() => { + const next = new Set(selected); + if (allSelected) { + for (const r of sortedRows) next.delete(r.id); + } else { + for (const r of sortedRows) next.add(r.id); + } + commit(next); + }, [allSelected, sortedRows, selected, commit]); + + const toggleRow = useCallback( + (id: string) => { + const next = new Set(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + commit(next); + }, + [selected, commit], + ); + + return { selected, allSelected, someSelected, toggleAll, toggleRow }; +} diff --git a/src/components/motion/table/utils.ts b/src/components/motion/table/utils.ts new file mode 100644 index 0000000..628251f --- /dev/null +++ b/src/components/motion/table/utils.ts @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import type { TableColumn } from "./types"; + +export const CHECKBOX_PX = 48; +export const CHECKBOX_WIDTH = `${CHECKBOX_PX}px`; + +/** Highlights the top edge of the active column's header cell. */ +export const COLUMN_ACTIVE_SHADOW = "inset 0 1px 0 var(--color-primary)"; + +export function alignFlex(align: TableColumn["align"]) { + if (align === "right") return "justify-end"; + if (align === "center") return "justify-center"; + return "justify-start"; +} + +export function alignText(align: TableColumn["align"]) { + if (align === "right") return "text-right"; + if (align === "center") return "text-center"; + return "text-left"; +} + +export function readCell(row: T, column: TableColumn): ReactNode { + if (column.cell) return column.cell(row); + return (row as Record)[column.key]; +} + +export function readSortValue( + row: T, + column: TableColumn, +): string | number { + if (column.sortValue) return column.sortValue(row); + return (row as Record)[column.key]; +} diff --git a/src/components/motion/tabs.tsx b/src/components/motion/tabs.tsx new file mode 100644 index 0000000..252484c --- /dev/null +++ b/src/components/motion/tabs.tsx @@ -0,0 +1,219 @@ +"use client"; +// beui.dev/components/motion/tabs + +import { + motion, + MotionConfig, + useReducedMotion, + type Transition, +} from "motion/react"; +import { + createContext, + useContext, + useId, + useState, + type ReactNode, +} from "react"; +import { EASE_OUT } from "src/lib/ease"; +import { cn } from "src/lib/utils"; + +type Variant = "pill" | "underline" | "segment"; + +type Ctx = { + value: string; + setValue: (v: string) => void; + layoutId: string; + variant: Variant; +}; + +const TabsCtx = createContext(null); + +function useTabs() { + const ctx = useContext(TabsCtx); + if (!ctx) throw new Error("Tabs.* must be used inside "); + return ctx; +} + +// Weighty spring for the active-tab indicator: a touch of overshoot so it +// settles with life instead of snapping. +const transition: Transition = { + type: "spring", + stiffness: 170, + damping: 24, + mass: 1.2, +}; + +export function Tabs({ + defaultValue, + value, + onValueChange, + variant = "pill", + children, + className, +}: { + defaultValue?: string; + value?: string; + onValueChange?: (v: string) => void; + variant?: Variant; + children: ReactNode; + className?: string; +}) { + const [internal, setInternal] = useState(defaultValue ?? ""); + const layoutId = useId(); + const reduce = useReducedMotion(); + const controlled = value !== undefined; + const current = controlled ? value : internal; + const setValue = (v: string) => { + if (!controlled) setInternal(v); + onValueChange?.(v); + }; + return ( + + + {/* layoutRoot: the indicator's layoutId measures in page coordinates, so + inside fixed/scrolled containers it would replay scroll offsets as + movement. The pill only ever travels within the list, so scoping + projection to the Tabs wrapper is always correct. */} + + {children} + + + + ); +} + +const listClasses: Record = { + pill: "inline-flex items-center gap-1 rounded-full bg-card p-1", + underline: "inline-flex items-center gap-1 border-b border-border", + segment: "inline-flex items-center gap-0 rounded-lg bg-card p-0.5", +}; + +export function TabsList({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + const { variant } = useTabs(); + return ( +
+ {children} +
+ ); +} + +export function TabsTrigger({ + value, + children, + className, + indicatorClassName, +}: { + value: string; + children: ReactNode; + className?: string; + indicatorClassName?: string; +}) { + const { value: current, setValue, layoutId, variant } = useTabs(); + const active = current === value; + + if (variant === "underline") { + return ( + + ); + } + + // Pill + Segment use the same trick: a max-contrast pill slides via layoutId, + // text uses `mix-blend-exclusion` so it inverts dynamically against the moving bg. + const radius = variant === "pill" ? "rounded-full" : "rounded-md"; + + return ( +
+ {active ? ( + + ) : null} + +
+ ); +} + +export function TabsContent({ + value, + children, + className, +}: { + value: string; + children: ReactNode; + className?: string; +}) { + const { value: current } = useTabs(); + const reduce = useReducedMotion(); + const active = current === value; + // Inactive panels stay mounted but hidden, so their content (e.g. source + // code) is present in the server-rendered HTML for crawlers and assistive + // tech, instead of being dropped from the DOM. + if (!active) { + return ( + + ); + } + return ( + + {children} + + ); +} diff --git a/src/components/motion/text-cascade.tsx b/src/components/motion/text-cascade.tsx new file mode 100644 index 0000000..7b18ca0 --- /dev/null +++ b/src/components/motion/text-cascade.tsx @@ -0,0 +1,23 @@ +"use client"; +// beui.dev/components/motion/text-animation + +import { ActionSwapText } from "./action-swap"; + +export interface TextCascadeProps { + /** Current text. Changing it cascades the letters to the new value. */ + text: string; + className?: string; +} + +/** + * Letter-by-letter slot roll for standalone text — the old letters drop away + * as the new ones land, left to right. Same motion as the action-swap + * cascade variant, with a text-first API. + */ +export function TextCascade({ text, className }: TextCascadeProps) { + return ( + + {text} + + ); +} diff --git a/src/components/motion/text-reveal.tsx b/src/components/motion/text-reveal.tsx new file mode 100644 index 0000000..5371e0f --- /dev/null +++ b/src/components/motion/text-reveal.tsx @@ -0,0 +1,113 @@ +"use client"; +// beui.dev/components/motion/text-animation + +import { + motion, + type Transition, + useInView, + useReducedMotion, +} from "motion/react"; +import { useRef, type ElementType, type ReactNode } from "react"; +import { EASE_OUT } from "src/lib/ease"; +import { cn } from "src/lib/utils"; + +type SplitMode = "word" | "char"; + +export interface TextRevealProps { + text: string | string[]; + as?: ElementType; + className?: string; + split?: SplitMode; + stagger?: number; + delay?: number; + blur?: number; + yOffset?: string | number; + spring?: { stiffness?: number; damping?: number; mass?: number }; + once?: boolean; + whileInView?: boolean; + children?: ReactNode; +} + +const DEFAULT_SPRING = { stiffness: 140, damping: 26, mass: 1.2 }; + +export function TextReveal({ + text, + as: Comp = "span", + className, + split = "word", + stagger = 0.09, + delay = 0, + blur = 12, + yOffset = "40%", + spring, + once = true, + whileInView = false, + children, +}: TextRevealProps) { + const ref = useRef(null); + const inView = useInView(ref, { once, amount: 0.4 }); + const reduce = useReducedMotion(); + const shouldAnimate = whileInView ? inView : true; + + const lines = Array.isArray(text) ? text : [text]; + const s = { ...DEFAULT_SPRING, ...spring }; + + let unitIndex = 0; + const lineCounts = new Map(); + + return ( + + {lines.map((line) => { + const units = split === "word" ? line.split(" ") : Array.from(line); + const lineCount = lineCounts.get(line) ?? 0; + lineCounts.set(line, lineCount + 1); + const lineKey = `${line}-${lineCount}`; + const unitCounts = new Map(); + + return ( + + {units.map((unit, i) => { + const d = delay + unitIndex * stagger; + unitIndex += 1; + const unitCount = unitCounts.get(unit) ?? 0; + unitCounts.set(unit, unitCount + 1); + const unitKey = `${unit}-${unitCount}`; + const initial = reduce + ? { opacity: 0 } + : { y: yOffset, opacity: 0, filter: `blur(${blur}px)` }; + const animate = shouldAnimate + ? reduce + ? { opacity: 1 } + : { y: 0, opacity: 1, filter: "blur(0px)" } + : initial; + const transition: Transition = reduce + ? { + opacity: { duration: 0.25, ease: EASE_OUT, delay: d * 0.3 }, + } + : { + y: { type: "spring" as const, ...s, delay: d }, + opacity: { duration: 0.7, ease: EASE_OUT, delay: d }, + filter: { duration: 0.9, ease: EASE_OUT, delay: d }, + }; + return ( + + {unit} + {split === "word" && i < units.length - 1 ? ( +   + ) : null} + + ); + })} + + ); + })} + {children} + + ); +} diff --git a/src/components/motion/text-shimmer.tsx b/src/components/motion/text-shimmer.tsx new file mode 100644 index 0000000..dc23ac2 --- /dev/null +++ b/src/components/motion/text-shimmer.tsx @@ -0,0 +1,34 @@ +import { cn } from "src/lib/utils"; +import type { ElementType, ReactNode } from "react"; + +export interface TextShimmerProps { + children: ReactNode; + as?: ElementType; + duration?: number; + className?: string; +} + +export function TextShimmer({ + children, + as: Comp = "span", + duration = 2.5, + className, +}: TextShimmerProps) { + return ( + <> + + + {children} + + + ); +} diff --git a/src/components/motion/theme-toggle.tsx b/src/components/motion/theme-toggle.tsx new file mode 100644 index 0000000..fa36472 --- /dev/null +++ b/src/components/motion/theme-toggle.tsx @@ -0,0 +1,202 @@ +"use client"; +// beui.dev/components/motion/theme-toggle + +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useReducedMotion } from "motion/react"; +import { useEffect, useState, type ComponentPropsWithoutRef } from "react"; +import { ActionSwapIcon } from "src/components/motion/action-swap"; +import { cn } from "src/lib/utils"; + +export type ThemeVariant = "rectangle" | "circle" | "circle-blur"; + +export type RectStart = + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "center" + | "bottom-up"; + +export interface ThemeToggleProps extends Omit< + ComponentPropsWithoutRef<"button">, + "children" | "onClick" +> { + /** Animation variant. Default: "rectangle". */ + variant?: ThemeVariant; + /** Origin direction for the reveal. Default: "bottom-up". */ + start?: RectStart; + iconClassName?: string; + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; +} + +const VT_STYLE_ID = "beui-theme-toggle-vt"; + +// Duration/easing is component-specific: View Transition API uses CSS, not +// motion springs. 400ms + ease-out mirrors native OS mode-switch timing. +const VT_CSS = ` +html[data-beui-vt="rect"]::view-transition-old(root) { + animation: none; + mix-blend-mode: normal; +} +html[data-beui-vt="rect"]::view-transition-new(root) { + mix-blend-mode: normal; + animation: beui-rect-reveal 400ms ease-out; +} +html[data-beui-vt="circle"]::view-transition-old(root), +html[data-beui-vt="circle-blur"]::view-transition-old(root) { + animation: none; + mix-blend-mode: normal; +} +html[data-beui-vt="circle"]::view-transition-new(root) { + mix-blend-mode: normal; + animation: beui-circle-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1); +} +html[data-beui-vt="circle-blur"]::view-transition-new(root) { + mix-blend-mode: normal; + animation: beui-circle-blur-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1); +} +@keyframes beui-rect-reveal { + from { clip-path: var(--beui-vt-from, inset(100% 0 0 0)); } + to { clip-path: inset(0 0 0 0); } +} +@keyframes beui-circle-reveal { + from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); } + to { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); } +} +@keyframes beui-circle-blur-reveal { + from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); filter: blur(8px); } + to { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); filter: blur(0px); } +} +`; + +const RECT_FROM: Record = { + "top-left": "inset(0 100% 100% 0)", + "top-right": "inset(0 0 100% 100%)", + "bottom-left": "inset(100% 100% 0 0)", + "bottom-right": "inset(100% 0 0 100%)", + center: "inset(50% 50% 50% 50%)", + "bottom-up": "inset(100% 0 0 0)", +}; + +const CIRCLE_ORIGIN: Record = { + "top-left": "0% 0%", + "top-right": "100% 0%", + "bottom-left": "0% 100%", + "bottom-right": "100% 100%", + center: "50% 50%", + "bottom-up": "50% 100%", +}; + +export function useThemeToggle({ + variant = "rectangle", + start = "bottom-up", + checked, + onCheckedChange, +}: { + variant?: ThemeVariant; + start?: RectStart; + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; +} = {}) { + const { setTheme, resolvedTheme } = useTheme(); + const reduce = useReducedMotion() ?? false; + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + useEffect(() => { + if (document.getElementById(VT_STYLE_ID)) return; + const el = document.createElement("style"); + el.id = VT_STYLE_ID; + el.textContent = VT_CSS; + document.head.appendChild(el); + }, []); + const isControlled = typeof checked === "boolean"; + const isDark = mounted && (isControlled ? checked : resolvedTheme === "dark"); + + const toggle = () => { + const next = isDark ? "light" : "dark"; + const applyTheme = () => { + if (isControlled) { + onCheckedChange?.(next === "dark"); + } else { + setTheme(next); + } + }; + + if (reduce || !("startViewTransition" in document)) { + applyTheme(); + return; + } + + const root = document.documentElement; + + if (variant === "rectangle") { + root.style.setProperty("--beui-vt-from", RECT_FROM[start]); + root.dataset.beuiVt = "rect"; + } else { + root.style.setProperty("--beui-vt-origin", CIRCLE_ORIGIN[start]); + root.dataset.beuiVt = variant; + } + + const vt = ( + document as Document & { + startViewTransition(cb: () => void): { finished: Promise }; + } + ).startViewTransition(applyTheme); + + vt.finished.finally(() => { + delete root.dataset.beuiVt; + }); + }; + + return { isDark, mounted, toggle }; +} + +export function ThemeToggle({ + variant = "rectangle", + start = "bottom-up", + className, + iconClassName, + checked, + onCheckedChange, + ...rest +}: ThemeToggleProps) { + const { isDark, mounted, toggle } = useThemeToggle({ + variant, + start, + checked, + onCheckedChange, + }); + + return ( + + ); +} diff --git a/src/components/motion/tilt-card.tsx b/src/components/motion/tilt-card.tsx new file mode 100644 index 0000000..178e2c2 --- /dev/null +++ b/src/components/motion/tilt-card.tsx @@ -0,0 +1,83 @@ +"use client"; +// beui.dev/components/motion/tilt-card + +import { + motion, + useMotionTemplate, + 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 TiltCardProps { + children: ReactNode; + max?: number; + glare?: boolean; + className?: string; +} + +export function TiltCard({ + children, + max = 12, + glare = true, + className, +}: TiltCardProps) { + 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 rx = useMotionValue(0); + const ry = useMotionValue(0); + const gx = useMotionValue(50); + const gy = useMotionValue(50); + + const srx = useSpring(rx, SPRING_MOUSE); + const sry = useSpring(ry, SPRING_MOUSE); + + const onMove = (e: React.MouseEvent) => { + const el = ref.current; + if (!el || !enabled) return; + const rect = el.getBoundingClientRect(); + const px = (e.clientX - rect.left) / rect.width; + const py = (e.clientY - rect.top) / rect.height; + ry.set((px - 0.5) * max); + rx.set((0.5 - py) * max); + gx.set(px * 100); + gy.set(py * 100); + }; + + const onLeave = () => { + rx.set(0); + ry.set(0); + }; + + const transform = useMotionTemplate`perspective(1000px) rotateX(${srx}deg) rotateY(${sry}deg)`; + const glareBg = useMotionTemplate`radial-gradient(circle at ${gx}% ${gy}%, var(--foreground), transparent 50%)`; + + return ( + + {children} + {glare && enabled ? ( + + ) : null} + + ); +} diff --git a/src/components/motion/tooltip.tsx b/src/components/motion/tooltip.tsx new file mode 100644 index 0000000..b1a11c3 --- /dev/null +++ b/src/components/motion/tooltip.tsx @@ -0,0 +1,245 @@ +"use client"; +// beui.dev/components/motion/tooltip + +import { + AnimatePresence, + motion, + useReducedMotion, + type Variants, +} from "motion/react"; +import { + cloneElement, + isValidElement, + useEffect, + useId, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { EASE_OUT } from "src/lib/ease"; +import { useHoverCapable } from "src/lib/hooks/use-hover-capable"; +import { cn } from "src/lib/utils"; + +type Side = "top" | "right" | "bottom" | "left"; + +export interface TooltipProps { + content: ReactNode; + children: ReactElement; + side?: Side; + /** Delay before showing (ms). Default 120. */ + delay?: number; + className?: string; + /** Classes for the outer wrapper span. Use to fix baseline / fill parent. */ + wrapperClassName?: string; +} + +type TooltipPosition = { + top: number; + left: number; + transform: string; +}; + +const transformOrigin: Record = { + top: "center bottom", + bottom: "center top", + left: "right center", + right: "left center", +}; + +// Offset is in the direction *away* from the trigger — content originates near +// the trigger and rises into resting position. +const offsetFrom: Record = { + top: { y: 10 }, + bottom: { y: -10 }, + left: { x: 10 }, + right: { x: -10 }, +}; + +function buildVariants(side: Side): Variants { + const o = offsetFrom[side]; + return { + initial: { + opacity: 0, + scale: 0.85, + filter: "blur(10px)", + x: o.x ?? 0, + y: o.y ?? 0, + }, + animate: { + opacity: 1, + scale: 1, + filter: "blur(0px)", + x: 0, + y: 0, + transition: { + type: "spring", + stiffness: 380, + damping: 30, + mass: 0.7, + opacity: { duration: 0.22, ease: EASE_OUT }, + filter: { duration: 0.3, ease: EASE_OUT }, + }, + }, + exit: { + opacity: 0, + scale: 0.92, + filter: "blur(6px)", + x: (o.x ?? 0) * 0.6, + y: (o.y ?? 0) * 0.6, + transition: { duration: 0.14, ease: EASE_OUT }, + }, + }; +} + +const REDUCED_VARIANTS: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } }, + exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } }, +}; + +// Once any tooltip has just closed, neighbouring tooltips open without the +// initial delay — moving along a toolbar feels instant after the first one. +const WARM_WINDOW_MS = 300; +let lastHiddenAt = 0; + +export function Tooltip({ + content, + children, + side = "top", + delay = 120, + className, + wrapperClassName, +}: TooltipProps) { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState(null); + const id = useId(); + const wrapperRef = useRef(null); + const timer = useRef | null>(null); + const reduce = useReducedMotion(); + const canHover = useHoverCapable(); + + useEffect(() => { + if (!open) return; + + const updatePosition = () => { + const node = wrapperRef.current; + if (!node) return; + + const rect = node.getBoundingClientRect(); + const gap = 8; + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const next: Record = { + top: { + top: rect.top - gap, + left: centerX, + transform: "translate(-50%, -100%)", + }, + right: { + top: centerY, + left: rect.right + gap, + transform: "translate(0, -50%)", + }, + bottom: { + top: rect.bottom + gap, + left: centerX, + transform: "translate(-50%, 0)", + }, + left: { + top: centerY, + left: rect.left - gap, + transform: "translate(-100%, -50%)", + }, + }; + + setPosition(next[side]); + }; + + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [open, side]); + + const show = () => { + if (!canHover) return; + if (timer.current) clearTimeout(timer.current); + const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS; + timer.current = setTimeout(() => setOpen(true), warm ? 0 : delay); + }; + const hide = () => { + if (timer.current) { + clearTimeout(timer.current); + timer.current = null; + } + if (open) lastHiddenAt = Date.now(); + setOpen(false); + }; + + if (!isValidElement(children)) return children; + + const trigger = cloneElement( + children as ReactElement>, + { + onMouseEnter: show, + onMouseLeave: hide, + onFocus: show, + onBlur: hide, + "aria-describedby": id, + }, + ); + + const variants = reduce ? REDUCED_VARIANTS : buildVariants(side); + + return ( + + {trigger} + {typeof document === "undefined" + ? null + : createPortal( + + {open && position ? ( + + + {content} + + + ) : null} + , + document.body, + )} + + ); +} diff --git a/src/lib/ease.ts b/src/lib/ease.ts new file mode 100644 index 0000000..bfc0f6a --- /dev/null +++ b/src/lib/ease.ts @@ -0,0 +1,45 @@ +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 EASE_DRAWER = [0.32, 0.72, 0, 1] as const; + +/** CSS string form of EASE_OUT for inline style transitions. */ +export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)"; + +/** Press feedback on buttons and other tappable surfaces. */ +export const SPRING_PRESS = { + type: "spring", + stiffness: 500, + damping: 30, + mass: 0.6, +} as const; + +/** Content swaps — label/icon slots trading places inside a control. */ +export const SPRING_SWAP = { + type: "spring", + stiffness: 460, + damping: 30, + mass: 0.55, +} as const; + +/** Overlay panel entrances — modals and sheets summoned by pointer. */ +export const SPRING_PANEL = { + type: "spring", + stiffness: 420, + damping: 40, + mass: 0.5, +} as const; + +/** Shared-layout glides — pills, indicators and panels morphing between positions. */ +export const SPRING_LAYOUT = { + type: "spring", + stiffness: 360, + damping: 32, + mass: 0.6, +} as const; + +/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */ +export const SPRING_MOUSE = { + stiffness: 200, + damping: 15, + mass: 0.3, +} 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..4b4bc40 --- /dev/null +++ b/src/lib/hooks/use-hover-capable.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Returns true only on devices that have a true hover (mouse / trackpad). + * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere + * — gate hover-only effects (scale lifts, magnetic pulls) behind this. + */ +export function useHoverCapable() { + const [canHover, setCanHover] = useState(false); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(hover: hover) and (pointer: fine)"); + const update = () => setCanHover(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.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..78ff536 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,25 +1,4 @@ -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: { - colors: { - primary: { - 50: "#eff6ff", - 100: "#dbeafe", - 200: "#bfdbfe", - 300: "#93c5fd", - 400: "#60a5fa", - 500: "#3b82f6", - 600: "#2563eb", - 700: "#1d4ed8", - 800: "#1e40af", - 900: "#1e3a8a", - }, - }, - }, - }, - plugins: [], -} satisfies Config; +}; diff --git a/tsconfig.json b/tsconfig.json index 9b364da..2dc90ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "extends": "./.wxt/tsconfig.json", "compilerOptions": { "allowImportingTsExtensions": true, + "baseUrl": ".", "jsx": "react-jsx" } } diff --git a/vitest.config.ts b/vitest.config.ts index 2b0b8c5..63c6298 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,14 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + src: fileURLToPath(new URL("./src", import.meta.url)), + }, + }, test: { environment: "jsdom", globals: true, diff --git a/wxt.config.ts b/wxt.config.ts index 5064ef1..05b01c3 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "wxt"; import { EventEmitter } from "events"; +import { fileURLToPath } from "node:url"; // Fix EventEmitter maxListeners warning EventEmitter.defaultMaxListeners = 15; @@ -11,6 +12,11 @@ export default defineConfig({ "process.emit": "(() => {})", "process.env": "{}", }, + resolve: { + alias: { + src: fileURLToPath(new URL("./src", import.meta.url)), + }, + }, }), manifest: { name: "__MSG_extensionName__", diff --git a/yarn.lock b/yarn.lock index dda3a9f..8888f9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -462,6 +462,16 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:^1.11.1": + version: 1.11.2 + resolution: "@emnapi/core@npm:1.11.2" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.2" + tslib: "npm:^2.4.0" + checksum: 10c0/424ca1607f498e524eb58db1095e6cc2082986edfd84a74b116d4e2c6e43b5e9d557ea7f0d7b9adf7f6d5023e25ac2ed6bd08caf0043d3d8602598d79579c2cd + languageName: node + linkType: hard + "@emnapi/runtime@npm:1.11.1": version: 1.11.1 resolution: "@emnapi/runtime@npm:1.11.1" @@ -471,6 +481,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.11.1": + version: 1.11.2 + resolution: "@emnapi/runtime@npm:1.11.2" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/d8d500059fe9c0864571c79ce29439c1b6fb44d7ec4ac865a2aaaa7469194e5d91ebdc820cabd37c3d373478b725a8b60771733e4743cfef41fc86d9a1f2eab0 + languageName: node + linkType: hard + "@emnapi/runtime@npm:^1.7.0": version: 1.7.1 resolution: "@emnapi/runtime@npm:1.7.1" @@ -480,7 +499,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/wasi-threads@npm:1.2.2": +"@emnapi/wasi-threads@npm:1.2.2, @emnapi/wasi-threads@npm:^1.2.2": version: 1.2.2 resolution: "@emnapi/wasi-threads@npm:1.2.2" dependencies: @@ -926,7 +945,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.2, @jridgewell/gen-mapping@npm:^0.3.5": +"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": version: 0.3.13 resolution: "@jridgewell/gen-mapping@npm:0.3.13" dependencies: @@ -970,7 +989,7 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.6": +"@napi-rs/wasm-runtime@npm:^1.1.4, @napi-rs/wasm-runtime@npm:^1.1.6": version: 1.1.6 resolution: "@napi-rs/wasm-runtime@npm:1.1.6" dependencies: @@ -982,33 +1001,6 @@ __metadata: languageName: node linkType: hard -"@nodelib/fs.scandir@npm:2.1.5": - version: 2.1.5 - resolution: "@nodelib/fs.scandir@npm:2.1.5" - dependencies: - "@nodelib/fs.stat": "npm:2.0.5" - run-parallel: "npm:^1.1.9" - checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb - languageName: node - linkType: hard - -"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": - version: 2.0.5 - resolution: "@nodelib/fs.stat@npm:2.0.5" - checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d - languageName: node - linkType: hard - -"@nodelib/fs.walk@npm:^1.2.3": - version: 1.2.8 - resolution: "@nodelib/fs.walk@npm:1.2.8" - dependencies: - "@nodelib/fs.scandir": "npm:2.1.5" - fastq: "npm:^1.6.0" - checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1 - languageName: node - linkType: hard - "@npmcli/agent@npm:^4.0.0": version: 4.0.0 resolution: "@npmcli/agent@npm:4.0.0" @@ -1202,6 +1194,189 @@ __metadata: languageName: node linkType: hard +"@tailwindcss/node@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/node@npm:4.3.2" + dependencies: + "@jridgewell/remapping": "npm:^2.3.5" + enhanced-resolve: "npm:5.21.6" + jiti: "npm:^2.7.0" + lightningcss: "npm:1.32.0" + magic-string: "npm:^0.30.21" + source-map-js: "npm:^1.2.1" + tailwindcss: "npm:4.3.2" + checksum: 10c0/3b83caeb3a913b1a537640bbe4df3ac68dcfba1c95b5c2dedc3788bf6437b6ebb06512ec31ae1fb3aaf570eee0a2672e35ef872969a441e07861c2d248a9da3e + languageName: node + linkType: hard + +"@tailwindcss/oxide-android-arm64@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-android-arm64@npm:4.3.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-darwin-arm64@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.3.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-darwin-x64@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-darwin-x64@npm:4.3.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-freebsd-x64@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.3.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm64-musl@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.3.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-x64-gnu@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.3.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-x64-musl@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.3.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@tailwindcss/oxide-wasm32-wasi@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.3.2" + dependencies: + "@emnapi/core": "npm:^1.11.1" + "@emnapi/runtime": "npm:^1.11.1" + "@emnapi/wasi-threads": "npm:^1.2.2" + "@napi-rs/wasm-runtime": "npm:^1.1.4" + "@tybys/wasm-util": "npm:^0.10.2" + tslib: "npm:^2.8.1" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-win32-x64-msvc@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.3.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide@npm:4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/oxide@npm:4.3.2" + dependencies: + "@tailwindcss/oxide-android-arm64": "npm:4.3.2" + "@tailwindcss/oxide-darwin-arm64": "npm:4.3.2" + "@tailwindcss/oxide-darwin-x64": "npm:4.3.2" + "@tailwindcss/oxide-freebsd-x64": "npm:4.3.2" + "@tailwindcss/oxide-linux-arm-gnueabihf": "npm:4.3.2" + "@tailwindcss/oxide-linux-arm64-gnu": "npm:4.3.2" + "@tailwindcss/oxide-linux-arm64-musl": "npm:4.3.2" + "@tailwindcss/oxide-linux-x64-gnu": "npm:4.3.2" + "@tailwindcss/oxide-linux-x64-musl": "npm:4.3.2" + "@tailwindcss/oxide-wasm32-wasi": "npm:4.3.2" + "@tailwindcss/oxide-win32-arm64-msvc": "npm:4.3.2" + "@tailwindcss/oxide-win32-x64-msvc": "npm:4.3.2" + dependenciesMeta: + "@tailwindcss/oxide-android-arm64": + optional: true + "@tailwindcss/oxide-darwin-arm64": + optional: true + "@tailwindcss/oxide-darwin-x64": + optional: true + "@tailwindcss/oxide-freebsd-x64": + optional: true + "@tailwindcss/oxide-linux-arm-gnueabihf": + optional: true + "@tailwindcss/oxide-linux-arm64-gnu": + optional: true + "@tailwindcss/oxide-linux-arm64-musl": + optional: true + "@tailwindcss/oxide-linux-x64-gnu": + optional: true + "@tailwindcss/oxide-linux-x64-musl": + optional: true + "@tailwindcss/oxide-wasm32-wasi": + optional: true + "@tailwindcss/oxide-win32-arm64-msvc": + optional: true + "@tailwindcss/oxide-win32-x64-msvc": + optional: true + checksum: 10c0/9c82a1eb1e6cd7a29118a4f9cfae5fbf83950757cfbb5e51fb212b1e17c9195632ce245f873aee6ad3133921dbb617d050d7f12530905ab1f2683d41909b1de4 + languageName: node + linkType: hard + +"@tailwindcss/postcss@npm:^4.3.2": + version: 4.3.2 + resolution: "@tailwindcss/postcss@npm:4.3.2" + dependencies: + "@alloc/quick-lru": "npm:^5.2.0" + "@tailwindcss/node": "npm:4.3.2" + "@tailwindcss/oxide": "npm:4.3.2" + postcss: "npm:^8.5.15" + tailwindcss: "npm:4.3.2" + checksum: 10c0/577998b219b5e209b1fdc33812f6024ff18b4fd43a0e5be9bba02c02ea593e10bcc776a6f5671780782f9ac29732b88cb185d0b944be009e19c1724c8d8b1970 + languageName: node + linkType: hard + +"@tanstack/react-virtual@npm:^3.14.5": + version: 3.14.5 + resolution: "@tanstack/react-virtual@npm:3.14.5" + dependencies: + "@tanstack/virtual-core": "npm:3.17.3" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/f3dd2da5e5e0a94756e47f11fa76bfe0485e845da72b8022a4a593e2906aa60b827a5f0d7b6063092c76874fed8ddc69bcbf759298733ced24a66a82e8d58f21 + languageName: node + linkType: hard + +"@tanstack/virtual-core@npm:3.17.3": + version: 3.17.3 + resolution: "@tanstack/virtual-core@npm:3.17.3" + checksum: 10c0/e6199a0c25d1780e62f57c9f72432342e2cabd5b1197cf66b0f2250c9c0bbecbd0c53e7bb639804c4c7931f9cfa57efa80f6a64fefe6614bf14871f682c17aa3 + languageName: node + linkType: hard + "@testing-library/dom@npm:^10.4.1": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1" @@ -1261,7 +1436,7 @@ __metadata: languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.10.3": +"@tybys/wasm-util@npm:^0.10.2, @tybys/wasm-util@npm:^0.10.3": version: 0.10.3 resolution: "@tybys/wasm-util@npm:0.10.3" dependencies: @@ -1706,30 +1881,6 @@ __metadata: languageName: node linkType: hard -"any-promise@npm:^1.0.0": - version: 1.3.0 - resolution: "any-promise@npm:1.3.0" - checksum: 10c0/60f0298ed34c74fef50daab88e8dab786036ed5a7fad02e012ab57e376e0a0b4b29e83b95ea9b5e7d89df762f5f25119b83e00706ecaccb22cfbacee98d74889 - languageName: node - linkType: hard - -"anymatch@npm:~3.1.2": - version: 3.1.3 - resolution: "anymatch@npm:3.1.3" - dependencies: - normalize-path: "npm:^3.0.0" - picomatch: "npm:^2.0.4" - checksum: 10c0/57b06ae984bc32a0d22592c87384cd88fe4511b1dd7581497831c56d41939c8a001b28e7b853e1450f2bf61992dfcaa8ae2d0d161a0a90c4fb631ef07098fbac - languageName: node - linkType: hard - -"arg@npm:^5.0.2": - version: 5.0.2 - resolution: "arg@npm:5.0.2" - checksum: 10c0/ccaf86f4e05d342af6666c569f844bec426595c567d32a8289715087825c2ca7edd8a3d204e4d2fb2aa4602e09a57d0c13ea8c9eea75aac3dbb4af5514e6800e - languageName: node - linkType: hard - "aria-query@npm:5.3.0": version: 5.3.0 resolution: "aria-query@npm:5.3.0" @@ -1849,13 +2000,6 @@ __metadata: languageName: node linkType: hard -"binary-extensions@npm:^2.0.0": - version: 2.3.0 - resolution: "binary-extensions@npm:2.3.0" - checksum: 10c0/75a59cafc10fb12a11d510e77110c6c7ae3f4ca22463d52487709ca7f18f69d886aa387557cc9864fbdb10153d0bdb4caacabf11541f55e89ed6e18d12ece2b5 - languageName: node - linkType: hard - "bluebird@npm:~3.7": version: 3.7.2 resolution: "bluebird@npm:3.7.2" @@ -1905,15 +2049,6 @@ __metadata: languageName: node linkType: hard -"braces@npm:^3.0.3, braces@npm:~3.0.2": - version: 3.0.3 - resolution: "braces@npm:3.0.3" - dependencies: - fill-range: "npm:^7.1.1" - checksum: 10c0/7c6dfd30c338d2997ba77500539227b9d1f85e388a5f43220865201e407e076783d0881f2d297b9f80951b4c957fcf0b51c1d2d24227631643c3f7c284b0aa04 - languageName: node - linkType: hard - "browserslist@npm:^4.24.0, browserslist@npm:^4.28.1": version: 4.28.1 resolution: "browserslist@npm:4.28.1" @@ -2009,13 +2144,6 @@ __metadata: languageName: node linkType: hard -"camelcase-css@npm:^2.0.1": - version: 2.0.1 - resolution: "camelcase-css@npm:2.0.1" - checksum: 10c0/1a1a3137e8a781e6cbeaeab75634c60ffd8e27850de410c162cce222ea331cd1ba5364e8fb21c95e5ca76f52ac34b81a090925ca00a87221355746d049c6e273 - languageName: node - linkType: hard - "camelcase@npm:^5.0.0": version: 5.3.1 resolution: "camelcase@npm:5.3.1" @@ -2051,25 +2179,6 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.6.0": - version: 3.6.0 - resolution: "chokidar@npm:3.6.0" - dependencies: - anymatch: "npm:~3.1.2" - braces: "npm:~3.0.2" - fsevents: "npm:~2.3.2" - glob-parent: "npm:~5.1.2" - is-binary-path: "npm:~2.1.0" - is-glob: "npm:~4.0.1" - normalize-path: "npm:~3.0.0" - readdirp: "npm:~3.6.0" - dependenciesMeta: - fsevents: - optional: true - checksum: 10c0/8361dcd013f2ddbe260eacb1f3cb2f2c6f2b0ad118708a343a5ed8158941a39cb8fb1d272e0f389712e74ee90ce8ba864eece9e0e62b9705cb468a2f6d917462 - languageName: node - linkType: hard - "chokidar@npm:^5.0.0": version: 5.0.0 resolution: "chokidar@npm:5.0.0" @@ -2162,6 +2271,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" @@ -2187,13 +2303,6 @@ __metadata: languageName: node linkType: hard -"commander@npm:^4.0.0": - version: 4.1.1 - resolution: "commander@npm:4.1.1" - checksum: 10c0/84a76c08fe6cc08c9c93f62ac573d2907d8e79138999312c92d4155bc2325d487d64d13f669b2000c9f8caf70493c1be2dac74fec3c51d5a04f8bc3ae1830bab - languageName: node - linkType: hard - "commander@npm:^9.1.0": version: 9.5.0 resolution: "commander@npm:9.5.0" @@ -2321,15 +2430,6 @@ __metadata: languageName: node linkType: hard -"cssesc@npm:^3.0.0": - version: 3.0.0 - resolution: "cssesc@npm:3.0.0" - bin: - cssesc: bin/cssesc - checksum: 10c0/6bcfd898662671be15ae7827120472c5667afb3d7429f1f917737f3bf84c4176003228131b643ae74543f17a394446247df090c597bb9a728cce298606ed0aa7 - languageName: node - linkType: hard - "cssom@npm:^0.5.0": version: 0.5.0 resolution: "cssom@npm:0.5.0" @@ -2472,13 +2572,6 @@ __metadata: languageName: node linkType: hard -"didyoumean@npm:^1.2.2": - version: 1.2.2 - resolution: "didyoumean@npm:1.2.2" - checksum: 10c0/95d0b53d23b851aacff56dfadb7ecfedce49da4232233baecfeecb7710248c4aa03f0aa8995062f0acafaf925adf8536bd7044a2e68316fd7d411477599bc27b - languageName: node - linkType: hard - "dijkstrajs@npm:^1.0.1": version: 1.0.3 resolution: "dijkstrajs@npm:1.0.3" @@ -2486,13 +2579,6 @@ __metadata: languageName: node linkType: hard -"dlv@npm:^1.1.3": - version: 1.1.3 - resolution: "dlv@npm:1.1.3" - checksum: 10c0/03eb4e769f19a027fd5b43b59e8a05e3fd2100ac239ebb0bf9a745de35d449e2f25cfaf3aa3934664551d72856f4ae8b7822016ce5c42c2d27c18ae79429ec42 - languageName: node - linkType: hard - "dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" @@ -2607,6 +2693,16 @@ __metadata: languageName: node linkType: hard +"enhanced-resolve@npm:5.21.6": + version: 5.21.6 + resolution: "enhanced-resolve@npm:5.21.6" + dependencies: + graceful-fs: "npm:^4.2.4" + tapable: "npm:^2.3.3" + checksum: 10c0/4991b0ee020ce534c824e8f191a2cf068b9206dc6c9aef5797d41db5c45036c868145c2f0badee6084de2cc3703c7ca76426b254c6b2eac0e0e670ab4a33e528 + languageName: node + linkType: hard + "entities@npm:^4.2.0": version: 4.5.0 resolution: "entities@npm:4.5.0" @@ -2819,19 +2915,6 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.3.2": - version: 3.3.3 - resolution: "fast-glob@npm:3.3.3" - dependencies: - "@nodelib/fs.stat": "npm:^2.0.2" - "@nodelib/fs.walk": "npm:^1.2.3" - glob-parent: "npm:^5.1.2" - merge2: "npm:^1.3.0" - micromatch: "npm:^4.0.8" - checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe - languageName: node - linkType: hard - "fast-redact@npm:^3.1.1": version: 3.5.0 resolution: "fast-redact@npm:3.5.0" @@ -2839,15 +2922,6 @@ __metadata: languageName: node linkType: hard -"fastq@npm:^1.6.0": - version: 1.20.1 - resolution: "fastq@npm:1.20.1" - dependencies: - reusify: "npm:^1.0.4" - checksum: 10c0/e5dd725884decb1f11e5c822221d76136f239d0236f176fab80b7b8f9e7619ae57e6b4e5b73defc21e6b9ef99437ee7b545cff8e6c2c337819633712fa9d352e - languageName: node - linkType: hard - "fdir@npm:^6.5.0": version: 6.5.0 resolution: "fdir@npm:6.5.0" @@ -2867,15 +2941,6 @@ __metadata: languageName: node linkType: hard -"fill-range@npm:^7.1.1": - version: 7.1.1 - resolution: "fill-range@npm:7.1.1" - dependencies: - to-regex-range: "npm:^5.0.1" - checksum: 10c0/b75b691bbe065472f38824f694c2f7449d7f5004aa950426a2c28f0306c60db9b880c0b0e4ed819997ffb882d1da02cfcfc819bddc94d71627f5269682edf018 - languageName: node - linkType: hard - "find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" @@ -2922,6 +2987,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" @@ -2942,7 +3029,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": +"fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -2952,7 +3039,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -2961,13 +3048,6 @@ __metadata: languageName: node linkType: hard -"function-bind@npm:^1.1.2": - version: 1.1.2 - resolution: "function-bind@npm:1.1.2" - checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 - languageName: node - linkType: hard - "fx-runner@npm:1.4.0": version: 1.4.0 resolution: "fx-runner@npm:1.4.0" @@ -3028,24 +3108,6 @@ __metadata: languageName: node linkType: hard -"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": - version: 5.1.2 - resolution: "glob-parent@npm:5.1.2" - dependencies: - is-glob: "npm:^4.0.1" - checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee - languageName: node - linkType: hard - -"glob-parent@npm:^6.0.2": - version: 6.0.2 - resolution: "glob-parent@npm:6.0.2" - dependencies: - is-glob: "npm:^4.0.3" - checksum: 10c0/317034d88654730230b3f43bb7ad4f7c90257a426e872ea0bf157473ac61c99bf5d205fad8f0185f989be8d2fa6d3c7dce1645d99d545b6ea9089c39f838e7f8 - languageName: node - linkType: hard - "glob-to-regexp@npm:^0.4.1": version: 0.4.1 resolution: "glob-to-regexp@npm:0.4.1" @@ -3077,6 +3139,8 @@ __metadata: version: 0.0.0-use.local resolution: "gmail-alias-toolkit@workspace:." dependencies: + "@tailwindcss/postcss": "npm:^4.3.2" + "@tanstack/react-virtual": "npm:^3.14.5" "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/react": "npm:^16.3.2" @@ -3088,12 +3152,17 @@ __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" + next-themes: "npm:^0.4.6" postcss: "npm:^8.5.10" qrcode: "npm:^1.5.4" react: "npm:^19.2.3" react-dom: "npm:^19.2.3" - tailwindcss: "npm:^3.4.17" + tailwind-merge: "npm:^2.6.0" + tailwindcss: "npm:^4" typescript: "npm:^5.9.3" vite: "npm:^8.1.1" vitest: "npm:^4.1.9" @@ -3108,7 +3177,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -3129,15 +3198,6 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.2": - version: 2.0.2 - resolution: "hasown@npm:2.0.2" - dependencies: - function-bind: "npm:^1.1.2" - checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9 - languageName: node - linkType: hard - "hookable@npm:^6.1.0": version: 6.1.1 resolution: "hookable@npm:6.1.1" @@ -3281,24 +3341,6 @@ __metadata: languageName: node linkType: hard -"is-binary-path@npm:~2.1.0": - version: 2.1.0 - resolution: "is-binary-path@npm:2.1.0" - dependencies: - binary-extensions: "npm:^2.0.0" - checksum: 10c0/a16eaee59ae2b315ba36fad5c5dcaf8e49c3e27318f8ab8fa3cdb8772bf559c8d1ba750a589c2ccb096113bb64497084361a25960899cb6172a6925ab6123d38 - languageName: node - linkType: hard - -"is-core-module@npm:^2.16.1": - version: 2.16.1 - resolution: "is-core-module@npm:2.16.1" - dependencies: - hasown: "npm:^2.0.2" - checksum: 10c0/898443c14780a577e807618aaae2b6f745c8538eca5c7bc11388a3f2dc6de82b9902bcc7eb74f07be672b11bbe82dd6a6edded44a00cb3d8f933d0459905eedd - languageName: node - linkType: hard - "is-docker@npm:^2.0.0, is-docker@npm:^2.1.1": version: 2.2.1 resolution: "is-docker@npm:2.2.1" @@ -3317,13 +3359,6 @@ __metadata: languageName: node linkType: hard -"is-extglob@npm:^2.1.1": - version: 2.1.1 - resolution: "is-extglob@npm:2.1.1" - checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 - languageName: node - linkType: hard - "is-fullwidth-code-point@npm:^3.0.0": version: 3.0.0 resolution: "is-fullwidth-code-point@npm:3.0.0" @@ -3340,15 +3375,6 @@ __metadata: languageName: node linkType: hard -"is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": - version: 4.0.3 - resolution: "is-glob@npm:4.0.3" - dependencies: - is-extglob: "npm:^2.1.1" - checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a - languageName: node - linkType: hard - "is-in-ci@npm:^1.0.0": version: 1.0.0 resolution: "is-in-ci@npm:1.0.0" @@ -3393,13 +3419,6 @@ __metadata: languageName: node linkType: hard -"is-number@npm:^7.0.0": - version: 7.0.0 - resolution: "is-number@npm:7.0.0" - checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 - languageName: node - linkType: hard - "is-path-inside@npm:^4.0.0": version: 4.0.0 resolution: "is-path-inside@npm:4.0.0" @@ -3499,15 +3518,6 @@ __metadata: languageName: node linkType: hard -"jiti@npm:^1.21.7": - version: 1.21.7 - resolution: "jiti@npm:1.21.7" - bin: - jiti: bin/jiti.js - checksum: 10c0/77b61989c758ff32407cdae8ddc77f85e18e1a13fc4977110dbd2e05fc761842f5f71bce684d9a01316e1c4263971315a111385759951080bbfe17cbb5de8f7a - languageName: node - linkType: hard - "jiti@npm:^2.6.1": version: 2.6.1 resolution: "jiti@npm:2.6.1" @@ -3517,6 +3527,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^2.7.0": + version: 2.7.0 + resolution: "jiti@npm:2.7.0" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/1b1e2310a490dce1aeea3da5f5dfe18273516c20ce48be2e98eb8ea452d5f3dcc8fd0cfd6d28b4052a24c5dbab6e3089b2d7e79f0bce7915b10d750929563c42 + languageName: node + linkType: hard + "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -3773,7 +3792,7 @@ __metadata: languageName: node linkType: hard -"lightningcss@npm:^1.32.0": +"lightningcss@npm:1.32.0, lightningcss@npm:^1.32.0": version: 1.32.0 resolution: "lightningcss@npm:1.32.0" dependencies: @@ -3816,20 +3835,6 @@ __metadata: languageName: node linkType: hard -"lilconfig@npm:^3.1.1, lilconfig@npm:^3.1.3": - version: 3.1.3 - resolution: "lilconfig@npm:3.1.3" - checksum: 10c0/f5604e7240c5c275743561442fbc5abf2a84ad94da0f5adc71d25e31fa8483048de3dcedcb7a44112a942fed305fd75841cdf6c9681c7f640c63f1049e9a5dcc - languageName: node - linkType: hard - -"lines-and-columns@npm:^1.1.6": - version: 1.2.4 - resolution: "lines-and-columns@npm:1.2.4" - checksum: 10c0/3da6ee62d4cd9f03f5dc90b4df2540fb85b352081bee77fe4bbcd12c9000ead7f35e0a38b8d09a9bb99b13223446dd8689ff3c4959807620726d788701a83d2d - languageName: node - linkType: hard - "lines-and-columns@npm:^2.0.3": version: 2.0.4 resolution: "lines-and-columns@npm:2.0.4" @@ -3980,6 +3985,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" @@ -4057,23 +4071,6 @@ __metadata: languageName: node linkType: hard -"merge2@npm:^1.3.0": - version: 1.4.1 - resolution: "merge2@npm:1.4.1" - checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb - languageName: node - linkType: hard - -"micromatch@npm:^4.0.8": - version: 4.0.8 - resolution: "micromatch@npm:4.0.8" - dependencies: - braces: "npm:^3.0.3" - picomatch: "npm:^2.3.1" - checksum: 10c0/166fa6eb926b9553f32ef81f5f531d27b4ce7da60e5baf8c021d043b27a388fb95e46a8038d5045877881e673f8134122b59624d5cecbd16eb50a42e7a6b5ca8 - languageName: node - linkType: hard - "mimic-function@npm:^5.0.0": version: 5.0.1 resolution: "mimic-function@npm:5.0.1" @@ -4213,6 +4210,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" @@ -4232,17 +4266,6 @@ __metadata: languageName: node linkType: hard -"mz@npm:^2.7.0": - version: 2.7.0 - resolution: "mz@npm:2.7.0" - dependencies: - any-promise: "npm:^1.0.0" - object-assign: "npm:^4.0.1" - thenify-all: "npm:^1.0.0" - checksum: 10c0/103114e93f87362f0b56ab5b2e7245051ad0276b646e3902c98397d18bb8f4a77f2ea4a2c9d3ad516034ea3a56553b60d3f5f78220001ca4c404bd711bd0af39 - languageName: node - linkType: hard - "nano-spawn@npm:^2.0.0": version: 2.1.0 resolution: "nano-spawn@npm:2.1.0" @@ -4250,15 +4273,6 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.11": - version: 3.3.11 - resolution: "nanoid@npm:3.3.11" - bin: - nanoid: bin/nanoid.cjs - checksum: 10c0/40e7f70b3d15f725ca072dfc4f74e81fcf1fbb02e491cf58ac0c79093adc9b0a73b152bcde57df4b79cd097e13023d7504acb38404a4da7bc1cd8e887b82fe0b - languageName: node - linkType: hard - "nanoid@npm:^3.3.12": version: 3.3.15 resolution: "nanoid@npm:3.3.15" @@ -4284,6 +4298,16 @@ __metadata: languageName: node linkType: hard +"next-themes@npm:^0.4.6": + version: 0.4.6 + resolution: "next-themes@npm:0.4.6" + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + checksum: 10c0/83590c11d359ce7e4ced14f6ea9dd7a691d5ce6843fe2dc520fc27e29ae1c535118478d03e7f172609c41b1ef1b8da6b8dd2d2acd6cd79cac1abbdbd5b99f2c4 + languageName: node + linkType: hard + "node-fetch-native@npm:^1.6.7": version: 1.6.7 resolution: "node-fetch-native@npm:1.6.7" @@ -4350,7 +4374,7 @@ __metadata: languageName: node linkType: hard -"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0": +"normalize-path@npm:^3.0.0": version: 3.0.0 resolution: "normalize-path@npm:3.0.0" checksum: 10c0/e008c8142bcc335b5e38cf0d63cfd39d6cf2d97480af9abdbe9a439221fd4d749763bab492a8ee708ce7a194bb00c9da6d0a115018672310850489137b3da046 @@ -4379,20 +4403,6 @@ __metadata: languageName: node linkType: hard -"object-assign@npm:^4.0.1": - version: 4.1.1 - resolution: "object-assign@npm:4.1.1" - checksum: 10c0/1f4df9945120325d041ccf7b86f31e8bcc14e73d29171e37a7903050e96b81323784ec59f93f102ec635bcf6fa8034ba3ea0a8c7e69fa202b87ae3b6cec5a414 - languageName: node - linkType: hard - -"object-hash@npm:^3.0.0": - version: 3.0.0 - resolution: "object-hash@npm:3.0.0" - checksum: 10c0/a06844537107b960c1c8b96cd2ac8592a265186bfa0f6ccafe0d34eabdb526f6fa81da1f37c43df7ed13b12a4ae3457a16071603bcd39d8beddb5f08c37b0f47 - languageName: node - linkType: hard - "obug@npm:^2.1.1": version: 2.1.3 resolution: "obug@npm:2.1.3" @@ -4546,13 +4556,6 @@ __metadata: languageName: node linkType: hard -"path-parse@npm:^1.0.7": - version: 1.0.7 - resolution: "path-parse@npm:1.0.7" - checksum: 10c0/11ce261f9d294cc7a58d6a574b7f1b935842355ec66fba3c3fd79e0f036462eaf07d0aa95bb74ff432f9afef97ce1926c720988c6a7451d8a584930ae7de86e1 - languageName: node - linkType: hard - "path-scurry@npm:^2.0.2": version: 2.0.2 resolution: "path-scurry@npm:2.0.2" @@ -4584,7 +4587,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": +"picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be @@ -4605,13 +4608,6 @@ __metadata: languageName: node linkType: hard -"pify@npm:^2.3.0": - version: 2.3.0 - resolution: "pify@npm:2.3.0" - checksum: 10c0/551ff8ab830b1052633f59cb8adc9ae8407a436e06b4a9718bcb27dc5844b83d535c3a8512b388b6062af65a98c49bdc0dd523d8b2617b188f7c8fee457158dc - languageName: node - linkType: hard - "pino-abstract-transport@npm:^2.0.0": version: 2.0.0 resolution: "pino-abstract-transport@npm:2.0.0" @@ -4649,13 +4645,6 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.1": - version: 4.0.7 - resolution: "pirates@npm:4.0.7" - checksum: 10c0/a51f108dd811beb779d58a76864bbd49e239fa40c7984cd11596c75a121a8cc789f1c8971d8bb15f0dbf9d48b76c05bb62fcbce840f89b688c0fa64b37e8478a - languageName: node - linkType: hard - "pkg-types@npm:^1.3.1": version: 1.3.1 resolution: "pkg-types@npm:1.3.1" @@ -4696,93 +4685,14 @@ __metadata: languageName: node linkType: hard -"postcss-import@npm:^15.1.0": - version: 15.1.0 - resolution: "postcss-import@npm:15.1.0" - dependencies: - postcss-value-parser: "npm:^4.0.0" - read-cache: "npm:^1.0.0" - resolve: "npm:^1.1.7" - peerDependencies: - postcss: ^8.0.0 - checksum: 10c0/518aee5c83ea6940e890b0be675a2588db68b2582319f48c3b4e06535a50ea6ee45f7e63e4309f8754473245c47a0372632378d1d73d901310f295a92f26f17b - languageName: node - linkType: hard - -"postcss-js@npm:^4.0.1": - version: 4.1.0 - resolution: "postcss-js@npm:4.1.0" - dependencies: - camelcase-css: "npm:^2.0.1" - peerDependencies: - postcss: ^8.4.21 - checksum: 10c0/a3cf6e725f3e9ecd7209732f8844a0063a1380b718ccbcf93832b6ec2cd7e63ff70dd2fed49eb2483c7482296860a0f7badd3115b5d0fa05ea648eb6d9dfc9c6 - languageName: node - linkType: hard - -"postcss-load-config@npm:^4.0.2 || ^5.0 || ^6.0": - version: 6.0.1 - resolution: "postcss-load-config@npm:6.0.1" - dependencies: - lilconfig: "npm:^3.1.1" - peerDependencies: - jiti: ">=1.21.0" - postcss: ">=8.0.9" - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - checksum: 10c0/74173a58816dac84e44853f7afbd283f4ef13ca0b6baeba27701214beec33f9e309b128f8102e2b173e8d45ecba45d279a9be94b46bf48d219626aa9b5730848 - languageName: node - linkType: hard - -"postcss-nested@npm:^6.2.0": - version: 6.2.0 - resolution: "postcss-nested@npm:6.2.0" - dependencies: - postcss-selector-parser: "npm:^6.1.1" - peerDependencies: - postcss: ^8.2.14 - checksum: 10c0/7f9c3f2d764191a39364cbdcec350f26a312431a569c9ef17408021424726b0d67995ff5288405e3724bb7152a4c92f73c027e580ec91e798800ed3c52e2bc6e - languageName: node - linkType: hard - -"postcss-selector-parser@npm:^6.1.1, postcss-selector-parser@npm:^6.1.2": - version: 6.1.2 - resolution: "postcss-selector-parser@npm:6.1.2" - dependencies: - cssesc: "npm:^3.0.0" - util-deprecate: "npm:^1.0.2" - checksum: 10c0/523196a6bd8cf660bdf537ad95abd79e546d54180f9afb165a4ab3e651ac705d0f8b8ce6b3164fb9e3279ce482c5f751a69eb2d3a1e8eb0fd5e82294fb3ef13e - languageName: node - linkType: hard - -"postcss-value-parser@npm:^4.0.0, postcss-value-parser@npm:^4.2.0": +"postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" checksum: 10c0/f4142a4f56565f77c1831168e04e3effd9ffcc5aebaf0f538eee4b2d465adfd4b85a44257bb48418202a63806a7da7fe9f56c330aebb3cac898e46b4cbf49161 languageName: node linkType: hard -"postcss@npm:^8.4.47": - version: 8.5.6 - resolution: "postcss@npm:8.5.6" - dependencies: - nanoid: "npm:^3.3.11" - picocolors: "npm:^1.1.1" - source-map-js: "npm:^1.2.1" - checksum: 10c0/5127cc7c91ed7a133a1b7318012d8bfa112da9ef092dddf369ae699a1f10ebbd89b1b9f25f3228795b84585c72aabd5ced5fc11f2ba467eedf7b081a66fad024 - languageName: node - linkType: hard - -"postcss@npm:^8.5.10, postcss@npm:^8.5.16": +"postcss@npm:^8.5.10, postcss@npm:^8.5.15, postcss@npm:^8.5.16": version: 8.5.16 resolution: "postcss@npm:8.5.16" dependencies: @@ -4913,13 +4823,6 @@ __metadata: languageName: node linkType: hard -"queue-microtask@npm:^1.2.2": - version: 1.2.3 - resolution: "queue-microtask@npm:1.2.3" - checksum: 10c0/900a93d3cdae3acd7d16f642c29a642aea32c2026446151f0778c62ac089d4b8e6c986811076e1ae180a694cedf077d453a11b58ff0a865629a4f82ab558e102 - languageName: node - linkType: hard - "quick-format-unescaped@npm:^4.0.3": version: 4.0.4 resolution: "quick-format-unescaped@npm:4.0.4" @@ -4983,15 +4886,6 @@ __metadata: languageName: node linkType: hard -"read-cache@npm:^1.0.0": - version: 1.0.0 - resolution: "read-cache@npm:1.0.0" - dependencies: - pify: "npm:^2.3.0" - checksum: 10c0/90cb2750213c7dd7c80cb420654344a311fdec12944e81eb912cd82f1bc92aea21885fa6ce442e3336d9fccd663b8a7a19c46d9698e6ca55620848ab932da814 - languageName: node - linkType: hard - "readable-stream@npm:^2.2.2, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" @@ -5014,15 +4908,6 @@ __metadata: languageName: node linkType: hard -"readdirp@npm:~3.6.0": - version: 3.6.0 - resolution: "readdirp@npm:3.6.0" - dependencies: - picomatch: "npm:^2.2.1" - checksum: 10c0/6fa848cf63d1b82ab4e985f4cf72bd55b7dcfd8e0a376905804e48c3634b7e749170940ba77b32804d5fe93b3cc521aa95a8d7e7d725f830da6d93f3669ce66b - languageName: node - linkType: hard - "real-require@npm:^0.2.0": version: 0.2.0 resolution: "real-require@npm:0.2.0" @@ -5079,32 +4964,6 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.1.7, resolve@npm:^1.22.8": - version: 1.22.11 - resolution: "resolve@npm:1.22.11" - dependencies: - is-core-module: "npm:^2.16.1" - path-parse: "npm:^1.0.7" - supports-preserve-symlinks-flag: "npm:^1.0.0" - bin: - resolve: bin/resolve - checksum: 10c0/f657191507530f2cbecb5815b1ee99b20741ea6ee02a59c57028e9ec4c2c8d7681afcc35febbd554ac0ded459db6f2d8153382c53a2f266cee2575e512674409 - languageName: node - linkType: hard - -"resolve@patch:resolve@npm%3A^1.1.7#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin": - version: 1.22.11 - resolution: "resolve@patch:resolve@npm%3A1.22.11#optional!builtin::version=1.22.11&hash=c3c19d" - dependencies: - is-core-module: "npm:^2.16.1" - path-parse: "npm:^1.0.7" - supports-preserve-symlinks-flag: "npm:^1.0.0" - bin: - resolve: bin/resolve - checksum: 10c0/ee5b182f2e37cb1165465e58c6abc797fec0a80b5ba3231607beb4677db0c9291ac010c47cf092b6daa2b7f518d69a0e21888e7e2b633f68d501a874212a8c63 - languageName: node - linkType: hard - "restore-cursor@npm:^5.0.0": version: 5.1.0 resolution: "restore-cursor@npm:5.1.0" @@ -5115,13 +4974,6 @@ __metadata: languageName: node linkType: hard -"reusify@npm:^1.0.4": - version: 1.1.0 - resolution: "reusify@npm:1.1.0" - checksum: 10c0/4eff0d4a5f9383566c7d7ec437b671cc51b25963bd61bf127c3f3d3f68e44a026d99b8d2f1ad344afff8d278a8fe70a8ea092650a716d22287e8bef7126bb2fa - languageName: node - linkType: hard - "rfdc@npm:^1.4.1": version: 1.4.1 resolution: "rfdc@npm:1.4.1" @@ -5194,15 +5046,6 @@ __metadata: languageName: node linkType: hard -"run-parallel@npm:^1.1.9": - version: 1.2.0 - resolution: "run-parallel@npm:1.2.0" - dependencies: - queue-microtask: "npm:^1.2.2" - checksum: 10c0/200b5ab25b5b8b7113f9901bfe3afc347e19bb7475b267d55ad0eb86a62a46d77510cb0f232507c9e5d497ebda569a08a9867d0d14f57a82ad5564d991588b39 - languageName: node - linkType: hard - "safe-buffer@npm:^5.0.1": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -5691,68 +5534,31 @@ __metadata: languageName: node linkType: hard -"sucrase@npm:^3.35.0": - version: 3.35.1 - resolution: "sucrase@npm:3.35.1" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.2" - commander: "npm:^4.0.0" - lines-and-columns: "npm:^1.1.6" - mz: "npm:^2.7.0" - pirates: "npm:^4.0.1" - tinyglobby: "npm:^0.2.11" - ts-interface-checker: "npm:^0.1.9" - bin: - sucrase: bin/sucrase - sucrase-node: bin/sucrase-node - checksum: 10c0/6fa22329c261371feb9560630d961ad0d0b9c87dce21ea74557c5f3ffbe5c1ee970ea8bcce9962ae9c90c3c47165ffa7dd41865c7414f5d8ea7a40755d612c5c +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 languageName: node linkType: hard -"supports-preserve-symlinks-flag@npm:^1.0.0": - version: 1.0.0 - resolution: "supports-preserve-symlinks-flag@npm:1.0.0" - checksum: 10c0/6c4032340701a9950865f7ae8ef38578d8d7053f5e10518076e6554a9381fa91bd9c6850193695c141f32b21f979c985db07265a758867bac95de05f7d8aeb39 +"tailwind-merge@npm:^2.6.0": + version: 2.6.1 + resolution: "tailwind-merge@npm:2.6.1" + checksum: 10c0/f9b5d7ba37f6c6dc7bb7a090f08252e8d827b5abfc1031bf468c5274ce104409e7952a0075a3e009aab53adda8c6d133bc1dd9d3427e2ae5bc00306f9ce1fbff languageName: node linkType: hard -"symbol-tree@npm:^3.2.4": - version: 3.2.4 - resolution: "symbol-tree@npm:3.2.4" - checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 +"tailwindcss@npm:4.3.2, tailwindcss@npm:^4": + version: 4.3.2 + resolution: "tailwindcss@npm:4.3.2" + checksum: 10c0/5a377846f77590812df234446175c4c2a45111a9626fd135e46f4552a33faee0d10c4e51aa5bbec955583265d48b380fb66c96f5da2775c961d4c26157617d19 languageName: node linkType: hard -"tailwindcss@npm:^3.4.17": - version: 3.4.19 - resolution: "tailwindcss@npm:3.4.19" - dependencies: - "@alloc/quick-lru": "npm:^5.2.0" - arg: "npm:^5.0.2" - chokidar: "npm:^3.6.0" - didyoumean: "npm:^1.2.2" - dlv: "npm:^1.1.3" - fast-glob: "npm:^3.3.2" - glob-parent: "npm:^6.0.2" - is-glob: "npm:^4.0.3" - jiti: "npm:^1.21.7" - lilconfig: "npm:^3.1.3" - micromatch: "npm:^4.0.8" - normalize-path: "npm:^3.0.0" - object-hash: "npm:^3.0.0" - picocolors: "npm:^1.1.1" - postcss: "npm:^8.4.47" - postcss-import: "npm:^15.1.0" - postcss-js: "npm:^4.0.1" - postcss-load-config: "npm:^4.0.2 || ^5.0 || ^6.0" - postcss-nested: "npm:^6.2.0" - postcss-selector-parser: "npm:^6.1.2" - resolve: "npm:^1.22.8" - sucrase: "npm:^3.35.0" - bin: - tailwind: lib/cli.js - tailwindcss: lib/cli.js - checksum: 10c0/e1063daccb9e5a508b357ec73b0011354204b2366b56496d6f0cc822733a55a0551502cb85856a2257ef9b676d0026616daaaa176d391f3216df57fbd693c581 +"tapable@npm:^2.3.3": + version: 2.3.3 + resolution: "tapable@npm:2.3.3" + checksum: 10c0/47992e861053f861154e92fb4a98ac4ab47b6463717e60792dd1e8c755da0c4964cd8bb68c308a9066d6da89000b6310457b4d5d985c30de4ccc29066068cc17 languageName: node linkType: hard @@ -5769,24 +5575,6 @@ __metadata: languageName: node linkType: hard -"thenify-all@npm:^1.0.0": - version: 1.6.0 - resolution: "thenify-all@npm:1.6.0" - dependencies: - thenify: "npm:>= 3.1.0 < 4" - checksum: 10c0/9b896a22735e8122754fe70f1d65f7ee691c1d70b1f116fda04fea103d0f9b356e3676cb789506e3909ae0486a79a476e4914b0f92472c2e093d206aed4b7d6b - languageName: node - linkType: hard - -"thenify@npm:>= 3.1.0 < 4": - version: 3.3.1 - resolution: "thenify@npm:3.3.1" - dependencies: - any-promise: "npm:^1.0.0" - checksum: 10c0/f375aeb2b05c100a456a30bc3ed07ef03a39cbdefe02e0403fb714b8c7e57eeaad1a2f5c4ecfb9ce554ce3db9c2b024eba144843cd9e344566d9fcee73b04767 - languageName: node - linkType: hard - "thread-stream@npm:^3.0.0": version: 3.1.0 resolution: "thread-stream@npm:3.1.0" @@ -5817,7 +5605,7 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.11, tinyglobby@npm:^0.2.12": +"tinyglobby@npm:^0.2.12": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" dependencies: @@ -5869,15 +5657,6 @@ __metadata: languageName: node linkType: hard -"to-regex-range@npm:^5.0.1": - version: 5.0.1 - resolution: "to-regex-range@npm:5.0.1" - dependencies: - is-number: "npm:^7.0.0" - checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892 - languageName: node - linkType: hard - "tough-cookie@npm:^6.0.1": version: 6.0.1 resolution: "tough-cookie@npm:6.0.1" @@ -5896,14 +5675,7 @@ __metadata: languageName: node linkType: hard -"ts-interface-checker@npm:^0.1.9": - version: 0.1.13 - resolution: "ts-interface-checker@npm:0.1.13" - checksum: 10c0/232509f1b84192d07b81d1e9b9677088e590ac1303436da1e92b296e9be8e31ea042e3e1fd3d29b1742ad2c959e95afe30f63117b8f1bc3a3850070a5142fea7 - languageName: node - linkType: hard - -"tslib@npm:^2.4.0": +"tslib@npm:^2.4.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -6105,7 +5877,7 @@ __metadata: languageName: node linkType: hard -"util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": +"util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942