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:
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
166
app/src/ui/review/ReviewScreen.tsx
Normal file
166
app/src/ui/review/ReviewScreen.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
139
app/src/ui/routes.tsx
Normal 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
68
app/src/ui/shell/Nav.tsx
Normal 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
95
app/src/ui/shell/Pop.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
88
app/src/ui/shell/Route.tsx
Normal file
88
app/src/ui/shell/Route.tsx
Normal 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
332
app/src/ui/shell/history.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
app/src/ui/shell/icons.tsx
Normal file
85
app/src/ui/shell/icons.tsx
Normal 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>
|
||||
);
|
||||
79
app/src/ui/shell/router.tsx
Normal file
79
app/src/ui/shell/router.tsx
Normal 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
579
app/src/ui/shell/shell.css
Normal 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;
|
||||
}
|
||||
27
app/src/ui/shell/useMedia.ts
Normal file
27
app/src/ui/shell/useMedia.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
278
app/src/ui/tabs/SettingsTab.tsx
Normal file
278
app/src/ui/tabs/SettingsTab.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
84
app/src/ui/tabs/settings.css
Normal file
84
app/src/ui/tabs/settings.css
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
160
app/src/ui/tutor/Composer.tsx
Normal file
160
app/src/ui/tutor/Composer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
103
app/src/ui/tutor/WordSheet.tsx
Normal file
103
app/src/ui/tutor/WordSheet.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
218
test/ui/history.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user