🚸 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:
2026-04-08 13:08:36 -04:00
parent 650d1d5f95
commit 722c0f0f7d
8 changed files with 881 additions and 151 deletions

View File

@@ -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"],
@@ -34,12 +35,13 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased min-h-screen flex flex-col`}
>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<ThemeProvider
attribute="class"
defaultTheme="dark"
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
@@ -60,14 +62,15 @@ export default function RootLayout({
{children}
</div>
</main>
<Toaster
closeButton
richColors
toastOptions={{
className: "glass-strong",
}}
/>
</ThemeProvider>
<Toaster
closeButton
richColors
toastOptions={{
className: "glass-strong",
}}
/>
</TooltipProvider>
</ThemeProvider>
</body>
</html>
);

View File

@@ -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,77 +127,185 @@ 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">
<Sparkles className="h-3.5 w-3.5 text-primary shrink-0" />
<span className="text-xs font-medium text-muted-foreground">
AI Command
</span>
</div>
<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..."
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
/>
<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"
>
<Image
src={imagePreview}
alt="Attached event flyer"
className="h-16 w-16 rounded-md object-cover ring-1 ring-border"
width={64}
height={64}
unoptimized
/>
<Button
variant="destructive"
size="icon"
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full"
onClick={onImageClear}
aria-label="Remove image"
>
<X className="h-2.5 w-2.5" />
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
/* ── 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-semibold tracking-wide text-primary uppercase">
AI
</span>
</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">
<ImagePicker
onFileSelect={onImageSelect}
disabled={aiLoading}
className="h-8 w-8"
{/* Textarea */}
<Textarea
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 }}
transition={{ duration: 0.15 }}
className="relative inline-block ml-3"
>
<ImageIcon className="h-3.5 w-3.5" />
</ImagePicker>
<Image
src={imagePreview}
alt="Attached event flyer"
className="h-16 w-16 rounded-md object-cover ring-1 ring-primary/30"
width={64}
height={64}
unoptimized
/>
<Button
variant="destructive"
size="icon"
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full"
onClick={onImageClear}
aria-label="Remove image"
>
<X className="h-2.5 w-2.5" />
</Button>
</motion.div>
)}
</AnimatePresence>
{/* ── 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}
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-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="icon"
className="h-8 w-8"
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={onAiCreate}
disabled={aiLoading || (!aiPrompt.trim() && !imagePreview)}
>
@@ -143,110 +314,118 @@ 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
</p>
/* ── 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">
<div className="flex items-center gap-2 flex-wrap">
<Button size="sm" onClick={onAddEvent} className="text-xs">
<CalendarPlus className="h-3.5 w-3.5" />
Add Event
</Button>
{/* ── 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="h-8 text-xs gap-1.5">
<CalendarPlus className="h-3.5 w-3.5" />
Add Event
</Button>
<IcsFilePicker
onFileSelect={onImport}
variant="outline"
size="sm"
className="text-xs"
>
<FileUp className="h-3.5 w-3.5" />
Import
</IcsFilePicker>
<IcsFilePicker
onFileSelect={onImport}
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5"
>
<FileUp className="h-3.5 w-3.5" />
Import
</IcsFilePicker>
{events.length > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={onExport}
className="text-xs"
>
<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"
>
<Trash2 className="h-3.5 w-3.5" />
Clear
</Button>
</>
)}
</div>
{events.length > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={onExport}
className="h-8 text-xs gap-1.5"
>
<Download className="h-3.5 w-3.5" />
Export
</Button>
<div className="flex items-center gap-3 sm:ml-auto">
{events.length > 0 && (
<Badge variant="secondary" className="text-xs">
{events.length} event{events.length !== 1 ? "s" : ""}
</Badge>
)}
{isAuthenticated && (
<Button
variant="ghost"
size="sm"
onClick={onAiSummarize}
disabled={aiLoading}
className="text-xs h-7"
onClick={onClearAll}
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
>
<Bot className="h-3 w-3" />
{aiLoading ? "Summarizing..." : "Summarize"}
<Trash2 className="h-3.5 w-3.5" />
Clear
</Button>
)}
</div>
</>
)}
{events.length > 0 && (
<Badge variant="secondary" className="ml-auto text-xs tabular-nums">
{events.length} event{events.length !== 1 ? "s" : ""}
</Badge>
)}
</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">
AI Summary
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground/60">
{summaryUpdated}
<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>
<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>
)}

View File

@@ -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();
};

View 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
View 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 }

View 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
View 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/);
});
});

View 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");
});
});