From 3a22350c37387e248b30fb49381e20658a88f598 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 16 Sep 2026 21:37:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20the=20mobile=20shell=20=E2=80=94=20?= =?UTF-8?q?routes=20with=20real=20history,=20five=20destinations,=20the=20?= =?UTF-8?q?lesson=20as=20one=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reworked artifact went mobile-first; this ports its shell, minus the quirks it shipped with. Routes. #lesson #today #settings #words #learn #sent #grammar #cj #hangul #drill, each a history entry, so the phone's back gesture works. The word sheet, a popover and the review screen are layers: each owns an entry while open, and Back closes the topmost before it leaves a route — the artifact read window.__onBack but never set it. shell/history.ts holds the rules, free of React and tested against a history whose traversals land late, as a browser's do. Unlike the artifact's router: · re-tapping the current destination pushes nothing; · leaving a route closes what is open on it, rewinding its entries first; · a sub-page's ← goes back only when history leads to its parent, and otherwise becomes the parent — history.length > 1 let ← leave the app. Shell. The page never scrolls; each route owns one scroller, in svh. A bottom bar under 600px, a 76px rail to 840px, a labelled rail beyond, with the artifact's icons and a due badge on 복습. Routes mount on first visit and stay mounted, so a draft or a drill survives a trip elsewhere. Settings, the conjugation trainer and the reading drill are pages of their own; 학습 is a hub. The design tokens gain the layout set and a second register: reference panels stay square, what a thumb works is rounded. 복습 is its own screen: tap anywhere to reveal, grades in the thumb zone, and an empty queue says so instead of doing nothing. Its pool is "my units" once there are twenty such words, as in the artifact — plus his own words, which the artifact dropped from review at that point. The lesson is a column that fits the screen: roadmap strip, conversation, composer with the quick-reply chips, and a ··· menu for focus and clearing. The word list is docked from 840px; below that it is a sheet with peek, half and full detents and a drag handle, whose height comes off the shell so the exercise above shrinks rather than being covered. Focusing an answer drops it to peek; a new exercise takes it down from full. Fixed on the way: the log now follows a reply by what the learner did, not by distance — its own scroll event arrived after the stream had added more than the threshold, which read as scrolling away and stopped the follow mid-reply. Sending re-sticks. The keyboard's focus guard moves to pointerdown; React 19 attaches touchstart passively, so preventDefault() there was ignored. Co-Authored-By: Claude Opus 5 (1M context) --- app/index.html | 2 +- app/src/domain/cards.ts | 36 +- app/src/main.tsx | 3 +- app/src/style/tokens.css | 43 ++- app/src/ui/App.tsx | 156 +------- app/src/ui/app.css | 137 +------ app/src/ui/keyboard/Keyboard.tsx | 9 +- app/src/ui/review/ReviewOverlay.tsx | 129 ------- app/src/ui/review/ReviewScreen.tsx | 166 ++++++++ app/src/ui/review/review.css | 173 ++++++--- app/src/ui/review/useReview.tsx | 57 +-- app/src/ui/routes.tsx | 139 +++++++ app/src/ui/shell/Nav.tsx | 68 ++++ app/src/ui/shell/Pop.tsx | 95 +++++ app/src/ui/shell/Route.tsx | 88 +++++ app/src/ui/shell/history.ts | 332 ++++++++++++++++ app/src/ui/shell/icons.tsx | 85 ++++ app/src/ui/shell/router.tsx | 79 ++++ app/src/ui/shell/shell.css | 579 ++++++++++++++++++++++++++++ app/src/ui/shell/useMedia.ts | 27 ++ app/src/ui/tabs/GrammarTab.tsx | 8 +- app/src/ui/tabs/HangulTab.tsx | 8 +- app/src/ui/tabs/SettingsTab.tsx | 278 +++++++++++++ app/src/ui/tabs/TodayTab.tsx | 286 +------------- app/src/ui/tabs/settings.css | 84 ++++ app/src/ui/tabs/today.css | 59 +-- app/src/ui/tutor/Composer.tsx | 160 ++++++++ app/src/ui/tutor/RoadStrip.tsx | 117 +++--- app/src/ui/tutor/TutorTab.tsx | 551 ++++++++++++++------------ app/src/ui/tutor/WordRail.tsx | 174 +++++---- app/src/ui/tutor/WordSheet.tsx | 103 +++++ app/src/ui/tutor/rail.css | 249 ++++++++---- app/src/ui/tutor/road.css | 178 +++++---- app/src/ui/tutor/task.css | 9 + app/src/ui/tutor/tutor.css | 311 ++++++++++----- test/ui/history.test.ts | 218 +++++++++++ 36 files changed, 3747 insertions(+), 1449 deletions(-) delete mode 100644 app/src/ui/review/ReviewOverlay.tsx create mode 100644 app/src/ui/review/ReviewScreen.tsx create mode 100644 app/src/ui/routes.tsx create mode 100644 app/src/ui/shell/Nav.tsx create mode 100644 app/src/ui/shell/Pop.tsx create mode 100644 app/src/ui/shell/Route.tsx create mode 100644 app/src/ui/shell/history.ts create mode 100644 app/src/ui/shell/icons.tsx create mode 100644 app/src/ui/shell/router.tsx create mode 100644 app/src/ui/shell/shell.css create mode 100644 app/src/ui/shell/useMedia.ts create mode 100644 app/src/ui/tabs/SettingsTab.tsx create mode 100644 app/src/ui/tabs/settings.css create mode 100644 app/src/ui/tutor/Composer.tsx create mode 100644 app/src/ui/tutor/WordSheet.tsx create mode 100644 test/ui/history.test.ts diff --git a/app/index.html b/app/index.html index 74799ee..1e73f35 100644 --- a/app/index.html +++ b/app/index.html @@ -2,7 +2,7 @@ - + Hankan — 한국어 읽기 diff --git a/app/src/domain/cards.ts b/app/src/domain/cards.ts index c5517d6..a14130f 100644 --- a/app/src/domain/cards.ts +++ b/app/src/domain/cards.ts @@ -9,6 +9,8 @@ import type { Db } from "../db/types.js"; import { editCard, editCardReset, editStudyLog, seedCard } from "../db/writes.js"; import { grade, markKnown, newCard, statusOf, type Card, type CardStatus, type Grade } from "@lib/srs.js"; import { GOOD } from "@lib/srs.js"; +import type { ProgressState } from "@lib/gate.js"; +import { unitIndex } from "./gate.js"; export interface CardRow extends Card { lemma_id: number; @@ -58,6 +60,36 @@ export interface DeckOptions { sentences?: boolean; /** Only this source — used by "practise these sentences". */ only?: "sentences"; + /** Narrow the words to his own study pool — see studyPool(). */ + pool?: ProgressState; +} + +/** Fewer words than this in his units, and the pool is the whole deck. */ +export const MY_UNITS_MIN = 20; + +/** + * Whether a word is his: introduced by a unit he has reached — finished, or + * at or before the one he is on — or added by him. + * + * The artifact counted only the units' words, so once there were twenty of + * them a word added from a lesson was never reviewed again. + */ +export function isMine(e: Pick, p: ProgressState): boolean { + if (e.source === "custom") return true; + if (!e.unitId) return false; + const at = unitIndex(e.unitId); + return at >= 0 && (Boolean(p.done[e.unitId]) || at <= unitIndex(p.current)); +} + +/** + * The words a review draws on: his units' words while there are at least + * MY_UNITS_MIN of them — reviewing words from six phases ahead is not + * studying — and the whole deck until then. + */ +export function studyPool(entries: DeckEntry[], p: ProgressState): DeckEntry[] { + const words = entries.filter((e) => e.source !== "sentence"); + if (words.filter((e) => isMine(e, p)).length < MY_UNITS_MIN) return entries; + return entries.filter((e) => e.source === "sentence" || isMine(e, p)); } function sourceClause(opts: DeckOptions): string { @@ -76,7 +108,7 @@ export async function deck(db: Db, opts: DeckOptions = {}): Promise ORDER BY l.headword`, ); - return rows.map((r) => { + const entries = rows.map((r): DeckEntry => { const card = toCard(r); return { lemmaId: r.lemmaId as number, @@ -90,6 +122,8 @@ export async function deck(db: Db, opts: DeckOptions = {}): Promise status: statusOf(card), }; }); + + return opts.pool && opts.only !== "sentences" ? studyPool(entries, opts.pool) : entries; } export interface Counts { diff --git a/app/src/main.tsx b/app/src/main.tsx index 3e98d2b..e893ac9 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -1,5 +1,6 @@ import { createRoot } from "react-dom/client"; -import { App } from "./ui/App.js"; +// First, so every stylesheet after it can build on the tokens and reset. import "./style/tokens.css"; +import { App } from "./ui/App.js"; createRoot(document.getElementById("root")!).render(); diff --git a/app/src/style/tokens.css b/app/src/style/tokens.css index e39bbd7..9ac03cc 100644 --- a/app/src/style/tokens.css +++ b/app/src/style/tokens.css @@ -1,9 +1,13 @@ /* Hankan's design tokens. - Carried over from the artifact, whose visual language is small and worth - keeping exactly: two hand-tuned palettes, three type stacks, and one hard - rule — NOTHING IS ROUNDED and there are no icons. Korean glyphs do the - work icons would. Deviating from that is what would make it look generic. + Carried over from the artifact: two hand-tuned palettes and three type + stacks. Korean glyphs still do most of the work icons would. + + The shape language has two registers, as the artifact's has since it went + mobile-first. Reference material — panels, tables, the jamo grids — stays + square-cornered. The things a thumb works — cards, calls to action, chips, + sheets, the nav — are rounded to --radius, and the five nav destinations + carry icons: at 390px a label alone is too small a target to find. Themes: light on bare :root so it is the default; the dark palette is redefined under prefers-color-scheme, guarded so an explicit light choice @@ -65,6 +69,18 @@ sans-serif; --serif: "Gowun Batang", "Nanum Myeongjo", "Apple SD Gothic Neo", Georgia, serif; --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + + /* Layout. */ + --gutter: 16px; + --radius: 12px; + --cta-h: 56px; + --row-h: 64px; + --safe-b: env(safe-area-inset-bottom, 0px); + --safe-t: env(safe-area-inset-top, 0px); + --dur-sheet: 220ms; + --ease: cubic-bezier(0.2, 0, 0, 1); + /* The word sheet's current height; the shell shrinks by it. */ + --sheet-h: 0px; } @media (prefers-color-scheme: dark) { @@ -245,15 +261,16 @@ p { } .wrap { - max-width: 1000px; + width: 100%; + max-width: 920px; margin: 0 auto; - padding: 0 20px; + padding-inline: var(--gutter); } -.stack { - display: flex; - flex-direction: column; - gap: 26px; +@media (min-width: 600px) { + .wrap { + padding-inline: 24px; + } } .eyebrow { @@ -268,10 +285,4 @@ p { body { font-size: 15px; } - .wrap { - padding: 0 13px; - } - .stack { - gap: 18px; - } } diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx index 3a2d131..83dd363 100644 --- a/app/src/ui/App.tsx +++ b/app/src/ui/App.tsx @@ -1,33 +1,18 @@ -/* The shell: header, tab bar, and one section per tab. +/* The app: the shell, the routes, and what sits above them. - Six tabs, Korean-labelled, with the English as a subtitle that drops away - on a phone. No icons anywhere — the Korean glyph is the icon. */ + Five destinations in a bottom bar on a phone, a rail on anything wider — + see shell/shell.css. The review screen is a layer over all of it. */ -import { useState } from "react"; -import { StoreProvider, useStore, type BootState } from "../state/store.js"; -import { TutorTab } from "./tutor/TutorTab.js"; -import { TodayTab } from "./tabs/TodayTab.js"; -import { VocabTab } from "./tabs/VocabTab.js"; -import { SentencesTab } from "./tabs/SentencesTab.js"; -import { GrammarTab } from "./tabs/GrammarTab.js"; -import { HangulTab } from "./tabs/HangulTab.js"; -import { ReviewOverlay } from "./review/ReviewOverlay.js"; -import { ReviewProvider, useReview } from "./review/useReview.js"; -import { currentUnit } from "../domain/progress.js"; +import { StoreProvider, type BootState } from "../state/store.js"; +import { RouterProvider } from "./shell/router.js"; +import { Nav } from "./shell/Nav.js"; +import { Routes } from "./routes.js"; +import { ReviewProvider } from "./review/useReview.js"; +import { ReviewScreen } from "./review/ReviewScreen.js"; import "../style/components.css"; +import "./shell/shell.css"; import "./app.css"; -const TABS = [ - { id: "lesson", ko: "수업", en: "Lesson" }, - { id: "today", ko: "오늘", en: "Today" }, - { id: "vocab", ko: "단어", en: "Vocabulary" }, - { id: "sent", ko: "문장", en: "Sentences" }, - { id: "grammar", ko: "문법", en: "Grammar" }, - { id: "hangul", ko: "한글", en: "Hangul" }, -] as const; - -type TabId = (typeof TABS)[number]["id"]; - function Boot({ boot }: { boot: BootState }) { return (
@@ -44,119 +29,20 @@ function Boot({ boot }: { boot: BootState }) { ); } -function Header({ tab, onTab }: { tab: TabId; onTab: (t: TabId) => void }) { - const { progress, dbInfo } = useStore(); - const unit = currentUnit(progress); - - return ( -
-
-
-
- 한칸 - Korean reading desk -
-
- - {unit.id} · {unit.ko} - - - {dbInfo.persistent ? "offline" : "session only"} - -
-
- - -
-
- ); -} - -function Shell() { - const [tab, setTab] = useState("lesson"); - const review = useReview(); - - const go = (t: TabId) => { - setTab(t); - window.scrollTo({ top: 0, behavior: "instant" }); - }; - - return ( - <> -
-
-
- - - - - - -
-
-