freezmo/lib/motion.ts
Martin Dvorak 8557b04621 Navigation restructure: five-tab bottom bar, no phone drawer
Drawer removed (phones only; desktop rail untouched)
- MobileSidebar, sidebarOpen/useSidebarOpen, MenuIcon and the drawer's
  back-gesture layer are gone; the shared nav content now serves only
  the desktop rail (dead mobile branches deleted). No drawer-specific
  CSS existed; the three drawer strings (header.openMenu,
  sidebar.closeMenu, common.menu) and library.importAria are removed.
- The header drops the hamburger: logo + name on the left, the single
  theme chip on the right.

Five-tab bottom bar
- Library / Progress / New deck (prominent centre +) / Exam / Settings,
  in that order; Progress and Exam reuse the existing /stats and /exam
  screens, Settings keeps opening the dialog. Sliding pill, icon bounce,
  press feedback, re-tap-to-top and safe-area padding kept; every item
  is >=44px (72px wide at 360px).
- New short tab.* keys (EN + CS) so all five labels stay on one line at
  360px — 'Zkouškový režim' became 'Zkouška'. No ellipsis anywhere.
- The bar is mounted once in AppHeader so library, progress, exam and
  share get it; the wizards keep their own bottom action bar and the
  tab bar steps aside there, exactly as before.

Re-homed drawer destinations
- Smart Review card loses the Exam link (it is a tab now); Review due
  and Mistakes stay.
- New deck screen (and /homework) get a Study deck / Make my homework
  segmented switch; the library's homework empty-state button stays.
- The phone import icon becomes a ... button opening a sheet with
  Import JSON and New folder (back-gesture and Escape close it); folder
  chips still appear only once at least one folder exists, and folder
  creation stays in the New deck options step.

Verified: mobile suite 134 checks (no hamburger/drawer, tab order and
44px targets, label matrix at 360/390 x EN/CS x dark/light x reduced
motion, per-tab routing, deep links, Back from every tab, the ... sheet
with folder creation) plus regression 74/74, wizard 83/83, declutter
116/116, animation 8/8, native 35/35, i18n 25/25, phase1 38/38, phase2
43/43, phase3 20/20, freeze-loading 6/6. Key parity 740/740. Lint +
build clean. SW freezmo-v28.
2026-10-10 16:53:55 +00:00

123 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* One shared motion language for the mobile animation pass.
*
* Every component imports its presets from here instead of inventing ad-hoc
* springs, so the whole app feels like one native surface:
*
* - `springSnappy` — press releases and small UI changes (180–220ms feel).
* - `springGentle` — screen content, lists, feedback panels (280–350ms).
* - `springSheet` — bottom sheets, tuned for dragging.
* - `EASE_OUT` — quick fades that don't need a spring.
*
* Only transform and opacity are ever animated (plus the explicit height
* animation of expanders); blur and backdrop-filter are never touched.
* `prefers-reduced-motion` is handled by the callers (CSS media query for
* press feedback, `useReducedMotion` for JS springs) so motion collapses to
* short fades without changing layout.
*/
import type { Transition, Variants } from "framer-motion";
import { useEffect, useState } from "react";
/** Press feedback and other tiny changes. ~200ms spring, no bounce overshoot. */
export const springSnappy: Transition = { type: "spring", stiffness: 520, damping: 34, mass: 0.7 };
/** Content entering/exiting a screen or list. ~300ms, a whisper of overshoot. */
export const springGentle: Transition = { type: "spring", stiffness: 300, damping: 30, mass: 0.9 };
/** Bottom sheets: responsive to release velocity, settles fast. */
export const springSheet: Transition = { type: "spring", stiffness: 380, damping: 36, mass: 1 };
/** Crisp fade for elements that should not move. */
export const fade: Transition = { duration: 0.18, ease: "easeOut" };
/** Plain ease for fades and progress bars. */
export const EASE_OUT = [0.22, 1, 0.36, 1] as const;
/** Durations (seconds) used across the pass. */
export const DURATION = {
press: 0.1,
small: 0.2,
screen: 0.3,
sheet: 0.34,
} as const;
/** A screen entering from the right (drill-in) or left (back). */
export function screenVariants(direction: 1 | -1, reduceMotion: boolean): Variants {
if (reduceMotion) {
return {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.12 } },
exit: { opacity: 0, transition: { duration: 0.1 } },
};
}
return {
initial: { opacity: 0, x: 24 * direction },
animate: { opacity: 1, x: 0, transition: springGentle },
exit: { opacity: 0, x: -16 * direction, transition: { duration: 0.18, ease: "easeIn" } },
};
}
/** Crossfade for same-depth route switches (tab to tab). */
export function tabVariants(reduceMotion: boolean): Variants {
if (reduceMotion) {
return { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } };
}
return {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0, transition: springGentle },
exit: { opacity: 0, y: -6, transition: { duration: 0.16, ease: "easeIn" } },
};
}
/** Wizard step: forward slides in from the right, Back from the left. */
export function stepVariants(direction: 1 | -1, reduceMotion: boolean): Variants {
if (reduceMotion) {
return { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.12 } } };
}
return {
initial: { opacity: 0, x: 18 * direction },
animate: { opacity: 1, x: 0, transition: { duration: 0.24, ease: EASE_OUT } },
};
}
/** Capped stagger for a list's first paint. */
export const listContainer: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.045, delayChildren: 0.02 } },
};
export const listItem: Variants = {
hidden: { opacity: 0, y: 12 },
show: { opacity: 1, y: 0, transition: springGentle },
};
/**
* Counts a number up over ~600ms with an ease-out curve (score, XP). Pass
* `disabled` (desktop, reduced motion) to jump straight to the value.
*/
export function useCountUp(target: number, disabled = false): number {
const [value, setValue] = useState(disabled ? target : 0);
useEffect(() => {
if (disabled) {
setValue(target);
return;
}
let frame = 0;
const start = performance.now();
const duration = 600;
const tick = (now: number) => {
const progress = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
setValue(Math.round(target * eased));
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [target, disabled]);
return value;
}