feat(ui): drive mobile layouts from useIsMobile

This commit is contained in:
2026-04-21 22:46:07 -04:00
parent 16bbd9ab08
commit 7a917e5c22
16 changed files with 350 additions and 150 deletions

View File

@@ -1,3 +1,5 @@
"use client";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Card, Card,
@@ -7,10 +9,19 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { CombinedDatePickerDemo } from "@/components/ui/combined-date-picker-demo"; import { CombinedDatePickerDemo } from "@/components/ui/combined-date-picker-demo";
import { useIsMobile } from "@/hooks/use-mobile";
export default function CombinedDatePickerDemoPage() { export default function CombinedDatePickerDemoPage() {
const isMobile = useIsMobile();
return ( return (
<div className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-4 py-10 sm:px-6 lg:px-8"> <div
className={
isMobile
? "mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-4 py-10"
: "mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-8 py-10"
}
>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Badge variant="outline" className="w-fit"> <Badge variant="outline" className="w-fit">
Demo Route Demo Route
@@ -19,7 +30,13 @@ export default function CombinedDatePickerDemoPage() {
<h1 className="text-3xl font-semibold tracking-tight"> <h1 className="text-3xl font-semibold tracking-tight">
Date &amp; Time Picker Date &amp; Time Picker
</h1> </h1>
<p className="max-w-2xl text-sm text-muted-foreground sm:text-base"> <p
className={
isMobile
? "max-w-2xl text-sm text-muted-foreground"
: "max-w-2xl text-base text-muted-foreground"
}
>
Inline date input paired with a locale-aware time picker. The return Inline date input paired with a locale-aware time picker. The return
calendar disables dates before departure, and time fields switch calendar disables dates before departure, and time fields switch
between 24-hour and 12-hour formats automatically. between 24-hour and 12-hour formats automatically.

View File

@@ -27,6 +27,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useIsMobile } from "@/hooks/use-mobile";
import { getAiCreateOutcome } from "@/lib/ai-create-flow"; import { getAiCreateOutcome } from "@/lib/ai-create-flow";
import { import {
getAiDisabledMessage, getAiDisabledMessage,
@@ -51,17 +52,14 @@ import { appendImagesDeduped } from "@/lib/multi-image";
import type { CalendarEvent } from "@/lib/types"; import type { CalendarEvent } from "@/lib/types";
import { import {
APP_ACTION_BAR_CLASSES, APP_ACTION_BAR_CLASSES,
APP_HEADER_SURFACE_CLASSES, getAppHeaderSurfaceClasses,
APP_NAV_SURFACE_CLASSES, getAppNavSurfaceClasses,
APP_SECTION_SURFACE_CLASSES, getAppSectionSurfaceClasses,
getConnectionBadgeClasses, getConnectionBadgeClasses,
} from "@/lib/ui-shell-contract"; } from "@/lib/ui-shell-contract";
import { useUserSettings } from "@/lib/user-settings"; import { useUserSettings } from "@/lib/user-settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const APP_FRAME_CLASSES =
"mx-auto flex min-h-screen w-full max-w-6xl flex-col px-4 pb-24 pt-4 sm:px-6 lg:px-8";
const NAV_BUTTON_CLASSES = "flex-1 gap-2"; const NAV_BUTTON_CLASSES = "flex-1 gap-2";
const getNavButtonClasses = (isActive: boolean) => const getNavButtonClasses = (isActive: boolean) =>
@@ -83,6 +81,7 @@ const validateImageFile = (file: File): string | null => {
}; };
export default function HomePage() { export default function HomePage() {
const isMobile = useIsMobile();
const [activeView, setActiveView] = useState<"list" | "settings">("list"); const [activeView, setActiveView] = useState<"list" | "settings">("list");
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -428,6 +427,18 @@ export default function HomePage() {
setDialogOpen(true); setDialogOpen(true);
}; };
const appFrameClasses = cn(
"mx-auto flex min-h-screen w-full max-w-6xl flex-col",
isMobile ? "px-4 pb-24 pt-4" : "px-8 py-4",
);
const appHeaderSurfaceClasses = getAppHeaderSurfaceClasses(isMobile);
const appSectionSurfaceClasses = getAppSectionSurfaceClasses(isMobile);
const appNavSurfaceClasses = getAppNavSurfaceClasses(isMobile);
const mainContentClasses = cn(
"grid items-start gap-4",
isMobile ? "grid-cols-1" : "grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]",
);
return ( return (
<DragDropContainer <DragDropContainer
isDragOver={isDragOver} isDragOver={isDragOver}
@@ -435,8 +446,8 @@ export default function HomePage() {
onImport={handleImport} onImport={handleImport}
onImageDrop={(file) => handleImagesSelect([file])} onImageDrop={(file) => handleImagesSelect([file])}
> >
<div className={APP_FRAME_CLASSES}> <div className={appFrameClasses}>
<header className={APP_HEADER_SURFACE_CLASSES}> <header className={appHeaderSurfaceClasses}>
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
<p className="font-mono text-[11px] uppercase text-muted-foreground"> <p className="font-mono text-[11px] uppercase text-muted-foreground">
Local Calendar Local Calendar
@@ -499,15 +510,15 @@ export default function HomePage() {
{activeView === "settings" ? ( {activeView === "settings" ? (
<SettingsPanel <SettingsPanel
adminAiEnabled={adminAiEnabled} adminAiEnabled={adminAiEnabled}
className={APP_SECTION_SURFACE_CLASSES} className={appSectionSurfaceClasses}
hasLoadedSettings={hasLoadedSettings} hasLoadedSettings={hasLoadedSettings}
onSettingsChange={updateSettings} onSettingsChange={updateSettings}
settings={settings} settings={settings}
/> />
) : ( ) : (
<section className="grid items-start gap-4 lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]"> <section className={mainContentClasses}>
<div className="order-1 lg:order-none space-y-4"> <div className="space-y-4">
<section className={APP_SECTION_SURFACE_CLASSES}> <section className={appSectionSurfaceClasses}>
<div className="mb-4 space-y-1"> <div className="mb-4 space-y-1">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
AI capture AI capture
@@ -543,8 +554,8 @@ export default function HomePage() {
</section> </section>
</div> </div>
<div className="order-2 lg:order-none space-y-4"> <div className="space-y-4">
<section className={APP_SECTION_SURFACE_CLASSES}> <section className={appSectionSurfaceClasses}>
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
@@ -593,7 +604,7 @@ export default function HomePage() {
)} )}
</main> </main>
<nav className={APP_NAV_SURFACE_CLASSES}> <nav className={appNavSurfaceClasses}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"

View File

@@ -19,6 +19,7 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useIsMobile } from "@/hooks/use-mobile";
import { extractAllImagesFromClipboard } from "@/lib/clipboard-image"; import { extractAllImagesFromClipboard } from "@/lib/clipboard-image";
import { import {
detectOs, detectOs,
@@ -114,6 +115,7 @@ export const AIToolbar = ({
summaryUpdated, summaryUpdated,
events, events,
}: AIToolbarProps) => { }: AIToolbarProps) => {
const isMobile = useIsMobile();
const examplePrompts = [ const examplePrompts = [
"Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.", "Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.",
"Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.", "Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.",
@@ -281,7 +283,13 @@ export const AIToolbar = ({
</div> </div>
</div> </div>
) : isAuthenticated ? ( ) : isAuthenticated ? (
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"> <div
className={
isMobile
? "grid gap-3"
: "grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"
}
>
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-[10px] bg-card shadow focus-within:ring-[3px] focus-within:ring-ring/20"> <div className="rounded-[10px] bg-card shadow focus-within:ring-[3px] focus-within:ring-ring/20">
<Textarea <Textarea
@@ -347,18 +355,22 @@ export const AIToolbar = ({
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{!isMobile ? (
<HoverCard <HoverCard
openDelay={300} openDelay={300}
closeDelay={100} closeDelay={100}
open={isPopoverOpen ? false : undefined} open={isPopoverOpen ? false : undefined}
> >
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> <Popover
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="hidden h-8 w-8 text-muted-foreground/70 hover:text-foreground md:inline-flex" className="h-8 w-8 text-muted-foreground/70 hover:text-foreground"
aria-label="Keyboard shortcuts" aria-label="Keyboard shortcuts"
> >
<Info className="h-3.5 w-3.5" /> <Info className="h-3.5 w-3.5" />
@@ -383,6 +395,7 @@ export const AIToolbar = ({
<ShortcutsList os={os} /> <ShortcutsList os={os} />
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
) : null}
{events.length > 0 && ( {events.length > 0 && (
<Button <Button
@@ -453,7 +466,9 @@ export const AIToolbar = ({
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }} exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
className="mt-3 grid gap-2 sm:grid-cols-2" className={
isMobile ? "mt-3 grid gap-2" : "mt-3 grid gap-2 grid-cols-2"
}
> >
{imagePreviews.map((preview, index) => ( {imagePreviews.map((preview, index) => (
<motion.div <motion.div

View File

@@ -19,6 +19,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useIsMobile } from "@/hooks/use-mobile";
import { import {
type EventFormValues, type EventFormValues,
getDefaultEventFormValues, getDefaultEventFormValues,
@@ -45,6 +46,7 @@ export const EventDialog = ({
onSave, onSave,
onReset, onReset,
}: EventDialogProps) => { }: EventDialogProps) => {
const isMobile = useIsMobile();
const isAiDraft = dialogSource === "ai" && !editingId; const isAiDraft = dialogSource === "ai" && !editingId;
const titleText = editingId const titleText = editingId
? "Edit Event" ? "Edit Event"
@@ -192,7 +194,11 @@ export const EventDialog = ({
/> />
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div
className={
isMobile ? "grid grid-cols-1 gap-3" : "grid grid-cols-2 gap-3"
}
>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="event-location">Location</Label> <Label htmlFor="event-location">Location</Label>
<Controller <Controller
@@ -329,7 +335,7 @@ export const EventDialog = ({
)} )}
</section> </section>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className={isMobile ? "gap-2" : "gap-0"}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"

View File

@@ -1,7 +1,10 @@
"use client";
import { Sparkles, Zap } from "lucide-react"; import { Sparkles, Zap } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useIsMobile } from "@/hooks/use-mobile";
import type { UserSettings } from "@/lib/user-settings"; import type { UserSettings } from "@/lib/user-settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -22,6 +25,7 @@ export function SettingsPanel({
onSettingsChange, onSettingsChange,
settings, settings,
}: SettingsPanelProps) { }: SettingsPanelProps) {
const isMobile = useIsMobile();
const valuePrefix = hasLoadedSettings const valuePrefix = hasLoadedSettings
? "Current preference" ? "Current preference"
: "Default value"; : "Default value";
@@ -35,7 +39,12 @@ export function SettingsPanel({
return ( return (
<section className={className}> <section className={className}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div
className={cn(
"gap-4",
isMobile ? "flex flex-col" : "flex items-start justify-between",
)}
>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary"> <p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
Settings Settings
@@ -51,7 +60,14 @@ export function SettingsPanel({
</Badge> </Badge>
</div> </div>
<div className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,1fr)]"> <div
className={cn(
"mt-5 grid gap-4",
isMobile
? "grid-cols-1"
: "grid-cols-[minmax(0,1.7fr)_minmax(18rem,1fr)]",
)}
>
<div className="grid gap-4"> <div className="grid gap-4">
<div className={settingRowClasses}> <div className={settingRowClasses}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -149,7 +165,12 @@ export function SettingsPanel({
{summaryDescription} {summaryDescription}
</p> </p>
</div> </div>
<dl className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1"> <dl
className={cn(
"grid gap-3",
isMobile ? "grid-cols-1" : "grid-cols-2",
)}
>
<div className="rounded-[8px] bg-secondary p-3 shadow-[inset_0_0_0_1px_var(--color-border)]"> <div className="rounded-[8px] bg-secondary p-3 shadow-[inset_0_0_0_1px_var(--color-border)]">
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground"> <dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Direct create preference Direct create preference

View File

@@ -12,6 +12,7 @@ import {
getDefaultClassNames, getDefaultClassNames,
} from "react-day-picker"; } from "react-day-picker";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Calendar({ function Calendar({
@@ -26,6 +27,7 @@ function Calendar({
}: React.ComponentProps<typeof DayPicker> & { }: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]; buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) { }) {
const isMobile = useIsMobile();
const defaultClassNames = getDefaultClassNames(); const defaultClassNames = getDefaultClassNames();
return ( return (
@@ -46,7 +48,9 @@ function Calendar({
classNames={{ classNames={{
root: cn("w-fit", defaultClassNames.root), root: cn("w-fit", defaultClassNames.root),
months: cn( months: cn(
"flex gap-4 flex-col md:flex-row relative", isMobile
? "relative flex flex-col gap-4"
: "relative flex flex-row gap-4",
defaultClassNames.months, defaultClassNames.months,
), ),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month), month: cn("flex flex-col w-full gap-4", defaultClassNames.month),

View File

@@ -20,6 +20,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { useIsMobile } from "@/hooks/use-mobile";
interface DropdownItem { interface DropdownItem {
disabled?: boolean; disabled?: boolean;
@@ -28,6 +29,7 @@ interface DropdownItem {
} }
function CalendarDropdown(props: DropdownProps) { function CalendarDropdown(props: DropdownProps) {
const isMobile = useIsMobile();
const { options, value, onChange, "aria-label": ariaLabel } = props; const { options, value, onChange, "aria-label": ariaLabel } = props;
const items: DropdownItem[] = const items: DropdownItem[] =
@@ -50,7 +52,8 @@ function CalendarDropdown(props: DropdownProps) {
return ( return (
<> <>
<div className="relative flex items-center sm:hidden"> {isMobile ? (
<div className="relative flex items-center">
<select <select
aria-label={ariaLabel} aria-label={ariaLabel}
className="absolute inset-0 z-10 w-full cursor-pointer opacity-0" className="absolute inset-0 z-10 w-full cursor-pointer opacity-0"
@@ -77,8 +80,7 @@ function CalendarDropdown(props: DropdownProps) {
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</Button> </Button>
</div> </div>
) : (
<div className="hidden sm:block">
<Combobox <Combobox
aria-label={ariaLabel} aria-label={ariaLabel}
autoHighlight autoHighlight
@@ -105,7 +107,7 @@ function CalendarDropdown(props: DropdownProps) {
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
</div> )}
</> </>
); );
} }
@@ -139,6 +141,7 @@ export function DatePicker({
month: controlledMonth, month: controlledMonth,
onMonthChange: controlledOnMonthChange, onMonthChange: controlledOnMonthChange,
}: DatePickerProps) { }: DatePickerProps) {
const isMobile = useIsMobile();
const fallbackId = React.useId(); const fallbackId = React.useId();
const id = providedId ?? fallbackId; const id = providedId ?? fallbackId;
@@ -239,9 +242,21 @@ export function DatePicker({
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
{triggerContent} {triggerContent}
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
<div className="flex max-sm:flex-col"> <div className={isMobile ? "flex flex-col" : "flex"}>
<div className="relative py-1 ps-1 max-sm:order-1 max-sm:border-t"> <div
<div className="flex h-full flex-col sm:border-e sm:pe-3"> className={
isMobile
? "relative order-1 border-t py-1 ps-1"
: "relative py-1 ps-1"
}
>
<div
className={
isMobile
? "flex h-full flex-col"
: "flex h-full flex-col border-e pe-3"
}
>
<Button <Button
className="w-full justify-start" className="w-full justify-start"
onClick={() => { onClick={() => {
@@ -292,7 +307,7 @@ export function DatePicker({
</div> </div>
</div> </div>
<Calendar <Calendar
className="max-sm:pb-3 sm:ps-2" className={isMobile ? "pb-3" : "ps-2"}
mode="single" mode="single"
captionLayout="dropdown" captionLayout="dropdown"
components={{ Dropdown: CalendarDropdown }} components={{ Dropdown: CalendarDropdown }}

View File

@@ -4,6 +4,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import type * as React from "react"; import type * as React from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Dialog({ function Dialog({
@@ -54,13 +55,16 @@ function DialogContent({
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean; showCloseButton?: boolean;
}) { }) {
const isMobile = useIsMobile();
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-5 rounded-[10px] p-6 shadow-xl duration-200 sm:max-w-lg", "bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-5 rounded-[10px] p-6 shadow-xl duration-200",
isMobile ? undefined : "max-w-lg",
className, className,
)} )}
{...props} {...props}
@@ -91,11 +95,15 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
const isMobile = useIsMobile();
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", isMobile
? "flex flex-col-reverse gap-2"
: "flex flex-row justify-end gap-2",
className, className,
)} )}
{...props} {...props}

View File

@@ -4,11 +4,19 @@ import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react"; import type * as React from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const inputGroupAddonVariants = cva( const inputGroupAddonBaseClasses =
"flex h-auto cursor-text select-none items-center justify-center gap-2 leading-none [&>kbd]:rounded-[calc(var(--radius)-5px)] in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:-mx-0.5 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80", "flex h-auto cursor-text select-none items-center justify-center gap-2 leading-none [&>kbd]:rounded-[calc(var(--radius)-5px)] [&_svg]:-mx-0.5 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80";
{
const inputGroupRootBaseClasses =
"relative inline-flex w-full min-w-0 items-center rounded-lg border border-input bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)] has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[[data-size=sm]_textarea]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_textarea]:pe-1.5 has-[[data-align=block-start],[data-align=block-end]]:**:[textarea]:h-auto";
const inputGroupTextBaseClasses =
"line-clamp-1 flex items-center gap-2 whitespace-nowrap text-muted-foreground leading-none [&_svg]:pointer-events-none [&_svg]:-mx-0.5";
const inputGroupAddonVariants = cva(inputGroupAddonBaseClasses, {
defaultVariants: { defaultVariants: {
align: "inline-start", align: "inline-start",
}, },
@@ -24,17 +32,25 @@ const inputGroupAddonVariants = cva(
"order-first ps-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-ms-1.5 has-[>button]:-ms-2 has-[>kbd:last-child]:ms-[-0.35rem] [[data-size=sm]+&]:ps-[calc(--spacing(2.5)-1px)]", "order-first ps-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-ms-1.5 has-[>button]:-ms-2 has-[>kbd:last-child]:ms-[-0.35rem] [[data-size=sm]+&]:ps-[calc(--spacing(2.5)-1px)]",
}, },
}, },
}, });
);
function getAddonResponsiveClasses(isMobile: boolean) {
return isMobile
? "in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5"
: "in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4";
}
export function InputGroup({ export function InputGroup({
className, className,
...props ...props
}: React.ComponentProps<"div">): React.ReactElement { }: React.ComponentProps<"div">): React.ReactElement {
const isMobile = useIsMobile();
return ( return (
<div <div
className={cn( className={cn(
"relative inline-flex w-full min-w-0 items-center rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)] has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]", inputGroupRootBaseClasses,
isMobile ? "text-base" : "text-sm",
className, className,
)} )}
data-slot="input-group" data-slot="input-group"
@@ -49,9 +65,15 @@ export function InputGroupAddon({
...props ...props
}: React.ComponentProps<"div"> & }: React.ComponentProps<"div"> &
VariantProps<typeof inputGroupAddonVariants>): React.ReactElement { VariantProps<typeof inputGroupAddonVariants>): React.ReactElement {
const isMobile = useIsMobile();
return ( return (
<div <div
className={cn(inputGroupAddonVariants({ align }), className)} className={cn(
inputGroupAddonVariants({ align }),
getAddonResponsiveClasses(isMobile),
className,
)}
data-align={align} data-align={align}
data-slot="input-group-addon" data-slot="input-group-addon"
{...props} {...props}
@@ -63,10 +85,13 @@ export function InputGroupText({
className, className,
...props ...props
}: React.ComponentProps<"span">): React.ReactElement { }: React.ComponentProps<"span">): React.ReactElement {
const isMobile = useIsMobile();
return ( return (
<span <span
className={cn( className={cn(
"line-clamp-1 flex items-center gap-2 whitespace-nowrap text-muted-foreground leading-none in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5", inputGroupTextBaseClasses,
getAddonResponsiveClasses(isMobile),
className, className,
)} )}
{...props} {...props}

View File

@@ -1,13 +1,19 @@
"use client";
import type * as React from "react"; import type * as React from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
const isMobile = useIsMobile();
return ( return (
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"placeholder:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex field-sizing-content min-h-16 w-full rounded-[8px] bg-secondary px-3 py-2 text-base shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "placeholder:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex field-sizing-content min-h-16 w-full rounded-[8px] bg-secondary px-3 py-2 shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50",
isMobile ? "text-base" : "text-sm",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,16 +1,22 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const APP_HEADER_SURFACE_CLASSES = export const getAppHeaderSurfaceClasses = (isMobile: boolean) =>
"mb-6 flex min-h-14 items-center justify-between gap-3 bg-background/95 px-4 py-3 shadow-[inset_0_-1px_0_0_var(--color-border)] sm:px-6"; cn(
"mb-6 flex min-h-14 items-center justify-between gap-3 bg-background/95 py-3 shadow-[inset_0_-1px_0_0_var(--color-border)]",
isMobile ? "px-4" : "px-6",
);
export const APP_SECTION_SURFACE_CLASSES = export const getAppSectionSurfaceClasses = (isMobile: boolean) =>
"rounded-[10px] bg-card px-4 py-4 shadow sm:px-5"; cn("rounded-[10px] bg-card py-4 shadow", isMobile ? "px-4" : "px-5");
export const APP_ACTION_BAR_CLASSES = export const APP_ACTION_BAR_CLASSES =
"rounded-[10px] bg-secondary px-3 py-3 shadow-sm"; "rounded-[10px] bg-secondary px-3 py-3 shadow-sm";
export const APP_NAV_SURFACE_CLASSES = export const getAppNavSurfaceClasses = (isMobile: boolean) =>
"fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between rounded-[10px] bg-card/95 px-3 py-2 shadow-lg sm:inset-x-6 lg:hidden"; cn(
"mx-auto max-w-3xl items-center justify-between rounded-[10px] bg-card/95 px-3 py-2 shadow-lg",
isMobile ? "fixed inset-x-4 bottom-4 flex" : "hidden",
);
const CONNECTION_BADGE_BASE_CLASSES = const CONNECTION_BADGE_BASE_CLASSES =
"gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-sm"; "gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-sm";

View File

@@ -159,10 +159,11 @@ describe("Event count badge positioning contract", () => {
}); });
describe("AI capture redesign", () => { describe("AI capture redesign", () => {
test("desktop composer treats prompt and attachments as peer panels", () => { test("composer layout is driven by useIsMobile instead of Tailwind breakpoint classes", () => {
const source = readToolbarSource(); const source = readToolbarSource();
expect(source).toContain("lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"); expect(source).toContain("useIsMobile");
expect(source).not.toContain("lg:grid-cols-");
}); });
test("attachments panel is a first-class surfaced region, not an inline footer affordance", () => { test("attachments panel is a first-class surfaced region, not an inline footer affordance", () => {
@@ -191,8 +192,8 @@ const ATTACH_BTN_CLASSES = "gap-1.5 text-xs";
/** Generate button: right side, primary variant, labeled */ /** Generate button: right side, primary variant, labeled */
const GENERATE_BTN_CLASSES = "gap-1.5 text-xs"; const GENERATE_BTN_CLASSES = "gap-1.5 text-xs";
/** Info popover trigger: hidden on mobile, small on desktop so it stays secondary */ /** Info popover trigger: compact affordance that only renders in desktop branches */
const INFO_TRIGGER_CLASSES = "hidden h-6 w-6 md:inline-flex"; const INFO_TRIGGER_CLASSES = "h-8 w-8 text-muted-foreground/70 hover:text-foreground";
describe("Composer footer bar layout contract", () => { describe("Composer footer bar layout contract", () => {
test("footer row uses justify-between so Attach sits left and Generate sits right", () => { test("footer row uses justify-between so Attach sits left and Generate sits right", () => {
@@ -223,16 +224,15 @@ describe("Composer footer bar layout contract", () => {
}); });
describe("Info popover trigger size contract", () => { describe("Info popover trigger size contract", () => {
test("info trigger is hidden on mobile so keyboard-only guidance does not appear in touch layouts", () => { test("info trigger is guarded by useIsMobile so keyboard-only guidance stays out of touch layouts", () => {
const resolved = cn(INFO_TRIGGER_CLASSES); const source = readToolbarSource();
expect(resolved).toContain("hidden"); expect(source).toContain("!isMobile ? (");
expect(resolved).toContain("md:inline-flex");
}); });
test("info trigger is small (h-6 w-6) so it doesn't compete with Generate", () => { test("info trigger stays visually secondary when rendered on desktop", () => {
const resolved = cn(INFO_TRIGGER_CLASSES); const resolved = cn(INFO_TRIGGER_CLASSES);
expect(resolved).toContain("h-6"); expect(resolved).toContain("h-8");
expect(resolved).toContain("w-6"); expect(resolved).toContain("w-8");
}); });
}); });
@@ -328,7 +328,7 @@ describe("Multi-image strip layout contract", () => {
describe("AI textarea prompt input spacing contract", () => { describe("AI textarea prompt input spacing contract", () => {
const TEXTAREA_BASE = 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"; "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";
const AI_TEXTAREA_OVERRIDE = 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"; "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";

View File

@@ -2,12 +2,11 @@ import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
describe("home page hierarchy", () => { describe("home page hierarchy", () => {
test("desktop layout defines aligned AI and timeline top-row sections", () => { test("desktop layout is selected with useIsMobile rather than Tailwind breakpoint classes", () => {
const source = readFileSync("src/app/page.tsx", "utf8"); const source = readFileSync("src/app/page.tsx", "utf8");
expect(source).toContain( expect(source).toContain("useIsMobile");
"lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]", expect(source).toContain("grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]");
);
expect(source).toContain("AI capture"); expect(source).toContain("AI capture");
expect(source).toContain("Event timeline"); expect(source).toContain("Event timeline");
}); });
@@ -19,10 +18,10 @@ describe("home page hierarchy", () => {
expect(source).not.toContain("New Event</button>"); expect(source).not.toContain("New Event</button>");
}); });
test("mobile layout keeps capture before timeline and keeps manual create secondary", () => { test("mobile layout keeps capture before timeline without order utility breakpoints", () => {
const source = readFileSync("src/app/page.tsx", "utf8"); const source = readFileSync("src/app/page.tsx", "utf8");
expect(source).toContain("order-1 lg:order-none"); expect(source).not.toContain("order-1 lg:order-none");
expect(source).toContain("Import"); expect(source).toContain("Import");
expect(source).toContain("Manual create"); expect(source).toContain("Manual create");
}); });

View File

@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const RESPONSIVE_PREFIX_PATTERN = /\b(?:max-sm|sm:|md:|lg:|xl:|2xl:)/;
const HOOK_DRIVEN_FILES = [
"src/app/page.tsx",
"src/app/demo/combined-date-picker/page.tsx",
"src/components/ai-toolbar.tsx",
"src/components/event-dialog.tsx",
"src/components/settings-panel.tsx",
"src/components/ui/calendar.tsx",
"src/components/ui/date-picker.tsx",
"src/components/ui/dialog.tsx",
"src/components/ui/input-group.tsx",
"src/components/ui/textarea.tsx",
"src/lib/ui-shell-contract.ts",
];
const DIRECT_HOOK_FILES = [
"src/app/page.tsx",
"src/app/demo/combined-date-picker/page.tsx",
"src/components/ai-toolbar.tsx",
"src/components/event-dialog.tsx",
"src/components/settings-panel.tsx",
"src/components/ui/calendar.tsx",
"src/components/ui/date-picker.tsx",
"src/components/ui/dialog.tsx",
"src/components/ui/input-group.tsx",
"src/components/ui/textarea.tsx",
];
const BOOLEAN_HELPER_FILES = [
"src/lib/ui-shell-contract.ts",
];
describe("mobile hook adoption", () => {
test("responsive source files stop using Tailwind breakpoint prefixes for mobile behavior", () => {
for (const filePath of HOOK_DRIVEN_FILES) {
const source = readFileSync(filePath, "utf8");
expect(source).not.toMatch(RESPONSIVE_PREFIX_PATTERN);
}
});
test("mobile-responsive component files explicitly depend on the shared useIsMobile hook", () => {
for (const filePath of DIRECT_HOOK_FILES) {
const source = readFileSync(filePath, "utf8");
expect(source).toContain("useIsMobile");
}
});
test("utility files accept isMobile booleans instead of embedding breakpoint strings", () => {
for (const filePath of BOOLEAN_HELPER_FILES) {
const source = readFileSync(filePath, "utf8");
expect(source).toContain("isMobile");
}
});
});

View File

@@ -16,7 +16,7 @@ import { cn } from "@/lib/utils";
// The base Textarea classes (copied from the component — this is the source of // The base Textarea classes (copied from the component — this is the source of
// truth we are locking down). // truth we are locking down).
const TEXTAREA_BASE = 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"; "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";
describe("Textarea placeholder spacing (base defaults)", () => { describe("Textarea placeholder spacing (base defaults)", () => {
test("base className includes horizontal padding px-3 so placeholder is not flush", () => { test("base className includes horizontal padding px-3 so placeholder is not flush", () => {

View File

@@ -1,30 +1,39 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
APP_ACTION_BAR_CLASSES, APP_ACTION_BAR_CLASSES,
APP_HEADER_SURFACE_CLASSES, getAppHeaderSurfaceClasses,
APP_NAV_SURFACE_CLASSES, getAppNavSurfaceClasses,
APP_SECTION_SURFACE_CLASSES, getAppSectionSurfaceClasses,
getConnectionBadgeClasses, getConnectionBadgeClasses,
} from "@/lib/ui-shell-contract"; } from "@/lib/ui-shell-contract";
import { EVENT_CARD_SURFACE_CLASSES } from "@/components/event-card"; import { EVENT_CARD_SURFACE_CLASSES } from "@/components/event-card";
describe("app shell surfaces", () => { describe("app shell surfaces", () => {
test("header surface is a thin structural bar instead of a glass panel", () => { test("header surface is a thin structural bar instead of a glass panel", () => {
expect(APP_HEADER_SURFACE_CLASSES).toContain("min-h-14"); const mobileHeaderClasses = getAppHeaderSurfaceClasses(true);
expect(APP_HEADER_SURFACE_CLASSES).toContain("shadow-[inset_0_-1px_0_0_var(--color-border)]");
expect(APP_HEADER_SURFACE_CLASSES).not.toContain("glass-surface"); expect(mobileHeaderClasses).toContain("min-h-14");
expect(mobileHeaderClasses).toContain("shadow-[inset_0_-1px_0_0_var(--color-border)]");
expect(mobileHeaderClasses).not.toContain("glass-surface");
}); });
test("section and action surfaces use tokenized shell classes instead of frozen light-mode shadows", () => { test("section and action surfaces use tokenized shell classes instead of frozen light-mode shadows", () => {
expect(APP_SECTION_SURFACE_CLASSES).not.toContain("glass-panel"); const mobileSectionClasses = getAppSectionSurfaceClasses(true);
const mobileNavClasses = getAppNavSurfaceClasses(true);
expect(mobileSectionClasses).not.toContain("glass-panel");
expect(APP_ACTION_BAR_CLASSES).not.toContain("glass-subtle"); expect(APP_ACTION_BAR_CLASSES).not.toContain("glass-subtle");
expect(APP_NAV_SURFACE_CLASSES).not.toContain("glass-surface"); expect(mobileNavClasses).not.toContain("glass-surface");
expect(APP_SECTION_SURFACE_CLASSES).toContain("shadow"); expect(mobileSectionClasses).toContain("shadow");
expect(APP_SECTION_SURFACE_CLASSES).not.toContain("rgba(0,0,0,0.08)"); expect(mobileSectionClasses).not.toContain("rgba(0,0,0,0.08)");
expect(APP_ACTION_BAR_CLASSES).toContain("shadow-sm"); expect(APP_ACTION_BAR_CLASSES).toContain("shadow-sm");
expect(APP_ACTION_BAR_CLASSES).not.toContain("rgba(0,0,0,0.08)"); expect(APP_ACTION_BAR_CLASSES).not.toContain("rgba(0,0,0,0.08)");
expect(APP_NAV_SURFACE_CLASSES).toContain("shadow-lg"); expect(mobileNavClasses).toContain("shadow-lg");
expect(APP_NAV_SURFACE_CLASSES).not.toContain("rgba(0,0,0,0.08)"); expect(mobileNavClasses).not.toContain("rgba(0,0,0,0.08)");
});
test("desktop nav surface is suppressed when useIsMobile resolves false", () => {
expect(getAppNavSurfaceClasses(false)).toContain("hidden");
}); });
}); });