feat(ui): drive mobile layouts from useIsMobile
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
58
tests/mobile-hook-adoption.test.ts
Normal file
58
tests/mobile-hook-adoption.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user