Wizard polish: step headlines, gated Next, real summary, portal action bar

Mobile only (<=639px); desktop markup untouched.

Bug fix: the Back/Next bar was fixed-positioned inside the screen
entrance animation's transformed wrapper, so it painted under the content
and jumped to the viewport bottom about a second in. It is now portalled
to <body> and only fades in, pinned to the bottom from the first frame.
The other fixed/sticky elements (tab bar, drawer, sheets, install sheet)
were checked and live outside the animated wrappers.

- The step name is now the large headline with a one-line description;
  the repeated page title is dropped on phones. Thin progress bar + 1/3.
- Step 1: formats/limits hint, one big dashed Add photos or files area
  (plus icon), Camera and Paste text instead as secondaries, and a
  'nothing added yet' placeholder that becomes the thumbnail/chip list.
- Next is disabled until a photo, file or pasted text exists; step 3 can
  never be reached empty.
- Step 1 shows Cancel (back to the Library) instead of a dead Back.
- Step 3 is a real summary: thumbnail/chip strip plus Files, Difficulty,
  Activities and Folder rows, each with an Edit link to its step; Freeze
  is the single primary action.
- Step 2 got a visible Difficulty label and a helper line under every
  group, defaults still preselected.
- Bottom chrome: flat full-width Back/Next bar above the safe area, and
  the tab bar hides inside both wizards. The lower-right orb is hidden on
  phones so it can never glow behind the bar.
- Make my homework matches: headline, hint, big add area, placeholder,
  Cancel, gated Next, flat bar.

Verified: new wizard suite 80/80 (bar position sampled at 5 frames during
the entrance from tab bar/drawer/empty state; 360 + 390, dark + light,
EN + CS, reduced motion on/off; gating, summary Edit links, hidden tab
bar and orb), declutter 116/116, regression 74/74, mobile 52/52,
animation 8/8, i18n 25/25. Lint + build clean. SW freezmo-v22.
This commit is contained in:
Martin Dvořák 2026-10-10 11:35:45 +00:00
commit e66141e9a0
9 changed files with 338 additions and 132 deletions

View file

@ -37,6 +37,7 @@ import { cn, uid } from "@/lib/utils";
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
import { SegmentedPill } from "@/components/SegmentedPill";
import { Button, ProgressBar } from "@/components/ui";
import { WizardActionBar } from "@/components/WizardActionBar";
type Phase = "idle" | "preparing" | "loading" | "error";
@ -492,18 +493,6 @@ export function UploadScreen() {
</div>
) : null;
const phoneSummary = [
text.trim() ? t("upload.summaryText") : "",
images.length > 0 ? plural(images.length, "upload.photoCount") : "",
documents.length > 0 ? plural(documents.length, "upload.docSummary") : "",
t(difficulty === "easy" ? "upload.easy" : difficulty === "hard" ? "upload.hard" : "upload.medium"),
plural(count, "deck.activities"),
folderId === UNORGANIZED_ID
? t("common.unorganized")
: (sortedFolders.find((folder) => folder.id === folderId)?.name ?? t("common.unorganized")),
]
.filter(Boolean)
.join(" · ");
const phoneHelper = documentsBusy
? t("upload.readingDocs")
: hasContent
@ -514,6 +503,31 @@ export function UploadScreen() {
if (isPhone) {
const stepLabel =
step === 1 ? t("upload.phone.notes") : step === 2 ? t("upload.phone.options") : t("upload.phone.summary");
const stepDesc =
step === 1
? t("upload.phone.notesDesc", { images: MAX_IMAGES, documents: MAX_DOCUMENTS })
: step === 2
? t("upload.phone.optionsDesc")
: t("upload.phone.summaryDesc");
const filesSummary =
images.length > 0 || documents.length > 0
? [
images.length > 0 ? plural(images.length, "upload.photoCount") : "",
documents.length > 0 ? plural(documents.length, "upload.docSummary") : "",
]
.filter(Boolean)
.join(" · ")
: t("upload.summaryText");
const difficultyLabel =
difficulty === "easy" ? t("upload.easy") : difficulty === "hard" ? t("upload.hard") : t("upload.medium");
const folderLabel =
folderId === UNORGANIZED_ID
? t("common.unorganized")
: (sortedFolders.find((folder) => folder.id === folderId)?.name ?? t("common.unorganized"));
const editStep = (target: 1 | 2 | 3) => {
setStepDir(target < step ? -1 : 1);
setStep(target);
};
const fileInput = (
<input
ref={fileInputRef}
@ -542,37 +556,35 @@ export function UploadScreen() {
);
return (
<div className="relative pb-24">
<section className="space-y-1.5">
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{t("upload.title")}
</h1>
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("upload.introShort")}</p>
</section>
<div className="relative">
{errorCard}
{/* step indicator */}
<div className="mt-5 space-y-2">
<div className="flex items-center justify-between gap-3 text-[12px] font-semibold text-[var(--text-subtle)]">
<h2 className="min-w-0 truncate">{stepLabel}</h2>
<span className="shrink-0 tabular-nums">{t("common.stepOf", { n: step, total: 3 })}</span>
</div>
{/* progress: thin bar + “1 / 3” */}
<div className="mt-1 flex items-center gap-3">
<div
role="progressbar"
aria-label={stepLabel}
aria-valuemin={1}
aria-valuemax={3}
aria-valuenow={step}
className="h-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
>
<div
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
style={{ width: `${(step / 3) * 100}%` }}
/>
</div>
<span className="shrink-0 text-[11px] font-semibold tabular-nums text-[var(--text-subtle)]">
{t("common.stepOf", { n: step, total: 3 })}
</span>
</div>
{/* the step name is the headline; the page title lives in the chrome */}
<h1 className="mt-4 text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{stepLabel}
</h1>
<p className="mt-1 text-[14px] text-frost-600 dark:text-frost-300">{stepDesc}</p>
<motion.div
key={step}
initial="initial"
@ -582,24 +594,35 @@ export function UploadScreen() {
>
{step === 1 ? (
<div className="space-y-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="press flex min-h-32 w-full flex-col items-center justify-center gap-2 rounded-3xl border-2 border-dashed border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-6 text-center transition hover:border-accent-400"
>
<span
aria-hidden
className="grid size-12 place-items-center rounded-2xl bg-[var(--surface-sunken)] text-accent-600 dark:text-accent-200"
>
<PlusIcon className="size-6" />
</span>
<span className="text-[15px] font-semibold text-[var(--text)]">{t("upload.addPhotosFiles")}</span>
</button>
<div className="grid grid-cols-2 gap-2">
<Button variant="secondary" size="lg" className="touch-target" onClick={() => fileInputRef.current?.click()}>
<FolderIcon className="size-4" /> {t("upload.addPhotosFiles")}
</Button>
<Button variant="secondary" size="lg" className="touch-target" onClick={() => cameraInputRef.current?.click()}>
<CameraIcon className="size-4" /> {t("upload.camera")}
</Button>
<Button
variant="secondary"
size="lg"
className="touch-target"
aria-expanded={pasteOpen || text.trim().length > 0}
onClick={() => setPasteOpen((value) => !value)}
>
{t("upload.pasteInstead")}
</Button>
</div>
<button
type="button"
onClick={() => setPasteOpen((value) => !value)}
aria-expanded={pasteOpen}
className="text-[13px] font-semibold text-accent-700 dark:text-accent-200"
>
{t("upload.pasteInstead")}
</button>
{pasteOpen ? (
{pasteOpen || text.trim().length > 0 ? (
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
@ -662,7 +685,11 @@ export function UploadScreen() {
</li>
))}
</ul>
) : null}
) : (
<p className="rounded-2xl border border-dashed border-[var(--hairline)] px-4 py-3 text-center text-[12.5px] text-[var(--text-subtle)]">
{t("upload.nothingAdded")}
</p>
)}
{documents
.filter((entry) => entry.status === "error")
@ -687,10 +714,22 @@ export function UploadScreen() {
) : null}
{step === 2 ? (
<div className="space-y-4">
{folderPicker}
{difficultyPicker}
{countPicker}
<div className="space-y-5">
<div className="space-y-2">
{folderPicker}
<p className="text-[12px] text-[var(--text-subtle)]">{t("upload.phone.folderHelp")}</p>
</div>
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("upload.difficultyAria")}
</p>
{difficultyPicker}
<p className="text-[12px] text-[var(--text-subtle)]">{t("upload.phone.difficultyHelp")}</p>
</div>
<div className="space-y-2">
{countPicker}
<p className="text-[12px] text-[var(--text-subtle)]">{t("upload.phone.activitiesHelp")}</p>
</div>
<details className="group rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<summary className="flex cursor-pointer list-none items-center gap-2 text-[13px] font-semibold text-[var(--text)] [&::-webkit-details-marker]:hidden">
<ChevronDownIcon className="size-4 shrink-0 transition-transform group-open:rotate-180" />
@ -734,16 +773,82 @@ export function UploadScreen() {
) : null}
{step === 3 ? (
<div className="space-y-2">
<p className="text-[16px] font-semibold leading-relaxed text-[var(--text)]">{phoneSummary}</p>
<div className="space-y-4">
{images.length > 0 || documents.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{images.map((image) => (
<li key={image.id}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.dataUrl}
alt=""
className="size-12 rounded-xl border border-[var(--hairline)] object-cover"
/>
</li>
))}
{documents.map((entry) => (
<li
key={entry.id}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5"
>
<span className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 text-[10px] font-bold uppercase text-[var(--text-muted)]">
{KIND_LABELS[entry.kind]}
</span>
<span className="max-w-32 truncate text-[12.5px] font-medium text-[var(--text)]" title={entry.name}>
{entry.name}
</span>
</li>
))}
</ul>
) : null}
<dl className="overflow-hidden rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3">
{[
{ label: t("upload.summary.files"), value: filesSummary, target: 1 as const },
{ label: t("upload.difficultyAria"), value: difficultyLabel, target: 2 as const },
{ label: t("upload.activities"), value: plural(count, "deck.activities"), target: 2 as const },
{ label: t("upload.summary.folder"), value: folderLabel, target: 2 as const },
].map((row, index) => (
<div
key={row.label}
className={cn(
"flex items-center justify-between gap-3 py-2.5",
index > 0 && "border-t border-[var(--hairline-soft)]",
)}
>
<dt className="shrink-0 text-[12px] text-[var(--text-subtle)]">{row.label}</dt>
<dd className="flex min-w-0 items-center gap-2">
<span className="truncate text-[13px] font-medium text-[var(--text)]">{row.value}</span>
<button
type="button"
onClick={() => editStep(row.target)}
className="shrink-0 text-[12px] font-semibold text-accent-700 dark:text-accent-200"
>
{t("common.edit")}
</button>
</dd>
</div>
))}
</dl>
<p className="text-[12.5px] text-[var(--text-subtle)]">{phoneHelper}</p>
</div>
) : null}
</motion.div>
{/* Fixed step bar, floating just above the tab bar. */}
<div className="fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 px-4">
<div className="glass-panel mx-auto flex max-w-3xl items-center gap-2 p-2">
{/* The action bar is portalled to <body> — never inside the animated
content wrapper, where a transform would break position: fixed. */}
<WizardActionBar>
{step === 1 ? (
<Button
variant="secondary"
size="lg"
className="touch-target flex-1"
onClick={() => router.push("/")}
>
{t("common.cancel")}
</Button>
) : (
<Button
variant="secondary"
size="lg"
@ -752,28 +857,28 @@ export function UploadScreen() {
setStepDir(-1);
setStep((value) => Math.max(1, value - 1));
}}
disabled={step === 1}
>
{t("common.back")}
</Button>
{step < 3 ? (
<Button
size="lg"
className="touch-target flex-[1.5]"
onClick={() => {
setStepDir(1);
setStep((value) => Math.min(3, value + 1));
}}
>
{t("common.next")}
</Button>
) : (
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate}>
<SparklesIcon className="size-5" /> {t("upload.freezeDeck")}
</Button>
)}
</div>
</div>
)}
{step < 3 ? (
<Button
size="lg"
className="touch-target flex-[1.5]"
onClick={() => {
setStepDir(1);
setStep((value) => Math.min(3, value + 1));
}}
disabled={!hasContent || documentsBusy}
>
{t("common.next")}
</Button>
) : (
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate}>
<SparklesIcon className="size-5" /> {t("upload.freezeDeck")}
</Button>
)}
</WizardActionBar>
</div>
);
}

View file

@ -0,0 +1,39 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
/**
* The wizard's Back / Next bar.
*
* Portalled to <body> on purpose: the page content animates its entrance with
* a transform, and a transform on an ancestor turns it into the containing
* block for `position: fixed` — the bar would briefly render inside the
* content and then jump to the viewport bottom. Rendering it outside every
* animated wrapper (and only fading it in, never transforming it) keeps it
* pinned to the bottom edge from the first frame.
*
* Flat, full-width and above the safe area. The wizard pages hide the tab
* bar, so this is the only bottom chrome while the flow is open.
*/
export function WizardActionBar({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
const reduceMotion = useReducedMotion();
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(
<motion.div
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--hairline)] bg-[color-mix(in_oklab,var(--page)_92%,transparent)] px-4 pb-safe pt-2 backdrop-blur-lg sm:hidden"
data-wizard-bar
>
<div className="mx-auto flex w-full max-w-3xl items-center gap-2">{children}</div>
</motion.div>,
document.body,
);
}

View file

@ -1,10 +1,12 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { WizardActionBar } from "@/components/WizardActionBar";
import {
DOCUMENT_ACCEPT_ATTR,
documentsToText,
@ -66,6 +68,7 @@ export function HomeworkInput({
onDelete: (id: string) => void;
}) {
const { t, plural } = useI18n();
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState(1);
@ -206,37 +209,41 @@ export function HomeworkInput({
: fileSummary || t("homework.source.need");
const showPaste = pasteOpen || text.trim().length > 0;
return (
<div className="relative pb-24">
<section className="space-y-1.5">
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{t("homeworkHeroTitle")}
</h1>
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
</section>
const stepDesc =
step === 1
? t("upload.phone.notesDesc", { images: 8, documents: 6 })
: t("homework.phone.modeDesc");
return (
<div className="relative">
{errorCard}
<div className="mt-5 space-y-2">
<div className="flex items-center justify-between gap-3 text-[12px] font-semibold text-[var(--text-subtle)]">
<h2 className="min-w-0 truncate">{stepLabel}</h2>
<span className="shrink-0 tabular-nums">{t("common.stepOf", { n: step, total: 2 })}</span>
</div>
{/* progress: thin bar + “1 / 2” */}
<div className="mt-1 flex items-center gap-3">
<div
role="progressbar"
aria-label={stepLabel}
aria-valuemin={1}
aria-valuemax={2}
aria-valuenow={step}
className="h-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
>
<div
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
style={{ width: `${(step / 2) * 100}%` }}
/>
</div>
<span className="shrink-0 text-[11px] font-semibold tabular-nums text-[var(--text-subtle)]">
{t("common.stepOf", { n: step, total: 2 })}
</span>
</div>
{/* the step name is the headline; the page title lives in the chrome */}
<h1 className="mt-4 text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{stepLabel}
</h1>
<p className="mt-1 text-[14px] text-frost-600 dark:text-frost-300">{stepDesc}</p>
<motion.div
key={step}
initial="initial"
@ -246,15 +253,21 @@ export function HomeworkInput({
>
{step === 1 ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<Button
variant="secondary"
size="lg"
className="touch-target"
onClick={() => fileInputRef.current?.click()}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="press flex min-h-32 w-full flex-col items-center justify-center gap-2 rounded-3xl border-2 border-dashed border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-6 text-center transition hover:border-accent-400"
>
<span
aria-hidden
className="grid size-12 place-items-center rounded-2xl bg-[var(--surface-sunken)] text-accent-600 dark:text-accent-200"
>
<FolderIcon className="size-4" /> {t("homework.source.add")}
</Button>
<PlusIcon className="size-6" />
</span>
<span className="text-[15px] font-semibold text-[var(--text)]">{t("homework.source.add")}</span>
</button>
<div className="grid grid-cols-2 gap-2">
<Button
variant="secondary"
size="lg"
@ -263,16 +276,16 @@ export function HomeworkInput({
>
<CameraIcon className="size-4" /> {t("upload.camera")}
</Button>
<Button
variant="secondary"
size="lg"
className="touch-target"
aria-expanded={showPaste}
onClick={() => setPasteOpen((value) => !value)}
>
{t("upload.pasteInstead")}
</Button>
</div>
<button
type="button"
onClick={() => setPasteOpen((value) => !value)}
aria-expanded={showPaste}
className="text-[13px] font-semibold text-accent-700 dark:text-accent-200"
>
{t("upload.pasteInstead")}
</button>
{showPaste ? (
<textarea
id="homework-assignment-phone"
@ -345,6 +358,12 @@ export function HomeworkInput({
</ul>
) : null}
{intake.images.length === 0 && intake.documents.length === 0 ? (
<p className="rounded-2xl border border-dashed border-[var(--hairline)] px-4 py-3 text-center text-[12.5px] text-[var(--text-subtle)]">
{t("upload.nothingAdded")}
</p>
) : null}
{intake.documents
.filter((entry) => entry.status === "error")
.map((entry) => (
@ -391,9 +410,19 @@ export function HomeworkInput({
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />
{/* Fixed step bar, floating just above the tab bar. */}
<div className="fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 px-4">
<div className="glass-panel mx-auto flex max-w-3xl items-center gap-2 p-2">
{/* The action bar is portalled to <body> — never inside the animated
content wrapper, where a transform would break position: fixed. */}
<WizardActionBar>
{step === 1 ? (
<Button
variant="secondary"
size="lg"
className="touch-target flex-1"
onClick={() => router.push("/")}
>
{t("common.cancel")}
</Button>
) : (
<Button
variant="secondary"
size="lg"
@ -402,25 +431,24 @@ export function HomeworkInput({
setStepDir(-1);
setStep(1);
}}
disabled={step === 1}
>
{t("common.back")}
</Button>
{step === 1 ? (
<Button
size="lg"
className="touch-target flex-[1.5]"
onClick={() => {
setStepDir(1);
setStep(2);
}}
disabled={!canSubmit}
>
{t("common.next")}
</Button>
) : null}
</div>
</div>
)}
{step === 1 ? (
<Button
size="lg"
className="touch-target flex-[1.5]"
onClick={() => {
setStepDir(1);
setStep(2);
}}
disabled={!canSubmit}
>
{t("common.next")}
</Button>
) : null}
</WizardActionBar>
</div>
);
}