-
+
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 (
+ >
+ );
+}
+
+export function Routes() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/src/ui/shell/Nav.tsx b/app/src/ui/shell/Nav.tsx
new file mode 100644
index 0000000..3bf2102
--- /dev/null
+++ b/app/src/ui/shell/Nav.tsx
@@ -0,0 +1,68 @@
+/* The five destinations. 복습 is not a route: it starts a review, and its
+ badge is the number that review would work through — the same number
+ 오늘's Start button shows. */
+
+import { useEffect, useState, type ReactNode } from "react";
+import { useStore } from "../../state/store.js";
+import { counts } from "../../domain/cards.js";
+import { useReview } from "../review/useReview.js";
+import { NAV_OF, useNavigator, useRoute, type NavId } from "./router.js";
+import { LearnIcon, LessonIcon, ReviewIcon, TodayIcon, WordsIcon } from "./icons.js";
+
+const ITEMS: { id: NavId; ko: string; en: string; icon: ReactNode }[] = [
+ { id: "lesson", ko: "선생님", en: "Lesson", icon: },
+ { id: "review", ko: "복습", en: "Review", icon: },
+ { id: "today", ko: "오늘", en: "Today", icon: },
+ { id: "words", ko: "단어", en: "Words", icon: },
+ { id: "learn", ko: "학습", en: "Learn", icon: },
+];
+
+/** Cards a review started now would hold: everything due, plus today's new ones. */
+export function useStudyCount(): number | null {
+ const { db, today, prefs, progress, revision } = useStore();
+ const [n, setN] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ void counts(db, today, { sentences: prefs.sentences, pool: progress }).then((c) => {
+ if (!cancelled) setN(c.due + Math.min(c.fresh, Math.max(0, prefs.newPerDay)));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [db, today, prefs.sentences, prefs.newPerDay, progress, revision]);
+
+ return n;
+}
+
+export function Nav() {
+ const nav = useNavigator();
+ const route = useRoute();
+ const { start } = useReview();
+ const due = useStudyCount();
+
+ return (
+
+ );
+}
diff --git a/app/src/ui/shell/Pop.tsx b/app/src/ui/shell/Pop.tsx
new file mode 100644
index 0000000..8fc20e9
--- /dev/null
+++ b/app/src/ui/shell/Pop.tsx
@@ -0,0 +1,95 @@
+/* A popover: anchored beside what opened it, or centred when nothing did.
+
+ A layer, so Back closes it. It closes on a press outside it and on
+ Escape. Unlike the artifact's, it does NOT close when the window
+ resizes — on a phone that is the virtual keyboard opening or closing,
+ so a word looked up while answering vanished under the finger. It moves
+ instead.
+
+ Opening it never takes focus: an answer field that loses focus closes
+ the keyboard, and the student loses their place. */
+
+import { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { useLayer } from "./router.js";
+
+export interface PopProps {
+ /** Its layer id — one popover per id is open at a time. */
+ id: string;
+ open: boolean;
+ onClose: () => void;
+ /** What it points at; centred when absent. */
+ anchor?: HTMLElement | null;
+ label: string;
+ className?: string;
+ children: ReactNode;
+}
+
+const MARGIN = 8;
+
+export function Pop({ id, open, onClose, anchor = null, label, className, children }: PopProps) {
+ const ref = useRef(null);
+ useLayer(`pop:${id}`, open, onClose);
+
+ const place = useCallback(() => {
+ const el = ref.current;
+ if (!el) return;
+ const host = document.body.getBoundingClientRect();
+ const vw = window.innerWidth;
+ const vh = window.visualViewport?.height ?? window.innerHeight;
+ const w = el.offsetWidth;
+ const h = el.offsetHeight;
+ let left: number;
+ let top: number;
+ if (anchor?.isConnected) {
+ const r = anchor.getBoundingClientRect();
+ left = Math.min(Math.max(MARGIN, r.left - MARGIN), vw - w - MARGIN);
+ top = r.bottom + MARGIN;
+ // No room below: flip above the anchor.
+ if (top + h > vh - MARGIN) top = Math.max(MARGIN, r.top - h - MARGIN);
+ } else {
+ left = Math.round((vw - w) / 2);
+ top = Math.round(Math.max(MARGIN, (vh - h) / 2));
+ }
+ el.style.left = `${left - host.left}px`;
+ el.style.top = `${top - host.top}px`;
+ }, [anchor]);
+
+ useLayoutEffect(() => {
+ if (open) place();
+ });
+
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (e: PointerEvent) => {
+ const t = e.target as Node | null;
+ if (ref.current?.contains(t)) return;
+ // The press that lands on the anchor again is the anchor's to handle.
+ if (anchor && t && anchor.contains(t)) return;
+ onClose();
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ const vv = window.visualViewport;
+ document.addEventListener("pointerdown", onDown, true);
+ document.addEventListener("keydown", onKey);
+ window.addEventListener("resize", place);
+ vv?.addEventListener("resize", place);
+ return () => {
+ document.removeEventListener("pointerdown", onDown, true);
+ document.removeEventListener("keydown", onKey);
+ window.removeEventListener("resize", place);
+ vv?.removeEventListener("resize", place);
+ };
+ }, [anchor, onClose, open, place]);
+
+ if (!open) return null;
+
+ return createPortal(
+
+ {children}
+
,
+ document.body,
+ );
+}
diff --git a/app/src/ui/shell/Route.tsx b/app/src/ui/shell/Route.tsx
new file mode 100644
index 0000000..94c9a0d
--- /dev/null
+++ b/app/src/ui/shell/Route.tsx
@@ -0,0 +1,88 @@
+/* A route, its header, and its one scroller.
+
+ A route is mounted the first time it is shown and then kept — hidden, not
+ unmounted — so a half-typed answer, a filter or a drill in progress
+ survives a trip to another route and back. */
+
+import {
+ createContext,
+ useContext,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { useNavigator, useRoute, type RouteId } from "./router.js";
+
+const ActiveContext = createContext(true);
+
+/** Whether the route this component lives in is the one showing. */
+export const useRouteActive = (): boolean => useContext(ActiveContext);
+
+export function Route({ id, children }: { id: RouteId; children: ReactNode }) {
+ const on = useRoute() === id;
+ const [visited, setVisited] = useState(on);
+ if (on && !visited) setVisited(true);
+
+ return (
+
+ {visited ? children : null}
+
+ );
+}
+
+export function RouteHead({
+ title,
+ sub,
+ back = false,
+ children,
+}: {
+ title: ReactNode;
+ sub?: ReactNode;
+ /** A sub-page: show ←, which goes up to the parent. */
+ back?: boolean;
+ /** Icon buttons, right-aligned. */
+ children?: ReactNode;
+}) {
+ const nav = useNavigator();
+ return (
+
+ {back && (
+
+ )}
+
+ {title}
+ {sub && {sub}}
+
+ {children && {children}}
+
+ );
+}
+
+/**
+ * The route's scroller. The page itself never scrolls. The position is
+ * put back when the route is shown again: a display:none box loses it.
+ */
+export function Scroll({ children }: { children: ReactNode }) {
+ const active = useRouteActive();
+ const ref = useRef(null);
+ const top = useRef(0);
+
+ useLayoutEffect(() => {
+ if (active && ref.current) ref.current.scrollTop = top.current;
+ }, [active]);
+
+ return (
+
+ );
+}
diff --git a/app/src/ui/shell/history.ts b/app/src/ui/shell/history.ts
new file mode 100644
index 0000000..92c54a2
--- /dev/null
+++ b/app/src/ui/shell/history.ts
@@ -0,0 +1,332 @@
+/* Routes and layers, over the browser's history.
+
+ Every move pushes a history entry, so the phone's back gesture does what
+ it should — out of a sub-page, out of the review screen, out of a sheet.
+ A layer (the word sheet, a popover, the review screen) pushes an entry
+ too, and Back closes the topmost layer before it ever leaves a route. The
+ artifact read `window.__onBack` in its popstate handler but never set it,
+ so Back behind an open sheet navigated away underneath it.
+
+ Also unlike the artifact:
+ · going to the route you are on pushes nothing — re-tapping a nav item
+ filled the history with copies of the same page;
+ · leaving a route closes whatever is open on it — the sheet survived a
+ route change and sat on top of the review screen;
+ · a sub-page's ← goes back through history only when history leads to
+ its parent, and otherwise becomes the parent — `history.length > 1`
+ is true for a page opened from any other site, so ← could leave the
+ app.
+
+ history.go() is asynchronous: its popstate arrives on a later task, and a
+ pushState() made in between is undone by it. So every operation waits
+ until the traversals this class started have landed. Kept free of React
+ so that ordering can be tested. */
+
+export const ROUTES = [
+ "lesson",
+ "today",
+ "settings",
+ "words",
+ "learn",
+ "sent",
+ "grammar",
+ "cj",
+ "hangul",
+ "drill",
+] as const;
+
+export type RouteId = (typeof ROUTES)[number];
+
+/** The five destinations in the nav. 복습 opens the review screen, not a route. */
+export type NavId = "lesson" | "review" | "today" | "words" | "learn";
+
+/** Where a sub-page's ← leads. */
+export const PARENT: Partial> = {
+ settings: "today",
+ sent: "learn",
+ grammar: "learn",
+ hangul: "learn",
+ cj: "grammar",
+ drill: "hangul",
+};
+
+/** Which nav destination a route lights up. */
+export const NAV_OF: Record = {
+ lesson: "lesson",
+ today: "today",
+ settings: "today",
+ words: "words",
+ learn: "learn",
+ sent: "learn",
+ grammar: "learn",
+ cj: "learn",
+ hangul: "learn",
+ drill: "learn",
+};
+
+export const isRoute = (s: unknown): s is RouteId =>
+ typeof s === "string" && (ROUTES as readonly string[]).includes(s);
+
+/** The part of window.history this uses. */
+export interface HistoryLike {
+ readonly state: unknown;
+ pushState(data: unknown, unused: string, url?: string): void;
+ replaceState(data: unknown, unused: string, url?: string): void;
+ go(delta: number): void;
+}
+
+/** What each of our entries carries. `i` is its position in our stack. */
+interface Entry {
+ hk: 1;
+ r: RouteId;
+ i: number;
+ /** The route this entry was pushed from. */
+ from?: RouteId;
+ /** Set on an entry pushed for a layer. */
+ layer?: string;
+}
+
+const isEntry = (s: unknown): s is Entry =>
+ typeof s === "object" && s !== null && (s as Entry).hk === 1 && isRoute((s as Entry).r);
+
+interface Layer {
+ id: string;
+ /** The `i` of the entry it pushed. */
+ depth: number;
+ close: () => void;
+}
+
+/** A traversal that never reports back must not wedge navigation for good. */
+const TRAVERSAL_TIMEOUT_MS = 1500;
+
+export class Navigator {
+ route: RouteId;
+ private index: number;
+ private from: RouteId | undefined;
+ private layers: Layer[] = [];
+ /** history.go() calls whose popstate has not arrived yet. */
+ private traversing = 0;
+ private queue: (() => void)[] = [];
+ private insertAt = -1;
+ private watchdog: ReturnType | null = null;
+ private listeners = new Set<() => void>();
+
+ constructor(
+ private readonly history: HistoryLike,
+ hash: string,
+ ) {
+ const s = history.state;
+ this.route = isRoute(hash) ? hash : isEntry(s) ? s.r : "lesson";
+ // A reload keeps the entry it was on, and its place in the stack. A
+ // layer entry comes back as a plain route: nothing is open any more.
+ this.index = isEntry(s) ? s.i : 0;
+ this.from = isEntry(s) ? s.from : undefined;
+ this.write("replace");
+ }
+
+ /* ── subscription, for useSyncExternalStore ── */
+
+ subscribe = (fn: () => void): (() => void) => {
+ this.listeners.add(fn);
+ return () => this.listeners.delete(fn);
+ };
+
+ getRoute = (): RouteId => this.route;
+
+ private emit() {
+ for (const fn of [...this.listeners]) fn();
+ }
+
+ /* ── routes ── */
+
+ /** Go to a route. Anything open on the current one closes first. */
+ go(to: RouteId): void {
+ this.settled(() => {
+ if (this.layers.length) {
+ this.unwind();
+ this.settled(() => this.push(to));
+ return;
+ }
+ this.push(to);
+ });
+ }
+
+ /** The ← in a sub-page's header. */
+ back(): void {
+ this.settled(() => {
+ const parent = PARENT[this.route];
+ if (!parent) return;
+ if (this.layers.length) {
+ this.unwind();
+ this.settled(() => this.back());
+ return;
+ }
+ if (this.from === parent && this.index > 0) {
+ this.traverse(-1);
+ return;
+ }
+ // Nothing behind us leads there — opened from a link, or reached from
+ // elsewhere. Become the parent rather than leave for wherever that was.
+ this.route = parent;
+ this.write("replace");
+ this.emit();
+ });
+ }
+
+ /* ── layers ── */
+
+ /**
+ * A layer opened: push an entry for it. `close` is called if Back (or a
+ * route change) closes it; its owner should then treat it as closed.
+ */
+ openLayer(id: string, close: () => void): void {
+ this.settled(() => {
+ const open = this.layers.find((l) => l.id === id);
+ if (open) {
+ open.close = close;
+ return;
+ }
+ this.index++;
+ this.layers.push({ id, depth: this.index, close });
+ this.write("push", id);
+ });
+ }
+
+ /**
+ * Its owner closed a layer (✕, Done, a tap outside): take its entry off
+ * the history, with any layer opened on top of it.
+ */
+ releaseLayer(id: string): void {
+ this.settled(() => {
+ const at = this.layers.findIndex((l) => l.id === id);
+ if (at < 0) return;
+ const [layer, ...above] = this.layers.splice(at);
+ for (const l of above.reverse()) l.close();
+ this.traverse(layer!.depth - 1 - this.index);
+ });
+ }
+
+ isOpen(id: string): boolean {
+ return this.layers.some((l) => l.id === id);
+ }
+
+ /* ── popstate ── */
+
+ onPopState(state: unknown, hash: string): void {
+ if (!isEntry(state)) {
+ // An entry we did not make — a hash typed into the address bar. It is
+ // already on the stack; adopt it as the next one.
+ this.closeAbove(-1);
+ this.index++;
+ this.from = this.route;
+ this.route = isRoute(hash) ? hash : "lesson";
+ this.write("replace");
+ this.landed();
+ this.emit();
+ return;
+ }
+
+ this.closeAbove(state.i);
+ this.index = state.i;
+ this.route = state.r;
+ this.from = state.from;
+ this.landed();
+ this.emit();
+ }
+
+ /* ── internals ── */
+
+ private push(to: RouteId) {
+ if (to === this.route) {
+ this.emit();
+ return;
+ }
+ this.from = this.route;
+ this.route = to;
+ this.index++;
+ this.write("push");
+ this.emit();
+ }
+
+ /** Close every layer and rewind the history to the route's own entry. */
+ private unwind() {
+ const base = this.layers[0]!.depth - 1;
+ this.closeAbove(base);
+ this.traverse(base - this.index);
+ }
+
+ private closeAbove(depth: number) {
+ while (this.layers.length && this.layers[this.layers.length - 1]!.depth > depth) {
+ this.layers.pop()!.close();
+ }
+ }
+
+ private write(how: "push" | "replace", layer?: string) {
+ const entry: Entry = { hk: 1, r: this.route, i: this.index };
+ if (this.from) entry.from = this.from;
+ if (layer) entry.layer = layer;
+ try {
+ if (how === "push") this.history.pushState(entry, "", `#${this.route}`);
+ else this.history.replaceState(entry, "", `#${this.route}`);
+ } catch {
+ // A sandboxed frame can refuse history writes. Routing still works;
+ // only Back is lost.
+ }
+ }
+
+ private traverse(delta: number) {
+ if (delta === 0) return;
+ this.traversing++;
+ this.armWatchdog();
+ try {
+ this.history.go(delta);
+ } catch {
+ this.landed();
+ }
+ }
+
+ private armWatchdog() {
+ if (this.watchdog) clearTimeout(this.watchdog);
+ this.watchdog = setTimeout(() => {
+ this.watchdog = null;
+ if (!this.traversing) return;
+ this.traversing = 0;
+ this.flush();
+ }, TRAVERSAL_TIMEOUT_MS);
+ }
+
+ /** A popstate arrived: one traversal fewer to wait for. */
+ private landed() {
+ if (this.traversing) this.traversing--;
+ if (!this.traversing && this.watchdog) {
+ clearTimeout(this.watchdog);
+ this.watchdog = null;
+ }
+ this.flush();
+ }
+
+ /** Run `fn` now, or once our own traversals have landed — in order. */
+ private settled(fn: () => void) {
+ if (!this.traversing && this.insertAt < 0) {
+ fn();
+ return;
+ }
+ // Continuations of the step being flushed run before the steps queued
+ // after it.
+ if (this.insertAt >= 0) this.queue.splice(this.insertAt++, 0, fn);
+ else this.queue.push(fn);
+ }
+
+ private flush() {
+ if (this.insertAt >= 0) return; // already flushing, further up the stack
+ while (!this.traversing && this.queue.length) {
+ const fn = this.queue.shift()!;
+ this.insertAt = 0;
+ try {
+ fn();
+ } finally {
+ this.insertAt = -1;
+ }
+ }
+ }
+}
diff --git a/app/src/ui/shell/icons.tsx b/app/src/ui/shell/icons.tsx
new file mode 100644
index 0000000..d888153
--- /dev/null
+++ b/app/src/ui/shell/icons.tsx
@@ -0,0 +1,85 @@
+/* The few icons the shell has — the artifact's own paths. Everything else
+ is still a glyph: ← ✕ ⚙ ⏱ ···. */
+
+import type { ReactNode } from "react";
+
+function Svg({ size, stroke, children }: { size: number; stroke: number; children: ReactNode }) {
+ return (
+
+ );
+}
+
+const nav = (children: ReactNode) => (
+
+);
+
+export const LessonIcon = () =>
+ nav(
+ <>
+
+
+ >,
+ );
+
+export const ReviewIcon = () =>
+ nav(
+ <>
+
+
+ >,
+ );
+
+export const TodayIcon = () =>
+ nav(
+ <>
+
+
+
+ >,
+ );
+
+export const WordsIcon = () =>
+ nav(
+ <>
+
+
+ >,
+ );
+
+export const LearnIcon = () =>
+ nav(
+ <>
+
+
+
+ >,
+ );
+
+export const MapIcon = () => (
+
+);
+
+export const SearchIcon = () => (
+
+);
diff --git a/app/src/ui/shell/router.tsx b/app/src/ui/shell/router.tsx
new file mode 100644
index 0000000..449286c
--- /dev/null
+++ b/app/src/ui/shell/router.tsx
@@ -0,0 +1,79 @@
+/* The navigator, for React: which route is showing, and layers that own a
+ history entry while they are open. See history.ts for the rules. */
+
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+ type ReactNode,
+} from "react";
+import { Navigator, type RouteId } from "./history.js";
+
+const NavigatorContext = createContext(null);
+
+export function RouterProvider({ children }: { children: ReactNode }) {
+ const [nav] = useState(() => new Navigator(window.history, window.location.hash.slice(1)));
+
+ useEffect(() => {
+ const onPop = (e: PopStateEvent) => nav.onPopState(e.state, window.location.hash.slice(1));
+ window.addEventListener("popstate", onPop);
+ return () => window.removeEventListener("popstate", onPop);
+ }, [nav]);
+
+ return {children};
+}
+
+export function useNavigator(): Navigator {
+ const nav = useContext(NavigatorContext);
+ if (!nav) throw new Error("useNavigator outside RouterProvider");
+ return nav;
+}
+
+export function useRoute(): RouteId {
+ const nav = useNavigator();
+ return useSyncExternalStore(nav.subscribe, nav.getRoute);
+}
+
+/**
+ * Give an open layer a history entry, so Back closes it.
+ *
+ * The owner keeps its own `open` state. When Back — or leaving the route —
+ * closes the layer, `close` is called and the owner should set it false;
+ * when the owner closes it, its entry comes off the history.
+ */
+export function useLayer(id: string, open: boolean, close: () => void): void {
+ const nav = useNavigator();
+ const closeRef = useRef(close);
+ const held = useRef(false);
+
+ useEffect(() => {
+ closeRef.current = close;
+ });
+
+ useEffect(() => {
+ if (open && !held.current) {
+ held.current = true;
+ nav.openLayer(id, () => {
+ held.current = false;
+ closeRef.current();
+ });
+ } else if (!open && held.current) {
+ held.current = false;
+ nav.releaseLayer(id);
+ }
+ }, [id, nav, open]);
+
+ useEffect(
+ () => () => {
+ if (!held.current) return;
+ held.current = false;
+ nav.releaseLayer(id);
+ },
+ [id, nav],
+ );
+}
+
+export { NAV_OF, PARENT, ROUTES, type NavId, type RouteId } from "./history.js";
diff --git a/app/src/ui/shell/shell.css b/app/src/ui/shell/shell.css
new file mode 100644
index 0000000..dae1d0e
--- /dev/null
+++ b/app/src/ui/shell/shell.css
@@ -0,0 +1,579 @@
+/* The shell — one design, mobile first.
+
+ The page never scrolls: each route owns a single scroller. svh, not dvh —
+ with dvh the bottom bar slides while the browser toolbar animates. The
+ navigation is a bottom bar under 600px, a 76px icon rail to 840px, and a
+ labelled 196px rail beyond. */
+
+html {
+ -webkit-text-size-adjust: 100%;
+ height: 100%;
+}
+
+body {
+ height: 100%;
+ overflow: hidden;
+ overscroll-behavior: none;
+ position: relative; /* the anchor for every overlay */
+}
+
+#root {
+ height: 100%;
+}
+
+.app {
+ height: 100%;
+ max-height: 100svh;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg);
+ /* The word sheet takes its height from the bottom of the shell, so what
+ is above it gets shorter rather than covered. */
+ padding-bottom: var(--sheet-h, 0px);
+ transition: padding-bottom var(--dur-sheet) var(--ease);
+}
+
+.stage {
+ order: 1;
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+}
+
+.nav {
+ order: 2;
+}
+
+@media (min-width: 600px) {
+ .app {
+ flex-direction: row;
+ }
+ .stage {
+ order: 2;
+ }
+ .nav {
+ order: 1;
+ }
+}
+
+@media (min-width: 840px) {
+ .app {
+ padding-bottom: 0;
+ }
+}
+
+/* ── routes ──────────────────────────────────────────────────────── */
+
+.route {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
+ padding-bottom: calc(20px + var(--safe-b));
+}
+
+.page {
+ padding-top: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+/* ── route header ────────────────────────────────────────────────── */
+
+.rhead {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: calc(8px + var(--safe-t)) var(--gutter) 8px;
+ min-height: calc(52px + var(--safe-t));
+ border-bottom: 1px solid var(--line);
+ background: var(--paper);
+}
+
+.rhead h1 {
+ font-family: var(--serif);
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.rhead .sub {
+ font-family: var(--kr);
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--ink3);
+ margin-left: 2px;
+ white-space: nowrap;
+}
+
+.rhead .sp {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.iconbtn {
+ position: relative;
+ width: 36px;
+ height: 36px;
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ color: var(--ink2);
+ border-radius: 9px;
+}
+
+/* A 44px target around a 36px button. */
+.iconbtn::before {
+ content: "";
+ position: absolute;
+ inset: -4px;
+}
+
+.iconbtn:hover {
+ background: var(--sunk);
+ color: var(--ink);
+}
+
+.iconbtn[aria-pressed="true"] {
+ background: var(--jade);
+ color: var(--on-jade);
+}
+
+.iconbtn.back {
+ font-size: 20px;
+}
+
+/* ── navigation: bottom bar → rail ───────────────────────────────── */
+
+.nav {
+ flex: none;
+ display: flex;
+ background: var(--paper);
+ border-top: 1px solid var(--line);
+ padding-bottom: var(--safe-b);
+ z-index: 30;
+}
+
+.nav button {
+ flex: 1 1 0;
+ min-width: 0;
+ min-height: 48px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ padding: 7px 2px 6px;
+ color: var(--ink3);
+ position: relative;
+}
+
+.nav .ic {
+ line-height: 0;
+ height: 23px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.nav .lb {
+ font-size: 10.5px;
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+ line-height: 1.3;
+}
+
+.nav button[aria-current="page"] {
+ color: var(--jade);
+}
+
+.nav button[aria-current="page"] .ic {
+ background: var(--jade-soft);
+ border-radius: 14px;
+ padding: 3px 15px;
+ height: 29px;
+ margin: -3px 0;
+}
+
+.nav .badge {
+ position: absolute;
+ top: 4px;
+ left: calc(50% + 8px);
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 8px;
+ background: var(--jeok);
+ color: #fff;
+ font-size: 9.5px;
+ font-weight: 600;
+ line-height: 16px;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+
+.navmark {
+ display: none;
+}
+
+@media (min-width: 600px) {
+ .nav {
+ flex-direction: column;
+ width: 76px;
+ gap: 2px;
+ border-top: none;
+ border-right: 1px solid var(--line);
+ padding: calc(10px + var(--safe-t)) 0 calc(10px + var(--safe-b));
+ }
+ .nav button {
+ flex: none;
+ height: 60px;
+ padding: 6px 2px;
+ }
+ .nav .lb {
+ font-size: 11px;
+ }
+}
+
+@media (min-width: 840px) {
+ .nav {
+ width: 196px;
+ padding-left: 10px;
+ padding-right: 10px;
+ }
+ .nav button {
+ flex-direction: row;
+ justify-content: flex-start;
+ gap: 12px;
+ height: 46px;
+ padding: 0 12px;
+ border-radius: 10px;
+ }
+ .nav .ic {
+ height: auto;
+ }
+ .nav .lb {
+ font-size: 14px;
+ }
+ .nav button[aria-current="page"] {
+ background: var(--jade-soft);
+ }
+ .nav button[aria-current="page"] .ic {
+ background: none;
+ padding: 0;
+ height: auto;
+ margin: 0;
+ }
+ .nav button:hover:not([aria-current="page"]) {
+ background: var(--raise);
+ color: var(--ink2);
+ }
+ .nav .badge {
+ position: static;
+ margin-left: auto;
+ }
+ .navmark {
+ display: block;
+ padding: 4px 12px 14px;
+ font-family: var(--serif);
+ font-size: 19px;
+ font-weight: 700;
+ color: var(--ink);
+ }
+}
+
+/* ── the mobile pieces ───────────────────────────────────────────── */
+
+.card {
+ background: var(--paper);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+}
+
+.cta {
+ width: 100%;
+ min-height: var(--cta-h);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ background: var(--jade);
+ color: var(--on-jade);
+ border: 1px solid var(--jade);
+ border-radius: var(--radius);
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.cta:hover:not(:disabled) {
+ background: var(--jade-ink);
+ border-color: var(--jade-ink);
+}
+
+.cta:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+.cta.ghost {
+ background: var(--raise);
+ color: var(--ink);
+ border-color: var(--line2);
+ font-weight: 500;
+}
+
+.cta.ghost:hover:not(:disabled) {
+ background: var(--sunk);
+ border-color: var(--line2);
+}
+
+.rows {
+ background: var(--paper);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ min-height: var(--row-h);
+ padding: 11px 14px;
+ text-align: left;
+ border-bottom: 1px solid var(--line);
+}
+
+.row:last-child {
+ border-bottom: none;
+}
+
+.row:hover {
+ background: var(--raise);
+}
+
+.row .ic {
+ width: 34px;
+ height: 34px;
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9px;
+ background: var(--jade-soft);
+ color: var(--jade-ink);
+ font-size: 17px;
+ font-weight: 600;
+}
+
+.row .tx {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.row .t1 {
+ font-size: 16px;
+ font-weight: 500;
+}
+
+.row .t2 {
+ font-size: 13px;
+ color: var(--ink3);
+}
+
+.row .go {
+ flex: none;
+ font-size: 16px;
+ color: var(--ink3);
+}
+
+.strip {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex-wrap: wrap;
+ padding: 2px;
+ font-size: 13.5px;
+ color: var(--ink2);
+}
+
+.strip b {
+ color: var(--ink);
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+details.fold {
+ background: var(--paper);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+}
+
+details.fold > summary {
+ list-style: none;
+ cursor: pointer;
+ min-height: 52px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 0 14px;
+ font-size: 15px;
+ font-weight: 500;
+}
+
+details.fold > summary::-webkit-details-marker {
+ display: none;
+}
+
+details.fold > summary::after {
+ content: "▸";
+ margin-left: auto;
+ color: var(--ink3);
+ font-size: 13px;
+}
+
+details.fold[open] > summary::after {
+ content: "▾";
+}
+
+details.fold > summary .note {
+ margin-left: auto;
+ font-size: 12.5px;
+ font-weight: 400;
+ color: var(--ink3);
+}
+
+details.fold[open] > summary .note {
+ display: none;
+}
+
+details.fold .fold-b {
+ padding: 14px;
+ border-top: 1px solid var(--line);
+}
+
+label.field {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ font-size: 12.5px;
+ color: var(--ink2);
+}
+
+.toggle {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ min-height: 44px;
+ font-size: 14px;
+ color: var(--ink2);
+ cursor: pointer;
+}
+
+.toggle input {
+ width: 24px;
+ height: 24px;
+ flex: none;
+ accent-color: var(--jade);
+ cursor: pointer;
+}
+
+.toggle i {
+ display: block;
+ font-style: normal;
+ font-size: 12px;
+ color: var(--ink3);
+}
+
+/* Inputs are never below 16px: iOS zooms the page on focus below that. */
+input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
+select,
+textarea {
+ font-size: 16px;
+}
+
+/* ── popover ─────────────────────────────────────────────────────── */
+
+.pop {
+ position: absolute;
+ z-index: 120;
+ width: min(320px, calc(100vw - 24px));
+ max-height: 56vh;
+ overflow: auto;
+ overscroll-behavior: contain;
+ border: 1px solid var(--line2);
+ border-radius: var(--radius);
+ background: var(--paper);
+ color: var(--ink);
+ box-shadow: var(--shadow);
+}
+
+.pop-b {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ padding: 13px 14px;
+}
+
+.pop-k {
+ font-size: 23px;
+ font-weight: 600;
+ line-height: 1.3;
+ word-break: keep-all;
+}
+
+.pop-m {
+ font-size: 15px;
+}
+
+.pop-n {
+ font-size: 12.5px;
+ color: var(--ink3);
+}
+
+.pop-f {
+ display: flex;
+ gap: 8px;
+ padding: 0 14px 13px;
+}
+
+.pop-f .btn {
+ flex: 1;
+ min-height: 40px;
+ border-radius: 9px;
+ font-size: 13.5px;
+}
+
+.pop select {
+ width: 100%;
+ padding: 9px 10px;
+ border-radius: 9px;
+}
diff --git a/app/src/ui/shell/useMedia.ts b/app/src/ui/shell/useMedia.ts
new file mode 100644
index 0000000..5d48ee4
--- /dev/null
+++ b/app/src/ui/shell/useMedia.ts
@@ -0,0 +1,27 @@
+/* Media queries and the viewport, as state. */
+
+import { useSyncExternalStore } from "react";
+
+export function useMedia(query: string): boolean {
+ return useSyncExternalStore(
+ (notify) => {
+ const mq = window.matchMedia(query);
+ mq.addEventListener("change", notify);
+ return () => mq.removeEventListener("change", notify);
+ },
+ () => window.matchMedia(query).matches,
+ );
+}
+
+/** At this width and above the word list is a docked column, not a sheet. */
+export const WIDE = "(min-width: 840px)";
+
+export function useViewportHeight(): number {
+ return useSyncExternalStore(
+ (notify) => {
+ window.addEventListener("resize", notify);
+ return () => window.removeEventListener("resize", notify);
+ },
+ () => window.innerHeight,
+ );
+}
diff --git a/app/src/ui/tabs/GrammarTab.tsx b/app/src/ui/tabs/GrammarTab.tsx
index bffc09c..b8a73b8 100644
--- a/app/src/ui/tabs/GrammarTab.tsx
+++ b/app/src/ui/tabs/GrammarTab.tsx
@@ -1,5 +1,5 @@
-/* 문법 — the conjugation trainer, the seven irregular classes, and the
- 50-point reference.
+/* 문법 — the seven irregular classes and the 50-point reference; and on a
+ page of its own, 활용 연습, the conjugation trainer.
The trainer marks itself with lib/conjugation.js, and when an answer is
wrong it names the rule via explain() rather than just saying "no". That
@@ -68,7 +68,8 @@ function expected(mode: Mode, dict: string): string | null {
return mode === "past" ? past(present) : present;
}
-function ConjugationTrainer() {
+/** 활용 연습 — its own page, reached from 학습 or 문법's ⚙. */
+export function ConjugationTab() {
const { db, today, invalidate } = useStore();
const [mode, setMode] = useState("present");
const [index, setIndex] = useState(0);
@@ -278,7 +279,6 @@ export function GrammarTab() {
return (
<>
-
diff --git a/app/src/ui/tabs/HangulTab.tsx b/app/src/ui/tabs/HangulTab.tsx
index bd84087..d68be66 100644
--- a/app/src/ui/tabs/HangulTab.tsx
+++ b/app/src/ui/tabs/HangulTab.tsx
@@ -1,4 +1,5 @@
-/* 한글 — the reading drill, the syllable diagram, and the jamo tables.
+/* 한글 — the syllable diagram and the jamo tables; and on a page of its
+ own, 읽기 연습, the reading drill.
The drill is reading-only by design: written form → spoken form, word →
meaning, sentence → meaning. Nothing here asks him to produce a sound. */
@@ -129,7 +130,8 @@ function buildQuestion(mode: Mode): Question | null {
};
}
-function Drill() {
+/** 읽기 연습 — its own page, reached from 학습 or 한글's ⏱. */
+export function DrillTab() {
const { db, today, invalidate } = useStore();
// `picked` drives the UI; this guards the write. State is a render behind,
// so a fast double-tap would otherwise log two answers for one question.
@@ -294,8 +296,6 @@ export function HangulTab() {
return (
<>
-
-
한 글자의 구조
diff --git a/app/src/ui/tabs/SettingsTab.tsx b/app/src/ui/tabs/SettingsTab.tsx
new file mode 100644
index 0000000..fdd9f5d
--- /dev/null
+++ b/app/src/ui/tabs/SettingsTab.tsx
@@ -0,0 +1,278 @@
+/* 설정 — the session settings, the server, where the dictionary came from,
+ and starting over. Its own page now, reached from 오늘's ⚙: on a phone the
+ settings buried the one thing 오늘 is for under four panels. */
+
+import { useEffect, useState } from "react";
+import { useStore, type Prefs } from "../../state/store.js";
+import { editReset, type ResetScope } from "../../db/writes.js";
+import { stats } from "../../domain/lexicon.js";
+import "./settings.css";
+
+function Preferences() {
+ const { prefs, setPref } = useStore();
+
+ const num = (key: "newPerDay" | "goal", label: string, min: number, max: number, step: number) => (
+
+ );
+
+ const toggle = (key: keyof Prefs, label: string, hint?: string) => (
+
+ );
+
+ return (
+ <>
+
+
+
+ {num("newPerDay", "New words per review", 0, 60, 5)}
+ {num("goal", "Daily review goal", 5, 200, 5)}
+
+
+
+
+ {toggle("sentences", "Mix sentences into reviews")}
+ {toggle("cover", "Cover word meanings until tapped")}
+ {toggle(
+ "romanization",
+ "Show romanization",
+ "Off by default — 선생님 never writes it, and reading is the goal",
+ )}
+
+ >
+ );
+}
+
+/* The Pi, if there is one. Everything works without it; this is what turns
+ on the real tutor and syncing between devices. */
+function ServerPanel() {
+ const { server, setServer, syncState, syncNow } = useStore();
+ const [url, setUrl] = useState(server?.baseUrl ?? "");
+ const [token, setToken] = useState(server?.token ?? "");
+
+ const ago =
+ syncState.at === null
+ ? "never"
+ : `${Math.max(0, Math.round((Date.now() - syncState.at) / 1000))}s ago`;
+
+ const status = !server ? "off" : syncState.error ? "fail" : syncState.result ? "on" : "wait";
+
+ return (
+
+
+ 서버 · Server and sync
+
+
+ {!server
+ ? "not set — everything stays on this device"
+ : syncState.running
+ ? "syncing…"
+ : syncState.error
+ ? `last sync failed ${ago}`
+ : syncState.result
+ ? `synced ${ago}`
+ : "not synced yet"}
+
+
+
+
+
+
+
+
+ {server && (
+ <>
+
+
+ >
+ )}
+
+
+ {server && (
+
+ {syncState.error
+ ? `${syncState.error}. Your work is safe here and will go up when the server is reachable.`
+ : syncState.result
+ ? `Last sync: sent ${syncState.result.pushed}, received ${syncState.result.pulled}.`
+ : "The real 선생님 answers once the server is reachable."}
+
+ )}
+
+ );
+}
+
+/* Where the dictionary came from, how much of it is loaded, and the terms
+ it ships under. The attribution is a licence obligation, not decoration. */
+function About() {
+ const { db, dbInfo, manifest, revision } = useStore();
+ const [rows, setRows] = useState<{ lemmas: number; surfaces: number } | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ void stats(db).then((s) => {
+ if (!cancelled) setRows(s);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [db, revision]);
+
+ return (
+
Share-alike applies to the dictionary data, not to this app's code.
+
+
+ );
+}
+
+/* Reset is destructive and irreversible, so it asks twice and says plainly
+ what each scope destroys before the second press. */
+function DangerZone() {
+ const { db, refreshProgress, invalidate } = useStore();
+ const [asking, setAsking] = useState(null);
+
+ const run = async (scope: ResetScope) => {
+ await editReset(db, scope);
+ setAsking(null);
+ await refreshProgress();
+ invalidate();
+ // Every route holds an in-memory copy of what was just deleted; a
+ // reload is the honest way to drop all of them.
+ window.location.reload();
+ };
+
+ return (
+
+ 처음부터 · Start over
+ {asking === null ? (
+
+
+
+ The dictionary is never touched.
+
+ ) : (
+
+
+ {asking === "roadmap"
+ ? "This erases your place on the roadmap, every unit's confidence, and the lesson transcript. Your cards, review history and own words are kept."
+ : "This erases everything: roadmap, transcript, all cards and review history, your streak, your own words, your notes and every setting."}
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+export function SettingsTab() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/app/src/ui/tabs/TodayTab.tsx b/app/src/ui/tabs/TodayTab.tsx
index 5e2336f..4612959 100644
--- a/app/src/ui/tabs/TodayTab.tsx
+++ b/app/src/ui/tabs/TodayTab.tsx
@@ -1,14 +1,12 @@
-/* 오늘 — where you are, what is due, and the session settings. */
+/* 오늘 — where you are and what is due. The settings are their own page. */
import { useEffect, useState } from "react";
import { useStore } from "../../state/store.js";
-import { editReset, type ResetScope } from "../../db/writes.js";
-import { stats } from "../../domain/lexicon.js";
import { useReview } from "../review/useReview.js";
+import { useNavigator } from "../shell/router.js";
import { counts, streakFrom, studyLog, type Counts, type DayRow } from "../../domain/cards.js";
import { curriculum } from "../../domain/gate.js";
import { currentUnit } from "../../domain/progress.js";
-import type { Prefs } from "../../state/store.js";
import "./today.css";
const HEATMAP_DAYS = 7 * 22;
@@ -46,271 +44,9 @@ function Heatmap({ rows, today }: { rows: DayRow[]; today: number }) {
);
}
-function Settings() {
- const { prefs, setPref } = useStore();
-
- const num = (key: "newPerDay" | "goal", min: number, max: number, step: number) => (
- {
- const n = Number.parseInt(e.target.value, 10);
- if (Number.isFinite(n)) void setPref(key, Math.max(min, Math.min(max, n)));
- }}
- />
- );
-
- const toggle = (key: keyof Prefs, label: string, hint?: string) => (
-
- );
-
- return (
-
-
-
수업 설정
- Session settings
-
-
-
-
-
-
-
-
- {toggle("sentences", "Mix sentences into reviews")}
- {toggle("cover", "Cover meanings in the word rail")}
- {toggle(
- "romanization",
- "Show romanization",
- "Off by default — 선생님 never writes it, and reading is the goal",
- )}
-
-
- );
-}
-
-
-/* Reset is destructive and irreversible, so it asks twice and says plainly
- what each scope destroys before the second press. */
-function DangerZone() {
- const { db, refreshProgress, invalidate } = useStore();
- const [asking, setAsking] = useState(null);
-
- const run = async (scope: ResetScope) => {
- await editReset(db, scope);
- setAsking(null);
- await refreshProgress();
- invalidate();
- // The tutor tab reads its transcript on mount; a reload is the honest
- // way to drop every component's in-memory copy of what was just deleted.
- window.location.reload();
- };
-
- return (
-
-
-
처음부터
- Start over
-
-
- {asking === null ? (
-
-
-
- The dictionary is never touched.
-
- ) : (
-
-
- {asking === "roadmap"
- ? "This erases your place on the roadmap, every unit's confidence, and the lesson transcript. Your cards, review history and own words are kept."
- : "This erases everything: roadmap, transcript, all cards and review history, your streak, your own words, your notes and every setting."}
-
-
-
-
-
-
- )}
-
-
- );
-}
-
-/* Where the dictionary came from, how much of it is loaded, and the terms
- it ships under. The attribution is a licence obligation, not decoration. */
-function About() {
- const { db, dbInfo, manifest, revision } = useStore();
- const [rows, setRows] = useState<{ lemmas: number; surfaces: number } | null>(null);
-
- useEffect(() => {
- let cancelled = false;
- void stats(db).then((s) => {
- if (!cancelled) setRows(s);
- });
- return () => {
- cancelled = true;
- };
- }, [db, revision]);
-
- return (
-
- Share-alike applies to the dictionary data, not to this app's code.
-
-
-
-
- );
-}
-
-/* The Pi, if there is one. Everything works without it; this is what turns
- on the real tutor and syncing between devices. */
-function ServerPanel() {
- const { server, setServer, syncState, syncNow } = useStore();
- const [url, setUrl] = useState(server?.baseUrl ?? "");
- const [token, setToken] = useState(server?.token ?? "");
-
- const ago =
- syncState.at === null
- ? "never"
- : `${Math.max(0, Math.round((Date.now() - syncState.at) / 1000))}s ago`;
-
- return (
-
-
-
서버
-
- {server ? "Connected — the real 선생님, and sync" : "Not set — everything stays on this device"}
-
-
-
-
-
-
-
-
- {server && (
- <>
-
-
- >
- )}
-
-
- {server && (
-
- {syncState.error
- ? `Last sync failed (${ago}) — ${syncState.error}. Your work is safe here and will go up when the server is reachable.`
- : syncState.result
- ? `Last sync ${ago}: sent ${syncState.result.pushed}, received ${syncState.result.pulled}.`
- : "Not synced yet."}
-
+ {note ?? "Enter sends · Shift + Enter for a new line"}
+
+
+ );
+}
diff --git a/app/src/ui/tutor/RoadStrip.tsx b/app/src/ui/tutor/RoadStrip.tsx
index 3729632..479c14b 100644
--- a/app/src/ui/tutor/RoadStrip.tsx
+++ b/app/src/ui/tutor/RoadStrip.tsx
@@ -7,7 +7,9 @@
yet" parks it below the threshold rather than arguing with the model.
In a 다지기 review the bar is the checklist instead: rules and words
- confirmed out of all the phase introduced. */
+ confirmed out of all the phase introduced.
+
+ The picker opens from the map button in the lesson's header. */
import { useEffect, useState } from "react";
import { useStore } from "../../state/store.js";
@@ -27,9 +29,20 @@ import "./road.css";
export type UnitChange = "advance" | "jump";
-export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string, how: UnitChange) => void }) {
+export function RoadStrip({
+ open,
+ onClose,
+ busy,
+ onUnitChange,
+}: {
+ /** The unit picker is showing. */
+ open: boolean;
+ onClose: () => void;
+ /** A reply is on its way; moving unit now would talk over it. */
+ busy: boolean;
+ onUnitChange: (unitId: string, how: UnitChange) => void;
+}) {
const { db, progress, refreshProgress, revision } = useStore();
- const [open, setOpen] = useState(false);
const [cover, setCover] = useState(null);
const unit = currentUnit(progress);
@@ -60,23 +73,54 @@ export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string, how
};
const jump = async (id: string) => {
+ onClose();
await goToUnit(db, progress, id);
await refreshProgress();
- setOpen(false);
onUnitChange(id, "jump");
};
const review = unit.review && cover;
return (
-
+ <>
+ {ready && next && (
+
+
+
+ {review ? (
+ <>
+ Phase {unit.phase} confirmed — all {cover.total} rules and words.
+ >
+ ) : (
+ <>
+ 선생님 thinks you have {unit.ko}.
+ >
+ )}
+ {" "}
+ {!review && note} Ready for{" "}
+
+ {next.id} {next.ko}
+ {" "}
+ ({next.name})?
+
+
+ void move()}>
+ 다음으로 · Move on
+
+ void stay()}>
+ 아직 · Not yet
+
+
+
+ >
);
}
diff --git a/app/src/ui/tutor/TutorTab.tsx b/app/src/ui/tutor/TutorTab.tsx
index 5ffdc6d..e9e4045 100644
--- a/app/src/ui/tutor/TutorTab.tsx
+++ b/app/src/ui/tutor/TutorTab.tsx
@@ -1,4 +1,4 @@
-/* The tutor tab.
+/* The lesson.
This is where the curriculum, the dictionary and the prompt meet:
@@ -12,7 +12,11 @@
The transcript lives in the `chat` table, so the client owns it. The turn
itself — what the tutor may say and what its marking may change — lives
- in domain/turn.ts, where it is testable without React. */
+ in domain/turn.ts, where it is testable without React.
+
+ The layout is a column that fits the screen exactly: the roadmap strip,
+ the conversation (the one thing that scrolls), and the composer. The
+ word list is a docked column at 840px and up, and a sheet below that. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "../../state/store.js";
@@ -42,9 +46,15 @@ import type { ParsedMessage } from "@lib/blocks.js";
import { MessageBody } from "./MessageBody.js";
import { GlossBlocks } from "./GlossBlock.js";
import { TaskHost } from "./TaskHost.js";
-import { WordRail, collectWords, type RailWord } from "./WordRail.js";
+import { RailPanel, collectWords, type RailWord } from "./WordRail.js";
+import { WordSheet, type Detent } from "./WordSheet.js";
import { RoadStrip } from "./RoadStrip.js";
-import { Keyboard, useComposer } from "../keyboard/Keyboard.js";
+import { Composer } from "./Composer.js";
+import { RouteHead, useRouteActive } from "../shell/Route.js";
+import { useLayer } from "../shell/router.js";
+import { Pop } from "../shell/Pop.js";
+import { MapIcon, SearchIcon } from "../shell/icons.js";
+import { WIDE, useMedia } from "../shell/useMedia.js";
import promptTemplate from "@prompt/tutor-system.md?raw";
import "./tutor.css";
@@ -53,16 +63,17 @@ const STICK_PX = 80;
const KEEP_TURNS = 26;
-/* The seven modes from FOCUS_MODES, with labels for the picker. Listed
- rather than derived so the order is deliberate: auto first, free last. */
+/* The seven modes from FOCUS_MODES, labelled as the artifact labels them.
+ Listed rather than derived so the order is deliberate: auto first, free
+ last. */
const FOCUS_LABELS: [FocusMode, string][] = [
- ["auto", "자동 Auto"],
- ["sentence", "문장 Sentences"],
- ["vocab", "단어 Vocabulary"],
- ["particles", "조사 Particles"],
- ["sound", "소리 Sound"],
- ["manhwa", "만화 Manhwa"],
- ["free", "자유 Just talk"],
+ ["auto", "자동 · 선생님 follows the roadmap"],
+ ["sentence", "문장 논리 · Sentence logic"],
+ ["vocab", "단어 · Vocabulary drilling"],
+ ["particles", "조사 · Particles"],
+ ["sound", "소리 · Sound changes"],
+ ["manhwa", "만화 · Real manhwa lines"],
+ ["free", "자유 · Whatever I ask"],
];
/* Every mode must exist in FOCUS_MODES, or {{FOCUS}} silently renders
@@ -132,26 +143,34 @@ function retryNote(r: Retry): string {
return `선생님 used a word he has not taught (${words}) — asking him again…`;
}
-/* ── the tab ─────────────────────────────────────────────────────── */
+/* ── the lesson ──────────────────────────────────────────────────── */
export function TutorTab() {
const { db, progress, prefs, setPref, server, refreshProgress, invalidate, revision, today } = useStore();
+ const active = useRouteActive();
+ const wide = useMedia(WIDE);
+
const [turns, setTurns] = useState([]);
const [streaming, setStreaming] = useState(null);
const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
const [draft, setDraft] = useState("");
const [revealed, setRevealed] = useState>(new Set());
const [railWords, setRailWords] = useState([]);
+ const [railQuery, setRailQuery] = useState("");
const [bandWords, setBandWords] = useState([]);
- const [showKeyboard, setShowKeyboard] = useState(false);
+ const [keyboard, setKeyboard] = useState(false);
const [recent, setRecent] = useState([]);
const [met, setMet] = useState([]);
+ /** The line under the composer: a retry, an error, a confirmation. */
const [note, setNote] = useState(null);
/** Words flagged in the last reply shown despite the gate; the next turn names them. */
const [strays, setStrays] = useState([]);
/** Flagged words per turn id, so the flag stays under its own message. */
const [flags, setFlags] = useState>({});
+ const [roadOpen, setRoadOpen] = useState(false);
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [detent, setDetent] = useState("closed");
+
/** What he had looked up when he submitted the answer being marked. */
const lastLookups = useRef([]);
/** The 다지기 checklist rules still open — only the stand-in reads it. */
@@ -162,13 +181,21 @@ export function TutorTab() {
// closure is a render behind, which is not good enough for a guard.
const inFlight = useRef(false);
const log = useRef(null);
- const foot = useRef(null);
- const input = useRef(null);
- const composer = useComposer();
+ /** The log follows new text while this is set; see the follow effect. */
+ const stick = useRef(true);
+ const menuButton = useRef(null);
const unit = currentUnit(progress);
const unitId = progress.current;
+ /* The sheet is a layer below 840px. Leaving the route closes it, as does
+ growing past 840px, where the list is docked instead. */
+ const sheetOpen = !wide && active && detent !== "closed";
+ useLayer("sheet", sheetOpen, () => setDetent("closed"));
+ useEffect(() => {
+ if (wide) setDetent("closed");
+ }, [wide]);
+
/* ── the gate ── */
const bandQuery: BandQuery = useCallback(
@@ -241,23 +268,6 @@ export function TutorTab() {
/* ── the responder ── */
- const sample: Sample = useMemo(() => {
- // A configured server means the real 선생님; otherwise the local stand-in,
- // so the app is complete offline rather than degraded.
- if (server) return makeRemoteTutor(server);
-
- return makeStubTutor(() => {
- const words: StubWord[] = railVocabulary.current;
- return {
- gate,
- words,
- turn: turns.filter((t) => t.role === "user").length,
- confidence: progress.confidence[progress.current] ?? 0,
- openRules: openRules.current,
- };
- });
- }, [gate, turns, progress, server]);
-
/* The unit's own new words, glossed — what the stub builds exercises from
and what the real tutor would be told it may introduce. */
const railVocabulary = useRef([]);
@@ -276,6 +286,20 @@ export function TutorTab() {
};
}, [db, gate.newWords]);
+ const sample: Sample = useMemo(() => {
+ // A configured server means the real 선생님; otherwise the local stand-in,
+ // so the app is complete offline rather than degraded.
+ if (server) return makeRemoteTutor(server);
+
+ return makeStubTutor(() => ({
+ gate,
+ words: railVocabulary.current,
+ turn: turns.filter((t) => t.role === "user").length,
+ confidence: progress.confidence[progress.current] ?? 0,
+ openRules: openRules.current,
+ }));
+ }, [gate, turns, progress, server]);
+
/* ── sending ── */
const send = useCallback(
@@ -283,12 +307,15 @@ export function TutorTab() {
if (inFlight.current) return;
inFlight.current = true;
setBusy(true);
- setError(null);
- setNote(null);
+ setNote("선생님 is reading your answer…");
// What he looked up belongs to THIS answer; a typed message has none.
lastLookups.current = lookups;
+ // Answering scrolled the log up to the fields; sending means he wants
+ // the reply, so follow it again.
+ stick.current = true;
+
// Counted before the request, as the artifact does: the answer is
// given whether or not the reply arrives.
await noteAnswer(db, progress, body);
@@ -343,14 +370,17 @@ export function TutorTab() {
const applied = await applyReply(db, result.parsed, { lookups: lastLookups.current, today });
// A new exercise resets the per-exercise lookup set — that set is
- // what the answer reports back, so it must not carry over.
- if (result.parsed.task) setRevealed(new Set());
+ // what the answer reports back, so it must not carry over — and
+ // takes the word list down from full, so the exercise is in view.
+ if (result.parsed.task) {
+ setRevealed(new Set());
+ setDetent((d) => (d === "full" ? "peek" : d));
+ }
setRecent(applied.recent);
await refreshProgress();
invalidate();
} catch (err) {
const e = err as SampleError;
- setNote(null);
if (e?.code === "cancelled") {
if (e.text) {
await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`);
@@ -358,9 +388,10 @@ export function TutorTab() {
}
setStreaming(null);
setBusy(false);
+ setNote(null);
} else {
setStreaming(null);
- setError(e?.message ?? "The tutor could not be reached.");
+ setNote(e?.message ?? "The tutor could not be reached.");
}
} finally {
abort.current = null;
@@ -371,14 +402,6 @@ export function TutorTab() {
[db, gate, invalidate, loadTurns, prefs.focus, progress, readTurns, recent, refreshProgress, sample, strays, today, turns],
);
- /* `send` is rebuilt on every render because it closes over the gate, the
- transcript and progress. Effects that need it must not depend on its
- identity, or they re-run constantly — so they reach it through a ref. */
- const sendRef = useRef(send);
- useEffect(() => {
- sendRef.current = send;
- });
-
/* Open the lesson if there is no transcript yet — EXACTLY ONCE.
The guard is claimed synchronously, before the first await: setting it
after one would let every concurrent run past it, which is precisely how
@@ -412,6 +435,8 @@ export function TutorTab() {
await editChatClear(db);
setRevealed(new Set());
setRecent([]);
+ setFlags({});
+ setStrays([]);
await seedChatTurn(db, "assistant", openingTurn(unit), 0);
await loadTurns();
}, [db, loadTurns, unit]);
@@ -419,7 +444,7 @@ export function TutorTab() {
/** Nothing asked yet: the opening is all there is, and Start begins it. */
const notStarted = !turns.some((t) => t.role === "user");
- /* ── the rail ── */
+ /* ── the word list ── */
const lastTutor = useMemo(
() => [...turns].reverse().find((t) => t.role === "assistant"),
@@ -446,17 +471,14 @@ export function TutorTab() {
};
}, [db, lastTutor, parsedLast]);
- /* Autoscroll, but only the log and only when the learner is already at
- the bottom.
+ const reveal = useCallback((ko: string) => setRevealed((r) => new Set(r).add(ko)), []);
- scrollIntoView() scrolls every scrollable ancestor, so each token also
- nudged the page itself — the shake. And it fired unconditionally, so
- scrolling up to re-read an earlier turn was undone by the next token.
- Setting scrollTop on the log moves nothing else; the threshold leaves
- a reader who has scrolled away alone. */
- /* Every keystroke in the composer re-renders this tab, and the transcript
- was re-parsed from scratch each time — up to KEEP_TURNS messages of
- block parsing per character typed. Parse once per transcript instead. */
+ /* ── the conversation ── */
+
+ /* Every keystroke in the composer re-renders the lesson, and the
+ transcript was re-parsed from scratch each time — up to KEEP_TURNS
+ messages of block parsing per character typed. Parse once per
+ transcript instead. */
const parsedTurns = useMemo(
() =>
turns.map((t) => ({
@@ -467,7 +489,7 @@ export function TutorTab() {
);
/* parseMessage() re-parsed the entire partial reply on every token, and
- on every unrelated re-render of this tab. */
+ on every unrelated re-render of the lesson. */
const streamingBody = useMemo(() => {
if (streaming === null) return "";
/* parseMessage() drops directive markup from the body, which during a
@@ -483,131 +505,205 @@ export function TutorTab() {
return lines.join("\n");
}, [streaming]);
- const stick = useRef(true);
+ /* Follow the conversation — the log is the only thing that scrolls, so
+ setting its scrollTop moves nothing else — but only while the learner
+ is at the bottom: scrolling up to re-read an earlier turn is not undone
+ by the next token. `stick` is declared with the other refs above. */
- /* Which element actually scrolls depends on the width. Above 900px the
- log is a fixed-height scroller; below it, tutor.css sets max-height to
- none and the window scrolls instead. The autoscroll only ever handled
- the first case, so on a phone nothing followed the reply at all — a
- new message simply landed below the fold. */
- const scroller = useCallback((): HTMLElement | null => {
+ /* Only moving UP lets go. A scroll event lands a task after the scroll
+ that caused it, so the follow's own scroll is often measured after the
+ stream has added more than STICK_PX below it — judged by distance
+ alone, that read as the learner scrolling away, and the log stopped
+ following mid-reply. Growing content never moves scrollTop up; a
+ finger does. */
+ const lastTop = useRef(0);
+
+ const onLogScroll = useCallback(() => {
const el = log.current;
- if (!el) return null;
- return el.scrollHeight > el.clientHeight + 1 ? el : null;
+ if (!el || !active) return;
+ const top = el.scrollTop;
+ if (el.scrollHeight - top - el.clientHeight < STICK_PX) stick.current = true;
+ else if (top < lastTop.current - 1) stick.current = false;
+ lastTop.current = top;
+ }, [active]);
+
+ const follow = useCallback(() => {
+ const el = log.current;
+ if (!el || !stick.current) return;
+ el.scrollTop = el.scrollHeight;
+ lastTop.current = el.scrollTop;
}, []);
- /* In the page-flow layout the end of the conversation is NOT the end of
- the document — the word rail is rendered below the chat panel — so the
- thing to keep in view is the composer, which sits directly under the
- log. Following the document's bottom would mean scrolling past the
- whole rail on every turn. */
- const footBottom = () =>
- foot.current ? foot.current.getBoundingClientRect().bottom - window.innerHeight : 0;
-
- const atBottom = useCallback(() => {
- const el = scroller();
- if (el) return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX;
- return footBottom() < STICK_PX;
- }, [scroller]);
-
- const onScroll = useCallback(() => {
- stick.current = atBottom();
- }, [atBottom]);
-
useEffect(() => {
- window.addEventListener("scroll", onScroll, { passive: true });
- return () => window.removeEventListener("scroll", onScroll);
- }, [onScroll]);
-
- useEffect(() => {
- if (!stick.current) return;
+ if (!active) return;
// One write per frame: setStreaming fires per token, and a scroll write
// per token is what makes the view judder while a reply lands.
- const id = requestAnimationFrame(() => {
- const el = scroller();
- if (el) el.scrollTop = el.scrollHeight;
- else {
- const by = footBottom();
- if (by > 0) window.scrollBy(0, by);
- }
- });
+ const id = requestAnimationFrame(follow);
return () => cancelAnimationFrame(id);
- }, [turns, streaming, busy, scroller]);
+ }, [turns, streaming, busy, active, follow]);
+
+ /* The log also changes height without changing content: the sheet
+ opening, the keyboard, the composer growing. Stay at the bottom. */
+ useEffect(() => {
+ const el = log.current;
+ if (!el) return;
+ const ro = new ResizeObserver(() => follow());
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, [follow]);
/* ── rendering ── */
const isLast = (i: number) => i === turns.length - 1;
+ const railProps = {
+ words: railWords,
+ revealed,
+ onReveal: reveal,
+ query: railQuery,
+ onQuery: setRailQuery,
+ };
+
return (
<>
- {
- opened.current = true;
- const u = UNITS.find((x) => x.id === id);
- if (!u) return;
- void send(
- how === "advance"
- ? `좋아 — I'm ready. Let's start unit ${u.id} ${u.ko} (${u.name}). Introduce it in a sentence or two and give me a first exercise.`
- : `Let's work on unit ${u.id} ${u.ko} (${u.name}). Give me an exercise for it.`,
- );
- }}
- />
+
+ setRoadOpen((o) => !o)}
+ >
+
+
+ {
+ setRailQuery("");
+ if (!wide) setDetent((d) => (d === "closed" ? "half" : "closed"));
+ }}
+ >
+
+
+ setMenuOpen((o) => !o)}
+ >
+ ···
+
+
-
-
-
-
선생님
-
-
- {server ? "connected" : "local stand-in"} · {gate.vocabulary.length} words unlocked ·{" "}
- {gate.newWords.length} new this unit
-
-
{
+ // Typing an answer brings the sheet down to peek: what is being
+ // answered is never behind it.
+ const t = e.target as HTMLElement;
+ if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
+ setDetent("peek");
+ }
+ }}
+ >
+ setRoadOpen(false)}
+ busy={busy}
+ onUnitChange={(id, how) => {
+ opened.current = true;
+ const u = UNITS.find((x) => x.id === id);
+ if (!u) return;
+ void send(
+ how === "advance"
+ ? `좋아 — I'm ready. Let's start unit ${u.id} ${u.ko} (${u.name}). Introduce it in a sentence or two and give me a first exercise.`
+ : `Let's work on unit ${u.id} ${u.ko} (${u.name}). Give me an exercise for it.`,
+ );
+ }}
+ />
+
+
{you ? "나" : "선생님"}
{/* A turn can be nothing but blocks — some models write no
prose around an exercise at all. Rendering the bubble
anyway left an empty box above it. */}
- {(() => {
- const body = parsed ? parsed.body : ownWords(t.body);
- if (!body.trim() && !parsed?.gloss) return null;
- return (
-
+ )}
- {/* An answered exercise stays rendered, read-only. It used
- to collapse to a single line, which threw away what the
- learner had typed and dropped ~170px out of the log the
- instant they pressed Send — the largest single jump in
- the whole view. */}
{flags[t.id] && (
Not taught yet: {flags[t.id]!.join(" · ")}
)}
+ {/* An answered exercise stays rendered, read-only. It used
+ to collapse to a single line, which threw away what the
+ learner had typed and dropped ~170px out of the log the
+ instant they pressed Send — the largest single jump in
+ the whole view. */}
{parsed?.task && (
+
@@ -648,104 +744,61 @@ export function TutorTab() {
)}
)}
-
- {notStarted && !busy && (
-
-
- void send(
- `Let's start unit ${unit.id} ${unit.ko} (${unit.name}). Give me the full introduction, then a first exercise.`,
- )
- }
- >
- 시작 · Start unit
-
-
+
+ {!wide && active && (
+
+
+ setDetent((d) => (d === "full" ? "half" : "full"))}
+ >
+ {detent === "full" ? "▼" : "▲"}
+
+ setDetent("closed")}
+ >
+ ✕
+
+ >
+ }
+ />
+
+ )}
>
);
}
diff --git a/app/src/ui/tutor/WordRail.tsx b/app/src/ui/tutor/WordRail.tsx
index f2ddae6..9602b11 100644
--- a/app/src/ui/tutor/WordRail.tsx
+++ b/app/src/ui/tutor/WordRail.tsx
@@ -1,4 +1,8 @@
-/* The word rail — every Korean form in the message, glossed.
+/* The word list — every Korean form in the current message, glossed.
+
+ One panel, two places to put it: a docked column at 840px and up, and
+ below that the word sheet (WordSheet.tsx). The list is the third tier of
+ looking a word up; the first two are the words in the messages themselves.
COVER/PEEK. Meanings are covered by default and revealed by tapping. The
covered text is NOT RENDERED AT ALL, not merely hidden: it cannot be read
@@ -13,11 +17,9 @@
Korean. It survives sessions and is never sent to the tutor.
Words come from two places, tutor-declared first: the ::words block, and
- a scan of every Korean run in the message looked up in the database. The
- artifact's particle-stripping fallback is gone — surfaceForms() put every
- conjugation in the `surface` table at build time, so this is an index hit. */
+ a scan of every Korean run in the message looked up in the database. */
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState, type ReactNode } from "react";
import type { WordEntry } from "@lib/blocks.js";
import { useStore } from "../../state/store.js";
import { editPeek } from "../../db/writes.js";
@@ -33,6 +35,9 @@ export interface RailWord {
fromTutor: boolean;
}
+/** Search results shown at most; the note says "60+". */
+const SEARCH_LIMIT = 60;
+
/**
* Merge the tutor's ::words block with a scan of the message. Tutor entries
* win; scanned tokens the dictionary cannot gloss are dropped rather than
@@ -71,43 +76,23 @@ export async function collectWords(
return out;
}
-interface RowProps {
- word: RailWord;
- covered: boolean;
- peeked: boolean;
- onReveal: () => void;
-}
-
-function Row({ word, covered, peeked, onReveal }: RowProps) {
- return (
-