feat(layout): add tablet viewport tier (mobile/tablet/desktop)

Previously the app used a binary matchMedia split at 1023.98px, so iPad
portrait + half-screen-on-13"-Mac both fell into the mobile shell —
neither is really mobile. The tablet tier fills that gap.

- `use-is-mobile.ts` gains `useViewportTier()` returning
  'mobile' | 'tablet' | 'desktop' (mobile < 768, tablet 768-1023,
  desktop ≥ 1024). Backed by useSyncExternalStore so render reads
  stay pure. `useIsMobile()` retained as a back-compat alias =
  `tier !== 'desktop'` so existing call sites don't have to change
  in lockstep.

- `app-shell.tsx` now renders three branches. Mobile + desktop
  unchanged. Tablet renders the desktop shell, but the Sidebar lives
  inside a left-side `<Sheet>` opened by a new leading logo button
  in the Topbar. SheetContent width matches `--width-sidebar` so the
  open state reads consistent. Children subtree position stays
  invariant across tier flips so inline-edit drafts survive a resize.

- `topbar.tsx` accepts an optional `leadingSlot` rendered before the
  back button + breadcrumbs in the LEFT column. AppShell mounts a
  port-logo button in that slot on tablet (or a three-bar menu icon
  when the port has no logo yet) that triggers the sheet.

- `page-header.tsx` was the dashboard "title card looks bad on
  tablet" surface — the actions row was forced no-wrap at sm (640px)
  which crushed the title on iPad-portrait. Stack point moved from
  sm to lg, so tablet stacks vertically (title above, actions
  below); desktop returns to side-by-side.

tsc clean, 1454/1454 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 13:37:23 +02:00
parent 6af75eda01
commit 6d665d0113
4 changed files with 213 additions and 56 deletions

View File

@@ -10,6 +10,8 @@ import { MobileTopbar } from '@/components/layout/mobile/mobile-topbar';
import { MobileBottomTabs } from '@/components/layout/mobile/mobile-bottom-tabs';
import { MoreSheet } from '@/components/layout/mobile/more-sheet';
import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { useUIStore } from '@/stores/ui-store';
type SidebarProps = ComponentProps<typeof Sidebar>;
type TopbarProps = ComponentProps<typeof Topbar>;
@@ -32,30 +34,51 @@ interface AppShellProps {
children: ReactNode;
}
const MOBILE_QUERY = '(max-width: 1023.98px)';
// Three tiers, aligned with Tailwind and the `useViewportTier` hook:
// mobile : < 768px (sm and below) → mobile shell (bottom tabs, slide-over more sheet)
// tablet : 768-1023 (md) → desktop shell, sidebar wrapped in Sheet, logo trigger in topbar
// desktop : >= 1024 (lg and up) → desktop shell, sidebar always visible
//
// Previously the app used a binary `(max-width: 1023.98px)` split, which
// rendered the mobile shell on iPad portrait AND on a half-screen 13"
// browser. Neither is really "mobile"; the tablet tier fills that gap.
const MOBILE_QUERY = '(max-width: 767.98px)';
const TABLET_QUERY = '(min-width: 768px) and (max-width: 1023.98px)';
type Tier = 'mobile' | 'tablet' | 'desktop';
function computeInitialTier(initialFormFactor: 'mobile' | 'desktop'): Tier {
// Server UA classification is binary; we can only distinguish "looks
// like a phone/tablet UA" from "looks like a desktop UA." Tablets get
// classified as 'mobile' by the UA token check (iPad/Android), so
// initialFormFactor=mobile on first paint covers both mobile + tablet.
// The matchMedia subscription below corrects to the precise tier on
// hydration without a flash because useSyncExternalStore is mismatch-safe.
return initialFormFactor === 'mobile' ? 'mobile' : 'desktop';
}
/**
* #26 + H-09: single-tree responsive shell with stable children subtree.
*
* The shell renders ONE `<main>` and ONE `<MobileLayoutProvider>` at all
* viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs)
* conditionally renders. Two payoffs:
* viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs
* vs tablet's hidden-sidebar-via-Sheet) conditionally renders. Three payoffs:
*
* - #26 / first ship: no double-mount of chrome subtrees (Sidebar +
* MobileTopbar both running fetches / providers in parallel like the
* old layout did).
* - #26 / first ship: no double-mount of chrome subtrees.
* - H-09: `{children}` stays mounted across viewport flips. A rep
* editing an inline field on desktop who resizes through the mobile
* breakpoint no longer loses the draft mid-edit — the children tree's
* position in the DOM is invariant, so React preserves its state.
* breakpoint no longer loses the draft mid-edit.
* - Tier-aware sidebar: tablet width gets the desktop shell with
* sidebar hidden behind a Sheet (slide-over from left, opened by
* the topbar logo button) instead of falling all the way back to
* the mobile shell. Closes the half-screen-on-13"-Mac usability gap.
*
* The mobile-only floating panels (MoreSheet, MobileSearchOverlay) only
* mount in the mobile branch — they have no desktop counterpart and would
* be wasteful to keep mounted otherwise.
* mount in the mobile branch — they have no desktop counterpart.
*
* SSR safety: the server passes its UA-classified hint via `initialFormFactor`;
* the first client render uses the same value so hydration matches. After
* mount, a matchMedia subscription overrides if the viewport disagrees.
* mount, a matchMedia subscription overrides to the precise tier.
*/
export function AppShell({
portRoles,
@@ -66,35 +89,69 @@ export function AppShell({
initialFormFactor,
children,
}: AppShellProps) {
const [isMobile, setIsMobile] = useState(initialFormFactor === 'mobile');
const [tier, setTier] = useState<Tier>(computeInitialTier(initialFormFactor));
const [moreOpen, setMoreOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [tabletSidebarOpen, setTabletSidebarOpen] = useState(false);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
useEffect(() => {
const mq = window.matchMedia(MOBILE_QUERY);
const update = () => setIsMobile(mq.matches);
const mqMobile = window.matchMedia(MOBILE_QUERY);
const mqTablet = window.matchMedia(TABLET_QUERY);
const update = () => {
if (mqMobile.matches) setTier('mobile');
else if (mqTablet.matches) setTier('tablet');
else setTier('desktop');
};
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
mqMobile.addEventListener('change', update);
mqTablet.addEventListener('change', update);
return () => {
mqMobile.removeEventListener('change', update);
mqTablet.removeEventListener('change', update);
};
}, []);
// Build the chrome subtree based on form factor; the children's parent
// chain (MobileLayoutProvider > div > main) is invariant across both
// branches, so React reconciliation keeps the children subtree mounted
// when isMobile flips.
const chrome = isMobile ? (
<>
<MobileTopbar />
</>
) : (
<Sidebar
portRoles={portRoles}
isSuperAdmin={isSuperAdmin}
user={user}
ports={ports}
portLogoUrls={portLogoUrls}
/>
);
const isMobile = tier === 'mobile';
const isTablet = tier === 'tablet';
// Close the tablet sheet when crossing breakpoints so it doesn't stay
// "open" after a resize back to desktop (Sheet keeps its open prop).
useEffect(() => {
if (!isTablet && tabletSidebarOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTabletSidebarOpen(false);
}
}, [isTablet, tabletSidebarOpen]);
const sidebarProps: SidebarProps = {
portRoles,
isSuperAdmin,
user,
ports,
portLogoUrls,
};
// Chrome subtree per tier.
let chrome: ReactNode = null;
if (isMobile) {
chrome = <MobileTopbar />;
} else if (isTablet) {
// Tablet: sidebar lives inside a left-side Sheet, opened by the
// topbar's leading logo button. SheetContent has its own width;
// override to match the inline Sidebar's width so the layout
// reads consistent when the sheet is open.
chrome = (
<Sheet open={tabletSidebarOpen} onOpenChange={setTabletSidebarOpen}>
<SheetContent side="left" className="p-0 w-[var(--width-sidebar)]">
<Sidebar {...sidebarProps} />
</SheetContent>
</Sheet>
);
} else {
chrome = <Sidebar {...sidebarProps} />;
}
const footer = isMobile ? (
<>
@@ -107,7 +164,46 @@ export function AppShell({
</>
) : null;
const desktopTopbar = !isMobile ? <Topbar ports={ports} user={user} /> : null;
// Desktop topbar; on tablet it gains a leading logo button that
// opens the sidebar Sheet.
const desktopTopbar = !isMobile ? (
<Topbar
ports={ports}
user={user}
leadingSlot={
isTablet ? (
<button
type="button"
aria-label="Open menu"
onClick={() => setTabletSidebarOpen(true)}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md hover:bg-accent transition-colors"
>
{logoUrl ? (
<img src={logoUrl} alt="" className="h-6 w-6 object-contain" />
) : (
// Neutral fallback when the port has no branding logo yet —
// a three-bar menu icon keeps the affordance discoverable.
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
)}
</button>
) : null
}
/>
) : null;
return (
<MobileLayoutProvider>

View File

@@ -3,6 +3,7 @@
import { ChevronLeft, Plus } from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
import type { Route } from 'next';
import type { ReactNode } from 'react';
import { useUIStore } from '@/stores/ui-store';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
@@ -27,9 +28,13 @@ import type { Port } from '@/lib/db/schema/ports';
interface TopbarProps {
ports: Port[];
user?: { name: string; email: string };
/** Optional leading slot rendered before the breadcrumbs on tablet
* viewports — used by AppShell to mount a sidebar trigger button
* (logo) when the sidebar is hidden behind a slide-over Sheet. */
leadingSlot?: ReactNode;
}
export function Topbar({ ports, user }: TopbarProps) {
export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
const router = useRouter();
const pathname = usePathname();
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
@@ -55,8 +60,9 @@ export function Topbar({ ports, user }: TopbarProps) {
// The brand logo lives in the sidebar header (per design feedback) so the
// topbar center is dedicated to the global search bar.
<header className="grid h-14 grid-cols-[minmax(0,1fr)_minmax(420px,800px)_minmax(0,1fr)] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
{/* LEFT: optional back button + breadcrumbs / page title */}
{/* LEFT: optional sidebar trigger (tablet) + optional back button + breadcrumbs */}
<div className="min-w-0 flex items-center gap-1.5">
{leadingSlot}
{showBackButton && (
<button
type="button"

View File

@@ -45,13 +45,19 @@ export function PageHeader({
</div>
) : null}
{/* Desktop: full strip with title, eyebrow, description, kpi line, actions. */}
{/* Tablet + desktop: full strip with title, eyebrow, description, kpi line, actions.
Stacks vertically at sm (640px) up to lg (1024px) so the title
doesn't get truncated next to a wide actions row on tablet — the
previous `sm:flex-row sm:flex-nowrap` forced four-button toolbars
(e.g. the dashboard's DateRange + ExportPdf + Rearrange + Customize)
onto one row at 768px, crushing the title. At lg+ the row layout
returns. */}
<div
className={cn(
// Removed `sm:mb-6` - the parent shell already provides
// appropriate gap-y between header and the next section, and the
// double-spacing produced an oversized top margin on dashboards.
'hidden sm:flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4',
'hidden sm:flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between lg:gap-4',
isGradient &&
'rounded-xl border border-slate-200 bg-gradient-brand-soft px-5 py-4 shadow-xs',
className,
@@ -79,7 +85,7 @@ export function PageHeader({
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:flex-nowrap">{actions}</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 lg:flex-nowrap">{actions}</div>
) : null}
</div>
</>

View File

@@ -2,34 +2,83 @@
import { useSyncExternalStore } from 'react';
const MOBILE_QUERY = '(max-width: 1023.98px)';
// 3-tier breakpoints aligned with Tailwind:
// mobile : < 768px (sm and below)
// tablet : 768-1023 (md)
// desktop : >= 1024 (lg and up)
//
// Previously the app used a binary `(max-width: 1023.98px)` split, which
// rendered the mobile shell on iPad portrait AND on a half-screen 13"
// Mac browser — neither is really "mobile." The tablet tier fills that
// gap so the desktop shell can render with a hidden-but-accessible
// sidebar at those widths.
function subscribe(callback: () => void): () => void {
const mq = window.matchMedia(MOBILE_QUERY);
export type ViewportTier = 'mobile' | 'tablet' | 'desktop';
const TABLET_QUERY = '(min-width: 768px) and (max-width: 1023.98px)';
const DESKTOP_QUERY = '(min-width: 1024px)';
function subscribeTier(callback: () => void): () => void {
const mqTablet = window.matchMedia(TABLET_QUERY);
const mqDesktop = window.matchMedia(DESKTOP_QUERY);
mqTablet.addEventListener('change', callback);
mqDesktop.addEventListener('change', callback);
return () => {
mqTablet.removeEventListener('change', callback);
mqDesktop.removeEventListener('change', callback);
};
}
function getTierSnapshot(): ViewportTier {
if (window.matchMedia(DESKTOP_QUERY).matches) return 'desktop';
if (window.matchMedia(TABLET_QUERY).matches) return 'tablet';
return 'mobile';
}
function getTierServerSnapshot(): ViewportTier {
// Server has no window — default to desktop so the desktop shell
// mounts on first paint. Client re-evaluates immediately on hydration
// (useSyncExternalStore is server-mismatch-safe).
return 'desktop';
}
/**
* Returns the active viewport tier. Backed by useSyncExternalStore so
* render reads stay pure (no useEffect → setState cascade); React
* Compiler-safe.
*/
export function useViewportTier(): ViewportTier {
return useSyncExternalStore(subscribeTier, getTierSnapshot, getTierServerSnapshot);
}
// ─── Back-compat alias ──────────────────────────────────────────────────────
// Every existing call site treats "mobile OR tablet" as one bucket (e.g.
// "show short labels", "stack vertically"). Returning `tier !== 'desktop'`
// preserves that behaviour so the tier rollout doesn't have to touch
// dozens of components in lockstep.
const LEGACY_QUERY = '(max-width: 1023.98px)';
function subscribeLegacy(callback: () => void): () => void {
const mq = window.matchMedia(LEGACY_QUERY);
mq.addEventListener('change', callback);
return () => mq.removeEventListener('change', callback);
}
function getSnapshot(): boolean {
return window.matchMedia(MOBILE_QUERY).matches;
function getLegacySnapshot(): boolean {
return window.matchMedia(LEGACY_QUERY).matches;
}
function getServerSnapshot(): boolean {
// Server has no window — default to desktop. Client hydrates to the
// true viewport state without a flash because useSyncExternalStore
// is React 18's "this is server-mismatch safe" primitive.
function getLegacyServerSnapshot(): boolean {
return false;
}
/**
* Returns true when the viewport is below the `lg` Tailwind breakpoint.
* Backed by useSyncExternalStore so render reads stay pure (no
* useEffect → setState cascade); React Compiler-safe.
*
* Not unit-tested: the repo's vitest is configured for environment='node'
* (no @testing-library/react / DOM env). Verified through the mobile-shell
* Playwright visual snapshots in Task 23.
* Returns true when the viewport is below the `lg` Tailwind breakpoint
* (i.e. mobile OR tablet). Kept as an alias for backwards compatibility
* with call sites that don't care about the mobile-vs-tablet distinction.
* New code should prefer `useViewportTier()` for explicit tier checks.
*/
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return useSyncExternalStore(subscribeLegacy, getLegacySnapshot, getLegacyServerSnapshot);
}