feat(web): give phone surfaces their push, rise and swipe

The phone entry screen pushes in from the end edge and slides back out on
Back, reversing mid-push. The Design sheet keeps its full height and moves
by translate instead of animating height, and rises from the tab bar when
Design opens. The selection bar rises and fades in.

Sheet is now backed by Base UI Drawer: bottom sheets follow a downward
swipe, dismiss on a flick or a drag past half their height, and settle back
otherwise. Side and top sheets are unchanged.

On phones, Settings rows push the section in from the end and the back link
returns the list from the start, layered on the page view transition.
This commit is contained in:
Amruth Pillai
2026-09-29 13:11:18 +02:00
parent c652a288ba
commit 8e0a76bcb1
7 changed files with 127 additions and 53 deletions
@@ -2,11 +2,13 @@ import type { MessageDescriptor } from "@lingui/core";
import type { KeyboardEvent, ReactNode } from "react";
import type { PageSettings } from "./entries";
import type { Entry, WriteSection } from "./model";
import { useDirection } from "@base-ui/react/direction-provider";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { msg, t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, m } from "motion/react";
import { useEffect, useMemo } from "react";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Collapsible, CollapsibleContent } from "@reactive-resume/ui/components/collapsible";
@@ -26,7 +28,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { DRAG_SETTLE } from "@/libs/motion";
import { D3, DRAG_SETTLE, EASE, EXIT } from "@/libs/motion";
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
import { useEditorStore } from "../store";
import { EntryFields } from "./entries";
@@ -177,17 +179,20 @@ export function EntryCard({ section, entryId, index, count, page, locked, onMove
</Collapsible>
)}
{open && isPhone && (
<PhoneEntryScreen
section={section}
title={title}
locked={locked}
onDelete={() => deleteEntry(section, entryId)}
onBack={() => select(null)}
>
{fields}
</PhoneEntryScreen>
)}
<AnimatePresence>
{open && isPhone && (
<PhoneEntryScreen
key="entry"
section={section}
title={title}
locked={locked}
onDelete={() => deleteEntry(section, entryId)}
onBack={() => select(null)}
>
{fields}
</PhoneEntryScreen>
)}
</AnimatePresence>
</div>
);
}
@@ -204,12 +209,17 @@ type PhoneEntryScreenProps = {
/** Phones: an open entry pushes in full screen, with a back label naming its section and 44px fields. */
function PhoneEntryScreen({ section, title, locked, onBack, onDelete, children }: PhoneEntryScreenProps) {
const sectionTitle = useSectionTitle(section);
// Off-screen at the end edge: the screen pushes in from there and goes back there.
const offscreen = useDirection() === "rtl" ? "translateX(-100%)" : "translateX(100%)";
return (
<div
<m.div
role="dialog"
aria-label={title || t`Untitled`}
className="fixed inset-0 z-40 flex flex-col bg-surface pb-[env(safe-area-inset-bottom)]"
initial={{ transform: offscreen }}
animate={{ transform: "translateX(0%)", transition: { duration: D3, ease: EASE } }}
exit={{ transform: offscreen, transition: { duration: D3 * EXIT, ease: EASE } }}
>
<div className="flex h-14 shrink-0 items-center gap-1 border-line border-b px-1.5">
<button
@@ -231,7 +241,7 @@ function PhoneEntryScreen({ section, title, locked, onBack, onDelete, children }
>
{children}
</fieldset>
</div>
</m.div>
);
}
+9 -3
View File
@@ -12,6 +12,12 @@ import { orpc } from "@/libs/orpc/client";
import { themeMap } from "@/libs/theme";
import { SignOutButton } from "./account/page";
// Phones: a section pushes in from the end and Back returns from the start (styles in index.css, after the page
// transition). Browsers without view-transition types swap instantly, as for every other navigation.
const supportsTransitionTypes = globalThis.CSS?.supports?.("selector(:active-view-transition-type(a))") === true;
export const pushTransition = supportsTransitionTypes && { types: ["page", "forward"] };
export const popTransition = supportsTransitionTypes && { types: ["page", "back"] };
type RowProps = { icon: IconName; label: ReactNode; value?: ReactNode };
function Row({ icon, label, value }: RowProps) {
@@ -52,13 +58,13 @@ export function SettingsRoot() {
</div>
<nav className="divide-y divide-line overflow-hidden rounded-[14px] border border-line bg-surface">
<Link to="/dashboard/settings/account" className={rowClass}>
<Link to="/dashboard/settings/account" viewTransition={pushTransition} className={rowClass}>
<Row icon="account_circle" label={<Trans>Account</Trans>} />
</Link>
<Link to="/dashboard/settings/preferences" className={rowClass}>
<Link to="/dashboard/settings/preferences" viewTransition={pushTransition} className={rowClass}>
<Row icon="tune" label={<Trans>Preferences</Trans>} value={i18n.t(themeMap[theme])} />
</Link>
<Link to="/dashboard/settings/ai" className={rowClass}>
<Link to="/dashboard/settings/ai" viewTransition={pushTransition} className={rowClass}>
<Row icon="hub" label={<Trans>AI & developer</Trans>} value={provider?.label ?? <Trans>Not set up</Trans>} />
</Link>
</nav>
+37
View File
@@ -91,3 +91,40 @@ html:active-view-transition-type(page)::view-transition-old(*):only-child {
html:active-view-transition-type(page)::view-transition-new(root) {
animation: var(--d2) var(--ease) both vt-page-in;
}
/*
* Phones, Settings: a section pushes in from the end and Back returns to the list from the start. The links add a
* `forward` or `back` type next to `page`, so the page rules above still fade the old page out; these replace only
* the new page's entry. Same specificity as the page rules, so they must stay after them.
*/
@keyframes vt-push-in {
from {
opacity: 0;
translate: var(--vt-push) 0;
}
}
@keyframes vt-pop-in {
from {
opacity: 0;
translate: calc(var(--vt-push) * -1) 0;
}
}
html {
--vt-push: 24px;
}
html[dir="rtl"] {
--vt-push: -24px;
}
@media (width < 640px) {
html:active-view-transition-type(forward)::view-transition-new(root) {
animation: var(--d2) var(--ease) both vt-push-in;
}
html:active-view-transition-type(back)::view-transition-new(root) {
animation: var(--d2) var(--ease) both vt-pop-in;
}
}
@@ -102,7 +102,8 @@ type SheetTab = "template" | "type" | "color" | "page";
/**
* Phones: Design is a sheet over the lower half of the page, so every change shows above it. The handle
* raises it to full height. Tabs replace the group nav; Advanced sits under Page.
* raises it to full height. It keeps its full height and moves by `translate` (half out of view when lowered),
* and rises into view when Design opens. Tabs replace the group nav; Advanced sits under Page.
*/
export function DesignSheet() {
const locked = useIsResumeLocked();
@@ -113,8 +114,8 @@ export function DesignSheet() {
<section
aria-label={t`Design`}
className={cn(
"absolute inset-x-0 bottom-0 z-20 flex flex-col rounded-t-2xl border-line border-t bg-surface shadow-e3 transition-[height] duration-emphasized ease-enter",
expanded ? "h-[calc(100%-1rem)]" : "h-1/2",
"absolute inset-x-0 bottom-0 z-20 flex h-[calc(100%-1rem)] starting:translate-y-full flex-col rounded-t-2xl border-line border-t bg-surface shadow-e3 transition-[translate] duration-emphasized ease-enter [container-type:size]",
!expanded && "translate-y-[calc(50%-0.5rem)]",
)}
>
<button
@@ -149,6 +150,8 @@ export function DesignSheet() {
<AdvancedGroup />
</div>
)}
{/* Lowered, the sheet's lower half is out of view: this keeps the end of the list scrollable into sight. */}
{!expanded && <div aria-hidden="true" className="h-[calc(50cqh-0.5rem)]" />}
</fieldset>
</Tabs>
</section>
@@ -238,7 +238,7 @@ function MobileBody({ mode, onModeChange }: MobileBodyProps) {
return (
<div className="grid min-h-0 grid-rows-[minmax(0,1fr)_auto]">
<div className="relative min-h-0">
<div className="relative min-h-0 overflow-hidden">
<main id="main-content" className={cn("h-full", !pageVisible && "invisible")}>
<Outlet />
</main>
@@ -303,7 +303,7 @@ function SelectionBar({ onEdit }: { onEdit: () => void }) {
if (!selection) return null;
return (
<div className="absolute bottom-[76px] left-1/2 z-10 flex -translate-x-1/2 items-center rounded-xl bg-ink p-1 text-bg shadow-e3">
<div className="absolute bottom-[76px] left-1/2 z-10 flex -translate-x-1/2 starting:translate-y-2 items-center rounded-xl bg-ink p-1 text-bg starting:opacity-0 shadow-e3 transition-[opacity,translate] duration-standard ease-enter">
<button
type="button"
className="flex h-10 items-center gap-1.5 rounded-lg px-3 font-semibold text-[15px]"
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
import { Icon } from "@reactive-resume/ui/components/icon";
import { popTransition } from "@/features/settings/root";
type Page = {
to: "/dashboard/settings/account" | "/dashboard/settings/preferences" | "/dashboard/settings/ai";
@@ -76,6 +77,7 @@ function RouteComponent() {
{!isRoot && (
<Link
to="/dashboard/settings"
viewTransition={popTransition}
className="mb-4 inline-flex h-9 items-center gap-1 text-ink-2 text-sm sm:hidden"
>
<Icon name="chevron_left" size={20} />
+47 -31
View File
@@ -1,5 +1,5 @@
import type * as React from "react";
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { Drawer as SheetPrimitive } from "@base-ui/react/drawer";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
@@ -41,8 +41,9 @@ type SheetContentProps = SheetPrimitive.Popup.Props & {
};
/**
* A task beside the page. Side sheets slide in from their edge over 320ms; the bottom variant is the
* mobile form (18px top radius and a grabber).
* A task beside the page. Side sheets slide in from their edge over 320ms; the bottom variant is the mobile
* form (18px top radius and a grabber) and follows a downward swipe: a flick or a drag past half its height
* dismisses it, anything less settles back.
*/
function SheetContent({
className,
@@ -52,37 +53,52 @@ function SheetContent({
closeLabel = "Close",
...props
}: SheetContentProps) {
const popup = (
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-raised text-ink text-sm shadow-e3 outline-none transition-[translate] duration-emphasized ease-enter data-ending-style:duration-[calc(var(--d3)*0.7)]",
"data-[side=left]:data-ending-style:-translate-x-full data-[side=left]:data-starting-style:-translate-x-full data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-full data-[side=left]:sm:max-w-[440px]",
"data-[side=right]:data-ending-style:translate-x-full data-[side=right]:data-starting-style:translate-x-full data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-full data-[side=right]:sm:max-w-[440px]",
"rtl:data-[side=left]:data-ending-style:translate-x-full rtl:data-[side=left]:data-starting-style:translate-x-full rtl:data-[side=right]:data-ending-style:-translate-x-full rtl:data-[side=right]:data-starting-style:-translate-x-full",
"data-[side=top]:data-ending-style:-translate-y-full data-[side=top]:data-starting-style:-translate-y-full data-[side=top]:inset-x-0 data-[side=top]:top-0",
"data-[side=bottom]:data-ending-style:translate-y-full data-[side=bottom]:data-starting-style:translate-y-full data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:max-h-[calc(100svh-2rem)] data-[side=bottom]:rounded-t-3xl data-[side=bottom]:pt-3",
// Swipe: Base UI moves the popup inline while dragging; on release it rests at the swipe offset and
// transitions back to 0, or leaves on the drawer curve, faster for a harder flick.
"data-swipe-dismiss:data-ending-style:duration-[calc(var(--d3)*0.7*var(--drawer-swipe-strength,1))] data-swipe-dismiss:data-ending-style:ease-drawer data-[side=bottom]:pointer-events-auto data-swiping:select-none data-[side=bottom]:transition-[translate,transform] data-[side=bottom]:[transform:translateY(var(--drawer-swipe-movement-y,0px))]",
className,
)}
{...props}
>
{side === "bottom" && (
<span aria-hidden="true" className="mx-auto block h-[5px] w-9 shrink-0 rounded-full bg-line-2" />
)}
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
aria-label={closeLabel}
render={<Button variant="ghost" className="absolute inset-e-3 top-3 text-ink-2" size="icon" />}
>
<Icon name="close" />
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
);
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-raised text-ink text-sm shadow-e3 outline-none transition-[translate] duration-emphasized ease-enter data-ending-style:duration-[calc(var(--d3)*0.7)]",
"data-[side=left]:data-ending-style:-translate-x-full data-[side=left]:data-starting-style:-translate-x-full data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-full data-[side=left]:sm:max-w-[440px]",
"data-[side=right]:data-ending-style:translate-x-full data-[side=right]:data-starting-style:translate-x-full data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-full data-[side=right]:sm:max-w-[440px]",
"rtl:data-[side=left]:data-ending-style:translate-x-full rtl:data-[side=left]:data-starting-style:translate-x-full rtl:data-[side=right]:data-ending-style:-translate-x-full rtl:data-[side=right]:data-starting-style:-translate-x-full",
"data-[side=top]:data-ending-style:-translate-y-full data-[side=top]:data-starting-style:-translate-y-full data-[side=top]:inset-x-0 data-[side=top]:top-0",
"data-[side=bottom]:data-ending-style:translate-y-full data-[side=bottom]:data-starting-style:translate-y-full data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:max-h-[calc(100svh-2rem)] data-[side=bottom]:rounded-t-3xl data-[side=bottom]:pt-3",
className,
)}
{...props}
>
{side === "bottom" && (
<span aria-hidden="true" className="mx-auto block h-[5px] w-9 shrink-0 rounded-full bg-line-2" />
)}
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
aria-label={closeLabel}
render={<Button variant="ghost" className="absolute inset-e-3 top-3 text-ink-2" size="icon" />}
>
<Icon name="close" />
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
{side === "bottom" ? (
// The viewport tracks the swipe. It covers the screen without taking clicks, which fall through to the
// backdrop, while the popup's pointer events still bubble to it.
<SheetPrimitive.Viewport data-slot="sheet-viewport" className="pointer-events-none fixed inset-0 z-50">
{popup}
</SheetPrimitive.Viewport>
) : (
popup
)}
</SheetPortal>
);
}