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.
This commit is contained in:
Martin Dvořák 2026-10-10 16:53:55 +00:00
commit 8557b04621
19 changed files with 357 additions and 325 deletions

View file

@ -3,18 +3,17 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { MenuIcon } from "@/components/icons";
import { SettingsDialog } from "@/components/SettingsDialog";
import { TabBar } from "@/components/TabBar";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useI18n } from "@/lib/i18n";
import { sidebarOpen } from "@/lib/nav";
import { OPEN_SETTINGS_EVENT } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
/**
* Minimal, sticky frosted top bar: brand on the left, quiet actions on the
* right. The phone layout keeps the brand compact and relies on the tab bar
* for navigation.
* Minimal, sticky frosted top bar: brand on the left, theme chip on the
* right. Phones navigate with the bottom tab bar (also mounted here, so every
* screen that has a header gets it); the desktop rail carries navigation.
*/
export function AppHeader({ className, compact = false }: { className?: string; compact?: boolean }) {
const { t } = useI18n();
@ -48,41 +47,34 @@ export function AppHeader({ className, compact = false }: { className?: string;
)}
>
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3">
<button
type="button"
onClick={() => sidebarOpen.toggle()}
aria-label={t("header.openMenu")}
className="mr-1 grid size-9 shrink-0 touch-target place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
<Link
href="/"
className="group flex min-w-0 items-center gap-2.5 rounded-xl px-1 py-1"
aria-label={t("header.home")}
>
<MenuIcon className="size-5" />
</button>
<Link href="/" className="group flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label={t("header.home")}>
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
src="/icons/icon-192.png"
alt=""
aria-hidden
className="size-8 rounded-[0.6rem] transition-transform group-hover:scale-[1.04]"
/>
<span className="flex items-baseline gap-2">
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">
Freezmo
</span>
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
src="/icons/icon-192.png"
alt=""
aria-hidden
className="size-8 shrink-0 rounded-[0.6rem] transition-transform group-hover:scale-[1.04]"
/>
<span className="flex min-w-0 items-baseline gap-2">
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">Freezmo</span>
{!compact ? (
<span className="hidden text-[13px] text-[var(--text-subtle)] sm:inline">
<span className="hidden truncate text-[13px] text-[var(--text-subtle)] sm:inline">
{t("header.tagline")}
</span>
) : null}
</span>
</Link>
<div className="flex items-center gap-1">
{/* Settings lives in the tab bar and the drawer; the old header
gear was the same control a third time. */}
<div className="flex shrink-0 items-center gap-1">
<ThemeToggle />
</div>
</div>
</header>
<TabBar />
<SettingsDialog
open={settingsOpen}
onClose={() => setSettingsOpen(false)}

View file

@ -7,20 +7,21 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "reac
import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard";
import { FolderIcon, UploadIcon } from "@/components/icons";
import { EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
import { SegmentedPill } from "@/components/SegmentedPill";
import { Button, EmptyState, ProgressBar } from "@/components/ui";
import { folderStore, renameFolder } from "@/lib/folders";
import { createFolder, folderStore, renameFolder } from "@/lib/folders";
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
import { learningStore } from "@/lib/history";
import { useI18n } from "@/lib/i18n";
import { computeStats, dueCount, mistakeCount } from "@/lib/learning";
import { settingsStore } from "@/lib/settings";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { springGentle } from "@/lib/motion";
import { springGentle, springSheet } from "@/lib/motion";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage";
import { backupNudgeDue, downloadBackup, snoozeBackupNudge } from "@/lib/backup";
import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
@ -51,6 +52,40 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [nudgeDue, setNudgeDue] = useState(false);
/** Phone "⋯" sheet: Import JSON + New folder. */
const [sheetOpen, setSheetOpen] = useState(false);
const [folderCreating, setFolderCreating] = useState(false);
const [folderDraft, setFolderDraft] = useState("");
const [folderError, setFolderError] = useState<string | null>(null);
useBackClose(sheetOpen, () => setSheetOpen(false));
useEffect(() => {
if (!sheetOpen) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setSheetOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [sheetOpen]);
const closeSheet = () => {
setSheetOpen(false);
setFolderCreating(false);
setFolderDraft("");
setFolderError(null);
};
const submitFolderFromSheet = () => {
const result = createFolder(folderDraft);
if ("error" in result) {
setFolderError(result.error);
return;
}
setSelected(result.folder.id);
setMessage({ tone: "ok", text: t("library.folderCreated") });
closeSheet();
};
useEffect(() => {
setNudgeDue(backupNudgeDue(decks.length));
@ -202,18 +237,19 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</div>
<button
type="button"
onClick={() => importRef.current?.click()}
aria-label={t("library.importAria")}
title={t("library.import")}
onClick={() => setSheetOpen(true)}
aria-label={t("library.moreActions")}
title={t("library.moreActions")}
className="touch-target grid size-11 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] sm:hidden"
>
<UploadIcon className="size-4" />
<EllipsisIcon className="size-5" />
</button>
</div>
{/* Mobile folder switcher; the sidebar covers desktop. Hidden while
there are no folders — the drawer's “New folder” is the way in. */}
{tab === "decks" && !isEmpty && hasFolders ? (
{/* Mobile folder switcher; the desktop rail covers desktop. Shown only
once at least one folder exists — creating one lives in the ⋯ sheet
and in the New deck options step. */}
{tab === "decks" && hasFolders ? (
<FolderBar
className="lg:hidden"
folders={folderList}
@ -288,12 +324,6 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
>
{t("review.mistakesTitle")} · {plural(mistakes, "review.mistakes")}
</Link>
<Link
href="/exam"
className="btn-ghost touch-target inline-flex h-10 items-center rounded-xl px-3.5 text-[13px] font-semibold transition"
>
{t("exam.title")}
</Link>
</div>
</section>
@ -498,6 +528,93 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</motion.div>
</AnimatePresence>
{/* phone "⋯" sheet: Import JSON + New folder ---------------------- */}
<AnimatePresence>
{sheetOpen ? (
<motion.div
className="fixed inset-0 z-50 flex flex-col justify-end sm:hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<button
type="button"
aria-label={t("common.cancel")}
onClick={closeSheet}
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
/>
<motion.div
role="dialog"
aria-modal="true"
aria-label={t("library.moreTitle")}
initial={{ y: 300 }}
animate={{ y: 0 }}
exit={{ y: 300 }}
transition={springSheet}
className="glass-panel relative z-10 rounded-t-3xl px-4 pb-safe pt-3"
>
<div className="sheet-grabber" aria-hidden />
<h2 className="mb-3 text-[15px] font-semibold text-[var(--text)]">{t("library.moreTitle")}</h2>
{folderCreating ? (
<div className="space-y-2 pb-4">
<input
autoFocus
value={folderDraft}
onChange={(event) => setFolderDraft(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") submitFolderFromSheet();
}}
placeholder={t("common.folderName")}
aria-label={t("common.newFolderName")}
maxLength={60}
className="w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2.5 text-[15px] text-[var(--text)] outline-none focus:border-accent-400"
/>
{folderError ? (
<p className="text-[12px] font-medium text-rose-600 dark:text-rose-300">{folderError}</p>
) : null}
<div className="flex gap-2">
<Button className="touch-target flex-1" onClick={submitFolderFromSheet}>
{t("common.add")}
</Button>
<Button variant="ghost" className="touch-target" onClick={() => setFolderCreating(false)}>
{t("common.cancel")}
</Button>
</div>
</div>
) : (
<div className="space-y-1 pb-4">
<button
type="button"
onClick={() => {
closeSheet();
importRef.current?.click();
}}
className="nav-row touch-target"
>
<UploadIcon className="size-4 shrink-0 opacity-70" />
<span className="flex-1 text-left">{t("library.import")}</span>
</button>
<button
type="button"
onClick={() => {
setFolderDraft("");
setFolderError(null);
setFolderCreating(true);
}}
className="nav-row touch-target"
>
<FolderIcon className="size-4 shrink-0 opacity-70" />
<span className="flex-1 text-left">{t("common.newFolder")}</span>
</button>
</div>
)}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}

View file

@ -2,7 +2,6 @@
import Link from "next/link";
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
ChevronLeftIcon,
@ -21,26 +20,19 @@ import { useTheme } from "@/components/ThemeToggle";
import { createFolder, folderStore } from "@/lib/folders";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { UNORGANIZED_ID } from "@/lib/schemas";
import { deckStore } from "@/lib/storage";
import { openSettings } from "@/lib/ui-events";
import { useBackClose } from "@/lib/use-back-close";
import { cn } from "@/lib/utils";
const COLLAPSE_KEY = "freezmo.sidebarCollapsed";
/** Shared nav content for the desktop rail and the mobile drawer. */
function NavContent({
collapsed = false,
onNavigate,
desktop = false,
}: {
collapsed?: boolean;
onNavigate?: () => void;
desktop?: boolean;
}) {
/**
* Desktop navigation rail (lg and up). Phones use the bottom tab bar instead —
* there is no drawer any more, so this is the only consumer of the rail.
*/
function NavContent({ collapsed = false }: { collapsed?: boolean }) {
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
const folders = useSyncExternalStore(
folderStore.subscribe,
@ -60,16 +52,6 @@ function NavContent({
[folders],
);
const counts = useMemo(() => {
const map: Record<string, number> = { [UNORGANIZED_ID]: 0 };
for (const folder of sortedFolders) map[folder.id] = 0;
for (const deck of decks) {
const key = deck.folderId && map[deck.folderId] !== undefined ? deck.folderId : UNORGANIZED_ID;
map[key] = (map[key] ?? 0) + 1;
}
return map;
}, [decks, sortedFolders]);
const submit = () => {
const result = createFolder(draft);
if ("error" in result) {
@ -82,20 +64,15 @@ function NavContent({
setCreating(false);
};
const select = (id: string) => {
folderSelection.set(id);
onNavigate?.();
};
/** Counts of 0 are noise — only show a count when there is something in it. */
const count = (value: number) =>
!collapsed && value > 0 ? <span className="text-[12px] tabular-nums opacity-60">{value}</span> : null;
/** One shared view row (All decks / Unorganized) for both nav layouts. */
/** One shared view row (All decks / Unorganized). */
const viewRow = (id: string, label: string, icon: ReactNode, trailing: ReactNode) => (
<button
type="button"
onClick={() => select(id)}
onClick={() => folderSelection.set(id)}
aria-pressed={selected === id}
title={label}
className={cn("nav-row", collapsed && "justify-center px-2")}
@ -112,7 +89,6 @@ function NavContent({
<div className={cn("mb-1 flex items-center", collapsed ? "justify-center" : "justify-between")}>
<Link
href="/"
onClick={onNavigate}
className={cn("nav-row gap-2.5", collapsed ? "w-auto px-2" : "flex-1")}
aria-label={t("header.home")}
title="Freezmo"
@ -144,7 +120,6 @@ function NavContent({
{/* new deck: a quiet row, not a competing primary button ---------- */}
<Link
href="/create"
onClick={onNavigate}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("common.newDeck")}
>
@ -155,7 +130,6 @@ function NavContent({
{/* make my homework: same quiet row --------------------------------- */}
<Link
href="/homework"
onClick={onNavigate}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("homework.nav")}
>
@ -163,29 +137,7 @@ function NavContent({
{!collapsed ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
</Link>
{/* folders --------------------------------------------------------- */}
{/* Mobile keeps the original layout: one Folders label above the list. */}
{!desktop && !collapsed ? (
<div className="flex items-center justify-between px-2 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("common.folders")}
</span>
<button
type="button"
onClick={() => setCreating(true)}
aria-label={t("common.newFolder")}
title={t("common.newFolder")}
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-3.5" />
</button>
</div>
) : null}
<nav
aria-label={desktop ? t("sidebar.library") : t("common.folders")}
className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto"
>
<nav aria-label={t("sidebar.library")} className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{collapsed ? (
<button
type="button"
@ -198,42 +150,23 @@ function NavContent({
</button>
) : null}
{/* Desktop: All decks + Unorganized are views, so they sit above the
Folders label; mobile keeps them inside the folder list. */}
{desktop ? (
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
{viewRow(
ALL_FOLDERS,
t("common.allDecks"),
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
t("common.unorganized"),
<FolderIcon className="size-4 shrink-0 opacity-70" />,
null,
)}
</div>
) : (
<>
{viewRow(
ALL_FOLDERS,
t("common.allDecks"),
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
t("common.unorganized"),
<FolderIcon className="size-4 shrink-0 opacity-70" />,
count(counts[UNORGANIZED_ID] ?? 0),
)}
</>
)}
{/* All decks + Unorganized are views, so they sit above Folders. */}
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
{viewRow(
ALL_FOLDERS,
t("common.allDecks"),
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
t("common.unorganized"),
<FolderIcon className="size-4 shrink-0 opacity-70" />,
null,
)}
</div>
{/* Desktop: the Folders label sits between the views and the folders. */}
{desktop && !collapsed ? (
{!collapsed ? (
<div className="flex items-center justify-between px-2 pb-1 pt-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("common.folders")}
@ -254,14 +187,13 @@ function NavContent({
<button
key={folder.id}
type="button"
onClick={() => select(folder.id)}
onClick={() => folderSelection.set(folder.id)}
aria-pressed={selected === folder.id}
title={folder.name}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<FolderIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{folder.name}</span> : null}
{desktop ? null : count(counts[folder.id] ?? 0)}
</button>
))}
@ -298,7 +230,6 @@ function NavContent({
<div className="mt-2 space-y-0.5 border-t border-[var(--hairline)] pt-2">
<Link
href="/stats"
onClick={onNavigate}
title={t("nav.stats")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
@ -307,7 +238,6 @@ function NavContent({
</Link>
<Link
href="/exam"
onClick={onNavigate}
title={t("nav.exam")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
@ -350,7 +280,7 @@ function NavContent({
}
/* ------------------------------------------------------------------ */
/* Collapsed state (shared between rail and drawer) */
/* Collapsed state (desktop rail) */
/* ------------------------------------------------------------------ */
let collapsed = false;
@ -395,7 +325,7 @@ function useCollapsed(): boolean {
}
/* ------------------------------------------------------------------ */
/* Desktop rail + mobile drawer */
/* Desktop rail */
/* ------------------------------------------------------------------ */
export function Sidebar() {
@ -412,61 +342,12 @@ export function Sidebar() {
>
{/* One tall inset glass panel, so the ice scene shows around it. */}
<div className="sidebar-surface flex min-h-0 flex-1 flex-col px-2.5 py-3">
<NavContent collapsed={isCollapsed} desktop />
<NavContent collapsed={isCollapsed} />
</div>
</aside>
);
}
export function MobileSidebar() {
const open = useSidebarOpen();
const { t } = useI18n();
/* Back closes the drawer before it navigates away. */
const { skipRewind } = useBackClose(open, () => sidebarOpen.set(false));
return (
<AnimatePresence>
{open ? (
<motion.div
className="fixed inset-0 z-50 flex lg:hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<button
type="button"
aria-label={t("sidebar.closeMenu")}
onClick={() => sidebarOpen.set(false)}
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
/>
<motion.aside
aria-label={t("common.menu")}
initial={{ x: -280 }}
animate={{ x: 0 }}
exit={{ x: -280 }}
transition={springSheet}
drag="x"
dragConstraints={{ left: -280, right: 0 }}
dragElastic={{ left: 0.45, right: 0 }}
dragMomentum={false}
onDragEnd={(_, gesture) => {
if (gesture.offset.x < -80 || gesture.velocity.x < -500) sidebarOpen.set(false);
}}
className="sidebar-surface relative z-10 flex h-full w-[272px] flex-col rounded-l-none rounded-r-[1.5rem] px-2.5 py-3 pb-safe"
>
<NavContent
onNavigate={() => {
skipRewind();
sidebarOpen.set(false);
}}
/>
</motion.aside>
</motion.div>
) : null}
</AnimatePresence>
);
}
/** Spacer so page content clears the fixed rail (desktop only). */
export function SidebarOffset() {
const isCollapsed = useCollapsed();

View file

@ -4,7 +4,7 @@ import { motion, useReducedMotion } from "framer-motion";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
import { CompassIcon, FlameIcon, GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { springSnappy } from "@/lib/motion";
@ -12,20 +12,25 @@ import { openSettings } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
/**
* iOS-style bottom tab bar. Rendered on the library and create screens on
* phones; hidden on desktop (the header carries the same actions there) and on
* the full-screen study feed.
* iOS-style bottom tab bar, phones only: Library · Progress · New deck (the
* prominent centre button) · Exam mode · Settings. The active item sits on a
* sliding pill (shared `layoutId`) and its icon gives a small bounce when you
* land on it; re-tapping the active tab scrolls back to the top.
*
* The active item sits on a sliding pill (shared `layoutId`), and its icon
* gives a small bounce when you land on it.
* Labels use the short `tab.*` keys so they fit on one line at 360px in both
* languages — no ellipsis, no wrapping.
*/
export function TabBar() {
const pathname = usePathname();
const { t } = useI18n();
const reduceMotion = useReducedMotion();
/* The wizards keep their own bottom action bar (Cancel / Next / Freeze) —
the flow is deliberately focused, so the tab bar steps aside there. */
const inWizard = pathname === "/create" || pathname === "/homework";
const item =
"relative flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
"relative flex min-h-[52px] min-w-0 flex-1 flex-col items-center justify-center gap-1 py-2 text-[10.5px] font-medium leading-none transition-colors";
const active = "text-[var(--text)]";
const idle = "text-[var(--text-subtle)]";
@ -53,58 +58,67 @@ export function TabBar() {
);
const label = (text: string, isActive: boolean) => (
<span className={cn("relative z-10", isActive ? active : idle)}>{text}</span>
<span className={cn("relative z-10 whitespace-nowrap", isActive ? active : idle)}>{text}</span>
);
const libraryActive = pathname === "/";
const createActive = pathname === "/create";
const isActive = (key: "library" | "progress" | "create" | "exam") => {
if (key === "library") return pathname === "/";
if (key === "progress") return pathname === "/stats";
// The homework wizard is the other half of the New deck screen.
if (key === "create") return pathname === "/create" || pathname === "/homework";
return pathname === "/exam";
};
const link = (
key: "library" | "progress" | "create" | "exam",
href: string,
labelText: string,
iconNode: React.ReactNode,
) => {
const current = isActive(key);
return (
<Link
key={key}
href={href}
aria-current={current ? "page" : undefined}
className={cn(item, current ? active : idle)}
onClick={(event) => {
haptic("tap");
// Re-tapping the active tab scrolls back to the top.
if (current) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
}
}}
>
{pill(current)}
{icon(iconNode, current, key)}
{label(labelText, current)}
</Link>
);
};
if (inWizard) return null;
return (
<nav
aria-label={t("tabbar.main")}
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
>
<Link
href="/"
aria-current={libraryActive ? "page" : undefined}
className={cn(item, libraryActive ? active : idle)}
onClick={(event) => {
haptic("tap");
// Re-tapping the active tab scrolls back to the top.
if (libraryActive) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
}
}}
>
{pill(libraryActive)}
{icon(<HomeIcon className="size-6" />, libraryActive, "library")}
{label(t("common.library"), libraryActive)}
</Link>
<Link
href="/create"
aria-current={createActive ? "page" : undefined}
className={cn(item, createActive ? active : idle)}
onClick={(event) => {
haptic("tap");
if (createActive) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
}
}}
>
{pill(createActive)}
{icon(
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
<PlusIcon className="size-4" />
</span>,
createActive,
"create",
)}
{label(t("common.newDeck"), createActive)}
</Link>
{link("library", "/", t("tab.library"), <HomeIcon className="size-6" />)}
{link("progress", "/stats", t("tab.progress"), <FlameIcon className="size-6" />)}
{link(
"create",
"/create",
t("tab.new"),
<span className="grad-ice grid size-7 place-items-center rounded-full text-[#06111f]">
<PlusIcon className="size-4" />
</span>,
)}
{link("exam", "/exam", t("tab.exam"), <CompassIcon className="size-6" />)}
<button
type="button"
aria-haspopup="dialog"
onClick={() => {
haptic("tap");
openSettings();
@ -112,7 +126,7 @@ export function TabBar() {
className={cn(item, idle)}
>
{icon(<GearIcon className="size-6" />, false, "settings")}
{label(t("common.settings"), false)}
{label(t("tab.settings"), false)}
</button>
</nav>
);

View file

@ -1054,8 +1054,8 @@ export function UploadScreen() {
</section>
{/* Dedicated flow: this page is the Study Deck uploader. Homework has
its own screen (/homework), reached from the library tab, the
drawer or the bottom bar — no mode picker here. */}
its own screen (/homework), reached from the mode switch above, the
centre tab or the library's homework tab. */}
{errorCard}

View file

@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { SegmentedPill } from "@/components/SegmentedPill";
import { useI18n } from "@/lib/i18n";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
/**
* In-page switch between the two wizards (New deck ⇄ Make my homework).
* Phones reach both through this control and the centre tab; desktop keeps
* the rail rows as well.
*/
export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" }) {
const { t } = useI18n();
const isPhone = useIsPhone();
return (
<div className="segmented mb-5" role="group" aria-label={t("wizard.mode.aria")}>
<Link
href="/create"
aria-current={mode === "deck" ? "page" : undefined}
className={cn("relative", isPhone && "seg-slide")}
>
{isPhone && mode === "deck" ? <SegmentedPill layoutId="wizard-mode" /> : null}
<span className="seg-label relative z-10">{t("wizard.mode.deck")}</span>
</Link>
<Link
href="/homework"
aria-current={mode === "homework" ? "page" : undefined}
className={cn("relative", isPhone && "seg-slide")}
>
{isPhone && mode === "homework" ? <SegmentedPill layoutId="wizard-mode" /> : null}
<span className="seg-label relative z-10">{t("wizard.mode.homework")}</span>
</Link>
</div>
);
}

View file

@ -214,14 +214,6 @@ export function ChevronLeftIcon({ className }: IconProps) {
);
}
export function MenuIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="M4 7h16M4 12h16M4 17h16" />
</Svg>
);
}
export function ChevronRightIcon({ className }: IconProps) {
return (
<Svg className={className}>