🚸 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";
}