From 8e0a76bcb1a697cfe7d25cbe04ca14952c36099b Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 13:10:59 +0200 Subject: [PATCH] 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. --- .../resume/editor/write/entry-card.tsx | 38 +++++---- apps/web/src/features/settings/root.tsx | 12 ++- apps/web/src/index.css | 37 +++++++++ .../$resumeId/-components/design-panel.tsx | 9 ++- .../$resumeId/-components/editor-shell.tsx | 4 +- .../src/routes/dashboard/settings/route.tsx | 2 + packages/ui/src/components/sheet.tsx | 78 +++++++++++-------- 7 files changed, 127 insertions(+), 53 deletions(-) diff --git a/apps/web/src/features/resume/editor/write/entry-card.tsx b/apps/web/src/features/resume/editor/write/entry-card.tsx index 5a531c45d..5cdab8a6a 100644 --- a/apps/web/src/features/resume/editor/write/entry-card.tsx +++ b/apps/web/src/features/resume/editor/write/entry-card.tsx @@ -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 )} - {open && isPhone && ( - deleteEntry(section, entryId)} - onBack={() => select(null)} - > - {fields} - - )} + + {open && isPhone && ( + deleteEntry(section, entryId)} + onBack={() => select(null)} + > + {fields} + + )} + ); } @@ -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 ( -
+ ); } diff --git a/apps/web/src/features/settings/root.tsx b/apps/web/src/features/settings/root.tsx index 6c3783823..90dd711ed 100644 --- a/apps/web/src/features/settings/root.tsx +++ b/apps/web/src/features/settings/root.tsx @@ -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() {
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f22e52ee3..4c0df7910 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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; + } +} diff --git a/apps/web/src/routes/builder/$resumeId/-components/design-panel.tsx b/apps/web/src/routes/builder/$resumeId/-components/design-panel.tsx index 2b09f276b..68e05c2e5 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/design-panel.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/design-panel.tsx @@ -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() {
diff --git a/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx b/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx index 33de65898..ce2a6585b 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx @@ -238,7 +238,7 @@ function MobileBody({ mode, onModeChange }: MobileBodyProps) { return (
-
+
@@ -303,7 +303,7 @@ function SelectionBar({ onEdit }: { onEdit: () => void }) { if (!selection) return null; return ( -
+