feat(ui): the mobile shell — routes with real history, five destinations, the lesson as one screen

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 21:37:58 +02:00
parent a1c86d9550
commit 3a22350c37
36 changed files with 3747 additions and 1449 deletions

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#0F6B5C" />
<title>Hankan — 한국어 읽기</title>
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />

View File

@@ -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<DeckEntry, "source" | "unitId">, 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<DeckEntry[]>
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<DeckEntry[]>
status: statusOf(card),
};
});
return opts.pool && opts.only !== "sentences" ? studyPool(entries, opts.pool) : entries;
}
export interface Counts {

View File

@@ -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(<App />);

View File

@@ -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;
}
}

View File

@@ -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 (
<div className="boot">
@@ -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 (
<header className="top">
<div className="wrap">
<div className="topbar">
<div className="mark">
<span className="name ko serif"></span>
<span className="sub">Korean reading desk</span>
</div>
<div className="chips">
<span className="chip ko" title="Where you are on the roadmap">
{unit.id} · {unit.ko}
</span>
<span
className="chip offline"
title={
dbInfo.persistent
? `Stored on this device — ${dbInfo.driver}`
: "This browser has no OPFS, so progress lasts for this session only"
}
>
{dbInfo.persistent ? "offline" : "session only"}
</span>
</div>
</div>
<nav className="tabs" role="tablist" aria-label="Sections">
{TABS.map((t) => (
<button
key={t.id}
role="tab"
aria-selected={tab === t.id}
onClick={() => onTab(t.id)}
>
<span className="ko">{t.ko}</span>
<span className="en">{t.en}</span>
</button>
))}
</nav>
</div>
</header>
);
}
function Shell() {
const [tab, setTab] = useState<TabId>("lesson");
const review = useReview();
const go = (t: TabId) => {
setTab(t);
window.scrollTo({ top: 0, behavior: "instant" });
};
return (
<>
<Header tab={tab} onTab={go} />
<main>
<div className="wrap">
<section className="stack" hidden={tab !== "lesson"}>
{tab === "lesson" && <TutorTab />}
</section>
<section className="stack" hidden={tab !== "today"}>
{tab === "today" && <TodayTab onGoTo={go} />}
</section>
<section className="stack" hidden={tab !== "vocab"}>
{tab === "vocab" && <VocabTab />}
</section>
<section className="stack" hidden={tab !== "sent"}>
{tab === "sent" && <SentencesTab />}
</section>
<section className="stack" hidden={tab !== "grammar"}>
{tab === "grammar" && <GrammarTab />}
</section>
<section className="stack" hidden={tab !== "hangul"}>
{tab === "hangul" && <HangulTab />}
</section>
</div>
</main>
<Footer />
{review.session && <ReviewOverlay />}
</>
);
}
function Footer() {
const { manifest, dbInfo } = useStore();
return (
<footer className="foot">
<div className="wrap">
<p>
Dictionary: {manifest?.builtWith.dictionary ?? "—"} ·{" "}
{manifest?.totals.lemmas.toLocaleString() ?? "—"} entries ·{" "}
{manifest?.totals.surfaces.toLocaleString() ?? "—"} surface forms · {dbInfo.driver}
</p>
{manifest?.attribution.map((a) => (
<p key={a} className="attrib">
{a}
</p>
))}
</div>
</footer>
);
}
export function App() {
return (
<StoreProvider fallback={(boot) => <Boot boot={boot} />}>
<ReviewProvider>
<Shell />
</ReviewProvider>
<RouterProvider>
<ReviewProvider>
<div className="app">
<main className="stage">
<Routes />
</main>
<Nav />
</div>
<ReviewScreen />
</ReviewProvider>
</RouterProvider>
</StoreProvider>
);
}

View File

@@ -1,13 +1,15 @@
/* Shell chrome: header, tab bar, footer, boot screen. */
/* The boot screen, and the few bits of page chrome that belong to no route. */
.boot {
min-height: 100dvh;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 var(--gutter);
color: var(--ink3);
text-align: center;
}
.boot-mark {
@@ -25,133 +27,8 @@
font-size: 15px;
}
/* ── header ──────────────────────────────────────────────────────── */
.top {
position: sticky;
top: 0;
z-index: 40;
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.topbar {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 0 9px;
}
.mark {
margin-right: auto;
display: flex;
align-items: baseline;
gap: 10px;
}
.mark .name {
font-size: 23px;
font-weight: 600;
}
.mark .sub {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
.hub-note {
padding: 0 2px;
font-size: 12.5px;
color: var(--ink3);
}
.chips {
display: flex;
gap: 7px;
align-items: center;
}
.chip {
font-size: 12px;
padding: 3px 9px;
border: 1px solid var(--line2);
background: var(--sunk);
color: var(--ink2);
white-space: nowrap;
}
.chip.offline {
border-color: var(--jade);
background: var(--jade-soft);
color: var(--jade-ink);
}
.tabs {
display: flex;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tabs button {
display: flex;
align-items: baseline;
gap: 7px;
padding: 9px 13px;
border-bottom: 2px solid transparent;
color: var(--ink2);
white-space: nowrap;
}
.tabs button .ko {
font-size: 16px;
}
.tabs button .en {
font-size: 11px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--ink3);
}
.tabs button[aria-selected="true"] {
border-bottom-color: var(--jade);
color: var(--ink);
}
.tabs button[aria-selected="true"] .en {
color: var(--jade);
}
main {
padding: 26px 0 80px;
}
/* ── footer ──────────────────────────────────────────────────────── */
.foot {
border-top: 1px solid var(--line);
padding: 18px 0 40px;
font-size: 11px;
color: var(--ink3);
}
.foot .attrib {
margin-top: 3px;
color: var(--line2);
}
@media (max-width: 760px) {
.mark .sub {
display: none;
}
}
@media (max-width: 640px) {
.tabs button .en {
display: none;
}
main {
padding: 18px 0 64px;
}
}

View File

@@ -11,8 +11,11 @@
that the on-screen keyboard only appends at the end of the field, which is
fine for the short answers it is for.
mousedown inside the keyboard is prevented so the field never loses focus
— without that, every tap would blur the input. */
pointerdown inside the keyboard is prevented so the field never loses
focus — without that, every tap would blur the input and, on a phone,
bring the system keyboard up over this one. Not touchstart: React 19
listens to it passively, so preventDefault() there does nothing. A
cancelled pointerdown still clicks; it only stops the focus change. */
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { Composer, KEYBOARD } from "@lib/hangul.js";
@@ -87,8 +90,8 @@ export function Keyboard({ composer, onChange, target, onDismiss }: KeyboardProp
return (
<div
className="kb"
onPointerDown={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
onTouchStart={(e) => e.preventDefault()}
>
{KEYBOARD.rows.map((row, i) => (
<div className="kb-row" key={i}>

View File

@@ -1,129 +0,0 @@
/* The review overlay — a full-screen takeover, not a modal card.
The grade buttons' interval labels come from srs.preview() directly. The
artifact had a second, hand-written copy of that function; there is one
scheduler here and the buttons read from it. */
import { useEffect } from "react";
import { useReview } from "./useReview.js";
import { useStore } from "../../state/store.js";
import { preview, AGAIN, HARD, GOOD, EASY, type Grade } from "@lib/srs.js";
import "./review.css";
const GRADES: { label: string; g: Grade; key: string }[] = [
{ label: "Again", g: AGAIN, key: "1" },
{ label: "Hard", g: HARD, key: "2" },
{ label: "Good", g: GOOD, key: "3" },
{ label: "Easy", g: EASY, key: "4" },
];
export function ReviewOverlay() {
const { session, current, finished, reveal, answerCard, end } = useReview();
const { today } = useStore();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
end();
return;
}
if (finished) return;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (!session?.revealed) reveal();
return;
}
if (session?.revealed) {
const hit = GRADES.find((g) => g.key === e.key);
if (hit) void answerCard(hit.g);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [answerCard, end, finished, reveal, session?.revealed]);
if (!session) return null;
const pct = session.total ? Math.round((session.done / session.total) * 100) : 0;
const minutes = Math.max(1, Math.round((Date.now() - session.startedAt) / 60000));
const attempts = session.total + session.again;
const accuracy = attempts ? Math.round((session.correct / attempts) * 100) : 0;
return (
<div className="overlay">
<div className="ov-top">
<button className="btn sm" onClick={end}>
Esc · End
</button>
<div className="ov-prog">
<i style={{ width: `${pct}%` }} />
</div>
<span className="ov-count tnum">
{session.done} / {session.total}
{session.again > 0 && ` · ${session.again} again`}
</span>
</div>
<div className="ov-body">
{finished || !current ? (
<div className="summary">
<div className="big ko serif">!</div>
<div className="sum-row">
<span>
<b className="tnum">{session.total}</b> cards
</span>
<span>
<b className="tnum">{accuracy}%</b> first pass
</span>
<span>
<b className="tnum">{minutes}</b> min
</span>
</div>
<button className="btn big primary" onClick={end}>
Back to today
</button>
</div>
) : (
<>
<div className="card-dir eyebrow">
{session.direction === "ko-en" ? "한국어 → English" : "English → 한국어"}
</div>
<div
className={`card-front ${session.direction === "ko-en" ? "ko" : "en serif"}`}
>
{session.direction === "ko-en" ? current.headword : current.glossEn}
</div>
{!session.revealed ? (
<button className="btn big" onClick={reveal}>
Show answer <span className="kbd">space</span>
</button>
) : (
<>
<div className={`mean ${session.direction === "ko-en" ? "" : "ko"}`}>
{session.direction === "ko-en" ? current.glossEn : current.headword}
</div>
<div className="tag">{current.pos}</div>
<div className="grades">
{GRADES.map((g) => (
<button
key={g.g}
className={`btn g${g.g}`}
onClick={() => void answerCard(g.g)}
>
<span className="lab">{g.label}</span>
<span className="nxt">{preview(current.card, g.g, today)}</span>
<span className="kbd">{g.key}</span>
</button>
))}
</div>
</>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
/* The review screen — its own screen, graded from the thumb zone.
The whole card area reveals: a giant target beats a button (Anki does
this). The grades sit at the bottom, where a thumb already is.
The grade buttons' interval labels come from srs.preview() directly. The
artifact had a second, hand-written copy of that function; there is one
scheduler here and the buttons read from it. */
import { useEffect } from "react";
import { useReview } from "./useReview.js";
import { useStore } from "../../state/store.js";
import { preview, AGAIN, HARD, GOOD, EASY, type Grade } from "@lib/srs.js";
import "./review.css";
const GRADES: { label: string; g: Grade; key: string }[] = [
{ label: "Again", g: AGAIN, key: "1" },
{ label: "Hard", g: HARD, key: "2" },
{ label: "Good", g: GOOD, key: "3" },
{ label: "Easy", g: EASY, key: "4" },
];
export function ReviewScreen() {
const { session, current, finished, reveal, answerCard, end } = useReview();
const { today } = useStore();
useEffect(() => {
if (!session) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
end();
return;
}
if (finished) {
if (e.key === "Enter") end();
return;
}
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (!session.revealed) reveal();
return;
}
if (session.revealed) {
const hit = GRADES.find((g) => g.key === e.key);
if (hit) {
e.preventDefault();
void answerCard(hit.g);
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [answerCard, end, finished, reveal, session]);
if (!session) return null;
const pct = session.total ? Math.min(100, Math.round((session.done / session.total) * 100)) : 100;
const koFront = session.direction === "ko-en";
return (
<div className="rv" role="dialog" aria-modal="true" aria-label="Review">
<div className="rv-top">
<button className="iconbtn" onClick={end} aria-label="End review">
</button>
<div className="rv-prog">
<i style={{ width: `${pct}%` }} />
</div>
<span className="rv-count tnum">
{finished
? "done"
: `${session.done} / ${session.total}${session.again ? ` · ${session.again} again` : ""}`}
</span>
</div>
{finished || !current ? (
<Summary />
) : (
<>
<div
className="rv-card"
onClick={() => {
if (!session.revealed) reveal();
}}
>
<div className="rv-dir">{koFront ? "한국어 → English" : "English → 한국어"}</div>
<div className={koFront ? "rv-front ko" : "rv-front en"}>
{koFront ? current.headword : current.glossEn}
</div>
{!session.revealed ? (
<div className="rv-hint">Tap anywhere to reveal</div>
) : (
<div className="rv-back">
<div className={koFront ? "mean" : "mean ko"}>
{koFront ? current.glossEn : current.headword}
</div>
<div className="tag">{[current.topic, current.pos].filter(Boolean).join(" · ")}</div>
</div>
)}
</div>
<div className="rv-foot">
{session.revealed ? (
<div className="grades">
{GRADES.map((g) => (
<button key={g.g} className={`g${g.g}`} onClick={() => void answerCard(g.g)}>
<span className="lab">{g.label}</span>
<span className="nxt">{preview(current.card, g.g, today)}</span>
</button>
))}
</div>
) : (
<button className="cta ghost" onClick={reveal}>
Show answer
</button>
)}
</div>
</>
)}
</div>
);
}
function Summary() {
const { session, end } = useReview();
if (!session) return null;
const empty = session.total === 0;
const minutes = Math.max(1, Math.round((performance.now() - session.startedAt) / 60000));
const attempts = session.total + session.again;
const accuracy = attempts ? Math.round((session.correct / attempts) * 100) : 0;
return (
<>
<div className="rv-card rv-summary">
{empty ? (
<div className="rv-done">
<div className="big ko"> </div>
<p className="rv-empty">Nothing is due right now, and no new cards are waiting.</p>
</div>
) : (
<div className="rv-done">
<div className="big ko">!</div>
<div className="sum-row">
<span>
<b className="tnum">{session.total}</b> cards
</span>
<span>
<b className="tnum">{accuracy}%</b> first pass
</span>
<span>
<b className="tnum">{minutes}</b> min
</span>
</div>
</div>
)}
</div>
<div className="rv-foot">
<button className="cta" onClick={end}>
Done
</button>
</div>
</>
);
}

View File

@@ -1,129 +1,198 @@
/* Full-screen review takeover. */
/* The review screen: over everything — the nav, and the word sheet, which
in the artifact sat on top of it. */
.overlay {
position: fixed;
.rv {
position: absolute;
inset: 0;
z-index: 100;
background: var(--bg);
z-index: 115;
display: flex;
flex-direction: column;
background: var(--bg);
}
.ov-top {
.rv-top {
flex: none;
display: flex;
align-items: center;
gap: 14px;
padding: 12px 20px;
gap: 12px;
padding: calc(8px + var(--safe-t)) var(--gutter) 8px;
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.ov-prog {
.rv-prog {
flex: 1;
height: 5px;
position: relative;
overflow: hidden;
border-radius: 3px;
background: var(--sunk);
border: 1px solid var(--line);
}
.ov-prog i {
display: block;
height: 100%;
.rv-prog i {
position: absolute;
inset: 0 auto 0 0;
background: var(--jade);
transition: width 0.25s;
transition: width 0.2s;
}
.ov-count {
font-size: 12px;
.rv-count {
font-size: 12.5px;
color: var(--ink3);
white-space: nowrap;
}
.ov-body {
.rv-card {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
gap: 18px;
padding: 24px var(--gutter);
text-align: center;
padding: 24px 20px 60px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.card-dir {
.rv-summary {
cursor: default;
}
.rv-dir {
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink3);
}
.card-front {
font-size: clamp(38px, 8vw, 68px);
line-height: 1.25;
max-width: 20ch;
.rv-front {
max-width: 18ch;
font-size: clamp(30px, 8vw, 54px);
font-weight: 500;
line-height: 1.35;
word-break: keep-all;
}
.card-front.en {
font-size: clamp(28px, 5vw, 44px);
.rv-front.en {
max-width: 22ch;
font-family: var(--serif);
font-size: clamp(24px, 6vw, 40px);
font-weight: 400;
}
.mean {
font-size: 20px;
color: var(--ink2);
max-width: 34ch;
.rv-hint {
padding: 11px 20px;
border: 1px dashed var(--line2);
border-radius: 10px;
font-size: 13px;
color: var(--ink3);
}
.mean.ko {
font-size: 30px;
color: var(--ink);
.rv-back {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 460px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.tag {
.rv-back .mean {
font-size: 19px;
}
.rv-back .mean.ko {
font-size: 28px;
font-weight: 500;
}
.rv-back .tag {
font-size: 12px;
color: var(--ink3);
}
.rv-foot {
flex: none;
padding: 10px var(--gutter) calc(10px + var(--safe-b));
background: var(--paper);
border-top: 1px solid var(--line);
}
.rv-foot > * {
max-width: 560px;
margin: 0 auto;
}
.grades {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 9px;
width: 100%;
max-width: 520px;
margin-top: 8px;
gap: 8px;
}
.grades .btn {
.grades button {
min-height: var(--cta-h);
display: flex;
flex-direction: column;
gap: 3px;
padding: 11px 6px;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 2px;
border: 1px solid var(--line2);
border-radius: var(--radius);
background: var(--raise);
}
.grades button:hover {
background: var(--sunk);
}
.grades .lab {
font-size: 14px;
font-weight: 600;
}
.grades .nxt {
font-size: 11px;
color: var(--ink3);
font-variant-numeric: tabular-nums;
}
.grades .g0:hover {
.grades .g0 {
border-color: var(--jeok);
background: var(--jeok-soft);
color: var(--jeok);
}
.grades .g3:hover {
.grades .g3 {
border-color: var(--jade);
background: var(--jade-soft);
color: var(--jade-ink);
}
.summary {
.rv-done {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
gap: 20px;
}
.summary .big {
.rv-done .big {
font-family: var(--serif);
font-size: 40px;
font-weight: 700;
color: var(--jade);
}
.rv-empty {
max-width: 32ch;
font-size: 15px;
color: var(--ink2);
}
.sum-row {
display: flex;
gap: 26px;
@@ -134,12 +203,6 @@
.sum-row b {
display: block;
font-size: 24px;
color: var(--ink);
font-weight: 600;
}
@media (max-width: 760px) {
.grades {
grid-template-columns: repeat(2, 1fr);
}
color: var(--ink);
}

View File

@@ -1,5 +1,7 @@
/* The review session, held above the tabs so the overlay can be opened from
the vocabulary tab, the sentences tab or Today and survive a tab switch. */
/* The review session, held above the routes so it can be started from the
nav, from Today or from the sentences page, and survive whatever is under
it. It is a layer: it owns a history entry while it is open, so the back
gesture ends it instead of leaving the route beneath. */
import {
createContext,
@@ -13,6 +15,7 @@ import {
import { useStore } from "../../state/store.js";
import { answer, buildQueue, type DeckEntry, type DeckOptions } from "../../domain/cards.js";
import { AGAIN, GOOD, type Grade } from "@lib/srs.js";
import { useLayer } from "../shell/router.js";
export interface Session {
queue: DeckEntry[];
@@ -24,6 +27,7 @@ export interface Session {
/** Per-card direction, resolved once so "mixed" does not flip mid-card. */
direction: "ko-en" | "en-ko";
revealed: boolean;
/** Performance-clock milliseconds, for the summary's duration. */
startedAt: number;
}
@@ -46,13 +50,14 @@ export function useReview(): ReviewApi {
}
export function ReviewProvider({ children }: { children: ReactNode }) {
const { db, prefs, today, invalidate } = useStore();
const { db, prefs, progress, today, invalidate } = useStore();
const [session, setSession] = useState<Session | null>(null);
// Grading writes to the database before it updates state, so a second
// press arriving inside that gap would grade the same card twice — once
// in `card`, twice in `study_log`. React state is a render behind and
// cannot guard it; a ref can.
const grading = useRef(false);
const starting = useRef(false);
const pickDirection = useCallback(
(): Session["direction"] =>
@@ -62,33 +67,41 @@ export function ReviewProvider({ children }: { children: ReactNode }) {
const start = useCallback(
async (opts: DeckOptions = {}) => {
const queue = await buildQueue(db, today, prefs.newPerDay, {
sentences: prefs.sentences,
...opts,
});
if (!queue.length) return;
grading.current = false;
setSession({
queue,
done: 0,
total: queue.length,
again: 0,
correct: 0,
direction: pickDirection(),
revealed: false,
startedAt: Date.now(),
});
document.body.style.overflow = "hidden";
if (starting.current) return;
starting.current = true;
try {
const queue = await buildQueue(db, today, prefs.newPerDay, {
sentences: prefs.sentences,
pool: progress,
...opts,
});
grading.current = false;
// An empty queue still opens the screen: tapping 복습 and having
// nothing happen reads as a broken button.
setSession({
queue,
done: 0,
total: queue.length,
again: 0,
correct: 0,
direction: pickDirection(),
revealed: false,
startedAt: performance.now(),
});
} finally {
starting.current = false;
}
},
[db, pickDirection, prefs.newPerDay, prefs.sentences, today],
[db, pickDirection, prefs.newPerDay, prefs.sentences, progress, today],
);
const end = useCallback(() => {
setSession(null);
document.body.style.overflow = "";
invalidate();
}, [invalidate]);
useLayer("review", session !== null, end);
const reveal = useCallback(() => {
setSession((s) => (s ? { ...s, revealed: true } : s));
}, []);

139
app/src/ui/routes.tsx Normal file
View File

@@ -0,0 +1,139 @@
/* Every route: its header and its content. The screens themselves live in
tabs/ and tutor/; this is only where each one sits. */
import { useStore } from "../state/store.js";
import { currentUnit } from "../domain/progress.js";
import { Route, RouteHead, Scroll } from "./shell/Route.js";
import { useNavigator, type RouteId } from "./shell/router.js";
import { TutorTab } from "./tutor/TutorTab.js";
import { TodayTab } from "./tabs/TodayTab.js";
import { SettingsTab } from "./tabs/SettingsTab.js";
import { VocabTab } from "./tabs/VocabTab.js";
import { SentencesTab } from "./tabs/SentencesTab.js";
import { ConjugationTab, GrammarTab } from "./tabs/GrammarTab.js";
import { DrillTab, HangulTab } from "./tabs/HangulTab.js";
function Go({ to, label, glyph }: { to: RouteId; label: string; glyph: string }) {
const nav = useNavigator();
return (
<button className="iconbtn" title={label} aria-label={label} onClick={() => nav.go(to)}>
{glyph}
</button>
);
}
/** 학습 — a hub: five places, one tap each. */
function LearnHub() {
const nav = useNavigator();
const { progress } = useStore();
const unit = currentUnit(progress);
const row = (to: RouteId, ic: string, ko: string, en: string) => (
<button className="row" onClick={() => nav.go(to)}>
<span className="ic ko">{ic}</span>
<span className="tx">
<span className="t1 ko">{ko}</span>
<span className="t2">{en}</span>
</span>
<span className="go" aria-hidden="true">
</span>
</button>
);
return (
<>
<div className="rows">
{row("sent", "문", "문장", "Sentences — the ending word carries the line")}
{row("grammar", "법", "문법", "Grammar reference and the irregular classes")}
{row("hangul", "가", "한글", "Letters, batchim, sound changes")}
</div>
<div className="rows">
{row("cj", "활", "활용 연습", "Conjugation drill, marked by the app")}
{row("drill", "읽", "읽기 연습", "Reading drill — sound changes, speed, sentences")}
</div>
<p className="hub-note">
You are on <span className="ko">{unit.id} {unit.ko}</span> {unit.name}.
</p>
</>
);
}
export function Routes() {
return (
<>
<Route id="lesson">
<TutorTab />
</Route>
<Route id="today">
<RouteHead title="오늘" sub="Today">
<Go to="settings" label="Settings" glyph="⚙" />
</RouteHead>
<Scroll>
<TodayTab />
</Scroll>
</Route>
<Route id="settings">
<RouteHead title="설정" sub="Settings" back />
<Scroll>
<SettingsTab />
</Scroll>
</Route>
<Route id="words">
<RouteHead title="단어" sub="Vocabulary" />
<Scroll>
<VocabTab />
</Scroll>
</Route>
<Route id="learn">
<RouteHead title="학습" sub="Reference & practice" />
<Scroll>
<LearnHub />
</Scroll>
</Route>
<Route id="sent">
<RouteHead title="문장" sub="Sentences" back />
<Scroll>
<SentencesTab />
</Scroll>
</Route>
<Route id="grammar">
<RouteHead title="문법" sub="Grammar" back>
<Go to="cj" label="Conjugation drill" glyph="⚙" />
</RouteHead>
<Scroll>
<GrammarTab />
</Scroll>
</Route>
<Route id="cj">
<RouteHead title="활용 연습" sub="Conjugation" back />
<Scroll>
<ConjugationTab />
</Scroll>
</Route>
<Route id="hangul">
<RouteHead title="한글" sub="Letters & sounds" back>
<Go to="drill" label="Reading drill" glyph="⏱" />
</RouteHead>
<Scroll>
<HangulTab />
</Scroll>
</Route>
<Route id="drill">
<RouteHead title="읽기 연습" sub="Reading drill" back />
<Scroll>
<DrillTab />
</Scroll>
</Route>
</>
);
}

68
app/src/ui/shell/Nav.tsx Normal file
View File

@@ -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: <LessonIcon /> },
{ id: "review", ko: "복습", en: "Review", icon: <ReviewIcon /> },
{ id: "today", ko: "오늘", en: "Today", icon: <TodayIcon /> },
{ id: "words", ko: "단어", en: "Words", icon: <WordsIcon /> },
{ id: "learn", ko: "학습", en: "Learn", icon: <LearnIcon /> },
];
/** 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<number | null>(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 (
<nav className="nav" aria-label="Sections">
<span className="navmark ko"></span>
{ITEMS.map((item) => (
<button
key={item.id}
title={item.en}
aria-current={NAV_OF[route] === item.id ? "page" : undefined}
onClick={() => {
if (item.id === "review") void start();
else nav.go(item.id);
}}
>
<span className="ic">{item.icon}</span>
<span className="lb ko">{item.ko}</span>
{item.id === "review" && due ? (
<span className="badge" aria-label={`${due} to review`}>
{due > 99 ? "99+" : due}
</span>
) : null}
</button>
))}
</nav>
);
}

95
app/src/ui/shell/Pop.tsx Normal file
View File

@@ -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<HTMLDivElement>(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(
<div ref={ref} className={`pop${className ? ` ${className}` : ""}`} role="dialog" aria-label={label}>
{children}
</div>,
document.body,
);
}

View File

@@ -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 (
<section className="route" id={`rt-${id}`} hidden={!on}>
<ActiveContext.Provider value={on}>{visited ? children : null}</ActiveContext.Provider>
</section>
);
}
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 (
<header className="rhead">
{back && (
<button className="iconbtn back" onClick={() => nav.back()} aria-label="Back">
</button>
)}
<h1 className="ko">
{title}
{sub && <span className="sub"> {sub}</span>}
</h1>
{children && <span className="sp">{children}</span>}
</header>
);
}
/**
* 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<HTMLDivElement>(null);
const top = useRef(0);
useLayoutEffect(() => {
if (active && ref.current) ref.current.scrollTop = top.current;
}, [active]);
return (
<div
ref={ref}
className="scroll"
onScroll={(e) => {
if (active) top.current = e.currentTarget.scrollTop;
}}
>
<div className="wrap page">{children}</div>
</div>
);
}

332
app/src/ui/shell/history.ts Normal file
View File

@@ -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<Record<RouteId, RouteId>> = {
settings: "today",
sent: "learn",
grammar: "learn",
hangul: "learn",
cj: "grammar",
drill: "hangul",
};
/** Which nav destination a route lights up. */
export const NAV_OF: Record<RouteId, NavId> = {
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<typeof setTimeout> | 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;
}
}
}
}

View File

@@ -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 (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
const nav = (children: ReactNode) => (
<Svg size={23} stroke={1.7}>
{children}
</Svg>
);
export const LessonIcon = () =>
nav(
<>
<path d="M20 14a3 3 0 0 1-3 3H8l-4 3.5V6a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3z" />
<path d="M8.5 8.5h7M8.5 12h4.5" />
</>,
);
export const ReviewIcon = () =>
nav(
<>
<path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1" />
<path d="M20.6 4.2v4.6h-4.6" />
</>,
);
export const TodayIcon = () =>
nav(
<>
<rect x="3.2" y="4.8" width="17.6" height="16" rx="2.6" />
<path d="M3.2 9.6h17.6M8 3.2v3.2M16 3.2v3.2" />
<path d="M8.4 14.2h3.2v3.2H8.4z" fill="currentColor" stroke="none" />
</>,
);
export const WordsIcon = () =>
nav(
<>
<path d="M4 4.6h5.4A2.6 2.6 0 0 1 12 7.2v12a2.2 2.2 0 0 0-2.2-2.2H4z" />
<path d="M20 4.6h-5.4A2.6 2.6 0 0 0 12 7.2v12a2.2 2.2 0 0 1 2.2-2.2H20z" />
</>,
);
export const LearnIcon = () =>
nav(
<>
<path d="M12 4.2 2.8 8.4 12 12.6l9.2-4.2z" />
<path d="M6.4 10.6v4.8c0 1.6 2.5 2.9 5.6 2.9s5.6-1.3 5.6-2.9v-4.8" />
<path d="M21.2 8.4v5.2" />
</>,
);
export const MapIcon = () => (
<Svg size={19} stroke={1.8}>
<path d="M4 5.6 9 4l6 2 5-1.6v13.8L15 20l-6-2-5 1.6z" />
<path d="M9 4v14M15 6v14" />
</Svg>
);
export const SearchIcon = () => (
<Svg size={19} stroke={1.8}>
<circle cx="10.8" cy="10.8" r="6.4" />
<path d="M15.6 15.6 20.4 20.4" />
</Svg>
);

View File

@@ -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<Navigator | null>(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 <NavigatorContext.Provider value={nav}>{children}</NavigatorContext.Provider>;
}
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";

579
app/src/ui/shell/shell.css Normal file
View File

@@ -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;
}

View File

@@ -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,
);
}

View File

@@ -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<Mode>("present");
const [index, setIndex] = useState(0);
@@ -278,7 +279,6 @@ export function GrammarTab() {
return (
<>
<ConjugationTrainer />
<Irregulars />
<div className="panel">

View File

@@ -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 (
<>
<Drill />
<div className="panel">
<div className="panel-h">
<h2 className="ko"> </h2>

View File

@@ -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) => (
<label className="field">
{label}
<input
type="number"
inputMode="numeric"
min={min}
max={max}
step={step}
value={prefs[key]}
onChange={(e) => {
const n = Number.parseInt(e.target.value, 10);
if (Number.isFinite(n)) void setPref(key, Math.max(min, Math.min(max, n)));
}}
/>
</label>
);
const toggle = (key: keyof Prefs, label: string, hint?: string) => (
<label className="toggle">
<input
type="checkbox"
checked={Boolean(prefs[key])}
onChange={(e) => void setPref(key, e.target.checked as Prefs[typeof key])}
/>
<span>
{label}
{hint && <i>{hint}</i>}
</span>
</label>
);
return (
<>
<div className="card set-card">
<label className="field">
Review direction
<select
value={prefs.dir}
onChange={(e) => void setPref("dir", e.target.value as Prefs["dir"])}
>
<option value="ko-en"> English (recognition)</option>
<option value="en-ko">English (recall)</option>
<option value="mixed">Mixed</option>
</select>
</label>
<div className="set-pair">
{num("newPerDay", "New words per review", 0, 60, 5)}
{num("goal", "Daily review goal", 5, 200, 5)}
</div>
</div>
<div className="card set-card">
{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",
)}
</div>
</>
);
}
/* 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 (
<div className="card set-card">
<div className="set-h">
<span className="eyebrow"> · Server and sync</span>
<span className="sync" data-s={status}>
<i />
{!server
? "not set — everything stays on this device"
: syncState.running
? "syncing…"
: syncState.error
? `last sync failed ${ago}`
: syncState.result
? `synced ${ago}`
: "not synced yet"}
</span>
</div>
<label className="field">
Server URL
<input
type="url"
value={url}
placeholder="https://hankan.example.com"
autoComplete="off"
onChange={(e) => setUrl(e.target.value)}
/>
</label>
<label className="field">
Token
<input
type="password"
value={token}
placeholder="HANKAN_TOKEN"
autoComplete="off"
onChange={(e) => setToken(e.target.value)}
/>
</label>
<div className="toolbar">
<button
className="btn primary"
disabled={!url.trim() || !token.trim()}
onClick={() => void setServer({ baseUrl: url.trim(), token: token.trim() })}
>
{server ? "Update" : "Connect"}
</button>
{server && (
<>
<button className="btn" disabled={syncState.running} onClick={() => void syncNow()}>
{syncState.running ? "Syncing…" : "Sync now"}
</button>
<button
className="btn"
onClick={() => {
setUrl("");
setToken("");
void setServer(null);
}}
>
Disconnect
</button>
</>
)}
</div>
{server && (
<p className="set-note">
{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."}
</p>
)}
</div>
);
}
/* 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 (
<div className="card set-card about">
<span className="eyebrow"> · About</span>
<dl>
<dt>Dictionary</dt>
<dd>{manifest?.builtWith.dictionary ?? "—"}</dd>
<dt>Loaded here</dt>
<dd className="tnum">
{rows ? `${rows.lemmas.toLocaleString()} words · ${rows.surfaces.toLocaleString()} forms` : "…"}
</dd>
<dt>Shipped total</dt>
<dd className="tnum">
{manifest
? `${manifest.totals.lemmas.toLocaleString()} words · ${manifest.totals.surfaces.toLocaleString()} forms`
: "—"}
</dd>
<dt>Storage</dt>
<dd>
{dbInfo.driver} · {dbInfo.persistent ? "on this device" : "this session only"}
</dd>
</dl>
<div className="attrib-block">
{manifest?.attribution.map((a) => (
<p key={a}>{a}</p>
))}
<p>Share-alike applies to the dictionary data, not to this app's code.</p>
</div>
</div>
);
}
/* 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<ResetScope | null>(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 (
<div className="card set-card">
<span className="eyebrow">처음부터 · Start over</span>
{asking === null ? (
<div className="toolbar">
<button className="btn" onClick={() => setAsking("roadmap")}>
Reset the roadmap
</button>
<button className="btn" onClick={() => setAsking("everything")}>
Reset everything
</button>
<span className="set-note">The dictionary is never touched.</span>
</div>
) : (
<div className="callout warn">
<p>
{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."}
</p>
<div className="toolbar" style={{ marginTop: 10 }}>
<button className="btn sm primary" onClick={() => void run(asking)}>
Yes, reset {asking === "roadmap" ? "the roadmap" : "everything"}
</button>
<button className="btn sm" onClick={() => setAsking(null)}>
Cancel
</button>
</div>
</div>
)}
</div>
);
}
export function SettingsTab() {
return (
<>
<Preferences />
<ServerPanel />
<About />
<DangerZone />
</>
);
}

View File

@@ -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) => (
<input
type="number"
min={min}
max={max}
step={step}
value={prefs[key]}
onChange={(e) => {
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) => (
<label className="toggle set-row">
<input
type="checkbox"
checked={Boolean(prefs[key])}
onChange={(e) => void setPref(key, e.target.checked as Prefs[typeof key])}
/>
<span>
{label}
{hint && <i>{hint}</i>}
</span>
</label>
);
return (
<div className="panel">
<div className="panel-h">
<h2> </h2>
<span className="note">Session settings</span>
</div>
<div className="panel-b set-grid">
<label className="set-row">
<span>Direction</span>
<select
value={prefs.dir}
onChange={(e) => void setPref("dir", e.target.value as Prefs["dir"])}
>
<option value="ko-en"> English</option>
<option value="en-ko">English </option>
<option value="mixed">Mixed</option>
</select>
</label>
<label className="set-row">
<span>New cards per day</span>
{num("newPerDay", 0, 60, 5)}
</label>
<label className="set-row">
<span>Daily goal</span>
{num("goal", 5, 200, 5)}
</label>
{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",
)}
</div>
</div>
);
}
/* 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<ResetScope | null>(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 (
<div className="panel">
<div className="panel-h">
<h2></h2>
<span className="note">Start over</span>
</div>
<div className="panel-b">
{asking === null ? (
<div className="toolbar">
<button className="btn" onClick={() => setAsking("roadmap")}>
Reset the roadmap
</button>
<button className="btn" onClick={() => setAsking("everything")}>
Reset everything
</button>
<span className="add-note">The dictionary is never touched.</span>
</div>
) : (
<div className="callout warn">
<p>
{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."}
</p>
<div className="road-ready-acts" style={{ marginTop: 10 }}>
<button className="btn sm primary" onClick={() => void run(asking)}>
Yes, reset {asking === "roadmap" ? "the roadmap" : "everything"}
</button>
<button className="btn sm" onClick={() => setAsking(null)}>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
);
}
/* 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 (
<div className="panel">
<div className="panel-h">
<h2></h2>
<span className="note">About</span>
</div>
<div className="panel-b about">
<dl>
<dt>Dictionary</dt>
<dd>{manifest?.builtWith.dictionary ?? "—"}</dd>
<dt>Loaded here</dt>
<dd className="tnum">
{rows ? `${rows.lemmas.toLocaleString()} words · ${rows.surfaces.toLocaleString()} forms` : "…"}
</dd>
<dt>Shipped total</dt>
<dd className="tnum">
{manifest
? `${manifest.totals.lemmas.toLocaleString()} words · ${manifest.totals.surfaces.toLocaleString()} forms`
: "—"}
</dd>
<dt>Storage</dt>
<dd>
{dbInfo.driver} · {dbInfo.persistent ? "on this device" : "this session only"}
</dd>
</dl>
<div className="attrib-block">
{manifest?.attribution.map((a) => (
<p key={a}>{a}</p>
))}
<p>
Share-alike applies to the dictionary data, not to this app's code.
</p>
</div>
</div>
</div>
);
}
/* 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 (
<div className="panel">
<div className="panel-h">
<h2></h2>
<span className="note">
{server ? "Connected — the real 선생님, and sync" : "Not set — everything stays on this device"}
</span>
</div>
<div className="panel-b set-grid">
<label className="set-row">
<span>Server URL</span>
<input
type="url"
value={url}
placeholder="https://hankan.example.com"
onChange={(e) => setUrl(e.target.value)}
/>
</label>
<label className="set-row">
<span>Token</span>
<input
type="password"
value={token}
placeholder="HANKAN_TOKEN"
onChange={(e) => setToken(e.target.value)}
/>
</label>
<div className="toolbar" style={{ gridColumn: "1 / -1" }}>
<button
className="btn primary"
disabled={!url.trim() || !token.trim()}
onClick={() => void setServer({ baseUrl: url.trim(), token: token.trim() })}
>
{server ? "Update" : "Connect"}
</button>
{server && (
<>
<button className="btn" disabled={syncState.running} onClick={() => void syncNow()}>
{syncState.running ? "Syncing…" : "Sync now"}
</button>
<button
className="btn"
onClick={() => {
setUrl("");
setToken("");
void setServer(null);
}}
>
Disconnect
</button>
</>
)}
</div>
{server && (
<p className="add-note" style={{ gridColumn: "1 / -1" }}>
{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."}
</p>
)}
</div>
</div>
);
}
export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void }) {
export function TodayTab() {
const { db, progress, prefs, today, revision } = useStore();
const nav = useNavigator();
const { start } = useReview();
const [stats, setStats] = useState<Counts | null>(null);
const [log, setLog] = useState<DayRow[]>([]);
@@ -319,7 +55,7 @@ export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void
let cancelled = false;
(async () => {
const [c, rows] = await Promise.all([
counts(db, today, { sentences: prefs.sentences }),
counts(db, today, { sentences: prefs.sentences, pool: progress }),
studyLog(db, today - HEATMAP_DAYS),
]);
if (cancelled) return;
@@ -329,7 +65,7 @@ export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void
return () => {
cancelled = true;
};
}, [db, prefs.sentences, revision, today]);
}, [db, prefs.sentences, progress, revision, today]);
const unit = currentUnit(progress);
const doneUnits = Object.keys(progress.done).length;
@@ -357,8 +93,8 @@ export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void
>
Start review{stats ? ` · ${stats.due + Math.min(stats.fresh, prefs.newPerDay)}` : ""}
</button>
<button className="btn big" onClick={() => onGoTo("lesson")}>
Go to
<button className="btn big" onClick={() => nav.go("lesson")}>
Go to
</button>
</div>
@@ -412,7 +148,7 @@ export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void
<div className="ph-title">
<span className="ko">{p.ko}</span>
<span className="nm">{p.name}</span>
<span className="badge">
<span className="ph-badge">
{state === "done" ? "complete" : state === "now" ? "you are here" : "ahead"}
</span>
</div>
@@ -433,10 +169,6 @@ export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void
</div>
<Heatmap rows={log} today={today} />
<Settings />
<ServerPanel />
<About />
<DangerZone />
</>
);
}

View File

@@ -0,0 +1,84 @@
/* 설정. */
.set-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.set-pair {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.set-pair .field {
flex: 1 1 130px;
}
.set-h {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px 12px;
}
.set-note {
font-size: 12px;
color: var(--ink3);
}
.sync {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 12px;
color: var(--ink3);
}
.sync i {
width: 7px;
height: 7px;
flex: none;
border-radius: 50%;
background: var(--ink3);
}
.sync[data-s="on"] i {
background: var(--jade);
}
.sync[data-s="fail"] i {
background: var(--jeok);
}
.sync[data-s="wait"] i {
background: var(--hwang);
}
.about dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 5px 16px;
margin: 0;
font-size: 13.5px;
}
.about dt {
color: var(--ink3);
white-space: nowrap;
}
.about dd {
margin: 0;
min-width: 0;
}
.attrib-block {
padding-top: 11px;
border-top: 1px solid var(--line);
font-size: 11.5px;
color: var(--ink3);
}

View File

@@ -1,4 +1,4 @@
/* 오늘 — hero, tiles, phase cards, heatmap, settings. */
/* 오늘 — hero, tiles, phase cards, heatmap. */
.hero {
display: grid;
@@ -150,7 +150,7 @@
color: var(--ink3);
}
.badge {
.ph-badge {
margin-left: auto;
font-size: 10.5px;
padding: 1px 7px;
@@ -159,13 +159,13 @@
white-space: nowrap;
}
.phase[data-st="now"] .badge {
.phase[data-st="now"] .ph-badge {
border-color: var(--hwang);
color: var(--hwang);
background: var(--hwang-soft);
}
.phase[data-st="done"] .badge {
.phase[data-st="done"] .ph-badge {
border-color: var(--jade);
color: var(--jade-ink);
background: var(--jade-soft);
@@ -214,32 +214,6 @@
.hm i[data-level="3"] { background: var(--h3); }
.hm i[data-level="4"] { background: var(--h4); }
/* ── settings ────────────────────────────────────────────────────── */
.set-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 13px;
}
.set-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
.set-row > span:first-child {
flex: 1;
}
.set-row span i {
display: block;
font-style: normal;
font-size: 11px;
color: var(--ink3);
}
@media (max-width: 820px) {
.phases { grid-template-columns: 1fr; }
}
@@ -248,28 +222,3 @@
.hero { grid-template-columns: 1fr; }
.hero-l h1 { font-size: 28px; }
}
/* About panel. */
.about dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 5px 16px;
margin: 0 0 14px;
font-size: 13.5px;
}
.about dt {
color: var(--ink3);
white-space: nowrap;
}
.about dd {
margin: 0;
}
.attrib-block {
padding-top: 11px;
border-top: 1px solid var(--line);
font-size: 11.5px;
color: var(--ink3);
}

View File

@@ -0,0 +1,160 @@
/* The foot of the lesson: quick replies, the message field, the keyboard.
The quick replies are the artifact's, and every one of them is a request
rather than an answer — progress.ts's NOT_AN_ANSWER knows each opening.
시작 is always first; before the lesson has started it is the one to press. */
import { useLayoutEffect, useRef, type Dispatch, type SetStateAction } from "react";
import type { FlatUnit } from "@lib/gate.js";
import { Keyboard, useComposer } from "../keyboard/Keyboard.js";
export const startMessage = (u: FlatUnit) =>
`Let's start unit ${u.id} ${u.ko} (${u.name}). Give me the full introduction, then a first exercise.`;
const CHIPS: { label: string; title?: string; q: string | null }[] = [
{ label: "시작 · Start unit", q: null },
{ label: "새 문제 · New", q: "새 문제 주세요. Give me a new exercise." },
{ label: "다시 · Again", q: "Explain that again, more slowly." },
{ label: "더 쉽게", title: "Easier", q: "That was too hard — give me an easier one." },
{ label: "더 어렵게", title: "Harder", q: "That was easy — push me harder." },
{ label: "점검 · How am I doing?", q: "Where am I making mistakes? What should I work on next?" },
{ label: "타이핑", title: "Translating", q: "Give me a typing exercise — Korean lines for me to translate." },
{
label: "쓰기 · Write",
q: "Give me a writing exercise — English prompts for me to write in Korean.",
},
{ label: "짝 맞추기", title: "Matching", q: "Give me a matching exercise — Korean words to pair with meanings." },
{
label: "조립",
title: "Sentence building",
q: "Give me a sentence-building exercise — I assemble the Korean from word chips.",
},
{ label: "객관식", title: "Multiple choice", q: "Give me a multiple-choice exercise." },
];
export interface ComposerProps {
unit: FlatUnit;
busy: boolean;
notStarted: boolean;
draft: string;
setDraft: Dispatch<SetStateAction<string>>;
keyboard: boolean;
setKeyboard: (on: boolean) => void;
/** Something to say instead of the hint: a retry, an error, the wait. */
note: string | null;
onSend: (text: string) => void;
onStop: () => void;
}
export function Composer({
unit,
busy,
notStarted,
draft,
setDraft,
keyboard,
setKeyboard,
note,
onSend,
onStop,
}: ComposerProps) {
const field = useRef<HTMLTextAreaElement>(null);
const composer = useComposer();
// Grow with the text, to the cap in the stylesheet.
useLayoutEffect(() => {
const el = field.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [draft]);
const send = () => {
const text = draft.trim();
if (!text || busy) return;
onSend(text);
setDraft("");
composer.reset(); // clearing in code fires no input event
};
return (
<div className="chat-foot">
<div className="chips-row" role="group" aria-label="Quick replies">
{CHIPS.map((c) => (
<button
key={c.label}
className="ko"
title={c.title}
data-primary={c.q === null && notStarted ? "1" : undefined}
disabled={busy}
onClick={() => onSend(c.q ?? startMessage(unit))}
>
{c.label}
</button>
))}
</div>
<div className="chat-in">
<textarea
ref={field}
rows={1}
value={draft}
placeholder="Answer or ask…"
aria-label="Answer or ask 선생님"
// The on-screen keyboard is up: the system one stays down.
inputMode={keyboard ? "none" : undefined}
onChange={(e) => {
composer.onExternalInput();
setDraft(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
send();
}
}}
/>
<button
className="kb-toggle ko"
aria-pressed={keyboard}
aria-label="한글 keyboard"
title="한글 keyboard"
onClick={() => {
setKeyboard(!keyboard);
field.current?.focus({ preventScroll: true });
}}
>
</button>
{busy ? (
<button className="sendbtn stop" aria-label="Stop" title="Stop" onClick={onStop}>
</button>
) : (
<button
className="sendbtn"
aria-label="Send"
title="Send"
disabled={!draft.trim()}
onClick={send}
>
</button>
)}
</div>
{keyboard && (
<Keyboard
composer={composer}
onChange={setDraft}
target="message"
onDismiss={() => setKeyboard(false)}
/>
)}
<div className="chat-note" data-say={note ? "1" : undefined} aria-live="polite">
<span>{note ?? "Enter sends · Shift + Enter for a new line"}</span>
</div>
</div>
);
}

View File

@@ -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<Coverage | null>(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 (
<div className="road">
<>
{ready && next && (
<div className="road-ready">
<span>
<b>
{review ? (
<>
Phase {unit.phase} confirmed all {cover.total} rules and words.
</>
) : (
<>
thinks you have <span className="ko">{unit.ko}</span>.
</>
)}
</b>{" "}
{!review && note} Ready for{" "}
<span className="ko">
{next.id} {next.ko}
</span>{" "}
({next.name})?
</span>
<span className="sp">
<button className="btn primary ko" disabled={busy} onClick={() => void move()}>
· Move on
</button>
<button className="btn ko" onClick={() => void stay()}>
· Not yet
</button>
</span>
</div>
)}
<div className="road-strip">
<div className="road-now">
<span className="eyebrow">
<span className="ph">
Phase {unit.phase} · {unit.id}
</span>
<span className="ko">{unit.ko}</span>
<span className="nm">{unit.name}</span>
<span className="u ko">{unit.ko}</span>
<span className="n">{unit.name}</span>
</div>
{review ? (
@@ -94,58 +138,26 @@ export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string, how
</>
) : (
<>
<div className="road-bar" data-ready={ready ? "1" : "0"} title={note || "선생님's read of this unit"}>
<div
className="road-bar"
data-ready={ready ? "1" : "0"}
title={note || "선생님's read of this unit"}
>
<i style={{ width: `${confidence}%` }} />
</div>
<span className="road-pct tnum">{confidence}%</span>
</>
)}
<button className="btn sm" onClick={() => setOpen((o) => !o)}>
{open ? "Hide roadmap" : "Roadmap"}
</button>
</div>
{ready && next && (
<div className="road-ready callout warn">
<p>
{review ? (
<strong>
Phase {unit.phase} confirmed all {cover.total} rules and words.
</strong>
) : (
<>
<strong>
thinks you have <span className="ko">{unit.ko}</span>.
</strong>{" "}
{note}
</>
)}{" "}
Ready for{" "}
<strong className="ko">
{next.id} {next.ko}
</strong>{" "}
({next.name})?
</p>
<div className="road-ready-acts">
<button className="btn sm primary ko" onClick={() => void move()}>
· Move on
</button>
<button className="btn sm ko" onClick={() => void stay()}>
· Not yet
</button>
</div>
</div>
)}
{open && (
<div className="road-panel panel">
<div className="road-panel">
{curriculum.phases.map((phase) => (
<div key={phase.phase}>
<div className="road-ph">
<span className="eyebrow">Phase {phase.phase}</span>
<span className="ko">{phase.ko}</span>
<span className="nm">{phase.name}</span>
<span className="n">Phase {phase.phase}</span>
<span className="k ko">{phase.ko}</span>
<span className="n">{phase.name}</span>
</div>
{phase.units.map((u) => {
const state = progress.done[u.id] ? "done" : u.id === unit.id ? "now" : "todo";
@@ -155,6 +167,7 @@ export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string, how
key={u.id}
className="road-u"
data-s={state}
disabled={busy}
onClick={() => void jump(u.id)}
>
<span className="id mono">{u.id}</span>
@@ -164,7 +177,9 @@ export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string, how
{state === "done"
? "✓ done"
: state === "now"
? "studying now"
? conf
? `${conf}%`
: "studying now"
: conf >= READY_AT
? `${conf}% — ready`
: conf
@@ -178,6 +193,6 @@ export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string, how
))}
</div>
)}
</div>
</>
);
}

View File

@@ -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<Turn[]>([]);
const [streaming, setStreaming] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [revealed, setRevealed] = useState<Set<string>>(new Set());
const [railWords, setRailWords] = useState<RailWord[]>([]);
const [railQuery, setRailQuery] = useState("");
const [bandWords, setBandWords] = useState<string[]>([]);
const [showKeyboard, setShowKeyboard] = useState(false);
const [keyboard, setKeyboard] = useState(false);
const [recent, setRecent] = useState<string[]>([]);
const [met, setMet] = useState<string[]>([]);
/** The line under the composer: a retry, an error, a confirmation. */
const [note, setNote] = useState<string | null>(null);
/** Words flagged in the last reply shown despite the gate; the next turn names them. */
const [strays, setStrays] = useState<string[]>([]);
/** Flagged words per turn id, so the flag stays under its own message. */
const [flags, setFlags] = useState<Record<string, string[]>>({});
const [roadOpen, setRoadOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [detent, setDetent] = useState<Detent>("closed");
/** What he had looked up when he submitted the answer being marked. */
const lastLookups = useRef<string[]>([]);
/** 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<HTMLDivElement>(null);
const foot = useRef<HTMLDivElement>(null);
const input = useRef<HTMLTextAreaElement>(null);
const composer = useComposer();
/** The log follows new text while this is set; see the follow effect. */
const stick = useRef(true);
const menuButton = useRef<HTMLButtonElement>(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<StubWord[]>([]);
@@ -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 (
<>
<RoadStrip
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.`,
);
}}
/>
<RouteHead title="선생님" sub={`${unit.id} ${unit.ko}`}>
<button
className="iconbtn"
title="Roadmap"
aria-label="Roadmap"
aria-pressed={roadOpen}
onClick={() => setRoadOpen((o) => !o)}
>
<MapIcon />
</button>
<button
className="iconbtn"
title="Word list"
aria-label="Word list"
aria-pressed={!wide && detent !== "closed"}
onClick={() => {
setRailQuery("");
if (!wide) setDetent((d) => (d === "closed" ? "half" : "closed"));
}}
>
<SearchIcon />
</button>
<button
ref={menuButton}
className="iconbtn"
title="Lesson options"
aria-label="Lesson options"
aria-pressed={menuOpen}
onClick={() => setMenuOpen((o) => !o)}
>
···
</button>
</RouteHead>
<div className="lesson-grid">
<div className="chat panel">
<div className="panel-h">
<h2 className="ko"></h2>
<label className="focus-pick">
<span className="eyebrow">Focus</span>
<select
value={prefs.focus}
onChange={(e) => void setPref("focus", e.target.value as FocusMode)}
title="Biases the lesson without widening the gate"
>
{FOCUS_LABELS.map(([mode, label]) => (
<option key={mode} value={mode}>
{label}
</option>
))}
</select>
</label>
<span className="note">
{server ? "connected" : "local stand-in"} · {gate.vocabulary.length} words unlocked ·{" "}
{gate.newWords.length} new this unit
</span>
</div>
<Pop
id="lesson-menu"
open={menuOpen && active}
onClose={() => setMenuOpen(false)}
anchor={menuButton.current}
label="Lesson options"
>
<div className="pop-b">
<label className="pop-n" htmlFor="lesson-focus">
focus
</label>
<select
id="lesson-focus"
value={prefs.focus}
onChange={(e) => {
const mode = e.target.value as FocusMode;
void setPref("focus", mode);
setNote(mode === "auto" ? "선생님 is back on the roadmap." : "Focus set — your next message uses it.");
}}
>
{FOCUS_LABELS.map(([mode, label]) => (
<option key={mode} value={mode}>
{label}
</option>
))}
</select>
<span className="pop-n">
{server ? "The real 선생님" : "The offline stand-in"} · {gate.vocabulary.length} words unlocked ·{" "}
{gate.newWords.length} new this unit
</span>
</div>
<div className="pop-f">
<button
className="btn"
disabled={busy}
onClick={() => {
setMenuOpen(false);
void clearLesson();
}}
>
Clear lesson
</button>
<button className="btn" onClick={() => setMenuOpen(false)}>
Done
</button>
</div>
</Pop>
<div className="chat-log" ref={log} onScroll={onScroll}>
<div className="lesson-wrap">
<div
className="chatcol"
onFocusCapture={(e) => {
// 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");
}
}}
>
<RoadStrip
open={roadOpen}
onClose={() => 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.`,
);
}}
/>
<div className="chat-log" ref={log} onScroll={onLogScroll}>
{parsedTurns.map(({ turn: t, parsed }, i) => {
const you = t.role === "user";
const body = parsed ? parsed.body : ownWords(t.body);
return (
<div className={`msg${you ? " you" : ""}`} key={t.id}>
<span className="who ko">{you ? "나" : "선생님"}</span>
{/* 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 (
<div className="bubble">
<MessageBody text={body} />
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
</div>
);
})()}
{(body.trim() || parsed?.gloss) && (
<div className="bubble">
<MessageBody text={body} />
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
</div>
)}
{/* 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] && (
<p className="msg-flag">
Not taught yet: <span className="ko">{flags[t.id]!.join(" · ")}</span>
</p>
)}
{/* 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 && (
<TaskHost
task={parsed.task}
@@ -636,7 +732,7 @@ export function TutorTab() {
markup: there is genuinely nothing to read yet, and an
empty bubble reads as a failure rather than as waiting. */}
{streamingBody.trim() === "" ? (
<div className="bubble dots">
<div className="bubble dots" aria-label="선생님 is writing">
<i />
<i />
<i />
@@ -648,104 +744,61 @@ export function TutorTab() {
)}
</div>
)}
</div>
{notStarted && !busy && (
<div className="chat-start">
<button
className="btn primary"
onClick={() =>
void send(
`Let's start unit ${unit.id} ${unit.ko} (${unit.name}). Give me the full introduction, then a first exercise.`,
)
}
>
· Start unit
</button>
</div>
)}
{note && <div className="callout chat-note">{note}</div>}
{error && <div className="callout warn chat-error">{error}</div>}
<div className="chat-foot" ref={foot}>
<div className="chat-in">
<textarea
ref={input}
rows={2}
value={draft}
placeholder="Ask 선생님 something…"
aria-label="Ask 선생님 something"
onChange={(e) => {
composer.onExternalInput();
setDraft(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (draft.trim()) {
void send(draft.trim());
setDraft("");
composer.reset(); // clearing in code fires no input event
}
}
}}
/>
<div className="chat-acts">
<button
className="btn sm ko"
aria-pressed={showKeyboard}
onClick={() => setShowKeyboard((k) => !k)}
title="Korean keyboard"
>
</button>
<button
className="btn sm"
disabled={busy}
onClick={() => void clearLesson()}
title="Clear the transcript and start this unit again"
>
Clear
</button>
{busy ? (
<button className="btn sm" onClick={() => abort.current?.abort()}>
Stop
</button>
) : (
<button
className="btn sm primary"
disabled={!draft.trim()}
onClick={() => {
void send(draft.trim());
setDraft("");
composer.reset(); // clearing in code fires no input event
}}
>
Send
</button>
)}
</div>
</div>
{showKeyboard && (
<Keyboard
composer={composer}
onChange={setDraft}
target="message"
onDismiss={() => setShowKeyboard(false)}
/>
)}
</div>
<Composer
unit={unit}
busy={busy}
notStarted={notStarted}
draft={draft}
setDraft={setDraft}
keyboard={keyboard}
setKeyboard={(on) => {
setKeyboard(on);
// The two contend for the same space.
if (on) setDetent("closed");
}}
note={note}
onSend={(text) => void send(text)}
onStop={() => abort.current?.abort()}
/>
</div>
<WordRail
words={railWords}
revealed={revealed}
onReveal={(ko) => setRevealed((r) => new Set(r).add(ko))}
/>
{wide && (
<aside className="railcol" aria-label="Word list">
<RailPanel {...railProps} />
</aside>
)}
</div>
{!wide && active && (
<WordSheet detent={detent} onDetent={setDetent}>
<RailPanel
{...railProps}
titleId="sheet-title"
actions={
<>
<button
className="iconbtn"
title={detent === "full" ? "Shrink" : "Expand"}
aria-label={detent === "full" ? "Shrink" : "Expand"}
onClick={() => setDetent((d) => (d === "full" ? "half" : "full"))}
>
{detent === "full" ? "▼" : "▲"}
</button>
<button
className="iconbtn"
title="Close"
aria-label="Close the word list"
onClick={() => setDetent("closed")}
>
</button>
</>
}
/>
</WordSheet>
)}
</>
);
}

View File

@@ -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 (
<div className="wr-row" data-peeked={peeked ? "1" : "0"}>
<span className="wr-k ko">{word.ko}</span>
{covered ? (
<button className="wr-m hid" onClick={onReveal} aria-label={`Reveal ${word.ko}`}>
tap to reveal
</button>
) : (
<span className="wr-m">
{word.gloss}
{word.note && <i>{word.note}</i>}
</span>
)}
</div>
);
}
export interface WordRailProps {
export interface RailPanelProps {
words: RailWord[];
revealed: Set<string>;
onReveal: (ko: string) => void;
/** The search text — held by the lesson, so the sheet and the docked
column are one list however the window is resized. */
query: string;
onQuery: (q: string) => void;
/** The heading's id, for the sheet's aria-labelledby. */
titleId?: string;
/** Buttons at the end of the heading row — the sheet's expand and close. */
actions?: ReactNode;
}
export function WordRail({ words, revealed, onReveal }: WordRailProps) {
export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId, actions }: RailPanelProps) {
const { db, prefs, setPref } = useStore();
const [query, setQuery] = useState("");
const [results, setResults] = useState<Entry[]>([]);
const [open, setOpen] = useState(true);
const searching = query.trim().length > 0;
useEffect(() => {
@@ -117,10 +102,10 @@ export function WordRail({ words, revealed, onReveal }: WordRailProps) {
}
let cancelled = false;
const timer = setTimeout(async () => {
// The rail may reach past the gate: this is for glossing a word met in
// The list may reach past the gate: this is for glossing a word met in
// the wild, not for teaching one. The gate's vocabQuery never does.
await ensureReferenceBand(db);
const hits = await search(db, query, { includeReference: true, limit: 60 });
const hits = await search(db, query, { includeReference: true, limit: SEARCH_LIMIT });
if (!cancelled) setResults(hits);
}, 160);
return () => {
@@ -129,33 +114,56 @@ export function WordRail({ words, revealed, onReveal }: WordRailProps) {
};
}, [db, query, searching]);
const lookedUp = useMemo(
() => words.filter((w) => revealed.has(w.ko)).length,
[words, revealed],
);
const lookedUp = revealed.size;
return (
<aside className="wordrail panel" data-open={open ? "1" : "0"}>
<div className="panel-h" onClick={() => setOpen((o) => !o)}>
<h2></h2>
<span className="note">{searching ? `${results.length} found` : `${words.length} here`}</span>
<>
<div className="sheet-h">
<h2 className="ko" id={titleId}>
</h2>
<span className="note tnum">
{searching
? results.length
? `${results.length}${results.length >= SEARCH_LIMIT ? "+" : ""} found`
: "nothing found"
: words.length
? `${words.length} words`
: ""}
</span>
{actions && <span className="sheet-acts">{actions}</span>}
</div>
<div className="wr-tools">
<div className="wr-search">
<input
type="search"
value={query}
placeholder="Look a word up…"
aria-label="Look a word up"
onChange={(e) => setQuery(e.target.value)}
placeholder="Search every word you have…"
aria-label="Search every word you have"
autoComplete="off"
onChange={(e) => onQuery(e.target.value)}
/>
</div>
<div className="wr-body">
<div className="wr-cover">
<label className="toggle">
<input
type="checkbox"
checked={prefs.cover}
onChange={(e) => void setPref("cover", e.target.checked)}
/>
<span>
<span className="ko"></span> · cover meanings
</span>
</label>
<span className="n tnum">{lookedUp ? `${lookedUp} looked up` : "none looked up"}</span>
</div>
<div className="sheet-b">
{searching ? (
results.length ? (
results.map((r) => (
<div className="wr-row" key={`${r.lemmaId}`}>
<div className="wr-row" key={r.lemmaId}>
<span className="wr-k ko">{r.headword}</span>
<span className="wr-m">
{r.glossEn}
@@ -164,41 +172,43 @@ export function WordRail({ words, revealed, onReveal }: WordRailProps) {
</div>
))
) : (
<p className="empty">Nothing for {query}.</p>
<p className="empty">
No word matches {query}. Search <span className="ko"></span> or English.
</p>
)
) : words.length ? (
words.map((w) => (
<Row
key={w.ko}
word={w}
covered={prefs.cover && !revealed.has(w.ko)}
peeked={revealed.has(w.ko)}
onReveal={() => {
onReveal(w.ko);
void editPeek(db, w.ko);
}}
/>
))
words.map((w) => {
const covered = prefs.cover && !revealed.has(w.ko);
return (
<div className="wr-row" key={w.ko} data-peeked={revealed.has(w.ko) ? "1" : undefined}>
<span className="wr-k ko">{w.ko}</span>
{covered ? (
<button
className="wr-m hid"
aria-label={`Reveal ${w.ko}`}
onClick={() => {
onReveal(w.ko);
void editPeek(db, w.ko);
}}
>
tap to reveal
</button>
) : (
<span className="wr-m">
{w.gloss}
{w.note && <i>{w.note}</i>}
</span>
)}
</div>
);
})
) : (
<p className="empty">Words from the lesson appear here.</p>
<p className="empty">
Every Korean word from the current exercise appears here. Tap any word in a message to
see it, or search above.
</p>
)}
</div>
{!searching && (
<div className="wr-foot">
<label className="toggle">
<input
type="checkbox"
checked={prefs.cover}
onChange={(e) => void setPref("cover", e.target.checked)}
/>
Cover meanings
</label>
<span className="tnum">
{lookedUp ? `${lookedUp} looked up` : "none looked up"}
</span>
</div>
)}
</aside>
</>
);
}

View File

@@ -0,0 +1,103 @@
/* The word sheet — the word list below 840px.
Three detents and a drag handle. At peek and half the sheet is not an
overlay: its height comes off the bottom of the shell (--sheet-h), so
the exercise above gets shorter and nothing being answered is covered.
Only at full does a scrim go over the rest.
It is a layer: opening it pushes a history entry, so Back closes it.
Moving detents does not push again. */
import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useViewportHeight } from "../shell/useMedia.js";
export type Detent = "closed" | "peek" | "half" | "full";
const PEEK_PX = 76;
function sizes(vh: number): Record<Detent, number> {
return { closed: 0, peek: PEEK_PX, half: Math.round(vh * 0.45), full: Math.round(vh * 0.88) };
}
const setSheetHeight = (px: number) =>
document.documentElement.style.setProperty("--sheet-h", `${px}px`);
export function WordSheet({
detent,
onDetent,
children,
}: {
detent: Detent;
onDetent: (d: Detent) => void;
children: ReactNode;
}) {
const vh = useViewportHeight();
const size = sizes(vh);
const px = size[detent];
const drag = useRef<{ y0: number; h0: number; id: number } | null>(null);
useLayoutEffect(() => {
setSheetHeight(px);
}, [px]);
// Unmounted — the window grew past 840px, or the lesson went away.
useEffect(() => () => setSheetHeight(0), []);
const heightFor = (clientY: number) => {
const d = drag.current!;
return Math.max(0, Math.min(size.full, d.h0 + (d.y0 - clientY)));
};
const endDrag = (clientY: number) => {
const h = heightFor(clientY);
drag.current = null;
delete document.documentElement.dataset.sheetDrag;
let best: Detent = "closed";
for (const d of Object.keys(size) as Detent[]) {
if (Math.abs(size[d] - h) < Math.abs(size[best] - h)) best = d;
}
// Snapping back to the detent it started at changes no state, so the
// height the drag left behind has to be put back by hand.
setSheetHeight(size[best]);
onDetent(best);
};
return createPortal(
<>
<div className="scrim" hidden={detent !== "full"} onClick={() => onDetent("half")} />
<div
className="sheet"
hidden={detent === "closed"}
role="dialog"
aria-modal={detent === "full"}
aria-labelledby="sheet-title"
data-detent={detent}
>
<div
className="sheet-grab"
aria-hidden="true"
onPointerDown={(e) => {
drag.current = { y0: e.clientY, h0: px, id: e.pointerId };
e.currentTarget.setPointerCapture(e.pointerId);
document.documentElement.dataset.sheetDrag = "1";
}}
onPointerMove={(e) => {
if (drag.current?.id !== e.pointerId) return;
setSheetHeight(heightFor(e.clientY));
}}
onPointerUp={(e) => {
if (drag.current?.id === e.pointerId) endDrag(e.clientY);
}}
onPointerCancel={(e) => {
if (drag.current?.id === e.pointerId) endDrag(drag.current.y0);
}}
>
<i />
</div>
{children}
</div>
</>,
document.body,
);
}

View File

@@ -1,115 +1,218 @@
/* The word rail. Sticky beside the chat on a desktop, a collapsible drawer
on a phone. */
/* The word list: docked column, sheet, and the rows they share. */
.wordrail {
position: sticky;
top: 116px;
align-self: start;
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 110;
display: flex;
flex-direction: column;
max-height: calc(100dvh - 140px);
height: var(--sheet-h, 0px);
max-height: 88svh;
padding-bottom: var(--safe-b);
background: var(--paper);
border-top: 1px solid var(--line2);
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 30px -18px rgba(0, 0, 0, 0.5);
transition: height var(--dur-sheet) var(--ease);
overflow: hidden;
}
.wordrail .panel-h {
cursor: pointer;
user-select: none;
:root[data-sheet-drag] .sheet,
:root[data-sheet-drag] .app {
transition: none;
}
.wr-tools {
.sheet-grab {
flex: none;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
cursor: grab;
}
.sheet-grab i {
display: block;
width: 38px;
height: 4px;
border-radius: 2px;
background: var(--line2);
}
.sheet-h {
flex: none;
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px 9px;
border-bottom: 1px solid var(--line);
}
.railcol .sheet-h {
padding-top: 12px;
}
.sheet-h h2 {
font-size: 14.5px;
font-weight: 600;
}
.sheet-h .note {
font-size: 12px;
color: var(--ink3);
}
.sheet-acts {
margin-left: auto;
display: flex;
gap: 4px;
}
.sheet-b {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.scrim {
position: absolute;
inset: 0;
z-index: 105;
background: rgba(0, 0, 0, 0.34);
}
@media (min-width: 840px) {
.sheet,
.scrim {
display: none !important;
}
}
/* The docked column, on wide screens only. */
.railcol {
display: none;
}
@media (min-width: 840px) {
.railcol {
display: flex;
flex-direction: column;
width: 302px;
flex: none;
min-height: 0;
border-left: 1px solid var(--line);
background: var(--paper);
}
.railcol .sheet-b {
padding-bottom: 16px;
}
}
.wr-search {
flex: none;
display: flex;
gap: 7px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.wr-tools input {
.wr-search input {
flex: 1;
width: 100%;
padding: 9px 11px;
border-radius: 9px;
}
.wr-body {
flex: 1;
overflow-y: auto;
min-height: 0;
.wr-cover {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-bottom: 1px solid var(--line);
font-size: 12px;
color: var(--ink3);
}
.wr-cover .toggle {
min-height: 34px;
gap: 8px;
font-size: 12px;
color: var(--ink3);
}
.wr-cover .toggle input {
width: 16px;
height: 16px;
}
.wr-cover .n {
margin-left: auto;
}
.wr-row {
display: flex;
align-items: baseline;
gap: 10px;
padding: 8px 12px;
align-items: center;
gap: 11px;
min-height: 52px;
padding: 10px 13px;
border-bottom: 1px solid var(--line);
}
.wr-k {
font-size: 17px;
min-width: 74px;
flex-shrink: 0;
.wr-row:last-child {
border-bottom: none;
}
/* Lifetime lookup tally — a word he keeps needing gets marked. */
.wr-k {
min-width: 66px;
flex-shrink: 0;
font-size: 18px;
font-weight: 500;
word-break: keep-all;
}
/* A word looked up in this exercise. */
.wr-row[data-peeked="1"] .wr-k {
box-shadow: inset 0 -2px 0 0 var(--hwang);
text-decoration: underline;
text-decoration-color: var(--hwang);
text-decoration-thickness: 2px;
text-underline-offset: 0.24em;
}
.wr-m {
font-size: 13.5px;
color: var(--ink2);
flex: 1;
min-width: 0;
font-size: 14px;
color: var(--ink);
text-align: left;
}
.wr-m i {
display: block;
font-style: normal;
font-size: 11px;
color: var(--ink3);
margin-top: 1px;
}
/* Covered. The gloss is not in the DOM at all — only this label is. */
.wr-m.hid {
flex: 1;
padding: 3px 8px;
background: var(--sunk);
border: 1px solid var(--line);
color: var(--ink3);
font-size: 11px;
letter-spacing: 0.04em;
text-align: center;
}
.wr-m.hid:hover {
border-color: var(--jade);
color: var(--jade);
}
.wr-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-top: 1px solid var(--line);
background: var(--raise);
font-style: normal;
font-size: 12px;
color: var(--ink3);
}
.wr-foot .tnum {
margin-left: auto;
/* Covered. The gloss is not in the DOM at all — only this label is. */
.wr-m.hid {
min-height: 30px;
padding: 4px 8px;
border-radius: 6px;
background: var(--sunk);
color: var(--ink3);
font-size: 11.5px;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
.wr-m.hid:hover {
color: var(--jade);
}
@media (max-width: 900px) {
.wordrail {
position: static;
max-height: none;
}
.wordrail[data-open="0"] .wr-tools,
.wordrail[data-open="0"] .wr-body,
.wordrail[data-open="0"] .wr-foot {
display: none;
}
.sheet-b .empty {
padding: 22px 16px;
}

View File

@@ -1,51 +1,60 @@
/* Roadmap strip, advancement banner, and the unit picker. */
.road {
display: flex;
flex-direction: column;
gap: 10px;
}
/* The roadmap strip, the move-on banner, and the unit picker. */
.road-strip {
flex: none;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 13px;
background: var(--paper);
border: 1px solid var(--line);
padding: 9px var(--gutter);
border-bottom: 1px solid var(--line);
background: var(--raise);
}
.road-now {
display: flex;
align-items: baseline;
gap: 9px;
gap: 8px;
min-width: 0;
font-size: 13px;
}
.road-now .ko {
font-size: 17px;
font-weight: 600;
}
.road-now .nm {
font-size: 12px;
.road-now .ph {
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink3);
white-space: nowrap;
}
.road-now .u {
font-size: 15px;
font-weight: 500;
white-space: nowrap;
}
.road-now .n {
font-size: 12.5px;
color: var(--ink2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.road-bar {
flex: 1;
min-width: 60px;
flex: 1 1 90px;
min-width: 70px;
max-width: 180px;
height: 6px;
position: relative;
overflow: hidden;
border-radius: 3px;
background: var(--sunk);
border: 1px solid var(--line);
}
.road-bar i {
position: absolute;
inset: 0 auto 0 0;
display: block;
height: 100%;
background: var(--jade);
transition: width 0.3s;
}
@@ -55,123 +64,162 @@
}
.road-pct {
min-width: 34px;
font-size: 12px;
color: var(--ink3);
min-width: 34px;
text-align: right;
}
.road-ready {
flex: none;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
gap: 10px;
padding: 10px var(--gutter);
border-bottom: 1px solid var(--hwang);
background: var(--hwang-soft);
font-size: 13.5px;
color: var(--ink);
}
.road-ready p {
flex: 1;
min-width: 240px;
.road-ready b {
font-weight: 600;
}
.road-ready-acts {
.road-ready .sp {
margin-left: auto;
display: flex;
gap: 7px;
}
.road-ready .btn {
padding: 5px 11px;
font-size: 13px;
border-radius: 8px;
}
/* ── the picker ──────────────────────────────────────────────────── */
.road-panel {
flex: none;
max-height: 420px;
overflow-y: auto;
overscroll-behavior: contain;
border-bottom: 1px solid var(--line);
background: var(--paper);
}
.road-ph {
display: flex;
align-items: baseline;
gap: 9px;
padding: 10px 13px;
background: var(--sunk);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
display: flex;
align-items: baseline;
gap: 8px;
padding: 9px var(--gutter) 5px;
border-top: 1px solid var(--line);
background: var(--paper);
}
.road-ph .ko {
font-size: 15px;
font-weight: 600;
.road-panel > div:first-child .road-ph {
border-top: none;
}
.road-ph .nm {
font-size: 12px;
.road-ph .n {
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink3);
}
.road-ph .k {
font-size: 14px;
font-weight: 600;
}
.road-u {
display: flex;
align-items: baseline;
gap: 11px;
gap: 10px;
width: 100%;
padding: 7px 13px;
border-bottom: 1px solid var(--line);
min-height: 40px;
padding: 8px var(--gutter) 8px 30px;
text-align: left;
font-size: 13.5px;
}
.road-u:hover {
.road-u:hover:not(:disabled) {
background: var(--raise);
}
.road-u:disabled {
cursor: default;
}
.road-u .id {
font-size: 12px;
min-width: 28px;
font-size: 11px;
color: var(--ink3);
min-width: 30px;
}
.road-u .k {
font-size: 15px;
min-width: 116px;
min-width: 104px;
font-weight: 500;
}
.road-u .nm {
font-size: 12.5px;
color: var(--ink2);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12.5px;
color: var(--ink2);
}
.road-u .st {
font-size: 11.5px;
margin-left: auto;
font-size: 11px;
color: var(--ink3);
white-space: nowrap;
}
.road-u[data-s="now"] {
background: var(--jade-soft);
box-shadow: inset 3px 0 0 0 var(--jade);
}
.road-u[data-s="done"] .id,
.road-u[data-s="done"] .k,
.road-u[data-s="done"] .st {
color: var(--jade);
}
.road-u[data-s="done"] .k,
.road-u[data-s="done"] .nm {
opacity: 0.65;
.road-u[data-s="now"] {
background: var(--jade-soft);
}
@media (max-width: 640px) {
.road-u[data-s="now"] .st {
color: var(--jade-ink);
font-weight: 600;
}
@media (max-width: 839px) {
.road-strip {
flex-wrap: wrap;
gap: 9px;
padding: 7px var(--gutter);
}
.road-now .nm {
.road-now .u,
.road-now .n {
display: none;
}
.road-now .ph {
font-size: 11px;
}
.road-bar {
max-width: none;
}
.road-panel {
max-height: min(62vh, 520px);
}
}
@media (max-width: 599px) {
.road-u .nm {
display: none;
}
.road-panel {
max-height: 62vh;
}
}

View File

@@ -278,3 +278,12 @@
background: var(--sunk);
color: var(--ink2);
}
/* An answered exercise stays on screen; this says it is done with. */
.task-spent {
padding: 7px 13px;
border-top: 1px solid var(--line);
font-size: 11.5px;
font-style: italic;
color: var(--ink3);
}

View File

@@ -1,25 +1,39 @@
/* The lesson layout: chat on the left, word rail on the right. */
/* The lesson: a column that fits the screen, with the conversation as its
one scroller, and the word list docked beside it from 840px. */
.lesson-grid {
display: grid;
grid-template-columns: 1.55fr 1fr;
gap: 18px;
align-items: start;
.lesson-wrap {
flex: 1;
min-height: 0;
display: flex;
}
.chat {
.chatcol {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-log {
padding: 16px;
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
display: flex;
flex-direction: column;
gap: 20px;
max-height: calc(100dvh - 260px);
overflow-y: auto;
gap: 16px;
padding: 14px var(--gutter) 8px;
/* Anchoring would fight the follow-the-reply scroll. */
overflow-anchor: none;
}
@media (min-width: 840px) {
/* 40-odd Korean characters is a line; do not let it run the full width. */
.chat-log {
padding-inline: max(var(--gutter), calc((100% - 780px) / 2));
}
}
.msg {
@@ -27,30 +41,55 @@
flex-direction: column;
gap: 5px;
min-width: 0;
max-width: 90%;
}
@media (max-width: 599px) {
.msg {
max-width: 97%;
}
}
@media (min-width: 840px) {
.msg {
max-width: min(92%, 660px);
}
}
.msg .who {
font-size: 11px;
letter-spacing: 0.06em;
font-size: 10.5px;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--ink3);
}
.msg.you {
align-self: flex-end;
align-items: flex-end;
}
.msg.you .bubble {
background: var(--jade-soft);
border-color: var(--jade);
max-width: 82%;
.msg.you .who {
color: var(--jade);
}
.bubble {
padding: 11px 13px;
background: var(--raise);
border: 1px solid var(--line);
font-size: 14.5px;
min-width: 0;
padding: 13px 15px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--raise);
font-size: 14.5px;
line-height: 1.62;
}
.msg:not(.you) .bubble {
border-top-left-radius: 4px;
}
.msg.you .bubble {
border-color: var(--jade-soft);
border-top-right-radius: 4px;
background: var(--jade-soft);
}
.bubble p {
@@ -58,123 +97,77 @@
}
.bubble .gap {
height: 9px;
height: 8px;
}
/* An example line, not prose. */
.bubble .kline {
margin: 3px 0;
font-size: 20px;
font-weight: 500;
line-height: 1.5;
margin: 3px 0;
letter-spacing: -0.01em;
}
.bubble .ok {
color: var(--jade);
margin-right: 6px;
color: var(--jade);
font-weight: 600;
}
.bubble .no {
color: var(--jeok);
margin-right: 6px;
color: var(--jeok);
font-weight: 600;
}
.task-spent {
font-size: 11.5px;
color: var(--ink3);
padding: 6px 0 0;
font-style: italic;
}
/* thinking */
.bubble.dots {
display: inline-flex;
gap: 5px;
align-items: center;
gap: 5px;
width: fit-content;
}
.bubble.dots i {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--ink3);
animation: blink 1.2s infinite;
animation: blink 1.1s infinite;
}
.bubble.dots i:nth-child(2) {
animation-delay: 0.18s;
}
.bubble.dots i:nth-child(3) {
animation-delay: 0.36s;
}
@keyframes blink {
0%, 60%, 100% { opacity: 0.25; }
30% { opacity: 1; }
}
.chat-error {
margin: 0 16px 12px;
}
.chat-foot {
border-top: 1px solid var(--line);
margin-top: auto;
}
.chat-in {
display: flex;
gap: 8px;
padding: 11px;
align-items: flex-end;
}
.chat-in textarea {
flex: 1;
min-width: 0;
}
.chat-acts {
display: flex;
flex-direction: column;
gap: 6px;
}
@media (max-width: 900px) {
.lesson-grid {
grid-template-columns: 1fr;
0%,
60%,
100% {
opacity: 0.25;
}
.chat-log {
max-height: none;
30% {
opacity: 1;
}
}
/* Focus picker, in the chat panel header. */
.focus-pick {
display: inline-flex;
align-items: center;
gap: 6px;
}
.focus-pick select {
padding: 3px 7px;
font-size: 12.5px;
}
/* Markdown a non-compliant model emits. The prompt forbids all of it; these
rules exist so it degrades into something readable instead of showing as
literal hyphens, hashes and asterisks. Deliberately quiet — this is not a
style to encourage. */
.bubble .mhead {
font-size: 14.5px;
margin-top: 8px;
font-size: 14.5px;
}
.bubble .mbullet {
padding-left: 15px;
position: relative;
padding-left: 15px;
}
.bubble .mbullet::before {
@@ -185,27 +178,153 @@
}
.bubble .mrule {
margin: 10px 0;
border: 0;
border-top: 1px solid var(--line);
margin: 10px 0;
}
/* Start: the only action on a transcript that is just the app's opening. */
.chat-start {
display: flex;
justify-content: center;
padding: 10px 0 4px;
}
/* A reply shown despite the gate, after its retries: name the words. */
.msg-flag {
margin: 4px 0 0;
margin: 2px 0 0;
font-size: 12px;
color: var(--jeok);
}
/* "asking him again…" — while a refused draft is rewritten. */
.chat-note {
/* ── the foot ────────────────────────────────────────────────────── */
.chat-foot {
flex: none;
padding: 8px var(--gutter) 0;
border-top: 1px solid var(--line);
background: var(--paper);
}
.chips-row {
display: flex;
gap: 6px;
margin: 0 calc(-1 * var(--gutter));
padding: 0 var(--gutter) 8px;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.chips-row::-webkit-scrollbar {
display: none;
}
.chips-row button {
flex: 0 0 auto;
min-height: 34px;
padding: 7px 13px;
border: 1px solid var(--line);
border-radius: 17px;
background: var(--raise);
font-size: 13px;
color: var(--ink2);
white-space: nowrap;
}
.chips-row button:hover:not(:disabled) {
border-color: var(--jade);
color: var(--jade);
}
.chips-row button:disabled {
opacity: 0.5;
cursor: default;
}
.chips-row button[data-primary="1"] {
border-color: var(--jade);
background: var(--jade);
color: var(--on-jade);
font-weight: 600;
}
.chat-in {
display: flex;
align-items: flex-end;
gap: 8px;
padding-bottom: 8px;
}
.chat-in textarea {
flex: 1;
min-width: 0;
min-height: 44px;
max-height: 30dvh;
padding: 11px 12px;
border-radius: 12px;
line-height: 1.45;
resize: none;
}
.sendbtn,
.kb-toggle {
flex: none;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
}
.sendbtn {
border: 1px solid var(--jade);
background: var(--jade);
color: var(--on-jade);
font-size: 17px;
}
.sendbtn:disabled {
opacity: 0.4;
cursor: default;
}
.sendbtn.stop {
border-color: var(--line2);
background: var(--raise);
color: var(--ink);
font-size: 14px;
}
.kb-toggle {
border: 1px solid var(--line2);
background: var(--raise);
font-size: 15px;
font-weight: 500;
}
.kb-toggle[aria-pressed="true"] {
border-color: var(--jade);
background: var(--jade);
color: var(--on-jade);
}
.chat-foot .kb {
margin: 0 calc(-1 * var(--gutter));
}
.chat-note {
display: flex;
align-items: center;
gap: 10px;
padding: 0 0 7px;
font-size: 11.5px;
color: var(--ink3);
}
.chat-note[data-say="1"] {
color: var(--ink2);
}
@media (max-width: 599px) {
.chat-note {
display: none;
}
.chat-note[data-say="1"] {
display: flex;
}
}

218
test/ui/history.test.ts Normal file
View File

@@ -0,0 +1,218 @@
/* Routes and layers over history — with the one property that makes it hard:
history.go() lands on a later task, and anything pushed in between is
undone by it. The fake below behaves like a browser in that respect: a
traversal only takes effect, and only fires popstate, when the test lets
the queue run. */
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Navigator, type HistoryLike } from "@app/ui/shell/history.js";
class FakeHistory implements HistoryLike {
entries: { state: unknown; url: string }[] = [{ state: null, url: "#lesson" }];
at = 0;
pending: number[] = [];
onPop: (state: unknown, hash: string) => void = () => {};
get state() {
return this.entries[this.at]!.state;
}
get hash() {
return this.entries[this.at]!.url.slice(1);
}
pushState(data: unknown, _: string, url = "") {
this.entries.splice(this.at + 1, Infinity, { state: structuredClone(data), url });
this.at++;
}
replaceState(data: unknown, _: string, url = "") {
this.entries[this.at] = { state: structuredClone(data), url };
}
go(delta: number) {
this.pending.push(delta);
}
/** The user's own back gesture. */
back() {
this.pending.push(-1);
}
/** Let queued traversals land, each firing popstate, as a browser would. */
run() {
while (this.pending.length) {
const delta = this.pending.shift()!;
const to = Math.max(0, Math.min(this.entries.length - 1, this.at + delta));
if (to === this.at) continue;
this.at = to;
this.onPop(this.state, this.hash);
}
}
/** Where the address bar is and how deep the stack goes. */
get view() {
return { at: this.at, urls: this.entries.map((e) => e.url) };
}
}
let h: FakeHistory;
let nav: Navigator;
beforeEach(() => {
h = new FakeHistory();
nav = new Navigator(h, "lesson");
h.onPop = (s, hash) => nav.onPopState(s, hash);
});
describe("routes", () => {
it("pushes one entry per move, and none for the route already showing", () => {
nav.go("words");
nav.go("words");
nav.go("words");
expect(h.view).toEqual({ at: 1, urls: ["#lesson", "#words"] });
});
it("follows the back gesture", () => {
nav.go("today");
nav.go("settings");
h.back();
h.run();
expect(nav.route).toBe("today");
h.back();
h.run();
expect(nav.route).toBe("lesson");
});
it("sends ← back through history when history leads to the parent", () => {
nav.go("learn");
nav.go("grammar");
nav.go("cj");
nav.back();
h.run();
expect(nav.route).toBe("grammar");
expect(h.view.at).toBe(2);
});
it("makes ← become the parent when history leads somewhere else", () => {
// Straight from the lesson to the trainer: ← goes up, not back to the lesson.
nav.go("cj");
nav.back();
h.run();
expect(nav.route).toBe("grammar");
expect(h.view).toEqual({ at: 1, urls: ["#lesson", "#grammar"] });
});
it("never lets ← on a page opened directly leave the app", () => {
const direct = new FakeHistory();
direct.entries = [{ state: null, url: "#drill" }];
const n = new Navigator(direct, "drill");
n.back();
expect(n.route).toBe("hangul");
expect(direct.pending).toEqual([]);
});
});
describe("layers", () => {
it("lets Back close the topmost layer before it leaves the route", () => {
const closed: string[] = [];
nav.go("words");
nav.openLayer("sheet", () => closed.push("sheet"));
nav.openLayer("pop", () => closed.push("pop"));
h.back();
h.run();
expect(closed).toEqual(["pop"]);
expect(nav.route).toBe("words");
h.back();
h.run();
expect(closed).toEqual(["pop", "sheet"]);
expect(nav.route).toBe("words");
h.back();
h.run();
expect(nav.route).toBe("lesson");
});
it("takes a layer's entry off the history when its owner closes it", () => {
nav.openLayer("review", () => {});
expect(h.view.at).toBe(1);
nav.releaseLayer("review");
h.run();
expect(h.view.at).toBe(0);
expect(nav.isOpen("review")).toBe(false);
});
it("closes what is open, and rewinds its entries, before moving to another route", () => {
const closed: string[] = [];
nav.openLayer("sheet", () => closed.push("sheet"));
nav.openLayer("pop", () => closed.push("pop"));
nav.go("today");
expect(closed).toEqual(["pop", "sheet"]);
// Nothing is pushed until the rewind has landed — a push made before it
// would be undone by it.
expect(nav.route).toBe("lesson");
h.run();
expect(nav.route).toBe("today");
expect(h.view).toEqual({ at: 1, urls: ["#lesson", "#today"] });
h.back();
h.run();
expect(nav.route).toBe("lesson");
});
it("keeps operations in order while a traversal is still in flight", () => {
// Close the popover and open another in the same tick — the second push
// must not be made before the first entry is gone.
nav.openLayer("pop", () => {});
nav.releaseLayer("pop");
nav.openLayer("pop", () => {});
expect(h.view.at).toBe(1); // still the first popover's entry
h.run();
expect(h.view.at).toBe(1);
expect(nav.isOpen("pop")).toBe(true);
h.back();
h.run();
expect(nav.isOpen("pop")).toBe(false);
expect(h.view.at).toBe(0);
});
it("closes the layers above one its owner closes", () => {
const closed: string[] = [];
nav.openLayer("sheet", () => closed.push("sheet"));
nav.openLayer("pop", () => closed.push("pop"));
nav.releaseLayer("sheet");
expect(closed).toEqual(["pop"]);
h.run();
expect(h.view.at).toBe(0);
});
it("does not wedge if a traversal never reports back", () => {
vi.useFakeTimers();
try {
nav.openLayer("sheet", () => {});
nav.releaseLayer("sheet");
h.pending = []; // lost
nav.go("today");
expect(nav.route).toBe("lesson");
vi.advanceTimersByTime(2000);
expect(nav.route).toBe("today");
} finally {
vi.useRealTimers();
}
});
});
describe("reloads and foreign entries", () => {
it("comes back on the route it was on, with nothing open", () => {
nav.go("words");
nav.openLayer("sheet", () => {});
const reloaded = new Navigator(h, "words");
expect(reloaded.route).toBe("words");
expect(reloaded.isOpen("sheet")).toBe(false);
expect((h.state as { layer?: string }).layer).toBeUndefined();
});
it("adopts a hash typed into the address bar", () => {
const closed: string[] = [];
nav.openLayer("sheet", () => closed.push("sheet"));
h.pushState(null, "", "#hangul");
nav.onPopState(null, "hangul");
expect(nav.route).toBe("hangul");
expect(closed).toEqual(["sheet"]);
});
});