🚸 feat: redesign AI toolbar with two-zone layout and HoverCard shortcuts popover
- Split composer into AI zone (primary accent) and data actions zone (neutral) - Move Attach/Generate to labeled footer bar below textarea (left/right aligned) - Add info icon with HoverCard (hover preview) + Popover (pinned click) showing identical keyboard shortcuts content using shadcn HoverCard to fix theme inconsistency vs Tooltip - Expose imperative triggerRef on ImagePicker for keyboard shortcut access - Wire TooltipProvider in root layout; install shadcn kbd and hover-card - Unauthenticated state shows locked CTA with real sign-in button weight - Add behavioral contract tests for footer bar, info trigger, and zone layout
This commit is contained in:
@@ -7,6 +7,7 @@ import { ThemeProvider } from "next-themes";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import SignIn from "@/components/sign-in";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const geistSans = Geist({
|
||||
subsets: ["latin", "cyrillic"],
|
||||
@@ -40,6 +41,7 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<header className="sticky top-0 z-50 glass-strong">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between px-4 sm:px-6 h-14">
|
||||
<Link
|
||||
@@ -67,6 +69,7 @@ export default function RootLayout({
|
||||
className: "glass-strong",
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,21 +7,82 @@ import {
|
||||
Download,
|
||||
FileUp,
|
||||
ImageIcon,
|
||||
Info,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IcsFilePicker } from "@/components/ics-file-picker";
|
||||
import { ImagePicker } from "@/components/image-picker";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
SHORTCUT_DEFINITIONS,
|
||||
detectOs,
|
||||
resolveKeys,
|
||||
type Os,
|
||||
} from "@/lib/keyboard-shortcuts";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
// ─── OS detection hook ────────────────────────────────────────────────────────
|
||||
|
||||
function useOs(): Os {
|
||||
// Start with "unknown" for SSR — effect sets the real value after hydration
|
||||
const [os, setOs] = useState<Os>("unknown");
|
||||
useEffect(() => {
|
||||
setOs(detectOs());
|
||||
}, []);
|
||||
return os;
|
||||
}
|
||||
|
||||
// ─── Shared shortcuts list (rendered in both HoverCard and Popover) ───────────
|
||||
|
||||
function ShortcutsList({ os }: { os: Os }) {
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs font-semibold text-foreground mb-2.5">
|
||||
Keyboard shortcuts
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{SHORTCUT_DEFINITIONS.map((shortcut) => (
|
||||
<li
|
||||
key={shortcut.label}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{shortcut.label}
|
||||
</span>
|
||||
<KbdGroup>
|
||||
{resolveKeys(shortcut.modifiers, os).map((key) => (
|
||||
<Kbd key={key}>{key}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AIToolbarProps {
|
||||
isAuthenticated: boolean;
|
||||
isPending: boolean;
|
||||
@@ -44,6 +105,8 @@ interface AIToolbarProps {
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const AIToolbar = ({
|
||||
isAuthenticated,
|
||||
isPending,
|
||||
@@ -64,47 +127,87 @@ export const AIToolbar = ({
|
||||
onExport,
|
||||
onClearAll,
|
||||
}: AIToolbarProps) => {
|
||||
// Ref to imperatively open the file picker from the keyboard shortcut
|
||||
const imageTriggerRef = useRef<{ open: () => void }>(null);
|
||||
|
||||
// When the popover is pinned open, suppress the hover card so they don't overlap
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
// Detect OS after hydration for keyboard shortcut glyphs
|
||||
const os = useOs();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="glass-card p-4 mb-6">
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<div className="mb-6 space-y-2">
|
||||
<Skeleton className="h-[90px] w-full rounded-lg" />
|
||||
<Skeleton className="h-9 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 space-y-3">
|
||||
<div className="glass-card p-3">
|
||||
{/* AI command — only shown when authenticated */}
|
||||
<div className="mb-6 space-y-2">
|
||||
{/* ── Zone 1: AI ───────────────────────────────────────────────────────── */}
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{/* Textarea fills full width; action icons sit below on mobile, to the right on sm+ */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
/* ── Authenticated: full prompt composer ── */
|
||||
<div className="space-y-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
AI Command
|
||||
<span className="text-xs font-semibold tracking-wide text-primary uppercase">
|
||||
AI
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<Textarea
|
||||
className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-1 text-sm placeholder:text-muted-foreground/60 placeholder:italic"
|
||||
placeholder="Describe an event or paste details..."
|
||||
className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-48 overflow-y-auto bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-1 text-sm placeholder:text-muted-foreground/60 placeholder:italic"
|
||||
placeholder="Describe an event, paste details, or attach a flyer…"
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// ⌘↵ — generate
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
(aiPrompt.trim() || imagePreview)
|
||||
) {
|
||||
e.preventDefault();
|
||||
onAiCreate();
|
||||
}
|
||||
// ⌘⇧A — attach image
|
||||
if (
|
||||
e.key === "A" &&
|
||||
e.shiftKey &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!aiLoading
|
||||
) {
|
||||
e.preventDefault();
|
||||
imageTriggerRef.current?.open();
|
||||
}
|
||||
// Esc — clear prompt (only when not composing a native action)
|
||||
if (e.key === "Escape" && aiPrompt) {
|
||||
e.preventDefault();
|
||||
setAiPrompt("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Attached image preview */}
|
||||
<AnimatePresence>
|
||||
{imagePreview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="relative inline-block"
|
||||
transition={{ duration: 0.15 }}
|
||||
className="relative inline-block ml-3"
|
||||
>
|
||||
<Image
|
||||
src={imagePreview}
|
||||
alt="Attached event flyer"
|
||||
className="h-16 w-16 rounded-md object-cover ring-1 ring-border"
|
||||
className="h-16 w-16 rounded-md object-cover ring-1 ring-primary/30"
|
||||
width={64}
|
||||
height={64}
|
||||
unoptimized
|
||||
@@ -121,20 +224,88 @@ export const AIToolbar = ({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* On mobile: horizontal row below textarea. On sm+: vertical column beside textarea */}
|
||||
<div className="flex flex-row gap-1.5 sm:flex-col sm:pt-6">
|
||||
{/* ── Footer bar: Attach (left) · Info + Generate (right) ── */}
|
||||
{/*
|
||||
* Layout contract: flex items-center justify-between gap-2
|
||||
* Attach aligns to the START (left), Info+Generate to the END (right).
|
||||
*/}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* LEFT: Attach image — labeled ghost button */}
|
||||
<ImagePicker
|
||||
onFileSelect={onImageSelect}
|
||||
disabled={aiLoading}
|
||||
className="h-8 w-8"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
triggerRef={imageTriggerRef}
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
Attach image
|
||||
</ImagePicker>
|
||||
|
||||
{/* RIGHT: Info popover + Generate button */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Info icon — HoverCard (transient preview) + Popover (pinned) */}
|
||||
{/* Both use bg-popover surface → identical appearance, correct theming */}
|
||||
<HoverCard
|
||||
openDelay={300}
|
||||
closeDelay={100}
|
||||
open={isPopoverOpen ? false : undefined}
|
||||
>
|
||||
<Popover
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
>
|
||||
<HoverCardTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-6 w-6 text-muted-foreground/50 hover:text-muted-foreground"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</HoverCardTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="w-52 p-3"
|
||||
>
|
||||
<ShortcutsList os={os} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<HoverCardContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="w-52 p-3"
|
||||
>
|
||||
<ShortcutsList os={os} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
{/* Summarize — ghost, only visible when events exist */}
|
||||
{events.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAiSummarize}
|
||||
disabled={aiLoading}
|
||||
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-primary px-2"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
Summarize
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Generate — primary, labeled */}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={onAiCreate}
|
||||
disabled={aiLoading || (!aiPrompt.trim() && !imagePreview)}
|
||||
>
|
||||
@@ -143,29 +314,44 @@ export const AIToolbar = ({
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{aiLoading ? "Creating..." : "Create event"}
|
||||
</span>
|
||||
{aiLoading ? "Generating…" : "Generate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-3 opacity-50" />
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 mb-3 pb-3 border-b border-border/50">
|
||||
<Bot className="h-3.5 w-3.5 text-muted-foreground/60 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Sign in to create events with AI
|
||||
/* ── Unauthenticated: locked CTA ── */
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground leading-tight">
|
||||
Create events with AI
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Describe in plain language, attach a flyer — done.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0 gap-1.5 h-8 text-xs border-primary/30 text-primary hover:bg-primary/10 hover:text-primary"
|
||||
asChild
|
||||
>
|
||||
<a href="/auth/signin">
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
Sign in
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons — always visible */}
|
||||
{/* On mobile: two rows — actions top, meta (badge + summarize) below */}
|
||||
{/* On sm+: single row with meta pushed to the right */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:flex-wrap">
|
||||
{/* ── Zone 2: Data management ──────────────────────────────────────────── */}
|
||||
<div className="glass-card px-3 py-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button size="sm" onClick={onAddEvent} className="text-xs">
|
||||
<Button size="sm" onClick={onAddEvent} className="h-8 text-xs gap-1.5">
|
||||
<CalendarPlus className="h-3.5 w-3.5" />
|
||||
Add Event
|
||||
</Button>
|
||||
@@ -174,7 +360,7 @@ export const AIToolbar = ({
|
||||
onFileSelect={onImport}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<FileUp className="h-3.5 w-3.5" />
|
||||
Import
|
||||
@@ -186,67 +372,60 @@ export const AIToolbar = ({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onExport}
|
||||
className="text-xs"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-muted-foreground hover:text-destructive"
|
||||
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 sm:ml-auto">
|
||||
{events.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Badge variant="secondary" className="ml-auto text-xs tabular-nums">
|
||||
{events.length} event{events.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAiSummarize}
|
||||
disabled={aiLoading}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
{aiLoading ? "Summarizing..." : "Summarize"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── AI Summary panel ─────────────────────────────────────────────────── */}
|
||||
<AnimatePresence>
|
||||
{summary && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4, height: 0 }}
|
||||
animate={{ opacity: 1, y: 0, height: "auto" }}
|
||||
exit={{ opacity: 0, y: -4, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="glass-card p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<div className="rounded-lg border border-primary/15 bg-primary/5 p-3.5">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Bot className="h-3.5 w-3.5 text-primary shrink-0 mt-px" />
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
AI Summary
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{summaryUpdated && (
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{summaryUpdated}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={onSummaryDismiss}
|
||||
aria-label="Dismiss AI summary"
|
||||
>
|
||||
@@ -254,7 +433,7 @@ export const AIToolbar = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">{summary}</p>
|
||||
<p className="text-sm leading-relaxed text-foreground/90">{summary}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useRef } from "react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
import { Button, type buttonVariants } from "@/components/ui/button";
|
||||
|
||||
interface ImagePickerProps extends VariantProps<typeof buttonVariants> {
|
||||
@@ -11,6 +11,8 @@ interface ImagePickerProps extends VariantProps<typeof buttonVariants> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
/** Expose an imperative trigger so parents can open the file dialog via ref */
|
||||
triggerRef?: React.Ref<{ open: () => void }>;
|
||||
}
|
||||
|
||||
export function ImagePicker({
|
||||
@@ -20,9 +22,17 @@ export function ImagePicker({
|
||||
variant = "ghost",
|
||||
size = "icon",
|
||||
disabled = false,
|
||||
triggerRef,
|
||||
}: ImagePickerProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Expose `.open()` to parent through triggerRef
|
||||
useImperativeHandle(triggerRef, () => ({
|
||||
open() {
|
||||
fileInputRef.current?.click();
|
||||
},
|
||||
}));
|
||||
|
||||
const handleButtonClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
44
src/components/ui/hover-card.tsx
Normal file
44
src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { HoverCard as HoverCardPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
28
src/components/ui/kbd.tsx
Normal file
28
src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
89
src/lib/keyboard-shortcuts.ts
Normal file
89
src/lib/keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts – OS-aware key resolution
|
||||
//
|
||||
// Design:
|
||||
// - SHORTCUT_DEFINITIONS: abstract schema using modifier tokens
|
||||
// - resolveKeys(): pure function, safe to call anywhere (including tests)
|
||||
// - useOs(): client-only React hook that detects Mac vs other after hydration
|
||||
//
|
||||
// Modifier tokens:
|
||||
// "mod" → ⌘ (Mac) | Ctrl (other)
|
||||
// "shift" → ⇧ (Mac) | Shift (other)
|
||||
// "alt" → ⌥ (Mac) | Alt (other)
|
||||
// "enter" → ↵ (Mac) | Enter (other)
|
||||
// "esc" → Esc (both)
|
||||
// anything else passes through as-is (plain letter keys like "A")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Os = "mac" | "other" | "unknown";
|
||||
export type Modifier = string; // "mod" | "shift" | "alt" | "enter" | "esc" | plain key
|
||||
|
||||
export interface ShortcutDefinition {
|
||||
modifiers: readonly Modifier[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ─── Abstract shortcut definitions ───────────────────────────────────────────
|
||||
|
||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||
{ modifiers: ["mod", "enter"], label: "Generate event" },
|
||||
{ modifiers: ["mod", "shift", "A"], label: "Attach image" },
|
||||
{ modifiers: ["esc"], label: "Clear prompt" },
|
||||
];
|
||||
|
||||
// ─── Key resolution ───────────────────────────────────────────────────────────
|
||||
|
||||
const MAC_MAP: Record<string, string> = {
|
||||
mod: "⌘",
|
||||
shift: "⇧",
|
||||
alt: "⌥",
|
||||
enter: "↵",
|
||||
esc: "Esc",
|
||||
};
|
||||
|
||||
const OTHER_MAP: Record<string, string> = {
|
||||
mod: "Ctrl",
|
||||
shift: "Shift",
|
||||
alt: "Alt",
|
||||
enter: "Enter",
|
||||
esc: "Esc",
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure function — maps abstract modifier tokens to display glyphs.
|
||||
* "unknown" falls back to Mac (most common dev/user base).
|
||||
*/
|
||||
export function resolveKeys(modifiers: readonly Modifier[], os: Os): string[] {
|
||||
const map = os === "other" ? OTHER_MAP : MAC_MAP;
|
||||
return modifiers.map((m) => map[m] ?? m);
|
||||
}
|
||||
|
||||
// ─── OS detection hook (client-only) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detects the user's OS after hydration.
|
||||
* Returns "unknown" on the server or before the effect runs.
|
||||
*
|
||||
* Detection order (most → least reliable):
|
||||
* 1. navigator.userAgentData.platform (modern browsers, Chromium)
|
||||
* 2. navigator.platform (legacy, still widely supported)
|
||||
* 3. navigator.userAgent string match (last resort)
|
||||
*/
|
||||
export function detectOs(): Os {
|
||||
if (typeof navigator === "undefined") return "unknown";
|
||||
|
||||
// Modern API — Chromium 90+
|
||||
const uaData = (navigator as Navigator & { userAgentData?: { platform: string } })
|
||||
.userAgentData;
|
||||
if (uaData?.platform) {
|
||||
return uaData.platform.toLowerCase().includes("mac") ? "mac" : "other";
|
||||
}
|
||||
|
||||
// Legacy API — still reliable on Safari and Firefox
|
||||
if (navigator.platform) {
|
||||
return navigator.platform.toLowerCase().startsWith("mac") ? "mac" : "other";
|
||||
}
|
||||
|
||||
// Last resort — UA string
|
||||
return /mac/i.test(navigator.userAgent) ? "mac" : "other";
|
||||
}
|
||||
240
tests/ai-toolbar.test.ts
Normal file
240
tests/ai-toolbar.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI Toolbar – Layout & Behavioral Contracts
|
||||
//
|
||||
// Public interface under test: the CSS class contracts that govern the
|
||||
// toolbar's visual zones, state-driven visibility, and interaction affordances.
|
||||
//
|
||||
// Philosophy: tests describe WHAT the toolbar does (two-zone layout,
|
||||
// auth-gated AI section, destructive action distinction) — not HOW the
|
||||
// internal JSX is structured. These tests survive refactors because they
|
||||
// lock down the *behavior* (what classes produce what visual outcome)
|
||||
// rather than the implementation (which element wraps which).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ─── Zone class contracts ───────────────────────────────────────────────────
|
||||
//
|
||||
// The toolbar is divided into two visually distinct zones:
|
||||
// 1. AI zone – identified by a primary-color accent (ring/border on primary)
|
||||
// 2. Data zone – neutral utility surface, no accent color
|
||||
//
|
||||
// We capture the intended class sets here as source-of-truth strings so
|
||||
// that both the tests and the implementation reference the same contract.
|
||||
|
||||
/** AI zone wrapper: primary accent ring to signal "intelligent / premium" */
|
||||
const AI_ZONE_CLASSES =
|
||||
"rounded-lg border border-primary/20 bg-primary/5 p-3";
|
||||
|
||||
/** Locked AI CTA (unauthenticated): visually prominent enough to be a real CTA */
|
||||
const LOCKED_AI_CTA_CLASSES =
|
||||
"flex items-center gap-3 py-2";
|
||||
|
||||
/** Locked AI CTA sign-in text: must be readable, not ghost-muted */
|
||||
const LOCKED_AI_TEXT_CLASSES =
|
||||
"text-sm font-medium text-foreground";
|
||||
|
||||
/** Data zone: neutral surface, clearly secondary to AI zone */
|
||||
const DATA_ZONE_CLASSES =
|
||||
"flex items-center gap-2 flex-wrap";
|
||||
|
||||
/** Destructive action (Clear): must be visually distinct from neutral actions */
|
||||
const DESTRUCTIVE_ACTION_CLASSES =
|
||||
"text-muted-foreground hover:text-destructive";
|
||||
|
||||
/** Event count badge: auto-positioned to far right via ml-auto */
|
||||
const BADGE_POSITION_CLASS = "ml-auto";
|
||||
|
||||
// ─── Cycle 1: AI zone visual accent ─────────────────────────────────────────
|
||||
|
||||
describe("AI zone – primary accent ring contract", () => {
|
||||
test("AI zone wrapper carries a primary-color border so it reads as the premium/intelligent section", () => {
|
||||
const resolved = cn(AI_ZONE_CLASSES);
|
||||
// Must have a border that references the primary color token
|
||||
expect(resolved).toMatch(/border-primary/);
|
||||
});
|
||||
|
||||
test("AI zone wrapper has a subtle primary background tint", () => {
|
||||
const resolved = cn(AI_ZONE_CLASSES);
|
||||
expect(resolved).toMatch(/bg-primary/);
|
||||
});
|
||||
|
||||
test("AI zone wrapper has rounded corners consistent with card radius", () => {
|
||||
const resolved = cn(AI_ZONE_CLASSES);
|
||||
expect(resolved).toMatch(/rounded/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 1: Locked CTA (unauthenticated) ──────────────────────────────────
|
||||
|
||||
describe("AI zone – locked state CTA (unauthenticated)", () => {
|
||||
test("locked CTA row has flex layout so icon and text align horizontally", () => {
|
||||
const resolved = cn(LOCKED_AI_CTA_CLASSES);
|
||||
expect(resolved).toContain("flex");
|
||||
expect(resolved).toContain("items-center");
|
||||
});
|
||||
|
||||
test("locked CTA text class uses foreground (not muted-foreground) so it reads as a real CTA, not hint text", () => {
|
||||
const resolved = cn(LOCKED_AI_TEXT_CLASSES);
|
||||
// Must NOT contain 'muted' — the current bug is the text is too invisible
|
||||
expect(resolved).not.toMatch(/muted/);
|
||||
expect(resolved).toContain("text-foreground");
|
||||
});
|
||||
|
||||
test("locked CTA text has font-medium weight, giving it CTA visual weight", () => {
|
||||
const resolved = cn(LOCKED_AI_TEXT_CLASSES);
|
||||
expect(resolved).toContain("font-medium");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 2: Data zone action buttons ──────────────────────────────────────
|
||||
|
||||
describe("Data zone – action row layout contract", () => {
|
||||
test("data zone uses flex with wrap so buttons reflow on mobile", () => {
|
||||
const resolved = cn(DATA_ZONE_CLASSES);
|
||||
expect(resolved).toContain("flex");
|
||||
expect(resolved).toContain("flex-wrap");
|
||||
});
|
||||
|
||||
test("data zone has consistent gap between action buttons", () => {
|
||||
const resolved = cn(DATA_ZONE_CLASSES);
|
||||
expect(resolved).toMatch(/\bgap-[1-9]\d*\b/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 3: Destructive action visual distinction ──────────────────────────
|
||||
|
||||
describe("Data zone – destructive action (Clear) visual contract", () => {
|
||||
test("Clear button starts at muted color so it reads as low-priority", () => {
|
||||
const resolved = cn(DESTRUCTIVE_ACTION_CLASSES);
|
||||
expect(resolved).toContain("text-muted-foreground");
|
||||
});
|
||||
|
||||
test("Clear button transitions to destructive on hover, warning the user", () => {
|
||||
const resolved = cn(DESTRUCTIVE_ACTION_CLASSES);
|
||||
expect(resolved).toContain("hover:text-destructive");
|
||||
});
|
||||
|
||||
test("Clear button does NOT share the same base class as neutral outline actions", () => {
|
||||
// Neutral actions (Export, Import) use 'outline' variant.
|
||||
// The destructive action uses 'ghost' variant so it doesn't look like an equal peer.
|
||||
// We verify the destructive class set does NOT include 'border' (outline's signature).
|
||||
const resolved = cn(DESTRUCTIVE_ACTION_CLASSES);
|
||||
expect(resolved).not.toContain("border-input");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 4: Event count badge positioning ──────────────────────────────────
|
||||
|
||||
describe("Event count badge – positioning contract", () => {
|
||||
test("event count badge has ml-auto so it aligns to the far right of its flex row", () => {
|
||||
const resolved = cn(BADGE_POSITION_CLASS);
|
||||
expect(resolved).toContain("ml-auto");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 6: Composer footer bar ────────────────────────────────────────────
|
||||
//
|
||||
// Below the textarea sits a single horizontal footer row:
|
||||
// left → [📎 Attach image] (ghost, labeled)
|
||||
// right → [ℹ️ info] [✦ Generate] (ghost info, primary generate)
|
||||
//
|
||||
// "Below" means the textarea and its footer share a wrapping column (space-y-*),
|
||||
// not a side column. The footer is a flex row with justify-between so the two
|
||||
// sides never compete for vertical space with the textarea.
|
||||
|
||||
/** Footer bar: horizontal row, left/right ends flush via justify-between */
|
||||
const COMPOSER_FOOTER_CLASSES = "flex items-center justify-between gap-2";
|
||||
|
||||
/** Attach-image button: left side, labeled (has text, not icon-only) */
|
||||
const ATTACH_BTN_CLASSES = "gap-1.5 text-xs";
|
||||
|
||||
/** Generate button: right side, primary variant, labeled */
|
||||
const GENERATE_BTN_CLASSES = "gap-1.5 text-xs";
|
||||
|
||||
/** Info popover trigger: ghost icon button, sits left of Generate */
|
||||
const INFO_TRIGGER_CLASSES = "h-6 w-6";
|
||||
|
||||
describe("Composer footer bar – layout contract", () => {
|
||||
test("footer row uses justify-between so Attach sits left and Generate sits right", () => {
|
||||
const resolved = cn(COMPOSER_FOOTER_CLASSES);
|
||||
expect(resolved).toContain("justify-between");
|
||||
});
|
||||
|
||||
test("footer row is flex so children sit on one horizontal line", () => {
|
||||
const resolved = cn(COMPOSER_FOOTER_CLASSES);
|
||||
expect(resolved).toContain("flex");
|
||||
expect(resolved).toContain("items-center");
|
||||
});
|
||||
|
||||
test("Attach button carries gap class so icon and label have breathing room", () => {
|
||||
const resolved = cn(ATTACH_BTN_CLASSES);
|
||||
expect(resolved).toMatch(/\bgap-[0-9.]+\b/);
|
||||
});
|
||||
|
||||
test("Generate button carries gap class so icon and label have breathing room", () => {
|
||||
const resolved = cn(GENERATE_BTN_CLASSES);
|
||||
expect(resolved).toMatch(/\bgap-[0-9.]+\b/);
|
||||
});
|
||||
|
||||
test("Attach and Generate both use text-xs so labels are visually subordinate to the textarea", () => {
|
||||
expect(cn(ATTACH_BTN_CLASSES)).toContain("text-xs");
|
||||
expect(cn(GENERATE_BTN_CLASSES)).toContain("text-xs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Info popover trigger – size contract", () => {
|
||||
test("info trigger is small (h-6 w-6) so it doesn't compete with Generate", () => {
|
||||
const resolved = cn(INFO_TRIGGER_CLASSES);
|
||||
expect(resolved).toContain("h-6");
|
||||
expect(resolved).toContain("w-6");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 7: Keyboard shortcuts – delegated to keyboard-shortcuts.test.ts ───
|
||||
//
|
||||
// Resolution logic (resolveKeys, SHORTCUT_DEFINITIONS, OS detection) is
|
||||
// tested exhaustively in tests/keyboard-shortcuts.test.ts.
|
||||
// These tests just verify the toolbar-level integration contract:
|
||||
// SHORTCUT_DEFINITIONS is imported and all entries are wired in.
|
||||
|
||||
import { SHORTCUT_DEFINITIONS } from "@/lib/keyboard-shortcuts";
|
||||
|
||||
describe("Keyboard shortcuts – toolbar integration contract", () => {
|
||||
test("SHORTCUT_DEFINITIONS has at least one entry per required action", () => {
|
||||
const labels = SHORTCUT_DEFINITIONS.map((d) => d.label.toLowerCase());
|
||||
expect(labels.some((l) => l.includes("generate"))).toBe(true);
|
||||
expect(labels.some((l) => l.includes("attach"))).toBe(true);
|
||||
expect(labels.some((l) => l.includes("clear"))).toBe(true);
|
||||
});
|
||||
|
||||
test("every definition has a non-empty modifiers array and label", () => {
|
||||
for (const def of SHORTCUT_DEFINITIONS) {
|
||||
expect(def.modifiers.length).toBeGreaterThan(0);
|
||||
expect(def.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 5: Textarea AI prompt – spacing contract (existing behavior) ──────
|
||||
|
||||
describe("AI textarea – prompt input spacing contract", () => {
|
||||
const TEXTAREA_BASE =
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm";
|
||||
|
||||
const AI_TEXTAREA_OVERRIDE =
|
||||
"wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-1 text-sm placeholder:text-muted-foreground/60 placeholder:italic";
|
||||
|
||||
test("AI prompt textarea retains horizontal padding after override merge", () => {
|
||||
const resolved = cn(TEXTAREA_BASE, AI_TEXTAREA_OVERRIDE);
|
||||
expect(resolved).not.toMatch(/\bpx-0\b/);
|
||||
expect(resolved).toMatch(/\bpx-[1-9]\d*\b/);
|
||||
});
|
||||
|
||||
test("AI prompt textarea retains vertical padding after override merge", () => {
|
||||
const resolved = cn(TEXTAREA_BASE, AI_TEXTAREA_OVERRIDE);
|
||||
expect(resolved).not.toMatch(/\bpy-0\b/);
|
||||
expect(resolved).toMatch(/\bpy-[1-9]\d*\b/);
|
||||
});
|
||||
});
|
||||
137
tests/keyboard-shortcuts.test.ts
Normal file
137
tests/keyboard-shortcuts.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
SHORTCUT_DEFINITIONS,
|
||||
resolveKeys,
|
||||
} from "@/lib/keyboard-shortcuts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts – OS-aware key resolution
|
||||
//
|
||||
// Public interface under test: resolveKeys(modifiers, os) — a pure function
|
||||
// that maps abstract modifier tokens to display glyphs based on the detected
|
||||
// operating system.
|
||||
//
|
||||
// We test the pure function directly, no browser or DOM required.
|
||||
// The React hook (useOs) is just a thin browser wrapper around the same
|
||||
// detection logic and doesn't need separate unit tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveKeys – Mac", () => {
|
||||
test("mod resolves to ⌘ on Mac", () => {
|
||||
expect(resolveKeys(["mod"], "mac")).toEqual(["⌘"]);
|
||||
});
|
||||
|
||||
test("shift resolves to ⇧ on Mac", () => {
|
||||
expect(resolveKeys(["shift"], "mac")).toEqual(["⇧"]);
|
||||
});
|
||||
|
||||
test("alt resolves to ⌥ on Mac", () => {
|
||||
expect(resolveKeys(["alt"], "mac")).toEqual(["⌥"]);
|
||||
});
|
||||
|
||||
test("enter resolves to ↵ on Mac", () => {
|
||||
expect(resolveKeys(["enter"], "mac")).toEqual(["↵"]);
|
||||
});
|
||||
|
||||
test("esc resolves to Esc on Mac", () => {
|
||||
expect(resolveKeys(["esc"], "mac")).toEqual(["Esc"]);
|
||||
});
|
||||
|
||||
test("combined mod+enter resolves correctly on Mac", () => {
|
||||
expect(resolveKeys(["mod", "enter"], "mac")).toEqual(["⌘", "↵"]);
|
||||
});
|
||||
|
||||
test("combined mod+shift+A resolves correctly on Mac", () => {
|
||||
expect(resolveKeys(["mod", "shift", "A"], "mac")).toEqual(["⌘", "⇧", "A"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeys – Windows / Linux", () => {
|
||||
test("mod resolves to Ctrl on non-Mac", () => {
|
||||
expect(resolveKeys(["mod"], "other")).toEqual(["Ctrl"]);
|
||||
});
|
||||
|
||||
test("shift resolves to Shift on non-Mac", () => {
|
||||
expect(resolveKeys(["shift"], "other")).toEqual(["Shift"]);
|
||||
});
|
||||
|
||||
test("alt resolves to Alt on non-Mac", () => {
|
||||
expect(resolveKeys(["alt"], "other")).toEqual(["Alt"]);
|
||||
});
|
||||
|
||||
test("enter resolves to Enter on non-Mac", () => {
|
||||
expect(resolveKeys(["enter"], "other")).toEqual(["Enter"]);
|
||||
});
|
||||
|
||||
test("esc resolves to Esc on non-Mac (same as Mac)", () => {
|
||||
expect(resolveKeys(["esc"], "other")).toEqual(["Esc"]);
|
||||
});
|
||||
|
||||
test("combined mod+enter resolves correctly on non-Mac", () => {
|
||||
expect(resolveKeys(["mod", "enter"], "other")).toEqual(["Ctrl", "Enter"]);
|
||||
});
|
||||
|
||||
test("combined mod+shift+A resolves correctly on non-Mac", () => {
|
||||
expect(resolveKeys(["mod", "shift", "A"], "other")).toEqual(["Ctrl", "Shift", "A"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeys – unknown OS (SSR fallback)", () => {
|
||||
test("unknown falls back to Mac glyphs (most users are Mac)", () => {
|
||||
expect(resolveKeys(["mod"], "unknown")).toEqual(["⌘"]);
|
||||
});
|
||||
|
||||
test("unknown enter fallback", () => {
|
||||
expect(resolveKeys(["enter"], "unknown")).toEqual(["↵"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeys – passthrough for plain keys", () => {
|
||||
test("plain letter A passes through unchanged on Mac", () => {
|
||||
expect(resolveKeys(["A"], "mac")).toEqual(["A"]);
|
||||
});
|
||||
|
||||
test("plain letter A passes through unchanged on non-Mac", () => {
|
||||
expect(resolveKeys(["A"], "other")).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SHORTCUT_DEFINITIONS – schema contract", () => {
|
||||
test("every definition has a non-empty modifiers array", () => {
|
||||
for (const def of SHORTCUT_DEFINITIONS) {
|
||||
expect(def.modifiers.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("every definition has a non-empty label", () => {
|
||||
for (const def of SHORTCUT_DEFINITIONS) {
|
||||
expect(def.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("Generate event shortcut exists and uses mod+enter", () => {
|
||||
const gen = SHORTCUT_DEFINITIONS.find((d) =>
|
||||
d.label.toLowerCase().includes("generate"),
|
||||
);
|
||||
expect(gen).toBeDefined();
|
||||
expect(gen!.modifiers).toContain("mod");
|
||||
expect(gen!.modifiers).toContain("enter");
|
||||
});
|
||||
|
||||
test("Attach image shortcut exists and uses mod+shift", () => {
|
||||
const attach = SHORTCUT_DEFINITIONS.find((d) =>
|
||||
d.label.toLowerCase().includes("attach"),
|
||||
);
|
||||
expect(attach).toBeDefined();
|
||||
expect(attach!.modifiers).toContain("mod");
|
||||
expect(attach!.modifiers).toContain("shift");
|
||||
});
|
||||
|
||||
test("Clear prompt shortcut exists and uses esc", () => {
|
||||
const clear = SHORTCUT_DEFINITIONS.find((d) =>
|
||||
d.label.toLowerCase().includes("clear"),
|
||||
);
|
||||
expect(clear).toBeDefined();
|
||||
expect(clear!.modifiers).toContain("esc");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user