Files
Reactive-Resume/apps/web/src/routes/builder/$resumeId/-components/mobile-builder-shell.tsx
T
Amruth Pillai b48a9c2142 feat(web): refine motion system and simplify animated UI (#3546)
Audit every animation in the app and shared UI primitives against a
frequency-first motion bar: keyboard and high-frequency actions no longer
animate, remaining motion uses interruptible CSS transitions with shared
easing tokens, and redundant animation code is removed.

UI primitives (@reactive-resume/ui)
- Dialog, alert dialog, popover and tooltip move from tw-animate keyframes
  to Base UI data-starting/ending-style transitions; menus, popovers and
  tooltips skip motion when opened from the keyboard (data-instant).
- Dialog gains an `instant` prop; the command palette uses it.
- Accordion animates its real panel height; caret rotates instead of
  swapping icons.
- Menu backdrop blur moves onto the popup so it no longer snaps in after
  the fade; context menus and comboboxes fade only.
- Sidebar collapse uses the strong ease-out curve and snaps on Cmd+B.
- Toast, sheet, checkbox, tabs, toggle, inputs and message scroller get
  tokenised easing, correct transition properties and press feedback.
- Tabs no longer squeeze a trigger narrower than its label.
- Spinners keep spinning under prefers-reduced-motion.

Web app
- Remove the default route view transition and page-entrance slides.
- Add EASE_OUT_STRONG for Motion; replace built-in "easeOut" everywhere.
- Switch LazyMotion to domMax so layout and Reorder animations run.
- Builder: consolidate 12 section list files into one ItemsSection,
  opacity-only list rows with popLayout, instant Cmd+0, faster dock zoom,
  crossfade that no longer dips, transform-based progress bars.
- Dashboard: keep previous results while sorting/filtering, no empty-state
  flash, uncontrolled sidebar (no network round trip on collapse), calmer
  resume card tilt, no stacked hover wrappers.
- Settings: drop entrance/stagger wrappers and ActionButton.
- Agent: CSS marquee paused on hover; thread switches keep the layout.
- Homepage: fix invalid transition declarations, CSS spotlight drift,
  scroll-hiding header without a JS spring, tokenised curves.
- Theme switches change every color at once.
- Remove SSR-only useIsClient guards from the SPA.

Docs: rewrite the DESIGN.md animation section around the new tokens.
2026-09-27 17:10:42 +02:00

111 lines
3.7 KiB
TypeScript

import type { Icon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, NotePencilIcon, PaletteIcon } from "@phosphor-icons/react";
import { Outlet } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
import { BuilderSidebarLeft } from "../-sidebar/left";
import { BuilderSidebarRight } from "../-sidebar/right";
import { BuilderHeader } from "./header";
type MobileBuilderTab = "edit" | "preview" | "design";
const MOBILE_BUILDER_TABS = [
{ value: "edit", icon: NotePencilIcon },
{ value: "preview", icon: EyeIcon },
{ value: "design", icon: PaletteIcon },
] as const satisfies readonly { value: MobileBuilderTab; icon: Icon }[];
type MobileSidebarPanelProps = {
children: ReactNode;
};
function MobileSidebarPanel({ children }: MobileSidebarPanelProps) {
// Sits below the header (top-14) and above the tab bar (bottom-16). The sidebar's ScrollArea hardcodes
// `h-[calc(100svh-3.5rem)]`; override it to fill this panel so its last item isn't hidden under the tab bar.
return (
<div className="fixed inset-x-0 top-14 bottom-16 z-40 bg-background [&_[data-slot=scroll-area]]:h-full">
{children}
</div>
);
}
type MobileBuilderTabBarProps = {
activeTab: MobileBuilderTab;
onTabChange: (tab: MobileBuilderTab) => void;
};
function MobileBuilderTabBar({ activeTab, onTabChange }: MobileBuilderTabBarProps) {
const labels: Record<MobileBuilderTab, string> = { edit: t`Edit`, preview: t`Preview`, design: t`Design` };
return (
<nav className="fixed inset-x-0 bottom-0 z-50 flex border-t bg-popover pb-[env(safe-area-inset-bottom)]">
{MOBILE_BUILDER_TABS.map(({ value, icon: Icon }) => {
const isActive = value === activeTab;
return (
<button
key={value}
type="button"
aria-current={isActive ? "page" : undefined}
onClick={() => onTabChange(value)}
className={cn(
"flex min-h-16 flex-1 flex-col items-center justify-center gap-1 text-xs transition-[color,scale] duration-150 ease-out-strong active:scale-[0.97]",
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-5" weight={isActive ? "fill" : "regular"} />
<span>{labels[value]}</span>
</button>
);
})}
</nav>
);
}
export function MobileBuilderShell() {
// Local state is enough — mobile view mode is shell-scoped and doesn't need to persist.
const [tab, setTab] = useState<MobileBuilderTab>("edit");
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
// The preview stays mounted under the Edit/Design overlay; pause its render while it's covered.
useEffect(() => {
setPreviewPaused(tab !== "preview");
return () => setPreviewPaused(false);
}, [tab, setPreviewPaused]);
return (
<div className="flex min-h-[100dvh] flex-col">
<a
href="#main-content"
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
>
<Trans>Skip to main content</Trans>
</a>
<BuilderHeader />
{/* The preview (fixed inset-0) stays mounted so zoom/pan state survives tab switches. */}
<main id="main-content" className="flex-1">
<Outlet />
</main>
{tab === "edit" && (
<MobileSidebarPanel>
<BuilderSidebarLeft />
</MobileSidebarPanel>
)}
{tab === "design" && (
<MobileSidebarPanel>
<BuilderSidebarRight />
</MobileSidebarPanel>
)}
<MobileBuilderTabBar activeTab={tab} onTabChange={setTab} />
</div>
);
}