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

@@ -159,10 +159,11 @@ describe("Event count badge positioning contract", () => {
});
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();
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", () => {
@@ -191,8 +192,8 @@ 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: hidden on mobile, small on desktop so it stays secondary */
const INFO_TRIGGER_CLASSES = "hidden h-6 w-6 md:inline-flex";
/** Info popover trigger: compact affordance that only renders in desktop branches */
const INFO_TRIGGER_CLASSES = "h-8 w-8 text-muted-foreground/70 hover:text-foreground";
describe("Composer footer bar layout contract", () => {
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", () => {
test("info trigger is hidden on mobile so keyboard-only guidance does not appear in touch layouts", () => {
const resolved = cn(INFO_TRIGGER_CLASSES);
expect(resolved).toContain("hidden");
expect(resolved).toContain("md:inline-flex");
test("info trigger is guarded by useIsMobile so keyboard-only guidance stays out of touch layouts", () => {
const source = readToolbarSource();
expect(source).toContain("!isMobile ? (");
});
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);
expect(resolved).toContain("h-6");
expect(resolved).toContain("w-6");
expect(resolved).toContain("h-8");
expect(resolved).toContain("w-8");
});
});
@@ -328,7 +328,7 @@ describe("Multi-image strip layout contract", () => {
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";
"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 =
"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";
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");
expect(source).toContain(
"lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]",
);
expect(source).toContain("useIsMobile");
expect(source).toContain("grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]");
expect(source).toContain("AI capture");
expect(source).toContain("Event timeline");
});
@@ -19,10 +18,10 @@ describe("home page hierarchy", () => {
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");
expect(source).toContain("order-1 lg:order-none");
expect(source).not.toContain("order-1 lg:order-none");
expect(source).toContain("Import");
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
// truth we are locking down).
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)", () => {
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 {
APP_ACTION_BAR_CLASSES,
APP_HEADER_SURFACE_CLASSES,
APP_NAV_SURFACE_CLASSES,
APP_SECTION_SURFACE_CLASSES,
getAppHeaderSurfaceClasses,
getAppNavSurfaceClasses,
getAppSectionSurfaceClasses,
getConnectionBadgeClasses,
} from "@/lib/ui-shell-contract";
import { EVENT_CARD_SURFACE_CLASSES } from "@/components/event-card";
describe("app shell surfaces", () => {
test("header surface is a thin structural bar instead of a glass panel", () => {
expect(APP_HEADER_SURFACE_CLASSES).toContain("min-h-14");
expect(APP_HEADER_SURFACE_CLASSES).toContain("shadow-[inset_0_-1px_0_0_var(--color-border)]");
expect(APP_HEADER_SURFACE_CLASSES).not.toContain("glass-surface");
const mobileHeaderClasses = getAppHeaderSurfaceClasses(true);
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", () => {
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_NAV_SURFACE_CLASSES).not.toContain("glass-surface");
expect(APP_SECTION_SURFACE_CLASSES).toContain("shadow");
expect(APP_SECTION_SURFACE_CLASSES).not.toContain("rgba(0,0,0,0.08)");
expect(mobileNavClasses).not.toContain("glass-surface");
expect(mobileSectionClasses).toContain("shadow");
expect(mobileSectionClasses).not.toContain("rgba(0,0,0,0.08)");
expect(APP_ACTION_BAR_CLASSES).toContain("shadow-sm");
expect(APP_ACTION_BAR_CLASSES).not.toContain("rgba(0,0,0,0.08)");
expect(APP_NAV_SURFACE_CLASSES).toContain("shadow-lg");
expect(APP_NAV_SURFACE_CLASSES).not.toContain("rgba(0,0,0,0.08)");
expect(mobileNavClasses).toContain("shadow-lg");
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");
});
});