feat(ui): make the UI feel native on mobile
Reworks the frontend around phone-first interaction patterns and makes the app installable to a home screen. Shell - Bottom tab bar (Home / Requests / Agents / Alerts / Settings) below `md`, with the unread badge moved onto the Alerts tab; the bell stays on desktop. - Account menu opens as a bottom sheet on phones, dropdown on desktop. - Safe-area insets throughout: `viewport-fit=cover` plus `env(safe-area-inset-*)` on the sticky header, tab bar and sheet footers. Sheets - New `Sheet` primitive: drag-to-dismiss bottom sheet on phones, centred dialog from `sm` up. `Modal` now delegates to it, so every dialog inherits the gesture, the scroll lock and the safe-area padding. - Portalled to `<body>` — an ancestor with a transform (the page fade-in) was otherwise becoming the containing block and displacing the fixed overlay. - Scroll lock pins `<body>` and restores position, which iOS needs; plain `overflow: hidden` still rubber-bands there. - `ConfirmSheet` replaces `window.confirm` for destructive actions. Screens - Request detail gets a sticky decision bar above the tab bar; the sidebar decision card is now desktop-only. - Requests state filter becomes a swipeable pill row instead of a select. - Agent cards show two primary actions plus an overflow sheet on phones. - Diffs and code blocks contain their horizontal overscroll so a sideways swipe no longer triggers browser back; diffs gain a line-wrap toggle. - `min-w-0` on grid tracks — items default to `min-width: auto`, so truncated meta lines were widening columns past the viewport at 320px. Touch and input - 44px minimum touch targets, `:active` press feedback, no tap highlight. - 16px inputs on coarse pointers so iOS stops zooming on focus. - autocomplete/inputmode hints so password managers and keyboards behave. - `overscroll-behavior-y: none` disables pull-to-refresh; motion respects `prefers-reduced-motion`. PWA - Manifest, generated app icons (192/512/apple-touch) and standalone display metadata, so the app installs to a home screen without browser chrome. - Backend serves `.webmanifest` as `application/manifest+json`; rjweb's type map has no entry for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useIsMobile } from "../hooks/useMediaQuery";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
import { CloseIcon } from "./icons";
|
||||
|
||||
const EXIT_MS = 240;
|
||||
|
||||
/**
|
||||
* Responsive overlay primitive: a drag-dismissable bottom sheet on phones, a
|
||||
* centred dialog from `sm` up. Everything modal in the app renders through this
|
||||
* so the dismiss gesture, scroll lock and safe-area handling stay consistent.
|
||||
*/
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
wide,
|
||||
padded = true,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: () => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
wide?: boolean;
|
||||
padded?: boolean;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [mounted, setMounted] = useState(open);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dragStart = useRef<{ y: number; t: number } | null>(null);
|
||||
|
||||
// Keep the panel mounted through its exit transition.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
setExiting(false);
|
||||
setDragY(0);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setExiting(true);
|
||||
const t = window.setTimeout(() => {
|
||||
setMounted(false);
|
||||
setExiting(false);
|
||||
setDragY(0);
|
||||
}, EXIT_MS);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useScrollLock(mounted);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && onSubmit) onSubmit();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose, onSubmit]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
// ── drag-to-dismiss (mobile, from the grabber/header only, so it never
|
||||
// fights the scroll container underneath) ────────────────────────────────
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (!isMobile || e.pointerType === "mouse") return;
|
||||
dragStart.current = { y: e.clientY, t: e.timeStamp };
|
||||
setDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!dragStart.current) return;
|
||||
// Resist upward drags so the sheet feels anchored.
|
||||
const dy = e.clientY - dragStart.current.y;
|
||||
setDragY(dy > 0 ? dy : dy / 4);
|
||||
};
|
||||
|
||||
const endDrag = (e: React.PointerEvent) => {
|
||||
const start = dragStart.current;
|
||||
dragStart.current = null;
|
||||
setDragging(false);
|
||||
if (!start) return;
|
||||
const dy = e.clientY - start.y;
|
||||
const dt = Math.max(1, e.timeStamp - start.t);
|
||||
const velocity = dy / dt; // px/ms
|
||||
const height = panelRef.current?.offsetHeight ?? 400;
|
||||
if (dy > height * 0.28 || velocity > 0.5) onClose();
|
||||
else setDragY(0);
|
||||
};
|
||||
|
||||
const dismissProgress = Math.min(1, Math.max(0, dragY) / 320);
|
||||
|
||||
const panelTransform = exiting
|
||||
? isMobile
|
||||
? "translateY(100%)"
|
||||
: "scale(0.96)"
|
||||
: dragY
|
||||
? `translateY(${dragY}px)`
|
||||
: undefined;
|
||||
|
||||
// Portalled to <body>: any ancestor with a transform (page fade-ins, the
|
||||
// blurred header) would otherwise become the containing block and knock the
|
||||
// fixed overlay out of place.
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4" role="dialog" aria-modal="true">
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${
|
||||
exiting ? "opacity-0" : "animate-backdrop-in"
|
||||
}`}
|
||||
style={{ opacity: exiting ? 0 : 1 - dismissProgress * 0.7 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`relative z-10 flex max-h-[92dvh] w-full flex-col border border-border-strong bg-surface shadow-2xl ${
|
||||
wide ? "sm:max-w-3xl" : "sm:max-w-lg"
|
||||
} rounded-t-2xl sm:max-h-[85vh] sm:rounded-2xl ${
|
||||
exiting
|
||||
? isMobile
|
||||
? "" // slides out; fading as well reads as a glitch on phones
|
||||
: "opacity-0"
|
||||
: isMobile
|
||||
? "animate-sheet-up"
|
||||
: "animate-scale-in"
|
||||
}`}
|
||||
style={{
|
||||
transform: panelTransform,
|
||||
transition: dragging ? "none" : `transform ${EXIT_MS}ms cubic-bezier(0.32,0.72,0,1), opacity 180ms ease`,
|
||||
}}
|
||||
>
|
||||
{/* Grab area — the whole header is draggable, like iOS sheets. */}
|
||||
<div
|
||||
className="shrink-0 touch-none"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
>
|
||||
<div className="flex justify-center pt-2 sm:hidden">
|
||||
<span className="h-1 w-9 rounded-full bg-border-strong" />
|
||||
</div>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3.5 sm:py-4">
|
||||
<h2 className="min-w-0 truncate text-base font-semibold text-text sm:text-lg">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="tap-sm -mr-2 flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-muted hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The home-indicator inset is folded into the padding with calc so it
|
||||
adds to the base spacing instead of replacing it. */}
|
||||
<div
|
||||
className={`min-h-0 flex-1 overflow-y-auto overscroll-contain ${
|
||||
padded ? "px-4 py-4 sm:px-5" : ""
|
||||
} ${footer ? "" : padded ? "pb-[calc(1rem_+_var(--safe-bottom))]" : "pb-safe"}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2 border-t border-border px-4 pb-[calc(0.75rem_+_var(--safe-bottom))] pt-3 sm:px-5 sm:pb-[calc(1rem_+_var(--safe-bottom))] sm:pt-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user