diff --git a/README.md b/README.md
index 8092d27..fdfdf7e 100644
--- a/README.md
+++ b/README.md
@@ -43,12 +43,16 @@ no database, no data leaving your machine except the requests you send to Ollama
**Capture & generate**
- Drag & drop, file picker, **mobile camera capture** and **clipboard paste** (Ctrl/⌘+V)
-- **Phones get a 3-step flow** — notes → options → summary — with a progress bar and a
- floating Back / Next bar above the tab bar. Step 1 is just *Add photos or files*,
- *Camera* and a *Paste text instead* field (no drop zone on touch screens); step 2 keeps
- folder, difficulty and activity count at their defaults and tucks the topic hint and
- pasted notes behind one collapsed row; step 3 is a single summary line with **Freeze my
- deck**. Going Back keeps every input. The desktop page is unchanged.
+- **Phones get a 3-step flow** — notes → options → summary — with a thin progress bar and
+ “1 / 3”. Step 1 leads with the step name as the headline, a formats/limits hint, one big
+ *Add photos or files* area plus *Camera* and *Paste text instead*, and a “nothing added
+ yet” placeholder that becomes the thumbnail/chip list. Next stays disabled until something
+ is added. Step 2 keeps folder, difficulty and count at their defaults with a helper under
+ each group and tucks the topic hint and pasted notes behind one collapsed row. Step 3 is a
+ real summary — thumbnails/chips, files, difficulty, activities and folder, each with an
+ *Edit* link back to its step. A flat Back/Cancel + Next/Freeze bar sits at the bottom above
+ the safe area (the tab bar hides inside the wizard), and it is portalled to `
` so the
+ screen entrance animation can never displace it. The desktop page is unchanged.
- **Photos and documents**: JPG/PNG/WEBP/HEIC images plus **PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV**
- Documents are read **in the browser**: PDF text layers are extracted, scanned PDF pages are rendered
as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out.
@@ -92,8 +96,10 @@ no database, no data leaving your machine except the requests you send to Ollama
- Two modes: **Just make it** returns the finished homework (the answer only, ready to copy), and
**Explain how to make this homework** turns it into a guided walkthrough
- On phones this is a **2-step flow**: the assignment first (same simplified input as the
- deck flow), then the two mode cards under a short preview — tapping a card starts it
- immediately, so there is no separate button. Desktop keeps the select-then-confirm layout.
+ deck flow, with its own step headline, formats hint, big add area and empty placeholder),
+ then the two mode cards under a short preview — tapping a card starts it immediately, so
+ there is no separate button. Step 1 starts with *Cancel* and the flat action bar matches
+ the deck wizard. Desktop keeps the select-then-confirm layout.
- The guide is one step per screen in the same snap-feed feel as the study feed: what to do and
why, **three progressive hints** per step (nudge → hint → the full answer), and a **checkpoint**
built from the normal activity components and graded through `/api/grade`
@@ -954,6 +960,16 @@ Things I chose while building, with the reasoning:
the same feature set the current `motion` import already pulls in. Verified with a
4x-CPU-throttled mobile trace (no long tasks, CLS 0) and a reduced-motion run.
+38. **Wizard chrome lives outside the animated tree.** The step name is the headline, the
+ action bar is a flat full-width bar at the bottom (the tab bar hides inside the wizard),
+ and the bar is rendered through a React portal to ``. That last part is a real bug
+ fix: the screen entrance animation puts a transform on the content wrapper, and a
+ transform on an ancestor becomes the containing block for `position: fixed`, so the bar
+ used to paint inside the content and jump to the viewport bottom a second later. It now
+ only fades in, pinned to the bottom from its first frame — verified by sampling its
+ position across the entrance (5 frames, 60–450ms) from the tab bar, the drawer and the
+ empty state, at 360/390, both themes, both languages and with reduced motion.
+
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision
diff --git a/app/create/page.tsx b/app/create/page.tsx
index 58a62f1..538505e 100644
--- a/app/create/page.tsx
+++ b/app/create/page.tsx
@@ -4,7 +4,6 @@ import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
-import { TabBar } from "@/components/TabBar";
import { UploadScreen } from "@/components/UploadScreen";
export const metadata = {
@@ -34,7 +33,6 @@ export default async function CreatePage({
-
diff --git a/app/globals.css b/app/globals.css
index 49638ab..cec1d36 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -312,7 +312,7 @@ body {
.ice-blob { animation: none; }
}
-/* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered
+ /* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered
on purpose — layered rules would lose to the .ice-blob declarations above. */
@media (max-width: 1023px) {
.ice-blob-c,
@@ -339,11 +339,11 @@ body {
--ice-orb-opacity: 0.42;
}
/* Phones: orbs hug the screen edges so they can never sit under the
- content column's cards or primary buttons (the lower-right one used to
- glow right behind the main CTAs). */
+ content column's cards or primary buttons. The lower-right orb still
+ read as a glow behind the wizard action bar, so phones drop it. */
.ice-orb-1 { width: 96px; height: 96px; top: -44px; left: -48px; }
.ice-orb-2 { width: 72px; height: 72px; top: 60%; left: -44px; }
- .ice-orb-3 { width: 124px; height: 124px; bottom: 12vh; right: -84px; }
+ .ice-orb-3 { display: none; }
}
@layer components {
diff --git a/app/homework/page.tsx b/app/homework/page.tsx
index 2c4af0a..7e10e0c 100644
--- a/app/homework/page.tsx
+++ b/app/homework/page.tsx
@@ -5,7 +5,6 @@ import { AppHeader } from "@/components/AppHeader";
import { HomeworkScreen } from "@/components/homework/HomeworkScreen";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
-import { TabBar } from "@/components/TabBar";
export const metadata = {
title: "Make my homework",
@@ -34,7 +33,6 @@ export default async function HomeworkPage({
-
diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx
index ca7e5bf..008f332 100644
--- a/components/UploadScreen.tsx
+++ b/components/UploadScreen.tsx
@@ -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() {
) : 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 = (
-
-
- {t("upload.title")}
-
-
{t("upload.introShort")}
-
-
+
{errorCard}
- {/* step indicator */}
-
-
-
{stepLabel}
- {t("common.stepOf", { n: step, total: 3 })}
-
+ {/* progress: thin bar + “1 / 3” */}
+
+
+ {t("common.stepOf", { n: step, total: 3 })}
+
+ {/* the step name is the headline; the page title lives in the chrome */}
+