a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4 [[data-slot=empty-title]+&]:mt-1",
+ className,
+ )}
+ data-slot="empty-description"
+ {...props}
+ />
+ );
+}
+
+export function EmptyContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">): React.ReactElement {
+ return (
+
+ );
+}
diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx
new file mode 100644
index 0000000..ca97f9c
--- /dev/null
+++ b/src/components/ui/field.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { Field as FieldPrimitive } from "@base-ui/react/field";
+import type React from "react";
+import { cn } from "@/lib/utils";
+
+export function Field({
+ className,
+ ...props
+}: FieldPrimitive.Root.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function FieldLabel({
+ className,
+ ...props
+}: FieldPrimitive.Label.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function FieldItem({
+ className,
+ ...props
+}: FieldPrimitive.Item.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function FieldDescription({
+ className,
+ ...props
+}: FieldPrimitive.Description.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function FieldError({
+ className,
+ ...props
+}: FieldPrimitive.Error.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export const FieldControl: typeof FieldPrimitive.Control =
+ FieldPrimitive.Control;
+export const FieldValidity: typeof FieldPrimitive.Validity =
+ FieldPrimitive.Validity;
+
+export { FieldPrimitive };
diff --git a/src/components/ui/frame.tsx b/src/components/ui/frame.tsx
new file mode 100644
index 0000000..8b96910
--- /dev/null
+++ b/src/components/ui/frame.tsx
@@ -0,0 +1,87 @@
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Frame({
+ className,
+ ...props
+}: React.ComponentProps<"div">): React.ReactElement {
+ return (
+
+ );
+}
+
+export function FramePanel({
+ className,
+ ...props
+}: React.ComponentProps<"div">): React.ReactElement {
+ return (
+
+ );
+}
+
+export function FrameHeader({
+ className,
+ ...props
+}: React.ComponentProps<"header">): React.ReactElement {
+ return (
+
+ );
+}
+
+export function FrameTitle({
+ className,
+ ...props
+}: React.ComponentProps<"div">): React.ReactElement {
+ return (
+
+ );
+}
+
+export function FrameDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">): React.ReactElement {
+ return (
+
+ );
+}
+
+export function FrameFooter({
+ className,
+ ...props
+}: React.ComponentProps<"footer">): React.ReactElement {
+ return (
+
+ );
+}
diff --git a/src/components/ui/group.tsx b/src/components/ui/group.tsx
new file mode 100644
index 0000000..0a07154
--- /dev/null
+++ b/src/components/ui/group.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+import type * as React from "react";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+export const groupVariants = cva(
+ "flex w-fit *:focus-visible:z-1 has-[>[data-slot=group]]:gap-2 *:has-focus-visible:z-1 dark:*:[[data-slot=separator]:has(~button:hover):not(:has(~[data-slot=separator]~[data-slot]:hover)),[data-slot=separator]:has(~[data-slot][data-pressed]):not(:has(~[data-slot=separator]~[data-slot][data-pressed]))]:before:bg-input/64 dark:*:[button:hover~[data-slot=separator]:not([data-slot]:hover~[data-slot=separator]~[data-slot=separator]),[data-slot][data-pressed]~[data-slot=separator]:not([data-slot][data-pressed]~[data-slot=separator]~[data-slot=separator])]:before:bg-input/64",
+ {
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ variants: {
+ orientation: {
+ horizontal:
+ "*:pointer-coarse:after:min-w-auto *:data-slot:has-[~[data-slot]]:rounded-e-none *:data-slot:has-[~[data-slot]]:border-e-0 *:data-slot:not-data-[slot=separator]:has-[~[data-slot]]:before:-end-[0.5px] *:data-slot:has-[~[data-slot]]:before:rounded-e-none *:[[data-slot]~[data-slot]:not([data-slot=separator])]:before:-start-[0.5px] *:[[data-slot]~[data-slot]]:rounded-s-none *:[[data-slot]~[data-slot]]:border-s-0 *:[[data-slot]~[data-slot]]:before:rounded-s-none",
+ vertical:
+ "flex-col *:pointer-coarse:after:min-h-auto *:data-slot:has-[~[data-slot]]:rounded-b-none *:data-slot:has-[~[data-slot]]:border-b-0 *:data-slot:not-data-[slot=separator]:has-[~[data-slot]]:before:-bottom-[0.5px] *:data-slot:not-data-[slot=separator]:has-[~[data-slot]]:before:hidden *:data-slot:has-[~[data-slot]]:before:rounded-b-none dark:*:last:before:hidden dark:*:first:before:block *:[[data-slot]~[data-slot]:not([data-slot=separator])]:before:-top-[0.5px] *:[[data-slot]~[data-slot]]:rounded-t-none *:[[data-slot]~[data-slot]]:border-t-0 *:[[data-slot]~[data-slot]]:before:rounded-t-none",
+ },
+ },
+ },
+);
+
+export function Group({
+ className,
+ orientation,
+ children,
+ ...props
+}: {
+ className?: string;
+ orientation?: VariantProps
["orientation"];
+ children: React.ReactNode;
+} & React.ComponentProps<"div">): React.ReactElement {
+ return (
+
+ {children}
+
+ );
+}
+
+export function GroupText({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">): React.ReactElement {
+ const defaultProps = {
+ className: cn(
+ "relative inline-flex items-center gap-2 whitespace-nowrap rounded-lg border border-input bg-muted not-dark:bg-clip-padding px-[calc(--spacing(3)-1px)] text-base text-muted-foreground shadow-xs/5 outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/6%)] sm:text-sm dark:bg-input/64 dark:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:-mx-0.5 [&_svg]:shrink-0",
+ className,
+ ),
+ "data-slot": "group-text",
+ };
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps(defaultProps, props),
+ render,
+ });
+}
+
+export function GroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: {
+ className?: string;
+} & React.ComponentProps): React.ReactElement {
+ return (
+
+ );
+}
+
+export {
+ Group as ButtonGroup,
+ GroupText as ButtonGroupText,
+ GroupSeparator as ButtonGroupSeparator,
+};
diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx
new file mode 100644
index 0000000..238d973
--- /dev/null
+++ b/src/components/ui/input-group.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { cva, type VariantProps } from "class-variance-authority";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+import { Input, type InputProps } from "@/components/ui/input";
+import { Textarea, type TextareaProps } from "@/components/ui/textarea";
+
+const inputGroupAddonVariants = cva(
+ "flex h-auto cursor-text select-none items-center justify-center gap-2 [&>kbd]:rounded-[calc(var(--radius)-5px)] in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:-mx-0.5 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80",
+ {
+ defaultVariants: {
+ align: "inline-start",
+ },
+ variants: {
+ align: {
+ "block-end":
+ "order-last w-full justify-start px-[calc(--spacing(3)-1px)] pb-[calc(--spacing(3)-1px)] [.border-t]:pt-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
+ "block-start":
+ "order-first w-full justify-start px-[calc(--spacing(3)-1px)] pt-[calc(--spacing(3)-1px)] [.border-b]:pb-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
+ "inline-end":
+ "order-last pe-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-me-1.5 has-[>button]:-me-2 has-[>kbd:last-child]:me-[-0.35rem] [[data-size=sm]+&]:pe-[calc(--spacing(2.5)-1px)]",
+ "inline-start":
+ "order-first ps-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-ms-1.5 has-[>button]:-ms-2 has-[>kbd:last-child]:ms-[-0.35rem] [[data-size=sm]+&]:ps-[calc(--spacing(2.5)-1px)]",
+ },
+ },
+ },
+);
+
+export function InputGroup({
+ className,
+ ...props
+}: React.ComponentProps<"div">): React.ReactElement {
+ return (
+
+ );
+}
+
+export function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> &
+ VariantProps): React.ReactElement {
+ return (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- pointer convenience only: a click on the addon focuses the input; keyboard users reach the input directly
+ ) => {
+ const target = e.target as Element;
+ if (!e.currentTarget.contains(target)) return;
+
+ const isInteractive = target.closest(
+ "button, a, input, select, textarea, [role='button'], [role='combobox'], [role='listbox'], [data-slot='select-trigger']",
+ );
+ if (isInteractive) return;
+ e.preventDefault();
+ const parent = e.currentTarget.parentElement;
+ const input = parent?.querySelector<
+ HTMLInputElement | HTMLTextAreaElement
+ >("input, textarea");
+ if (input && !parent?.querySelector("input:focus, textarea:focus")) {
+ input.focus();
+ }
+ }}
+ {...props}
+ />
+ );
+}
+
+export function InputGroupText({
+ className,
+ ...props
+}: React.ComponentProps<"span">): React.ReactElement {
+ return (
+
+ );
+}
+
+export function InputGroupInput({
+ className,
+ ...props
+}: InputProps): React.ReactElement {
+ return
;
+}
+
+export function InputGroupTextarea({
+ className,
+ ...props
+}: TextareaProps): React.ReactElement {
+ return
;
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..93c9c2d
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { Input as InputPrimitive } from "@base-ui/react/input";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+export type InputProps = Omit<
+ InputPrimitive.Props & React.RefAttributes
,
+ "size"
+> & {
+ size?: "sm" | "default" | "lg" | number;
+ unstyled?: boolean;
+ nativeInput?: boolean;
+};
+
+export function Input({
+ className,
+ size = "default",
+ unstyled = false,
+ nativeInput = false,
+ style,
+ ...props
+}: InputProps): React.ReactElement {
+ const inputClassName = cn(
+ // QueryCraft AA: full muted-foreground — /72 is 3.1:1 light, 2.9:1 dark
+ "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] text-foreground leading-8.5 outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground sm:h-7.5 sm:leading-7.5 autofill:[-webkit-text-fill-color:var(--foreground)]",
+ size === "sm" &&
+ "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5",
+ size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5",
+ props.type === "search" &&
+ "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none",
+ props.type === "file" &&
+ "text-muted-foreground file:me-3 file:bg-transparent file:font-medium file:text-foreground file:text-sm",
+ );
+
+ return (
+
+ {nativeInput ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export { InputPrimitive };
diff --git a/src/components/ui/kbd.tsx b/src/components/ui/kbd.tsx
new file mode 100644
index 0000000..59c15c2
--- /dev/null
+++ b/src/components/ui/kbd.tsx
@@ -0,0 +1,31 @@
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Kbd({
+ className,
+ ...props
+}: React.ComponentProps<"kbd">): React.ReactElement {
+ return (
+
+ );
+}
+
+export function KbdGroup({
+ className,
+ ...props
+}: React.ComponentProps<"kbd">): React.ReactElement {
+ return (
+
+ );
+}
diff --git a/src/components/ui/menu.tsx b/src/components/ui/menu.tsx
new file mode 100644
index 0000000..76cfc68
--- /dev/null
+++ b/src/components/ui/menu.tsx
@@ -0,0 +1,362 @@
+"use client";
+
+import { Menu as MenuPrimitive } from "@base-ui/react/menu";
+import { ChevronRightIcon } from "lucide-react";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+export const MenuCreateHandle: typeof MenuPrimitive.createHandle =
+ MenuPrimitive.createHandle;
+
+export const Menu: typeof MenuPrimitive.Root = MenuPrimitive.Root;
+
+export const MenuPortal: typeof MenuPrimitive.Portal = MenuPrimitive.Portal;
+
+export function MenuTrigger({
+ className,
+ children,
+ ...props
+}: MenuPrimitive.Trigger.Props): React.ReactElement {
+ return (
+
+ {children}
+
+ );
+}
+
+export function MenuPopup({
+ children,
+ className,
+ sideOffset = 4,
+ // QueryCraft density: keep popups 8px inside the 380px popup (docs/redesign §5.3)
+ collisionPadding = 8,
+ align = "center",
+ alignOffset,
+ side = "bottom",
+ anchor,
+ portalProps,
+ ...props
+}: MenuPrimitive.Popup.Props & {
+ align?: MenuPrimitive.Positioner.Props["align"];
+ sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
+ collisionPadding?: MenuPrimitive.Positioner.Props["collisionPadding"];
+ alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
+ side?: MenuPrimitive.Positioner.Props["side"];
+ anchor?: MenuPrimitive.Positioner.Props["anchor"];
+ portalProps?: MenuPrimitive.Portal.Props;
+}): React.ReactElement {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export function MenuGroup(
+ props: MenuPrimitive.Group.Props,
+): React.ReactElement {
+ return ;
+}
+
+export function MenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}): React.ReactElement {
+ return (
+ svg:not([class*='opacity-'])]:opacity-80 [&>svg:not([class*='size-'])]:size-4.5 sm:[&>svg:not([class*='size-'])]:size-4 [&>svg]:pointer-events-none [&>svg]:-mx-0.5 [&>svg]:shrink-0",
+ className,
+ )}
+ data-inset={inset}
+ data-slot="menu-item"
+ data-variant={variant}
+ {...props}
+ />
+ );
+}
+
+export function MenuLinkItem({
+ className,
+ inset,
+ variant = "default",
+ closeOnClick = true,
+ ...props
+}: MenuPrimitive.LinkItem.Props & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}): React.ReactElement {
+ return (
+ svg:not([class*='opacity-'])]:opacity-80 [&>svg:not([class*='size-'])]:size-4.5 sm:[&>svg:not([class*='size-'])]:size-4 [&>svg]:pointer-events-none [&>svg]:-mx-0.5 [&>svg]:shrink-0",
+ className,
+ )}
+ closeOnClick={closeOnClick}
+ data-inset={inset}
+ data-slot="menu-link-item"
+ data-variant={variant}
+ {...props}
+ />
+ );
+}
+
+export function MenuCheckboxItem({
+ className,
+ children,
+ checked,
+ variant = "default",
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ variant?: "default" | "switch";
+}): React.ReactElement {
+ return (
+
+ {variant === "switch" ? (
+ <>
+ {children}
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ {children}
+ >
+ )}
+
+ );
+}
+
+export function MenuRadioGroup(
+ props: MenuPrimitive.RadioGroup.Props,
+): React.ReactElement {
+ return ;
+}
+
+export function MenuRadioItem({
+ className,
+ children,
+ ...props
+}: MenuPrimitive.RadioItem.Props): React.ReactElement {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+export function MenuGroupLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean;
+}): React.ReactElement {
+ return (
+
+ );
+}
+
+export function MenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function MenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"kbd">): React.ReactElement {
+ return (
+
+ );
+}
+
+export function MenuSub(
+ props: MenuPrimitive.SubmenuRoot.Props,
+): React.ReactElement {
+ return ;
+}
+
+export function MenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean;
+}): React.ReactElement {
+ return (
+ svg:not(:last-child)]:-mx-0.5 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
+ className,
+ )}
+ data-inset={inset}
+ data-slot="menu-sub-trigger"
+ {...props}
+ >
+ {children}
+
+
+ );
+}
+
+export function MenuSubPopup({
+ className,
+ sideOffset = 0,
+ alignOffset,
+ align = "start",
+ ...props
+}: MenuPrimitive.Popup.Props & {
+ align?: MenuPrimitive.Positioner.Props["align"];
+ sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
+ alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
+}): React.ReactElement {
+ const defaultAlignOffset = align !== "center" ? -5 : undefined;
+
+ return (
+
+ );
+}
+
+export {
+ MenuPrimitive,
+ MenuCreateHandle as DropdownMenuCreateHandle,
+ Menu as DropdownMenu,
+ MenuPortal as DropdownMenuPortal,
+ MenuTrigger as DropdownMenuTrigger,
+ MenuPopup as DropdownMenuContent,
+ MenuGroup as DropdownMenuGroup,
+ MenuItem as DropdownMenuItem,
+ MenuCheckboxItem as DropdownMenuCheckboxItem,
+ MenuRadioGroup as DropdownMenuRadioGroup,
+ MenuRadioItem as DropdownMenuRadioItem,
+ MenuGroupLabel as DropdownMenuLabel,
+ MenuSeparator as DropdownMenuSeparator,
+ MenuShortcut as DropdownMenuShortcut,
+ MenuSub as DropdownMenuSub,
+ MenuSubTrigger as DropdownMenuSubTrigger,
+ MenuSubPopup as DropdownMenuSubContent,
+};
diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..4de4f53
--- /dev/null
+++ b/src/components/ui/radio-group.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { Radio as RadioPrimitive } from "@base-ui/react/radio";
+import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
+import type React from "react";
+import { cn } from "@/lib/utils";
+
+export function RadioGroup({
+ className,
+ ...props
+}: RadioGroupPrimitive.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function Radio({
+ className,
+ ...props
+}: RadioPrimitive.Root.Props): React.ReactElement {
+ return (
+
+
+
+ );
+}
+
+export { RadioGroupPrimitive, RadioPrimitive, Radio as RadioGroupItem };
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..ddb649b
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
+import type React from "react";
+import { cn } from "@/lib/utils";
+
+export function ScrollArea({
+ className,
+ children,
+ scrollFade = false,
+ scrollbarGutter = false,
+ fill = false,
+ clampContentMinWidth = true,
+ overscrollContain = false,
+ ...props
+}: ScrollAreaPrimitive.Root.Props & {
+ scrollFade?: boolean;
+ scrollbarGutter?: boolean;
+ fill?: boolean;
+ clampContentMinWidth?: boolean;
+ overscrollContain?: boolean;
+}): React.ReactElement {
+ return (
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+export function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props): React.ReactElement {
+ return (
+
+
+
+ );
+}
+
+export { ScrollAreaPrimitive };
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..f3db7fb
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,23 @@
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
+import type React from "react";
+import { cn } from "@/lib/utils";
+
+export function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export { SeparatorPrimitive };
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..253000b
--- /dev/null
+++ b/src/components/ui/sheet.tsx
@@ -0,0 +1,254 @@
+"use client";
+
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { XIcon } from "lucide-react";
+import type React from "react";
+import { createContext, useContext } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+// QueryCraft density: coss sheets are sized for pages (p-6, text-xl); "compact" fits the 380px popup.
+export type SheetDensity = "default" | "compact";
+const SheetDensityContext = createContext("default");
+
+export const Sheet: typeof SheetPrimitive.Root = SheetPrimitive.Root;
+
+export const SheetPortal: typeof SheetPrimitive.Portal = SheetPrimitive.Portal;
+
+export function SheetTrigger(
+ props: SheetPrimitive.Trigger.Props,
+): React.ReactElement {
+ return ;
+}
+
+export function SheetClose(
+ props: SheetPrimitive.Close.Props,
+): React.ReactElement {
+ return ;
+}
+
+export function SheetBackdrop({
+ className,
+ ...props
+}: SheetPrimitive.Backdrop.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function SheetViewport({
+ className,
+ side,
+ variant = "default",
+ ...props
+}: SheetPrimitive.Viewport.Props & {
+ side?: "right" | "left" | "top" | "bottom";
+ variant?: "default" | "inset";
+}): React.ReactElement {
+ return (
+
+ );
+}
+
+export function SheetPopup({
+ className,
+ children,
+ showCloseButton = true,
+ side = "right",
+ variant = "default",
+ closeProps,
+ portalProps,
+ density = "default",
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ showCloseButton?: boolean;
+ side?: "right" | "left" | "top" | "bottom";
+ variant?: "default" | "inset";
+ // QueryCraft density: read by the header, title, panel and footer below.
+ density?: SheetDensity;
+ closeProps?: SheetPrimitive.Close.Props;
+ portalProps?: SheetPrimitive.Portal.Props;
+}): React.ReactElement {
+ return (
+
+
+
+
+
+ {children}
+
+ {showCloseButton && (
+ }
+ {...closeProps}
+ >
+
+
+ )}
+
+
+
+ );
+}
+
+export function SheetHeader({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">): React.ReactElement {
+ const density = useContext(SheetDensityContext);
+ const defaultProps = {
+ className: cn(
+ "flex flex-col gap-2 p-6 in-[[data-slot=sheet-popup]:has([data-slot=sheet-panel])]:pb-3 max-sm:pb-4",
+ // QueryCraft density: compact header
+ density === "compact" && "px-4 py-3",
+ className,
+ ),
+ "data-slot": "sheet-header",
+ };
+
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(defaultProps, props),
+ render,
+ });
+}
+
+export function SheetFooter({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"div"> & {
+ variant?: "default" | "bare";
+}): React.ReactElement {
+ const density = useContext(SheetDensityContext);
+ const defaultProps = {
+ className: cn(
+ "flex flex-col-reverse gap-2 px-6 sm:flex-row sm:justify-end",
+ variant === "default" && "border-t bg-muted/72 py-4",
+ variant === "bare" &&
+ "in-[[data-slot=sheet-popup]:has([data-slot=sheet-panel])]:pt-3 pt-4 pb-6",
+ // QueryCraft density: compact footer
+ density === "compact" && "px-4 py-3",
+ className,
+ ),
+ "data-slot": "sheet-footer",
+ };
+
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(defaultProps, props),
+ render,
+ });
+}
+
+export function SheetTitle({
+ className,
+ ...props
+}: SheetPrimitive.Title.Props): React.ReactElement {
+ const density = useContext(SheetDensityContext);
+ return (
+
+ );
+}
+
+export function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props): React.ReactElement {
+ return (
+
+ );
+}
+
+export function SheetPanel({
+ className,
+ scrollFade = true,
+ render,
+ ...props
+}: useRender.ComponentProps<"div"> & {
+ scrollFade?: boolean;
+}): React.ReactElement {
+ const density = useContext(SheetDensityContext);
+ const defaultProps = {
+ className: cn(
+ "p-6 in-[[data-slot=sheet-popup]:has([data-slot=sheet-header])]:pt-1 in-[[data-slot=sheet-popup]:has([data-slot=sheet-footer]:not(.border-t))]:pb-1",
+ // QueryCraft density: compact panel
+ density === "compact" && "px-4 pt-1 pb-4",
+ className,
+ ),
+ "data-slot": "sheet-panel",
+ };
+
+ return (
+
+ {useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(defaultProps, props),
+ render,
+ })}
+
+ );
+}
+
+export {
+ SheetPrimitive,
+ SheetBackdrop as SheetOverlay,
+ SheetPopup as SheetContent,
+};
diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..fcc8c7b
--- /dev/null
+++ b/src/components/ui/spinner.tsx
@@ -0,0 +1,17 @@
+import { Loader2Icon } from "lucide-react";
+import type React from "react";
+import { cn } from "@/lib/utils";
+
+export function Spinner({
+ className,
+ ...props
+}: React.ComponentProps): React.ReactElement {
+ return (
+
+ );
+}
diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx
new file mode 100644
index 0000000..1f9123d
--- /dev/null
+++ b/src/components/ui/switch.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
+import type React from "react";
+import { cn } from "@/lib/utils";
+
+export function Switch({
+ className,
+ ...props
+}: SwitchPrimitive.Root.Props): React.ReactElement {
+ return (
+
+
+
+ );
+}
+
+export { SwitchPrimitive };
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..fdd8e0d
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { Field as FieldPrimitive } from "@base-ui/react/field";
+import { mergeProps } from "@base-ui/react/merge-props";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+export type TextareaProps = React.ComponentPropsWithoutRef<"textarea"> &
+ React.RefAttributes & {
+ size?: "sm" | "default" | "lg" | number;
+ unstyled?: boolean;
+ };
+
+export function Textarea({
+ className,
+ size = "default",
+ unstyled = false,
+ ref,
+ ...props
+}: TextareaProps): React.ReactElement {
+ return (
+
+ ) => (
+
+ )}
+ />
+
+ );
+}
+
+export { FieldPrimitive };
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx
new file mode 100644
index 0000000..2061524
--- /dev/null
+++ b/src/components/ui/toast.tsx
@@ -0,0 +1,336 @@
+"use client";
+
+import { Toast } from "@base-ui/react/toast";
+import {
+ CircleAlertIcon,
+ CircleCheckIcon,
+ InfoIcon,
+ LoaderCircleIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import type React from "react";
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+const TOAST_ICONS = {
+ error: CircleAlertIcon,
+ info: InfoIcon,
+ loading: LoaderCircleIcon,
+ success: CircleCheckIcon,
+ warning: TriangleAlertIcon,
+} as const;
+
+type SwipeDirection = "up" | "down" | "left" | "right";
+
+type ToastData = {
+ rootProps?: Omit<
+ React.ComponentProps,
+ "children" | "className" | "swipeDirection" | "toast"
+ >;
+ tooltipStyle?: boolean;
+};
+
+function getSwipeDirection(position: ToastPosition): SwipeDirection[] {
+ const verticalDirection: SwipeDirection = position.startsWith("top")
+ ? "up"
+ : "down";
+
+ if (position.includes("center")) {
+ return [verticalDirection];
+ }
+
+ if (position.includes("left")) {
+ return ["left", verticalDirection];
+ }
+
+ return ["right", verticalDirection];
+}
+
+function upsertReplayClassName(toast: {
+ type?: string;
+ updateKey?: number;
+}): string | undefined {
+ const k = toast.updateKey ?? 0;
+ if (k <= 0) return undefined;
+ const isEven = k % 2 === 0;
+ if (toast.type === "error") {
+ return isEven ? "animate-toast-error-even" : "animate-toast-error-odd";
+ }
+ return isEven ? "animate-toast-success-even" : "animate-toast-success-odd";
+}
+
+function Toasts({
+ position,
+ portalProps,
+ viewportClassName,
+}: {
+ position: ToastPosition;
+ portalProps?: React.ComponentProps;
+ viewportClassName?: string;
+}): React.ReactElement {
+ const { toasts } = Toast.useToastManager();
+ const swipeDirection = getSwipeDirection(position);
+
+ return (
+
+
+ {toasts.map((toast) => {
+ const Icon = toast.type
+ ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS]
+ : null;
+ const toastData = toast.data as ToastData | undefined;
+
+ return (
+
+
+
+ {Icon && (
+
+
+
+ )}
+
+
+
+
+
+
+ {toast.actionProps && (
+
+ {toast.actionProps.children}
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
+
+function AnchoredToasts({
+ portalProps,
+}: {
+ portalProps?: React.ComponentProps;
+}): React.ReactElement {
+ const { toasts } = Toast.useToastManager();
+
+ return (
+
+
+ {toasts.map((toast) => {
+ const Icon = toast.type
+ ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS]
+ : null;
+ const toastData = toast.data as ToastData | undefined;
+ const tooltipStyle = toastData?.tooltipStyle ?? false;
+ const positionerProps = toast.positionerProps;
+
+ if (!positionerProps?.anchor) {
+ return null;
+ }
+
+ return (
+
+
+ {tooltipStyle ? (
+
+
+
+ ) : (
+
+
+ {Icon && (
+
+
+
+ )}
+
+
+
+
+
+
+ {toast.actionProps && (
+
+ {toast.actionProps.children}
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
+
+export const toastManager: ReturnType =
+ Toast.createToastManager();
+
+export const anchoredToastManager: ReturnType =
+ Toast.createToastManager();
+
+export type ToastPosition =
+ | "top-left"
+ | "top-center"
+ | "top-right"
+ | "bottom-left"
+ | "bottom-center"
+ | "bottom-right";
+
+export interface ToastProviderProps extends Toast.Provider.Props {
+ position?: ToastPosition;
+ portalProps?: React.ComponentProps;
+ // QueryCraft density: classes merged into the viewport after its defaults
+ viewportClassName?: string;
+}
+
+export function ToastProvider({
+ children,
+ position = "bottom-right",
+ portalProps,
+ viewportClassName,
+ ...props
+}: ToastProviderProps): React.ReactElement {
+ return (
+
+ {children}
+
+
+ );
+}
+
+export interface AnchoredToastProviderProps extends Toast.Provider.Props {
+ portalProps?: React.ComponentProps;
+}
+
+export function AnchoredToastProvider({
+ children,
+ portalProps,
+ ...props
+}: AnchoredToastProviderProps): React.ReactElement {
+ return (
+
+ {children}
+
+
+ );
+}
+
+export { Toast as ToastPrimitive };
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..7a890f5
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
+import type React from "react";
+import { cn } from "@/lib/utils";
+
+export const TooltipCreateHandle: typeof TooltipPrimitive.createHandle =
+ TooltipPrimitive.createHandle;
+
+export const TooltipProvider: typeof TooltipPrimitive.Provider =
+ TooltipPrimitive.Provider;
+
+export const Tooltip: typeof TooltipPrimitive.Root = TooltipPrimitive.Root;
+
+export function TooltipTrigger(
+ props: TooltipPrimitive.Trigger.Props,
+): React.ReactElement {
+ return ;
+}
+
+export function TooltipPopup({
+ className,
+ align = "center",
+ sideOffset = 4,
+ // QueryCraft density: keep tooltips 8px inside the 380px popup (docs/redesign §5.3)
+ collisionPadding = 8,
+ side = "top",
+ anchor,
+ children,
+ portalProps,
+ ...props
+}: TooltipPrimitive.Popup.Props & {
+ align?: TooltipPrimitive.Positioner.Props["align"];
+ side?: TooltipPrimitive.Positioner.Props["side"];
+ sideOffset?: TooltipPrimitive.Positioner.Props["sideOffset"];
+ collisionPadding?: TooltipPrimitive.Positioner.Props["collisionPadding"];
+ anchor?: TooltipPrimitive.Positioner.Props["anchor"];
+ portalProps?: TooltipPrimitive.Portal.Props;
+}): React.ReactElement {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export { TooltipPrimitive, TooltipPopup as TooltipContent };
diff --git a/src/hooks/useAccent.ts b/src/hooks/useAccent.ts
index df3d295..cba896e 100644
--- a/src/hooks/useAccent.ts
+++ b/src/hooks/useAccent.ts
@@ -5,40 +5,98 @@ import { adjustForContrast, readableOn, withAlpha } from '@/lib/contrast';
import type { AccentColor } from '@/types';
import type { ResolvedTheme } from './useTheme';
-/** Panel background per theme — the surface accent-colored text sits on. */
+/** The surface accent-coloured text sits on, per theme (coss background). */
const PANEL_BG: Record = {
light: '#ffffff',
- dark: '#262626',
+ dark: '#161616',
};
+/** Exactly what the accent setting overrides (docs/redesign §4.4). --ring never follows it. */
+const ACCENT_VARS = ['--primary', '--primary-foreground', '--key-foreground'] as const;
+
/**
- * Custom properties the accent overrides. Listed so clearing an accent can
- * remove exactly what it set and let the stylesheet defaults win again.
+ * Pre-redesign accent variables, still read by the CSS Modules components
+ * until they move to coss; removed with tokens.css in Phase 7. Their text
+ * variant keeps the old, lighter dark panel the old surfaces were tuned for.
*/
-const VARS = [
+const LEGACY_VARS = [
'--color-accent',
'--color-accent-hover',
'--color-on-accent',
'--color-accent-text',
'--color-accent-soft',
] as const;
+const LEGACY_PANEL_BG: Record = { light: '#ffffff', dark: '#262626' };
+
+/** Synchronous mirror read by public/theme-boot.js before first paint (§4.5). */
+export const ACCENT_MIRROR_KEY = 'qc-accent';
+
+/** The three variables for an accent swatch in a theme. */
+export function accentVars(
+ hex: string,
+ theme: ResolvedTheme,
+): Record<(typeof ACCENT_VARS)[number], string> {
+ return {
+ // The exact swatch, so the button and switch match what the user picked.
+ '--primary': hex,
+ '--primary-foreground': readableOn(hex),
+ // Text on the panel is darkened or lightened just enough to keep 4.5:1.
+ '--key-foreground': adjustForContrast(hex, PANEL_BG[theme]),
+ };
+}
function applyAccent(accent: AccentColor, theme: ResolvedTheme): void {
const root = document.documentElement;
if (!accent) {
- for (const v of VARS) root.style.removeProperty(v);
+ for (const v of [...ACCENT_VARS, ...LEGACY_VARS]) root.style.removeProperty(v);
return;
}
- // Solid fills keep the exact swatch the user picked, so the button matches
- // the swatch. Only the *label* and text-on-panel uses are corrected, since
- // every swatch in this palette fails AA otherwise.
+ for (const [name, value] of Object.entries(accentVars(accent, theme))) {
+ root.style.setProperty(name, value);
+ }
root.style.setProperty('--color-accent', accent);
- root.style.setProperty('--color-accent-hover', adjustForContrast(accent, PANEL_BG[theme], 3));
+ root.style.setProperty(
+ '--color-accent-hover',
+ adjustForContrast(accent, LEGACY_PANEL_BG[theme], 3),
+ );
root.style.setProperty('--color-on-accent', readableOn(accent));
- root.style.setProperty('--color-accent-text', adjustForContrast(accent, PANEL_BG[theme]));
+ root.style.setProperty('--color-accent-text', adjustForContrast(accent, LEGACY_PANEL_BG[theme]));
root.style.setProperty('--color-accent-soft', withAlpha(accent, 0.15));
}
+function readMirror(): AccentColor {
+ try {
+ const hex = JSON.parse(localStorage.getItem(ACCENT_MIRROR_KEY) ?? 'null')?.hex;
+ return typeof hex === 'string' && isKnownAccent(hex) ? hex : null;
+ } catch {
+ return null;
+ }
+}
+
+function writeMirror(accent: AccentColor): void {
+ try {
+ if (!accent) {
+ localStorage.removeItem(ACCENT_MIRROR_KEY);
+ return;
+ }
+ const light = accentVars(accent, 'light');
+ localStorage.setItem(
+ ACCENT_MIRROR_KEY,
+ JSON.stringify({
+ hex: accent,
+ primary: light['--primary'],
+ primaryForeground: light['--primary-foreground'],
+ keyForeground: {
+ light: light['--key-foreground'],
+ dark: accentVars(accent, 'dark')['--key-foreground'],
+ },
+ }),
+ );
+ } catch {
+ // Storage blocked: the boot script shows mono until the hook applies the accent.
+ }
+}
+
/**
* Accent color state, persisted in chrome.storage and projected onto CSS
* custom properties so components stay declarative — no component branches on
@@ -48,7 +106,9 @@ export function useAccent(theme: ResolvedTheme): {
accent: AccentColor;
setAccent: (next: AccentColor) => void;
} {
- const [accent, setAccentState] = useState(null);
+ // Start from the mirror, so the first render keeps what theme-boot.js already
+ // applied instead of stripping it until chrome.storage answers (§4.5).
+ const [accent, setAccentState] = useState(readMirror);
// Load once. On storage failure keep monochrome rather than surfacing an
// unhandled rejection.
@@ -58,7 +118,9 @@ export function useAccent(theme: ResolvedTheme): {
(stored) => {
if (cancelled) return;
// Guard against a value left by an older build or hand-edited storage.
- if (typeof stored === 'string' && isKnownAccent(stored)) setAccentState(stored);
+ const valid = typeof stored === 'string' && isKnownAccent(stored) ? stored : null;
+ setAccentState(valid);
+ writeMirror(valid);
},
() => undefined,
);
@@ -75,6 +137,7 @@ export function useAccent(theme: ResolvedTheme): {
const setAccent = useCallback((next: AccentColor) => {
setAccentState(next);
+ writeMirror(next);
void storage.setAccent(next);
}, []);
diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts
deleted file mode 100644
index fe2ec7a..0000000
--- a/src/hooks/useFocusTrap.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useEffect, useRef } from 'react';
-
-/**
- * Trap focus inside a container while it's active. Used for modal/drawer
- * components. Returns a ref to attach to the container's root element.
- *
- * - Remembers the element that had focus before activation and restores it on deactivate.
- * - Cycles Tab / Shift+Tab within the container's focusable descendants.
- * - Closes on Escape via the `onClose` callback.
- */
-export function useFocusTrap(
- active: boolean,
- onClose?: () => void,
-): React.RefObject {
- const ref = useRef(null);
- const previouslyFocused = useRef(null);
-
- // Read onClose through a ref so the effect below depends only on `active`.
- // Depending on the callback identity would re-run the effect on every parent
- // render, yanking focus back to the first focusable element mid-interaction.
- const onCloseRef = useRef(onClose);
- onCloseRef.current = onClose;
-
- useEffect(() => {
- if (!active) return;
-
- const container = ref.current;
- if (!container) return;
-
- previouslyFocused.current = document.activeElement as HTMLElement | null;
-
- // Focus first focusable child on activation.
- const focusables = getFocusable(container);
- (focusables[0] ?? container).focus();
-
- function onKeyDown(e: KeyboardEvent) {
- if (e.key === 'Escape' && onCloseRef.current) {
- e.preventDefault();
- onCloseRef.current();
- return;
- }
- if (e.key !== 'Tab') return;
- const nodes = getFocusable(container!);
- if (nodes.length === 0) return;
- const first = nodes[0];
- const last = nodes[nodes.length - 1];
- if (e.shiftKey && document.activeElement === first) {
- e.preventDefault();
- last.focus();
- } else if (!e.shiftKey && document.activeElement === last) {
- e.preventDefault();
- first.focus();
- }
- }
-
- document.addEventListener('keydown', onKeyDown);
-
- return () => {
- document.removeEventListener('keydown', onKeyDown);
- // Restore focus on deactivation — but only if the element still exists.
- previouslyFocused.current?.focus?.();
- };
- }, [active]);
-
- return ref;
-}
-
-/** Selector list adapted from WAI-ARIA authoring practices for focusable elements. */
-const FOCUSABLE_SELECTOR = [
- 'a[href]',
- 'button:not([disabled])',
- 'input:not([disabled])',
- 'select:not([disabled])',
- 'textarea:not([disabled])',
- '[tabindex]:not([tabindex="-1"])',
-].join(',');
-
-function getFocusable(root: HTMLElement): HTMLElement[] {
- return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
- (el) => !el.hasAttribute('aria-hidden'),
- );
-}
diff --git a/src/hooks/useSavedLinks.ts b/src/hooks/useSavedLinks.ts
index 5ae6c8b..9861b64 100644
--- a/src/hooks/useSavedLinks.ts
+++ b/src/hooks/useSavedLinks.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { DEFAULT_GROUP_ID, storage } from '@/lib/storage';
import { generateId } from '@/lib/id';
import type { Group, SavedLink } from '@/types';
@@ -10,6 +10,8 @@ export function useSavedLinks(): {
saveLink: (input: { url: string; label?: string; groupId?: string }) => Promise;
updateLink: (id: string, label: string | undefined, groupId: string) => Promise;
deleteLink: (id: string) => Promise;
+ /** Put a deleted link back at its old position (Undo). */
+ restoreLink: (link: SavedLink, index: number) => Promise;
createGroup: (name: string) => Promise;
renameGroup: (id: string, name: string) => Promise;
deleteGroup: (id: string) => Promise;
@@ -17,6 +19,10 @@ export function useSavedLinks(): {
const [links, setLinks] = useState([]);
const [groups, setGroups] = useState([]);
const [isLoading, setIsLoading] = useState(true);
+ // Undo runs from a toast created before the delete re-rendered: it must read
+ // the current list, not the one its callback closed over.
+ const linksRef = useRef(links);
+ linksRef.current = links;
useEffect(() => {
let cancelled = false;
@@ -63,6 +69,15 @@ export function useSavedLinks(): {
[links],
);
+ const restoreLink = useCallback(async (link: SavedLink, index: number) => {
+ const current = linksRef.current;
+ if (current.some((l) => l.id === link.id)) return;
+ const next = [...current];
+ next.splice(Math.min(index, next.length), 0, link);
+ setLinks(next);
+ await storage.setSavedLinks(next);
+ }, []);
+
const createGroup = useCallback(
async (name: string) => {
const group: Group = { id: generateId(), name, createdAt: Date.now() };
@@ -117,6 +132,7 @@ export function useSavedLinks(): {
saveLink,
updateLink,
deleteLink,
+ restoreLink,
createGroup,
renameGroup,
deleteGroup,
diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts
index d9af77a..4a003c3 100644
--- a/src/hooks/useTheme.ts
+++ b/src/hooks/useTheme.ts
@@ -1,42 +1,96 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { ThemePreference } from '@/types';
+import { storage } from '@/lib/storage';
export type ResolvedTheme = 'light' | 'dark';
-function resolveSystemTheme(): ResolvedTheme {
+/**
+ * Synchronous mirror of the preference, read by public/theme-boot.js before
+ * first paint. chrome.storage stays the source of truth (docs/redesign §4.5).
+ */
+export const THEME_MIRROR_KEY = 'qc-theme';
+
+const DARK_QUERY = '(prefers-color-scheme: dark)';
+
+function systemTheme(): ResolvedTheme {
if (typeof window === 'undefined' || !window.matchMedia) return 'light';
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ return window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light';
+}
+
+function readMirror(): ThemePreference {
+ try {
+ const value = localStorage.getItem(THEME_MIRROR_KEY);
+ return value === 'light' || value === 'dark' ? value : 'system';
+ } catch {
+ return 'system';
+ }
+}
+
+function writeMirror(preference: ThemePreference): void {
+ try {
+ localStorage.setItem(THEME_MIRROR_KEY, preference);
+ } catch {
+ // Storage blocked: the boot script falls back to the OS, nothing else breaks.
+ }
}
/**
- * Mirrors the OS color scheme onto `data-theme` on .
- *
- * There is deliberately no user-facing theme setting: the popup is a companion
- * to whatever the browser is already doing, so it follows the system and
- * updates live when that changes.
+ * Theme preference — follow the OS, or force light or dark for QueryCraft only —
+ * and the scheme it resolves to, applied to as coss's `.dark` class,
+ * `color-scheme`, and the `data-theme` the pre-redesign tokens still read.
*/
-export function useTheme(): ResolvedTheme {
- const [resolved, setResolved] = useState(resolveSystemTheme);
+export function useTheme(): {
+ preference: ThemePreference;
+ resolved: ResolvedTheme;
+ setPreference: (next: ThemePreference) => void;
+} {
+ const [preference, setPreferenceState] = useState(readMirror);
+ const [system, setSystem] = useState(systemTheme);
useEffect(() => {
- const mql = window.matchMedia('(prefers-color-scheme: dark)');
- const handler = (e: MediaQueryListEvent) => setResolved(e.matches ? 'dark' : 'light');
- setResolved(mql.matches ? 'dark' : 'light');
+ let cancelled = false;
+ storage.getThemePreference().then(
+ (stored) => {
+ if (cancelled) return;
+ setPreferenceState(stored);
+ writeMirror(stored);
+ },
+ () => undefined,
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Listen to the OS only while it decides the theme.
+ useEffect(() => {
+ if (preference !== 'system') return;
+ const mql = window.matchMedia(DARK_QUERY);
+ setSystem(mql.matches ? 'dark' : 'light');
+ const handler = (e: MediaQueryListEvent) => setSystem(e.matches ? 'dark' : 'light');
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
- }, []);
+ }, [preference]);
+
+ const resolved: ResolvedTheme = preference === 'system' ? system : preference;
// Apply to document root, suppressing transitions across the swap so the
// two palettes don't cross-fade into muddy intermediate colors.
const isFirstApply = useRef(true);
useEffect(() => {
const root = document.documentElement;
+ const apply = () => {
+ root.setAttribute('data-theme', resolved);
+ root.classList.toggle('dark', resolved === 'dark');
+ root.style.colorScheme = resolved;
+ };
if (isFirstApply.current) {
isFirstApply.current = false;
- root.setAttribute('data-theme', resolved);
+ apply();
return;
}
root.setAttribute('data-theme-switching', '');
- root.setAttribute('data-theme', resolved);
+ apply();
// Two frames: one for the attribute to take effect, one for the repaint.
const raf = requestAnimationFrame(() => {
requestAnimationFrame(() => root.removeAttribute('data-theme-switching'));
@@ -44,5 +98,11 @@ export function useTheme(): ResolvedTheme {
return () => cancelAnimationFrame(raf);
}, [resolved]);
- return resolved;
+ const setPreference = useCallback((next: ThemePreference) => {
+ setPreferenceState(next);
+ writeMirror(next);
+ void storage.setThemePreference(next);
+ }, []);
+
+ return { preference, resolved, setPreference };
}
diff --git a/src/lib/accents.ts b/src/lib/accents.ts
index 4563e7c..dc349c6 100644
--- a/src/lib/accents.ts
+++ b/src/lib/accents.ts
@@ -17,6 +17,9 @@ export const ACCENTS: readonly Accent[] = [
{ name: 'Orange', hex: '#d9824f' },
{ name: 'Red', hex: '#d9636b' },
{ name: 'Pink', hex: '#d9709c' },
+ // Between Pink and Violet on the hue wheel, at their lightness and chroma.
+ // With Mono first, 12 swatches fill the Settings grid's two rows of six.
+ { name: 'Fuchsia', hex: '#bf6fc3' },
{ name: 'Violet', hex: '#9575e0' },
] as const;
diff --git a/src/lib/contrast.ts b/src/lib/contrast.ts
index 3fa117f..e5f66b8 100644
--- a/src/lib/contrast.ts
+++ b/src/lib/contrast.ts
@@ -8,7 +8,7 @@
*/
const NEAR_WHITE = '#fafafa';
-const NEAR_BLACK = '#141414';
+const NEAR_BLACK = '#0a0a0a'; // docs/redesign §4.4: every swatch's label resolves to this
type Rgb = [number, number, number];
diff --git a/src/lib/platform.ts b/src/lib/platform.ts
new file mode 100644
index 0000000..9638bf4
--- /dev/null
+++ b/src/lib/platform.ts
@@ -0,0 +1,15 @@
+/**
+ * Shortcut labels follow the platform. useKeyboardShortcuts accepts Cmd or
+ * Ctrl, so the label only has to name the key the user actually has.
+ */
+const isApple = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform);
+
+/** Visible label for a Cmd/Ctrl shortcut: "⌘↵" on Apple, "Ctrl+↵" elsewhere. */
+export function shortcutLabel(key: string): string {
+ return isApple ? `⌘${key}` : `Ctrl+${key}`;
+}
+
+/** Value for aria-keyshortcuts, which names the modifier "Meta" or "Control". */
+export function ariaShortcut(key: string): string {
+ return `${isApple ? 'Meta' : 'Control'}+${key}`;
+}
diff --git a/src/lib/segmented-control.ts b/src/lib/segmented-control.ts
new file mode 100644
index 0000000..a719bbb
--- /dev/null
+++ b/src/lib/segmented-control.ts
@@ -0,0 +1,43 @@
+import { cva } from "class-variance-authority";
+
+export type SegmentedControlSize = "default" | "lg" | "sm";
+
+export const segmentedControlItemSizeClassNames: Record<
+ SegmentedControlSize,
+ string
+> = {
+ default: "h-8.5 px-[calc(--spacing(2.5)-1px)] sm:h-7.5",
+ lg: "h-9.5 px-[calc(--spacing(3)-1px)] sm:h-8.5",
+ sm: "h-7.5 px-[calc(--spacing(2)-1px)] sm:h-6.5",
+};
+
+export const segmentedControlRootClassName =
+ "relative z-0 flex w-fit items-center justify-center gap-0.5 rounded-lg bg-muted p-0.5";
+
+export const segmentedControlItemLayoutClassName =
+ "gap-1.5 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0";
+
+export const segmentedControlItemVariants = cva(
+ [
+ // QueryCraft AA: full muted-foreground for unchecked items — /72 fails 4.5:1
+ "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base text-muted-foreground outline-2 outline-transparent transition-[outline-color] hover:bg-transparent hover:text-muted-foreground focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm",
+ segmentedControlItemLayoutClassName,
+ ],
+ {
+ defaultVariants: {
+ size: "default",
+ },
+ variants: {
+ size: segmentedControlItemSizeClassNames,
+ state: {
+ checked:
+ // QueryCraft AA: selected = foreground-tone outline — the checked fill alone is 1.1:1
+ "data-checked:bg-background data-checked:text-foreground data-checked:inset-ring data-checked:inset-ring-foreground/60 data-checked:shadow-sm/5 dark:data-checked:bg-input",
+ current:
+ "aria-[current=page]:bg-background aria-[current=page]:text-foreground aria-[current=page]:shadow-sm/5 dark:aria-[current=page]:bg-input",
+ pressed:
+ "data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm/5 dark:data-pressed:bg-input",
+ },
+ },
+ },
+);
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
index 06ab3d5..c8838d5 100644
--- a/src/lib/storage.ts
+++ b/src/lib/storage.ts
@@ -1,4 +1,4 @@
-import type { SavedLink, Group, AccentColor } from '@/types';
+import type { SavedLink, Group, AccentColor, ThemePreference } from '@/types';
/**
* Adapter over chrome.storage.local.
@@ -12,8 +12,11 @@ const KEYS = {
savedLinks: 'qc.savedLinks',
groups: 'qc.groups',
accent: 'qc.accent',
+ themePreference: 'themePreference',
} as const;
+const THEME_PREFERENCES: readonly ThemePreference[] = ['system', 'light', 'dark'];
+
export const DEFAULT_GROUP_ID = 'default';
async function get(key: string, fallback: T): Promise {
@@ -59,4 +62,16 @@ export const storage = {
async setAccent(accent: AccentColor): Promise {
await set(KEYS.accent, accent);
},
+
+ /** 'system' unless a valid preference was stored. */
+ async getThemePreference(): Promise {
+ const stored = await get(KEYS.themePreference, 'system');
+ return THEME_PREFERENCES.includes(stored as ThemePreference)
+ ? (stored as ThemePreference)
+ : 'system';
+ },
+
+ async setThemePreference(preference: ThemePreference): Promise {
+ await set(KEYS.themePreference, preference);
+ },
};
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..c5cf235
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,10 @@
+import { createCn } from 'cn/config';
+
+/**
+ * Class merging aware of QueryCraft's theme additions (src/styles/theme.css).
+ * Without this, `text-code` reads as a text colour: it would drop
+ * `text-muted-foreground`, and never replace `sm:text-sm`.
+ */
+export const cn = createCn({
+ extend: { classGroups: { 'font-size': [{ text: ['code'] }] } },
+});
diff --git a/src/popup/App.module.css b/src/popup/App.module.css
deleted file mode 100644
index b184e9e..0000000
--- a/src/popup/App.module.css
+++ /dev/null
@@ -1,22 +0,0 @@
-.app {
- position: relative;
- display: flex;
- flex-direction: column;
- width: 100%;
- min-width: 0;
- height: 100%;
- min-height: 100%;
- max-height: 100%;
- background: var(--color-bg);
- color: var(--color-text-primary);
- overflow: hidden;
-}
-
-/* Header + footer are fixed; only this middle band scrolls. */
-.scrollArea {
- flex: 1;
- min-width: 0;
- overflow-y: auto;
- overflow-x: hidden;
- padding: var(--pad-block) var(--pad-inline) var(--space-4);
-}
diff --git a/src/popup/App.tsx b/src/popup/App.tsx
index 52ba629..8bc918a 100644
--- a/src/popup/App.tsx
+++ b/src/popup/App.tsx
@@ -1,4 +1,5 @@
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { SavedLink } from '@/types';
import { useAppStore, selectCurrentUrl, selectNavUrl } from '@/store/useAppStore';
import { useActiveTabUrl } from '@/hooks/useActiveTabUrl';
import { useClipboard } from '@/hooks/useClipboard';
@@ -12,19 +13,26 @@ import { Header } from '@/components/Header';
import { UrlPreview } from '@/components/UrlPreview';
import { ParamList } from '@/components/ParamList';
import { ActionBar } from '@/components/ActionBar';
-import { IconButton } from '@/components/IconButton';
-import { IconBookmark, IconSettings } from '@/components/icons';
-import { Toast } from '@/components/Toast';
+import { LiveRegion, announce as announceLive } from '@/components/LiveRegion';
+import { ToastProvider, toastManager } from '@/components/ui/toast';
import { SavedLinksDrawer } from '@/components/SavedLinksDrawer';
import { SettingsDrawer } from '@/components/SettingsDrawer';
import { EmptyState } from '@/components/EmptyState';
-import styles from './App.module.css';
+import { PanelMinHeightContext } from './popupHeight';
+
+/*
+ * Toasts sit bottom-centre, 8px above the action bar, at most 348px wide
+ * (docs/redesign §5.3): a 16px inset gives 380 - 2×16. `sm:` because coss sets
+ * its inset under `sm:`, which is always on in the popup (§4.1).
+ */
+const TOAST_VIEWPORT_CLASS =
+ 'sm:[--toast-inset:--spacing(4)] data-[position*=bottom]:bottom-[calc(var(--action-bar-height,0px)+--spacing(2))]';
export function App() {
// Load URL on mount, hydrate theme, bind storage-backed saved links.
useActiveTabUrl();
- const theme = useTheme();
- const { accent, setAccent } = useAccent(theme);
+ const { preference, resolved, setPreference } = useTheme();
+ const { accent, setAccent } = useAccent(resolved);
// Zustand store — individual selectors keep re-renders narrow.
const tabState = useAppStore((s) => s.tabState);
@@ -42,12 +50,25 @@ export function App() {
const currentUrl = useAppStore(selectCurrentUrl); // human-readable, for display
const navUrl = useAppStore(selectNavUrl); // encoded, for Apply / Copy
- const { copied, copy } = useClipboard();
- const { links, groups, saveLink, updateLink, deleteLink, createGroup } = useSavedLinks();
+ const { copied, copy } = useClipboard(1600);
+ const { links, groups, saveLink, updateLink, deleteLink, restoreLink, createGroup } =
+ useSavedLinks();
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerMode, setDrawerMode] = useState<'list' | 'save'>('list');
const [settingsOpen, setSettingsOpen] = useState(false);
+ const [panelMinHeight, setPanelMinHeight] = useState(null);
+
+ // Every announcement is shown as a toast, as the old status pill did. The
+ // toast viewport is the live region for it; Phase 4 moves the events with no
+ // visible feedback to (docs/redesign §7.3).
+ // Cleared once shown: the effect fires only on a changed value, so the same
+ // message twice in a row (remove "a", add "a", remove "a") still toasts twice.
+ useEffect(() => {
+ if (!announcement) return;
+ toastManager.add({ title: announcement });
+ useAppStore.setState({ announcement: '' });
+ }, [announcement]);
/* ---------- Action handlers ---------- */
@@ -67,6 +88,16 @@ export function App() {
}
}, [announce]);
+ // The new row is the visible feedback; screen readers hear it through the
+ // hidden live region, never a toast (docs/redesign §7.3).
+ const handleAddParam = useCallback(
+ (key: string, value: string) => {
+ addParam(key, value);
+ announceLive(`Added ${key}`);
+ },
+ [addParam],
+ );
+
const handleReset = useCallback(() => {
resetStore();
}, [resetStore]);
@@ -100,12 +131,6 @@ export function App() {
setSettingsOpen(false);
}, []);
- /** Escape closes whichever panel is on top. */
- const handleEscape = useCallback(() => {
- setSettingsOpen(false);
- setDrawerOpen(false);
- }, []);
-
const handleUpdateLink = useCallback(
(id: string, label: string | undefined, groupId: string) => {
void updateLink(id, label, groupId).then(
@@ -126,14 +151,43 @@ export function App() {
[saveLink, announce],
);
+ // Delete at once, with Undo in the toast for 6s (docs/redesign §7.3). Undo
+ // puts the link back where it was. ⌘Z for it arrives with Phase 4.
const handleDeleteLink = useCallback(
- (id: string) => {
- void deleteLink(id).then(
- () => announce('Saved URL deleted'),
+ (link: SavedLink) => {
+ const index = links.findIndex((l) => l.id === link.id);
+ void deleteLink(link.id).then(
+ () => {
+ const toastId = toastManager.add({
+ title: 'Saved URL deleted',
+ timeout: 6000,
+ actionProps: {
+ children: 'Undo',
+ onClick: () => {
+ toastManager.close(toastId);
+ void restoreLink(link, index).then(
+ () => announceLive('Saved URL restored'),
+ () => announce('Failed to restore saved URL'),
+ );
+ },
+ },
+ });
+ },
() => announce('Failed to delete saved URL'),
);
},
- [deleteLink, announce],
+ [links, deleteLink, restoreLink, announce],
+ );
+
+ // Copies a saved URL — not the editor's, so the footer's "Copied" stays put.
+ const handleCopySavedLink = useCallback(
+ (url: string) => {
+ navigator.clipboard.writeText(url).then(
+ () => announce('URL copied to clipboard'),
+ () => announce('Failed to copy URL'),
+ );
+ },
+ [announce],
);
const handleLoadSavedLink = useCallback(
@@ -170,100 +224,109 @@ export function App() {
() => [
{ key: 'Enter', mod: true, preventDefault: true, handler: () => void handleApply() },
{ key: 's', mod: true, preventDefault: true, handler: handleOpenDrawer },
- { key: 'Escape', handler: handleEscape },
+ // No Escape here: the sheets close on Esc themselves (Base UI), after any
+ // menu or list open inside them — a global handler closed both at once.
],
- [handleApply, handleOpenDrawer, handleEscape],
+ [handleApply, handleOpenDrawer],
);
useKeyboardShortcuts(shortcuts);
/* ---------- Render ---------- */
- const headerActions = (
- <>
- }
- variant="bordered"
- size="lg"
- onClick={handleOpenSettings}
- />
- }
- variant="bordered"
- size="lg"
- onClick={handleOpenDrawer}
- />
- >
- );
-
return (
-
-
-
- {tabState.status === 'loading' && (
-
- )}
-
- {tabState.status === 'unsupported' && (
-
- )}
-
- {tabState.status === 'error' && (
-
- )}
-
- {tabState.status === 'ready' && currentParsed && (
- <>
-
-
void handleApply()}
- onReset={handleReset}
- onCopy={() => void handleCopy()}
- onSave={handleOpenSaveDrawer}
- copied={copied}
- applyDisabled={!navUrl}
+
+
+ {/*
+ * Natural height, capped at Chrome's 600px popup limit; main.tsx sizes
+ * the popup from it (docs/redesign §5.1). An open panel raises the
+ * minimum so it is not clipped over a short view. `overflow-clip`, not
+ * hidden: the closed panel waits off to the right, and a clip is not a
+ * scroll container, so nothing can scroll the holder sideways to it.
+ */}
+
+
+
+ {tabState.status === 'loading' && (
+
+
+
+ )}
+
+ {tabState.status === 'unsupported' && (
+
+
+
+ )}
+
+ {tabState.status === 'error' && (
+
+
+
+ )}
+
+ {tabState.status === 'ready' && currentParsed && (
+ <>
+ {/* Interim: main scrolls as a whole until Phase 2 moves scrolling into the list (§5.2). */}
+
+
+
+
+
void handleApply()}
+ onReset={handleReset}
+ onCopy={() => void handleCopy()}
+ onSave={handleOpenSaveDrawer}
+ copied={copied}
+ applyDisabled={!navUrl}
+ />
+ >
+ )}
+
+
- >
- )}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/src/popup/index.html b/src/popup/index.html
index 27ef8f2..2301010 100644
--- a/src/popup/index.html
+++ b/src/popup/index.html
@@ -4,6 +4,8 @@
QueryCraft
+
+
diff --git a/src/popup/main.tsx b/src/popup/main.tsx
index d33bf20..249945f 100644
--- a/src/popup/main.tsx
+++ b/src/popup/main.tsx
@@ -1,23 +1,30 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
+import { syncPopupHeight } from './popupHeight';
+// Bundled fonts: MV3's content security policy blocks font CDNs.
+import '@fontsource-variable/inter';
+import '@fontsource/geist-mono/400.css';
+import '@fontsource/geist-mono/500.css';
+import '@fontsource/geist-mono/600.css';
import '@/styles/global.css';
-// Chrome sizes the popup from the document box and can ignore CSS height in
-// some cases, so the dimensions are pinned at runtime as well.
+// Chrome sizes the popup from the document box and can ignore CSS width in
+// some cases, so the width is pinned at runtime as well. The height follows
+// the content (see syncPopupHeight below).
const POPUP_WIDTH = '380px';
-const POPUP_HEIGHT = '600px';
document.documentElement.style.width = POPUP_WIDTH;
-document.documentElement.style.height = POPUP_HEIGHT;
document.body.style.width = POPUP_WIDTH;
-document.body.style.height = POPUP_HEIGHT;
-document.body.style.minHeight = POPUP_HEIGHT;
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Root element #root not found in popup.html');
}
+// #root holds only the app container, so its box is the container's natural
+// height — including the min-height an open panel asks for.
+syncPopupHeight(rootEl);
+
createRoot(rootEl).render(
diff --git a/src/popup/popupHeight.ts b/src/popup/popupHeight.ts
new file mode 100644
index 0000000..52bbaac
--- /dev/null
+++ b/src/popup/popupHeight.ts
@@ -0,0 +1,74 @@
+import { createContext, useContext, useEffect } from 'react';
+
+/** Chrome caps extension popups at 600px tall (docs/redesign §2). */
+export const POPUP_MAX_HEIGHT = 600;
+
+/**
+ * Chrome sizes the popup from the document box, so html and body are pinned
+ * from JS to the app's natural height, capped at the popup maximum (§5.1).
+ */
+export function syncPopupHeight(target: Element): () => void {
+ const observer = new ResizeObserver(([entry]) => {
+ if (!entry) return;
+ const blockSize = entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height;
+ const height = `${Math.min(POPUP_MAX_HEIGHT, Math.ceil(blockSize))}px`;
+ document.documentElement.style.height = height;
+ document.body.style.height = height;
+ });
+ observer.observe(target);
+ return () => observer.disconnect();
+}
+
+/**
+ * An open panel reports its natural height (capped at the popup maximum) so
+ * the app holder grows to fit it, and reports null when it closes. Otherwise a
+ * panel opened over a short view would be clipped (§5.1).
+ */
+export const PanelMinHeightContext = createContext<(height: number | null) => void>(() => {});
+
+/**
+ * While a sheet is open, report its natural height — every row of the sheet
+ * at its own height, with the scrolling panel at its content's full height —
+ * so the app holder grows to fit it (§5.1).
+ *
+ * The sheet is portaled into , so it is found by the `data-qc-sheet`
+ * attribute passed to SheetPopup rather than threaded through a ref.
+ * `contentKey` re-measures when the sheet swaps its content (list ↔ form).
+ */
+export function useReportSheetHeight(open: boolean, sheetId: string, contentKey?: unknown): void {
+ const setPanelMinHeight = useContext(PanelMinHeightContext);
+
+ // Released only when the sheet closes: dropping it between a list ↔ form
+ // swap would let the popup shrink for a frame and grow back.
+ useEffect(() => {
+ if (!open) return;
+ return () => setPanelMinHeight(null);
+ }, [open, setPanelMinHeight]);
+
+ useEffect(() => {
+ if (!open || typeof ResizeObserver === 'undefined') return;
+ let observer: ResizeObserver | null = null;
+ // The popup (or its new content) mounts after this render: wait a frame.
+ const frame = requestAnimationFrame(() => {
+ const popup = document.querySelector(`[data-qc-sheet="${sheetId}"]`);
+ if (!popup) return;
+ const rows = Array.from(popup.children) as HTMLElement[];
+ const naturalHeight = (row: HTMLElement) =>
+ row.querySelector('[data-slot=scroll-area-content]')?.offsetHeight ??
+ row.offsetHeight;
+ observer = new ResizeObserver(() => {
+ const total = rows.reduce((sum, row) => sum + naturalHeight(row), 0);
+ setPanelMinHeight(Math.min(POPUP_MAX_HEIGHT, Math.ceil(total)));
+ });
+ for (const row of rows) {
+ observer.observe(row);
+ const content = row.querySelector('[data-slot=scroll-area-content]');
+ if (content) observer.observe(content);
+ }
+ });
+ return () => {
+ cancelAnimationFrame(frame);
+ observer?.disconnect();
+ };
+ }, [open, sheetId, contentKey, setPanelMinHeight]);
+}
diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts
index c9e88a5..4887889 100644
--- a/src/store/useAppStore.ts
+++ b/src/store/useAppStore.ts
@@ -13,7 +13,7 @@ interface AppState {
/** Current editable parsed URL — what the user sees & edits. */
currentParsed: ParsedUrl | null;
- /** Last announcement for the aria-live region. Monotonically replaced on each event. */
+ /** Latest status message; the popup shows it as a toast, then clears it. */
announcement: string;
/** Actions */
@@ -141,7 +141,8 @@ export const useAppStore = create((set, get) => ({
...state.currentParsed,
params: [...state.currentParsed.params, newParam],
},
- announcement: `Added parameter ${key}.`,
+ // No announcement: the new row is the visible feedback, and the UI
+ // announces "Added {key}" through its hidden live region (docs/redesign §7.3).
});
},
diff --git a/src/styles/global.css b/src/styles/global.css
index 38e5ce5..f7195f5 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1,165 +1,188 @@
-@import './tokens.css';
-
-/* ------------ Reset ------------- */
-
-*,
-*::before,
-*::after {
- box-sizing: border-box;
-}
-
-html,
-body {
- margin: 0;
- padding: 0;
-}
-
-/*
- * Headings and paragraphs keep their UA margins otherwise — at these small
- * type sizes a stray `margin: 1em 0` silently doubles a card's header height.
- * Spacing is owned by the layout, never by the text element.
- */
-h1,
-h2,
-h3,
-h4,
-p,
-figure {
- margin: 0;
-}
-
-html {
- font-family: var(--font-sans);
- font-size: var(--font-size-md);
- line-height: var(--line-height-base);
- color: var(--color-text-primary);
- background: var(--color-bg-outer);
- width: var(--popup-width);
- height: var(--popup-height);
- overflow: hidden;
- -webkit-text-size-adjust: 100%;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-body {
- width: 100%;
- height: 100%;
- min-height: 100%;
- max-height: 100%;
- background: var(--color-bg);
- color: var(--color-text-primary);
- overflow: hidden;
- display: flex;
-}
-
-#root {
- flex: 1;
- height: 100%;
- min-height: 100%;
- /*
- * Flex items default to min-width:auto, which refuses to shrink below the
- * content's min-content width — a long unbroken URL would then push the
- * whole panel wider than the popup. Opt out so the panel owns its width.
- */
- min-width: 0;
- max-width: 100%;
-}
-
-button {
- font-family: inherit;
- font-size: inherit;
- color: inherit;
- background: none;
- border: none;
- cursor: pointer;
- padding: 0;
-}
-
-input,
-select,
-textarea {
- font-family: inherit;
- font-size: inherit;
- color: inherit;
-}
-
-input::placeholder,
-textarea::placeholder {
- color: var(--color-text-faint);
-}
-
-/* ------------ Global focus ------------- */
-
-/*
- * The reference design signals focus only by lightening an input's border.
- * That is not enough on its own for keyboard users, so the border treatment
- * is kept per-component and a :focus-visible ring is layered on top —
- * pointer interaction stays clean because :focus-visible excludes clicks.
- */
-*:focus {
- outline: none;
-}
-
-*:focus-visible {
- outline: var(--focus-ring-width) solid var(--color-focus);
- outline-offset: var(--focus-ring-offset);
-}
-
-/* ------------ Utilities ------------- */
-
-/* Visually hide but keep accessible to assistive tech (WCAG compliant pattern). */
-.visually-hidden {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
-}
-
-/* Scrollbar — subtle, theme-aware */
-::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-::-webkit-scrollbar-track {
- background: transparent;
-}
-
-::-webkit-scrollbar-thumb {
- background: var(--color-fill-strong);
- border-radius: var(--radius-full);
+/* Scan src/ only: docs/redesign mockups would otherwise add every class they mention. */
+@import 'tailwindcss' source('../');
+@import 'tw-animate-css';
+@import 'shadcn/tailwind.css';
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --font-heading: var(--font-sans);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+ --color-warning-foreground: var(--warning-foreground);
+ --color-warning: var(--warning);
+ --color-success-foreground: var(--success-foreground);
+ --color-success: var(--success);
+ --color-info-foreground: var(--info-foreground);
+ --color-info: var(--info);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --animate-toast-success-odd: toast-success-odd 0.32s cubic-bezier(0.5, 1, 0.89, 1);
+ --animate-toast-success-even: toast-success-even 0.32s cubic-bezier(0.5, 1, 0.89, 1);
+ --animate-toast-error-odd: toast-error-odd 0.28s cubic-bezier(0.5, 1, 0.89, 1);
+ --animate-toast-error-even: toast-error-even 0.28s cubic-bezier(0.5, 1, 0.89, 1);
+ @keyframes toast-success-odd {
+ 0% {
+ scale: 1;
+ }
+ 30% {
+ scale: 1.025;
+ }
+ 60% {
+ scale: 0.99;
+ }
+ 100% {
+ scale: 1;
+ }
+ }
+ @keyframes toast-error-odd {
+ 0% {
+ translate: 0 0;
+ }
+ 25% {
+ translate: -3px 0;
+ }
+ 50% {
+ translate: 3px 0;
+ }
+ 75% {
+ translate: -3px 0;
+ }
+ 100% {
+ translate: 0 0;
+ }
+ }
+ @keyframes toast-success-even {
+ 0% {
+ scale: 1;
+ }
+ 30% {
+ scale: 1.025;
+ }
+ 60% {
+ scale: 0.99;
+ }
+ 100% {
+ scale: 1;
+ }
+ }
+ @keyframes toast-error-even {
+ 0% {
+ translate: 0 0;
+ }
+ 25% {
+ translate: -3px 0;
+ }
+ 50% {
+ translate: 3px 0;
+ }
+ 75% {
+ translate: -3px 0;
+ }
+ 100% {
+ translate: 0 0;
+ }
+ }
}
-::-webkit-scrollbar-thumb:hover {
- background: var(--color-text-faint);
+:root {
+ --background: var(--color-white);
+ --foreground: var(--color-neutral-800);
+ --card: var(--color-white);
+ --card-foreground: var(--color-neutral-800);
+ --popover: var(--color-white);
+ --popover-foreground: var(--color-neutral-800);
+ --primary: var(--color-neutral-800);
+ --primary-foreground: var(--color-neutral-50);
+ --secondary: --alpha(var(--color-black) / 4%);
+ --secondary-foreground: var(--color-neutral-800);
+ --muted: --alpha(var(--color-black) / 4%);
+ --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black));
+ --accent: --alpha(var(--color-black) / 4%);
+ --accent-foreground: var(--color-neutral-800);
+ --destructive: var(--color-red-500);
+ --border: --alpha(var(--color-black) / 8%);
+ --input: --alpha(var(--color-black) / 10%);
+ --ring: var(--color-neutral-400);
+ --radius: 0.625rem;
+ --destructive-foreground: var(--color-red-700);
+ --info: var(--color-blue-500);
+ --info-foreground: var(--color-blue-700);
+ --success: var(--color-emerald-500);
+ --success-foreground: var(--color-emerald-700);
+ --warning: var(--color-amber-500);
+ --warning-foreground: var(--color-amber-700);
+}
+
+.dark {
+ --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white));
+ --foreground: var(--color-neutral-100);
+ --card: color-mix(in srgb, var(--background) 98%, var(--color-white));
+ --card-foreground: var(--color-neutral-100);
+ --popover: color-mix(in srgb, var(--background) 98%, var(--color-white));
+ --popover-foreground: var(--color-neutral-100);
+ --primary: var(--color-neutral-100);
+ --primary-foreground: var(--color-neutral-800);
+ --secondary: --alpha(var(--color-white) / 4%);
+ --secondary-foreground: var(--color-neutral-100);
+ --muted: --alpha(var(--color-white) / 4%);
+ --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));
+ --accent: --alpha(var(--color-white) / 4%);
+ --accent-foreground: var(--color-neutral-100);
+ --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));
+ --border: --alpha(var(--color-white) / 6%);
+ --input: --alpha(var(--color-white) / 8%);
+ --ring: var(--color-neutral-500);
+ --destructive-foreground: var(--color-red-400);
+ --info: var(--color-blue-500);
+ --info-foreground: var(--color-blue-400);
+ --success: var(--color-emerald-500);
+ --success-foreground: var(--color-emerald-400);
+ --warning: var(--color-amber-500);
+ --warning-foreground: var(--color-amber-400);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ html {
+ @apply font-sans;
+ }
}
/*
- * Applied for one frame while the theme flips. Without it, every element that
- * transitions `background` (inputs, toggles, buttons) animates between the two
- * palettes, so the switch reads as a smear rather than an instant change.
+ * QueryCraft layers. They come after the coss tokens above so they win; Tailwind
+ * inlines each @import where it stands.
+ * theme.css — AA patches, density re-key, fonts (docs/redesign §4.2)
+ * tokens.css + legacy.css — pre-redesign styles for the CSS Modules
+ * components, deleted in Phase 7
*/
-html[data-theme-switching] *,
-html[data-theme-switching] *::before,
-html[data-theme-switching] *::after {
- transition: none !important;
-}
-
-/* Respect reduced motion preference */
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- scroll-behavior: auto !important;
- }
-}
+@import './theme.css';
+@import './tokens.css';
+@import './legacy.css';
diff --git a/src/styles/legacy.css b/src/styles/legacy.css
new file mode 100644
index 0000000..91e1397
--- /dev/null
+++ b/src/styles/legacy.css
@@ -0,0 +1,171 @@
+/*
+ * Pre-redesign base styles, kept so the CSS Modules components look exactly as
+ * before while they are migrated to coss ui one by one (docs/redesign, §10).
+ *
+ * They sit in Tailwind's `base` layer on purpose: unlayered element rules such
+ * as `button { background: none }` would beat every coss utility. Inside the
+ * layer they still override Tailwind's preflight (same layer, later in order),
+ * and the unlayered CSS Modules still override them, as they did before.
+ *
+ * Deleted in Phase 7 together with tokens.css.
+ */
+@layer base {
+ /* ------------ Reset ------------- */
+
+ *,
+ *::before,
+ *::after {
+ box-sizing: border-box;
+ }
+
+ html,
+ body {
+ margin: 0;
+ padding: 0;
+ }
+
+ /*
+ * Headings and paragraphs keep their UA margins otherwise — at these small
+ * type sizes a stray `margin: 1em 0` silently doubles a card's header height.
+ * Spacing is owned by the layout, never by the text element.
+ */
+ h1,
+ h2,
+ h3,
+ h4,
+ p,
+ figure {
+ margin: 0;
+ }
+
+ html {
+ font-family: var(--font-sans);
+ line-height: var(--line-height-base);
+ color: var(--color-text-primary);
+ background: var(--color-bg-outer);
+ width: var(--qc-popup-width);
+ /* No height here: src/popup/popupHeight.ts sets it from the content (§5.1). */
+ overflow: hidden;
+ -webkit-text-size-adjust: 100%;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ body {
+ /* On body, not html: the root size stays 16px, so coss's rem-based sizes
+ come out as designed. Nothing below uses rem, only em letter-spacing. */
+ font-size: var(--font-size-md);
+ width: 100%;
+ background: var(--color-bg);
+ color: var(--color-text-primary);
+ overflow: hidden;
+ }
+
+ button {
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ }
+
+ input,
+ select,
+ textarea {
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ }
+
+ /*
+ * Tailwind's preflight gives form controls `font: inherit`, which also
+ * inherits line-height (1.5 here) where the UA default was `normal` — the
+ * filter input and the saved-group toggles grew 3–4px. Restored for the
+ * pre-redesign markup only: every coss element carries a data-slot.
+ */
+ :where(button, input, select, textarea):not([data-slot]) {
+ line-height: normal;
+ }
+
+ input::placeholder,
+ textarea::placeholder {
+ color: var(--color-text-faint);
+ }
+
+ /* ------------ Global focus ------------- */
+
+ /*
+ * The reference design signals focus only by lightening an input's border.
+ * That is not enough on its own for keyboard users, so the border treatment
+ * is kept per-component and a :focus-visible ring is layered on top —
+ * pointer interaction stays clean because :focus-visible excludes clicks.
+ */
+ *:focus {
+ outline: none;
+ }
+
+ *:focus-visible {
+ outline: var(--focus-ring-width) solid var(--color-focus);
+ outline-offset: var(--focus-ring-offset);
+ }
+
+ /* ------------ Utilities ------------- */
+
+ /* Visually hide but keep accessible to assistive tech (WCAG compliant pattern). */
+ .visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+
+ /* Scrollbar — subtle, theme-aware */
+ ::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background: var(--color-fill-strong);
+ border-radius: var(--radius-full);
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background: var(--color-text-faint);
+ }
+
+ /*
+ * Applied for one frame while the theme flips. Without it, every element that
+ * transitions `background` (inputs, toggles, buttons) animates between the two
+ * palettes, so the switch reads as a smear rather than an instant change.
+ */
+ html[data-theme-switching] *,
+ html[data-theme-switching] *::before,
+ html[data-theme-switching] *::after {
+ transition: none !important;
+ }
+
+ /* Respect reduced motion preference */
+ @media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+ }
+}
diff --git a/src/styles/theme.css b/src/styles/theme.css
new file mode 100644
index 0000000..147b0bb
--- /dev/null
+++ b/src/styles/theme.css
@@ -0,0 +1,46 @@
+/*
+ * QueryCraft overrides on top of the coss tokens (docs/redesign/README.md §4.2).
+ * Imported after the coss :root / .dark blocks in global.css, so it wins.
+ */
+
+@theme {
+ /* Always desktop density: coss switches to compact sizes at `sm`, and the
+ 380px popup would otherwise get the touch sizes (§4.1). */
+ --breakpoint-sm: 0rem;
+
+ --text-code: 0.8125rem; /* 13px — every monospace value/key/URL */
+ --text-code--line-height: 1.25rem; /* 20px */
+ --ease-out: cubic-bezier(0.2, 0, 0, 1);
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+
+ /* Bundled via @fontsource in src/popup/main.tsx — MV3 blocks font CDNs (§3.3). */
+ --font-sans: 'Inter Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+@theme inline {
+ --color-key-foreground: var(--key-foreground);
+ --color-segment-checked: var(--segment-checked);
+}
+
+/* AA patch layer — every value below exists because the coss default fails WCAG 2.2 AA */
+:root {
+ --ring: var(--color-neutral-500); /* coss neutral-400 = 2.6:1 on white → 4.7:1 */
+ --key-foreground: var(--foreground); /* URL keys; overridden by the accent pipeline */
+ --segment-checked: var(--background);
+}
+
+.dark {
+ /* coss neutral-500: focused vs resting border only 3.1:1 → 5.7:1 */
+ --ring: var(--color-neutral-400);
+ --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 80%, var(--color-white));
+ /* coss 90% mix (#818181) = 4.2–4.4:1 on muted/popover → #8f8f8f ≥ 4.8:1 */
+ --segment-checked: --alpha(var(--color-white) / 8%);
+}
+
+@layer base {
+ /* coss requirement: portaled popups stack inside their own context. */
+ #root {
+ isolation: isolate;
+ }
+}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
index 1aee8f7..b98917b 100644
--- a/src/styles/tokens.css
+++ b/src/styles/tokens.css
@@ -150,14 +150,8 @@
/* ── Theme-agnostic scales ────────────────────────────────────────── */
:root {
- /* Typography. Inter / JetBrains Mono are used when present on the system;
- the extension ships no webfonts (remote font CDNs are blocked by the
- MV3 content security policy), so the fallbacks matter. */
- --font-sans:
- 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
- --font-mono:
- 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-
+ /* Typography. --font-sans / --font-mono live in theme.css's @theme now:
+ an unlayered copy here would override Tailwind's for every element. */
--font-size-2xs: 10.5px;
--font-size-xs: 11px;
--font-size-sm: 11.5px;
@@ -207,11 +201,9 @@
--min-target: 24px;
/*
- * Popup dimensions. The handoff specifies 380x640, but Chrome hard-caps
- * extension popups at 800x600 — a 640px panel would simply be clipped.
- * The layout is a flex column with a scrolling middle, so 600 is the
- * faithful rendering of the design within the platform limit.
+ * Popup width. The height follows the content up to Chrome's 600px cap
+ * (src/popup/popupHeight.ts). Prefixed: Base UI sizes its popups with
+ * --popup-width / --popup-height, and an inherited :root value would win.
*/
- --popup-width: 380px;
- --popup-height: 600px;
+ --qc-popup-width: 380px;
}
diff --git a/src/types/index.ts b/src/types/index.ts
index 03d6dee..732e662 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -49,10 +49,12 @@ export interface Group {
/**
* Chosen accent color as a hex string, or `null` for the monochrome default.
- * The theme itself is not a preference — it always follows the OS.
*/
export type AccentColor = string | null;
+/** Theme setting: follow the OS, or force one scheme for QueryCraft only. */
+export type ThemePreference = 'system' | 'light' | 'dark';
+
/** Special state when the active tab's URL is a browser-internal page we can't edit. */
export type TabLoadState =
| { status: 'loading' }
diff --git a/store-assets/redesign/01-edit-params.png b/store-assets/redesign/01-edit-params.png
new file mode 100644
index 0000000..d9ddff1
Binary files /dev/null and b/store-assets/redesign/01-edit-params.png differ
diff --git a/store-assets/redesign/02-add-keys.png b/store-assets/redesign/02-add-keys.png
new file mode 100644
index 0000000..d6b84b6
Binary files /dev/null and b/store-assets/redesign/02-add-keys.png differ
diff --git a/store-assets/redesign/03-saved-urls.png b/store-assets/redesign/03-saved-urls.png
new file mode 100644
index 0000000..441dafd
Binary files /dev/null and b/store-assets/redesign/03-saved-urls.png differ
diff --git a/store-assets/redesign/04-keyboard.png b/store-assets/redesign/04-keyboard.png
new file mode 100644
index 0000000..33f766b
Binary files /dev/null and b/store-assets/redesign/04-keyboard.png differ
diff --git a/store-assets/redesign/05-theme-accent.png b/store-assets/redesign/05-theme-accent.png
new file mode 100644
index 0000000..472de8d
Binary files /dev/null and b/store-assets/redesign/05-theme-accent.png differ
diff --git a/store-assets/redesign/marquee-promo-1400x560.png b/store-assets/redesign/marquee-promo-1400x560.png
new file mode 100644
index 0000000..a9017e8
Binary files /dev/null and b/store-assets/redesign/marquee-promo-1400x560.png differ
diff --git a/store-assets/redesign/small-promo-440x280.png b/store-assets/redesign/small-promo-440x280.png
new file mode 100644
index 0000000..22d593c
Binary files /dev/null and b/store-assets/redesign/small-promo-440x280.png differ
diff --git a/vite.config.ts b/vite.config.ts
index a289048..6d04a22 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,13 +1,14 @@
// vitest/config's defineConfig is a superset of vite's — it types the `test` key.
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json' with { type: 'json' };
import path from 'node:path';
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react(), crx({ manifest })],
+ plugins: [react(), tailwindcss(), crx({ manifest })],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),