feat(app): design system, shell, and the six tabs
React + Vite + TypeScript, PWA, offline-first. Six tabs: 수업 오늘 단어 문장
문법 한글, plus the full-screen SRS review overlay, the reading drill, the
conjugation trainer and the 두벌식 keyboard.
The visual language is carried over deliberately: two hand-tuned palettes,
three type stacks, about a dozen component classes, zero border-radius and
no icons anywhere — Korean glyphs do the work icons would.
THE GATE is the reason this app exists. buildGate() already took a
vocabQuery hook; filling it with a band query is what turns 371 hand-typed
words into something that scales. Three refinements sit inside that hook,
all of them narrowing:
1. words a not-yet-finished unit is the first to introduce are excluded,
so a frequency ceiling cannot smuggle 3.4's material into 2.1;
2. Phase 1 is filtered by the phonological ladder;
3. the list is capped at 800 by frequency, because renderGate() inlines
it into the prompt — strictly more restrictive than the band, so it
cannot leak.
prompt/tutor-system.md ships unchanged with {{GATE}} filled by renderGate().
Confidence is clamped per turn. The artifact wrote the model's ::progress
number straight into the sole gate on advancement, so one hallucinated 95
skipped a unit.
stub-tutor.ts stands in for the model on the artifact's exact contract —
onText receives cumulative text, an aborted turn keeps what it streamed —
so the real endpoint drops in without touching the UI. It rotates all four
task types and climbs progress gradually, which makes every render path
reachable with no server.
Two artifact bugs are not ported: task state lived in the full-page
re-render, so anything arriving mid-answer wiped typed text and placed
chips; and the day number was computed once at module load, so a session
left open overnight scheduled against yesterday.
Verified in a browser: all six tabs work, and after a hard reload with the
network cut every tab still works — including dictionary search out of OPFS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
81
app/src/ui/tutor/GlossBlock.tsx
Normal file
81
app/src/ui/tutor/GlossBlock.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/* The colour-coded sentence breakdown.
|
||||
|
||||
Each chunk is a column: 한글 on top, a micro-gloss underneath, and a role
|
||||
carried by the underline. The optional fourth field highlights the
|
||||
meaningful piece INSIDE the word — the particle, the tense marker, the
|
||||
ending — so the grammatical morpheme reads distinctly from the stem. */
|
||||
|
||||
import { Fragment } from "react";
|
||||
import type { GlossBlock as Block, GlossPart, GlossRole } from "@lib/blocks.js";
|
||||
import { ROLE_STYLES, isRole, legendFor } from "./roles.js";
|
||||
import "./gloss.css";
|
||||
|
||||
/** Wrap the LAST occurrence of the highlight, which is where a suffix sits. */
|
||||
function withHighlight(ko: string, highlight: string) {
|
||||
if (!highlight) return ko;
|
||||
const at = ko.lastIndexOf(highlight);
|
||||
if (at < 0) return ko;
|
||||
return (
|
||||
<>
|
||||
{ko.slice(0, at)}
|
||||
<em>{highlight}</em>
|
||||
{ko.slice(at + highlight.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Word({ part }: { part: GlossPart }) {
|
||||
const role: GlossRole = isRole(part.role) ? part.role : "N";
|
||||
const style = ROLE_STYLES[role];
|
||||
|
||||
return (
|
||||
<span
|
||||
className="gw"
|
||||
data-role={role}
|
||||
data-underline={style.underline}
|
||||
style={
|
||||
{
|
||||
"--role-color": `var(${style.color})`,
|
||||
"--role-bg": `var(${style.bg})`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="k ko">{withHighlight(part.ko, part.highlight)}</span>
|
||||
{part.gloss && <span className="g">{part.gloss}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlossBlocks({ blocks }: { blocks: Block[] }) {
|
||||
const used = legendFor(
|
||||
blocks.flatMap((b) => b.parts.map((p) => (isRole(p.role) ? p.role : "N"))),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gloss-set">
|
||||
{blocks.map((b, i) => (
|
||||
<div className="gloss" key={i}>
|
||||
<div className="gloss-line">
|
||||
{b.parts.map((p, j) => (
|
||||
<Word part={p} key={j} />
|
||||
))}
|
||||
</div>
|
||||
{b.en && <div className="gloss-en">{b.en}</div>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{used.length > 0 && (
|
||||
<div className="gloss-key">
|
||||
{used.map((r) => (
|
||||
<Fragment key={r}>
|
||||
<span className="key-item">
|
||||
<i style={{ background: `var(${ROLE_STYLES[r].color})` }} />
|
||||
<span className="ko">{ROLE_STYLES[r].label}</span>
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
app/src/ui/tutor/MessageBody.tsx
Normal file
56
app/src/ui/tutor/MessageBody.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/* Tutor prose.
|
||||
|
||||
Deliberately almost no markup: the prompt allows **bold** and nothing
|
||||
else. Two behaviours carry over from the artifact because they do real
|
||||
work:
|
||||
|
||||
- a line that is mostly Korean and short is set larger, so an example
|
||||
sentence reads as an example rather than as prose;
|
||||
- a line opening with ✓ or ✗ is a marked answer, and gets the colour. */
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
const KOREAN = /[가-힣]/g;
|
||||
const PUNCT = /[\s.,!?·…"'“”()[\]:;~-]/g;
|
||||
|
||||
/** Mostly-Korean and short enough to be an example, not a sentence of prose. */
|
||||
function isKoreanLine(line: string): boolean {
|
||||
const bare = line.replace(PUNCT, "");
|
||||
if (!bare || bare.length > 60) return false;
|
||||
const korean = (bare.match(KOREAN) ?? []).length;
|
||||
return korean / bare.length > 0.55;
|
||||
}
|
||||
|
||||
/** **bold** is the only inline markup the prompt permits. */
|
||||
function inline(text: string) {
|
||||
return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) =>
|
||||
part.startsWith("**") && part.endsWith("**") && part.length > 4 ? (
|
||||
<strong key={i}>{part.slice(2, -2)}</strong>
|
||||
) : (
|
||||
<Fragment key={i}>{part}</Fragment>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBody({ text }: { text: string }) {
|
||||
const lines = text.split("\n");
|
||||
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, i) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return <div className="gap" key={i} />;
|
||||
|
||||
const mark = trimmed.startsWith("✓") ? "ok" : trimmed.startsWith("✗") ? "no" : null;
|
||||
const rest = mark ? trimmed.slice(1).trimStart() : trimmed;
|
||||
|
||||
return (
|
||||
<p key={i} className={isKoreanLine(rest) ? "kline ko" : undefined}>
|
||||
{mark && <span className={mark}>{mark === "ok" ? "✓" : "✗"}</span>}
|
||||
{inline(rest)}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
133
app/src/ui/tutor/RoadStrip.tsx
Normal file
133
app/src/ui/tutor/RoadStrip.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/* Where he is, and the one place a unit is actually chosen.
|
||||
|
||||
The prompt forbids 선생님 from offering advancement in prose — the app
|
||||
owns that affordance, and this is it. The bar shows the confidence the
|
||||
tutor reported; at 85 the banner appears; "not yet" parks it below the
|
||||
threshold rather than arguing with the model. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { curriculum } from "../../domain/gate.js";
|
||||
import {
|
||||
READY_AT,
|
||||
advanceUnit,
|
||||
currentUnit,
|
||||
isReady,
|
||||
nextUnit,
|
||||
goToUnit,
|
||||
stayOnUnit,
|
||||
} from "../../domain/progress.js";
|
||||
import "./road.css";
|
||||
|
||||
export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string) => void }) {
|
||||
const { db, progress, refreshProgress } = useStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const unit = currentUnit(progress);
|
||||
const next = nextUnit(progress);
|
||||
const confidence = progress.confidence?.[unit.id] ?? 0;
|
||||
const ready = isReady(progress);
|
||||
|
||||
const move = async () => {
|
||||
const id = await advanceUnit(db, progress);
|
||||
await refreshProgress();
|
||||
if (id) onUnitChange(id);
|
||||
};
|
||||
|
||||
const stay = async () => {
|
||||
await stayOnUnit(db, progress);
|
||||
await refreshProgress();
|
||||
};
|
||||
|
||||
const jump = async (id: string) => {
|
||||
await goToUnit(db, progress, id);
|
||||
await refreshProgress();
|
||||
setOpen(false);
|
||||
onUnitChange(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="road">
|
||||
<div className="road-strip">
|
||||
<div className="road-now">
|
||||
<span className="eyebrow">
|
||||
Phase {unit.phase} · {unit.id}
|
||||
</span>
|
||||
<span className="ko">{unit.ko}</span>
|
||||
<span className="nm">{unit.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="road-bar" data-ready={ready ? "1" : "0"} title={unit.goal}>
|
||||
<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>
|
||||
선생님 thinks you have <strong className="ko">{unit.ko}</strong> at {confidence}%.
|
||||
Ready for{" "}
|
||||
<strong className="ko">
|
||||
{next.id} {next.ko}
|
||||
</strong>
|
||||
?
|
||||
</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">
|
||||
{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>
|
||||
</div>
|
||||
{phase.units.map((u) => {
|
||||
const state = progress.done[u.id] ? "done" : u.id === unit.id ? "now" : "todo";
|
||||
const conf = progress.confidence?.[u.id] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
className="road-u"
|
||||
data-s={state}
|
||||
onClick={() => void jump(u.id)}
|
||||
>
|
||||
<span className="id mono">{u.id}</span>
|
||||
<span className="k ko">{u.ko}</span>
|
||||
<span className="nm">{u.name}</span>
|
||||
<span className="st tnum">
|
||||
{state === "done"
|
||||
? "✓ done"
|
||||
: state === "now"
|
||||
? "studying now"
|
||||
: conf >= READY_AT
|
||||
? `${conf}% — ready`
|
||||
: conf
|
||||
? `${conf}%`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
app/src/ui/tutor/TaskHost.tsx
Normal file
BIN
app/src/ui/tutor/TaskHost.tsx
Normal file
Binary file not shown.
452
app/src/ui/tutor/TutorTab.tsx
Normal file
452
app/src/ui/tutor/TutorTab.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
/* The tutor tab.
|
||||
|
||||
This is where the curriculum, the dictionary and the prompt meet:
|
||||
|
||||
progress + curriculum -> buildGate(vocabQuery) -> renderGate()
|
||||
|
|
||||
prompt/tutor-system.md <-- {{GATE}}
|
||||
|
|
||||
sample() (stub here)
|
||||
|
|
||||
parse() -> ::task ::words ::gloss ::progress
|
||||
|
||||
The transcript lives in the `chat` table, so the client owns it. When the
|
||||
Pi's endpoint lands, only `sample` changes. */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { editChatClear, editChatTrim, editChatTurn, seedChatTurn } from "../../db/writes.js";
|
||||
import { parseMessage } from "../../domain/gloss.js";
|
||||
import { assemblePrompt, gateFor, VOCAB_CAP, type BandQuery } from "../../domain/gate.js";
|
||||
import { applyProgressReport, currentUnit } from "../../domain/progress.js";
|
||||
import { makeStubTutor, SampleError, type Sample, type StubWord } from "../../domain/stub-tutor.js";
|
||||
import { lookupMany } from "../../domain/lexicon.js";
|
||||
import { REFERENCE_BAND, bandForUnit, ceilingForBand } from "@shared/bands.mjs";
|
||||
import type { Db } from "../../db/types.js";
|
||||
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 { RoadStrip } from "./RoadStrip.js";
|
||||
import { Keyboard, useComposer } from "../keyboard/Keyboard.js";
|
||||
import promptTemplate from "@prompt/tutor-system.md?raw";
|
||||
import "./tutor.css";
|
||||
|
||||
const KEEP_TURNS = 26;
|
||||
|
||||
interface Turn {
|
||||
id: number;
|
||||
role: "user" | "assistant";
|
||||
body: string;
|
||||
}
|
||||
|
||||
/* ── the band query, synchronously available to buildGate ─────────── */
|
||||
|
||||
/**
|
||||
* buildGate's vocabQuery is synchronous, so the band's words are read once
|
||||
* per unit and handed over as a snapshot rather than queried inline.
|
||||
*/
|
||||
async function readBandWords(db: Db, band: number, ceiling: number): Promise<string[]> {
|
||||
const rows = await db.all<{ headword: string }>(
|
||||
`SELECT DISTINCT headword FROM lemma
|
||||
WHERE unit_band <= ? AND unit_band < ?
|
||||
AND (freq_rank IS NOT NULL AND freq_rank <= ?
|
||||
OR source IN ('curated','grammar','sentence','sfx'))
|
||||
ORDER BY freq_rank IS NULL, freq_rank
|
||||
LIMIT ?`,
|
||||
[band, REFERENCE_BAND, ceiling, VOCAB_CAP * 3],
|
||||
);
|
||||
return rows.map((r) => r.headword);
|
||||
}
|
||||
|
||||
/* ── the tab ─────────────────────────────────────────────────────── */
|
||||
|
||||
export function TutorTab() {
|
||||
const { db, progress, prefs, refreshProgress } = useStore();
|
||||
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 [bandWords, setBandWords] = useState<string[]>([]);
|
||||
const [showKeyboard, setShowKeyboard] = useState(false);
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
|
||||
const abort = useRef<AbortController | null>(null);
|
||||
// `busy` drives the UI; `inFlight` guards re-entry. State read from a
|
||||
// closure is a render behind, which is not good enough for a guard.
|
||||
const inFlight = useRef(false);
|
||||
const logEnd = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLTextAreaElement>(null);
|
||||
const composer = useComposer();
|
||||
|
||||
const unit = currentUnit(progress);
|
||||
const unitId = progress.current;
|
||||
|
||||
/* ── the gate ── */
|
||||
|
||||
const bandQuery: BandQuery = useCallback(
|
||||
() => bandWords.map((headword) => ({ headword })),
|
||||
[bandWords],
|
||||
);
|
||||
|
||||
const gate = useMemo(() => gateFor({ progress, bandQuery }), [progress, bandQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const band = bandForUnit(unitId);
|
||||
const words = await readBandWords(db, band, ceilingForBand(band));
|
||||
if (!cancelled) setBandWords(words);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, unitId]);
|
||||
|
||||
/* ── the transcript ── */
|
||||
|
||||
const loadTurns = useCallback(async () => {
|
||||
const rows = await db.all<{ id: number; role: string; body: string }>(
|
||||
"SELECT id, role, body FROM chat ORDER BY id",
|
||||
);
|
||||
setTurns(rows.map((r) => ({ id: r.id, role: r.role as Turn["role"], body: r.body })));
|
||||
return rows.length;
|
||||
}, [db]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTurns();
|
||||
}, [loadTurns]);
|
||||
|
||||
/* ── the responder ── */
|
||||
|
||||
const sample: Sample = useMemo(() => {
|
||||
return makeStubTutor(() => {
|
||||
const words: StubWord[] = railVocabulary.current;
|
||||
return {
|
||||
gate,
|
||||
words,
|
||||
turn: turns.filter((t) => t.role === "user").length,
|
||||
confidence: progress.confidence?.[progress.current] ?? 0,
|
||||
};
|
||||
});
|
||||
}, [gate, turns, progress]);
|
||||
|
||||
/* 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[]>([]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const found = await lookupMany(db, gate.newWords.slice(0, 12));
|
||||
if (cancelled) return;
|
||||
railVocabulary.current = gate.newWords.slice(0, 12).flatMap<StubWord>((ko) => {
|
||||
const hit = found.get(ko);
|
||||
return hit ? [{ ko, gloss: hit.glossEn, note: hit.pos }] : [];
|
||||
});
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, gate.newWords]);
|
||||
|
||||
/* ── sending ── */
|
||||
|
||||
const send = useCallback(
|
||||
async (body: string, { record = true }: { record?: boolean } = {}) => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
if (record) {
|
||||
await editChatTurn(db, "user", body);
|
||||
await loadTurns();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abort.current = controller;
|
||||
|
||||
// The whole prompt is rebuilt every turn, so the gate is never stale.
|
||||
const systemPrompt = assemblePrompt({
|
||||
template: promptTemplate,
|
||||
gate,
|
||||
recent,
|
||||
focus: prefs.focus,
|
||||
});
|
||||
const history = turns.map((t) => ({ role: t.role, content: t.body }));
|
||||
|
||||
try {
|
||||
const result = await sample(
|
||||
[{ role: "user", content: systemPrompt }, ...history, { role: "user", content: body }],
|
||||
{ signal: controller.signal, onText: ({ text }) => setStreaming(text) },
|
||||
);
|
||||
|
||||
setStreaming(null);
|
||||
await editChatTurn(db, "assistant", result.text);
|
||||
await editChatTrim(db, KEEP_TURNS);
|
||||
await loadTurns();
|
||||
|
||||
const parsed = parseMessage(result.text);
|
||||
|
||||
// A new exercise resets the per-exercise lookup set — that set is
|
||||
// what the answer reports back, so it must not carry over.
|
||||
if (parsed.task) {
|
||||
setRevealed(new Set());
|
||||
setRecent((r) => [...r, parsed.task!.type].slice(-6));
|
||||
}
|
||||
|
||||
if (parsed.progress) {
|
||||
await applyProgressReport(db, progress, parsed.progress.score);
|
||||
await refreshProgress();
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as SampleError;
|
||||
setStreaming(null);
|
||||
if (e?.code === "cancelled") {
|
||||
if (e.text) {
|
||||
await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`);
|
||||
await loadTurns();
|
||||
}
|
||||
} else {
|
||||
setError(e?.message ?? "The tutor could not be reached.");
|
||||
}
|
||||
} finally {
|
||||
abort.current = null;
|
||||
inFlight.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[db, gate, loadTurns, prefs.focus, progress, recent, refreshProgress, sample, 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
|
||||
this managed to seed the opening turn three times over. */
|
||||
const opened = useRef(false);
|
||||
useEffect(() => {
|
||||
if (opened.current) return;
|
||||
opened.current = true;
|
||||
|
||||
void (async () => {
|
||||
if ((await loadTurns()) > 0) return; // a transcript already exists
|
||||
// The opening turn is app-supplied, not a user edit: unstamped.
|
||||
await seedChatTurn(db, "user", `Start unit ${unit.id}.`, 0);
|
||||
await loadTurns();
|
||||
await sendRef.current(`Start unit ${unit.id}.`, { record: false });
|
||||
})();
|
||||
// Mount only: `send` and `unit.id` are read through the ref / at mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/** Start the lesson over. Clears the transcript only — the roadmap, the
|
||||
cards and the study log are left alone. */
|
||||
const clearLesson = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
abort.current?.abort();
|
||||
await editChatClear(db);
|
||||
setRevealed(new Set());
|
||||
setRecent([]);
|
||||
await loadTurns();
|
||||
await sendRef.current(`Start unit ${unit.id}.`, { record: true });
|
||||
}, [db, loadTurns, unit.id]);
|
||||
|
||||
/* ── the rail ── */
|
||||
|
||||
const lastTutor = useMemo(
|
||||
() => [...turns].reverse().find((t) => t.role === "assistant"),
|
||||
[turns],
|
||||
);
|
||||
|
||||
const parsedLast: ParsedMessage | null = useMemo(
|
||||
() => (lastTutor ? parseMessage(lastTutor.body) : null),
|
||||
[lastTutor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastTutor) {
|
||||
setRailWords([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const words = await collectWords(db, lastTutor.body, parsedLast?.words ?? null);
|
||||
if (!cancelled) setRailWords(words);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, lastTutor, parsedLast]);
|
||||
|
||||
useEffect(() => {
|
||||
logEnd.current?.scrollIntoView({ block: "end" });
|
||||
}, [turns, streaming]);
|
||||
|
||||
/* ── rendering ── */
|
||||
|
||||
const isLast = (i: number) => i === turns.length - 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoadStrip
|
||||
onUnitChange={(id) => {
|
||||
opened.current = true;
|
||||
void send(`Let's start unit ${id}.`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="lesson-grid">
|
||||
<div className="chat panel">
|
||||
<div className="panel-h">
|
||||
<h2 className="ko">선생님</h2>
|
||||
<span className="note">
|
||||
{gate.vocabulary.length} words unlocked · {gate.newWords.length} new this unit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="chat-log">
|
||||
{turns.map((t, i) => {
|
||||
const you = t.role === "user";
|
||||
const parsed = you ? null : parseMessage(t.body);
|
||||
return (
|
||||
<div className={`msg${you ? " you" : ""}`} key={t.id}>
|
||||
<span className="who ko">{you ? "나" : "선생님"}</span>
|
||||
<div className="bubble">
|
||||
<MessageBody text={parsed ? parsed.body : t.body} />
|
||||
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
||||
</div>
|
||||
|
||||
{parsed?.task &&
|
||||
(isLast(i) ? (
|
||||
<TaskHost
|
||||
task={parsed.task}
|
||||
turnId={t.id}
|
||||
lookups={[...revealed]}
|
||||
disabled={busy}
|
||||
onSubmit={(message) => void send(message)}
|
||||
onSkip={() => void send("Let's skip that one and just talk.")}
|
||||
/>
|
||||
) : (
|
||||
<div className="task-spent">exercise answered</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{streaming !== null && (
|
||||
<div className="msg">
|
||||
<span className="who ko">선생님</span>
|
||||
<div className="bubble">
|
||||
<MessageBody text={parseMessage(streaming).body} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{busy && streaming === null && (
|
||||
<div className="msg">
|
||||
<span className="who ko">선생님</span>
|
||||
<div className="bubble dots">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={logEnd} />
|
||||
</div>
|
||||
|
||||
{error && <div className="callout warn chat-error">{error}</div>}
|
||||
|
||||
<div className="chat-foot">
|
||||
<div className="chat-in">
|
||||
<textarea
|
||||
ref={input}
|
||||
rows={2}
|
||||
value={draft}
|
||||
placeholder="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>
|
||||
</div>
|
||||
|
||||
<WordRail
|
||||
words={railWords}
|
||||
revealed={revealed}
|
||||
onReveal={(ko) => setRevealed((r) => new Set(r).add(ko))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
app/src/ui/tutor/WordRail.tsx
Normal file
203
app/src/ui/tutor/WordRail.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/* The word rail — every Korean form in the message, glossed.
|
||||
|
||||
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
|
||||
out of the inspector, which is the only way the cover means anything.
|
||||
|
||||
Two counters, doing different jobs:
|
||||
- `revealed` is per-exercise and resets when a new task arrives. It is
|
||||
what the answer reports as "I had to look up: …", and the prompt leans
|
||||
on it — words he keeps looking up are what the next exercise is built
|
||||
from.
|
||||
- the `peek` table is a lifetime tally per word, which underlines the
|
||||
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. */
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { WordEntry } from "@lib/blocks.js";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { editPeek } from "../../db/writes.js";
|
||||
import { koreanTokens, lookupMany, search, type Entry } from "../../domain/lexicon.js";
|
||||
import { ensureReferenceBand } from "../../domain/dictionary.js";
|
||||
import "./rail.css";
|
||||
|
||||
export interface RailWord {
|
||||
ko: string;
|
||||
gloss: string;
|
||||
note: string;
|
||||
/** Tutor-declared words come first and are never dropped. */
|
||||
fromTutor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* shown blank.
|
||||
*/
|
||||
export async function collectWords(
|
||||
db: ReturnType<typeof useStore>["db"],
|
||||
text: string,
|
||||
declared: WordEntry[] | null,
|
||||
): Promise<RailWord[]> {
|
||||
const out: RailWord[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const w of declared ?? []) {
|
||||
if (!w.ko || seen.has(w.ko)) continue;
|
||||
seen.add(w.ko);
|
||||
out.push({ ko: w.ko, gloss: w.gloss, note: w.note, fromTutor: true });
|
||||
}
|
||||
|
||||
const tokens = koreanTokens(text).filter((t) => !seen.has(t));
|
||||
if (tokens.length) {
|
||||
const found = await lookupMany(db, tokens);
|
||||
for (const t of tokens) {
|
||||
const hit = found.get(t);
|
||||
if (!hit || seen.has(t)) continue;
|
||||
seen.add(t);
|
||||
out.push({
|
||||
ko: t,
|
||||
gloss: hit.glossEn,
|
||||
note: hit.analysis && hit.headword !== t ? hit.analysis : "",
|
||||
fromTutor: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
words: RailWord[];
|
||||
revealed: Set<string>;
|
||||
onReveal: (ko: string) => void;
|
||||
}
|
||||
|
||||
export function WordRail({ words, revealed, onReveal }: WordRailProps) {
|
||||
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(() => {
|
||||
if (!searching) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
// The rail 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 });
|
||||
if (!cancelled) setResults(hits);
|
||||
}, 160);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [db, query, searching]);
|
||||
|
||||
const lookedUp = useMemo(
|
||||
() => words.filter((w) => revealed.has(w.ko)).length,
|
||||
[words, revealed],
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
<div className="wr-tools">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Look a word up…"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wr-body">
|
||||
{searching ? (
|
||||
results.length ? (
|
||||
results.map((r) => (
|
||||
<div className="wr-row" key={`${r.lemmaId}`}>
|
||||
<span className="wr-k ko">{r.headword}</span>
|
||||
<span className="wr-m">
|
||||
{r.glossEn}
|
||||
<i>{r.pos}</i>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="empty">Nothing for “{query}”.</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);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="empty">Words from the lesson appear here.</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>
|
||||
);
|
||||
}
|
||||
108
app/src/ui/tutor/gloss.css
Normal file
108
app/src/ui/tutor/gloss.css
Normal file
@@ -0,0 +1,108 @@
|
||||
/* Gloss block. The underline carries the role; the colour groups roles that
|
||||
fill the same slot. */
|
||||
|
||||
.gloss-set {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.gloss {
|
||||
padding: 12px 13px;
|
||||
background: var(--raise);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.gloss-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 4px 14px;
|
||||
}
|
||||
|
||||
.gw {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: var(--role-color);
|
||||
}
|
||||
|
||||
.gw .k {
|
||||
font-size: 21px;
|
||||
line-height: 1.35;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.gw .g {
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink3);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
/* The morpheme inside the word — particle, tense marker, ending. */
|
||||
.gw .k em {
|
||||
font-style: normal;
|
||||
background: var(--role-bg);
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* Subject and topic share the hue; only the underline tells them apart. */
|
||||
.gw[data-underline="solid"] .k {
|
||||
box-shadow: inset 0 -2px 0 0 var(--role-color);
|
||||
}
|
||||
|
||||
.gw[data-underline="dotted"] .k {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--role-color) 0 3px,
|
||||
transparent 3px 6px
|
||||
);
|
||||
background-size: 6px 2px;
|
||||
background-repeat: repeat-x;
|
||||
background-position: 0 100%;
|
||||
}
|
||||
|
||||
.gw[data-underline="hairline"] .k {
|
||||
box-shadow: inset 0 -1px 0 0 var(--role-color);
|
||||
}
|
||||
|
||||
.gw[data-underline="sides"] .k {
|
||||
border-left: 1px solid var(--role-color);
|
||||
border-right: 1px solid var(--role-color);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.gw[data-role="V"] .k em {
|
||||
box-shadow: inset 0 -2px 0 0 var(--role-color);
|
||||
}
|
||||
|
||||
.gloss-en {
|
||||
margin-top: 11px;
|
||||
padding-top: 9px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-family: var(--serif);
|
||||
font-size: 15px;
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
.gloss-key {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
}
|
||||
|
||||
.key-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.key-item i {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 3px;
|
||||
}
|
||||
115
app/src/ui/tutor/rail.css
Normal file
115
app/src/ui/tutor/rail.css
Normal file
@@ -0,0 +1,115 @@
|
||||
/* The word rail. Sticky beside the chat on a desktop, a collapsible drawer
|
||||
on a phone. */
|
||||
|
||||
.wordrail {
|
||||
position: sticky;
|
||||
top: 116px;
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 140px);
|
||||
}
|
||||
|
||||
.wordrail .panel-h {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wr-tools {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.wr-tools input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wr-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wr-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.wr-k {
|
||||
font-size: 17px;
|
||||
min-width: 74px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Lifetime lookup tally — a word he keeps needing gets marked. */
|
||||
.wr-row[data-peeked="1"] .wr-k {
|
||||
box-shadow: inset 0 -2px 0 0 var(--hwang);
|
||||
}
|
||||
|
||||
.wr-m {
|
||||
font-size: 13.5px;
|
||||
color: var(--ink2);
|
||||
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-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.wr-foot .tnum {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
177
app/src/ui/tutor/road.css
Normal file
177
app/src/ui/tutor/road.css
Normal file
@@ -0,0 +1,177 @@
|
||||
/* Roadmap strip, advancement banner, and the unit picker. */
|
||||
|
||||
.road {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.road-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 13px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.road-now {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.road-now .ko {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.road-now .nm {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.road-bar {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
height: 6px;
|
||||
background: var(--sunk);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.road-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--jade);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.road-bar[data-ready="1"] i {
|
||||
background: var(--hwang);
|
||||
}
|
||||
|
||||
.road-pct {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
min-width: 34px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.road-ready {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.road-ready p {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.road-ready-acts {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
/* ── the picker ──────────────────────────────────────────────────── */
|
||||
|
||||
.road-panel {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.road-ph .ko {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.road-ph .nm {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.road-u {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 7px 13px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.road-u:hover {
|
||||
background: var(--raise);
|
||||
}
|
||||
|
||||
.road-u .id {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.road-u .k {
|
||||
font-size: 15px;
|
||||
min-width: 116px;
|
||||
}
|
||||
|
||||
.road-u .nm {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.road-u .st {
|
||||
font-size: 11.5px;
|
||||
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"] .st {
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
.road-u[data-s="done"] .k,
|
||||
.road-u[data-s="done"] .nm {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.road-strip {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.road-now .nm {
|
||||
display: none;
|
||||
}
|
||||
.road-u .nm {
|
||||
display: none;
|
||||
}
|
||||
.road-panel {
|
||||
max-height: 62vh;
|
||||
}
|
||||
}
|
||||
42
app/src/ui/tutor/roles.ts
Normal file
42
app/src/ui/tutor/roles.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/* Sentence roles — one table, used by both the gloss renderer and its
|
||||
legend. The artifact kept this mapping twice, once in CSS and once in JS,
|
||||
and they were free to drift.
|
||||
|
||||
Four hues, not eight. SUBJECT AND TOPIC SHARE THE BLUE and differ only by
|
||||
a dotted vs solid underline: that is the visual argument that 은/는 and
|
||||
이/가 fill the same slot in the sentence, which is exactly the thing a
|
||||
reader of manhwa has to internalise. */
|
||||
|
||||
import type { GlossRole } from "@lib/blocks.js";
|
||||
import { ROLES } from "@lib/blocks.js";
|
||||
|
||||
export interface RoleStyle {
|
||||
/** CSS custom property holding the hue. */
|
||||
color: string;
|
||||
/** …and its tint, for the highlighted morpheme. */
|
||||
bg: string;
|
||||
underline: "solid" | "dotted" | "hairline" | "sides" | "none";
|
||||
/** Bilingual label, straight from lib/blocks.js. N is unlabelled. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const ROLE_STYLES: Record<GlossRole, RoleStyle> = {
|
||||
S: { color: "--r-sub", bg: "--r-sub-bg", underline: "dotted", label: ROLES.S },
|
||||
T: { color: "--r-sub", bg: "--r-sub-bg", underline: "solid", label: ROLES.T },
|
||||
O: { color: "--r-obj", bg: "--r-obj-bg", underline: "solid", label: ROLES.O },
|
||||
V: { color: "--r-pred", bg: "--r-pred-bg", underline: "solid", label: ROLES.V },
|
||||
C: { color: "--r-link", bg: "--r-link-bg", underline: "solid", label: ROLES.C },
|
||||
Q: { color: "--r-link", bg: "--r-link-bg", underline: "solid", label: ROLES.Q },
|
||||
P: { color: "--ink3", bg: "--sunk", underline: "hairline", label: ROLES.P },
|
||||
M: { color: "--ink2", bg: "--sunk", underline: "sides", label: ROLES.M },
|
||||
N: { color: "--ink", bg: "--sunk", underline: "none", label: ROLES.N },
|
||||
};
|
||||
|
||||
export const isRole = (r: string): r is GlossRole => r in ROLE_STYLES;
|
||||
|
||||
/** Only the roles a message actually used, and only those with a label. */
|
||||
export function legendFor(roles: Iterable<GlossRole>): GlossRole[] {
|
||||
const seen = new Set<GlossRole>();
|
||||
for (const r of roles) if (ROLE_STYLES[r]?.label) seen.add(r);
|
||||
return [...seen];
|
||||
}
|
||||
251
app/src/ui/tutor/task.css
Normal file
251
app/src/ui/tutor/task.css
Normal file
@@ -0,0 +1,251 @@
|
||||
/* Exercise chrome, shared by all four task types. */
|
||||
|
||||
.task {
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-top: 2px solid var(--jade);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.task-h {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 9px 13px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.task-h .hint {
|
||||
margin-left: auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.task-b {
|
||||
padding: 14px 13px;
|
||||
}
|
||||
|
||||
.task-f {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 13px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--raise);
|
||||
}
|
||||
|
||||
.task-f .left {
|
||||
margin-right: auto;
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
/* ── translate ───────────────────────────────────────────────────── */
|
||||
|
||||
.ti {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.ti-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ti-row .q {
|
||||
font-size: 20px;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.ti-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── match ───────────────────────────────────────────────────────── */
|
||||
|
||||
.mt-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mt-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.mt-chip {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--raise);
|
||||
text-align: left;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mt-chip.ko {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.mt-chip:hover:not(:disabled) {
|
||||
border-color: var(--jade);
|
||||
}
|
||||
|
||||
.mt-chip[data-sel="1"] {
|
||||
background: var(--jade);
|
||||
border-color: var(--jade);
|
||||
color: var(--on-jade);
|
||||
}
|
||||
|
||||
.mt-chip:disabled {
|
||||
opacity: 0.32;
|
||||
border-style: dashed;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mt-pairs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 13px;
|
||||
padding-top: 11px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.mt-pair {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--jade-soft);
|
||||
border: 1px solid var(--jade);
|
||||
font-size: 13px;
|
||||
color: var(--jade-ink);
|
||||
}
|
||||
|
||||
.mt-pair button {
|
||||
color: var(--jade-ink);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mt-pair button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── build ───────────────────────────────────────────────────────── */
|
||||
|
||||
.bd {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.bd-en {
|
||||
font-size: 14px;
|
||||
color: var(--ink2);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.bd-slot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 8px;
|
||||
border: 1px dashed var(--line2);
|
||||
background: var(--sunk);
|
||||
}
|
||||
|
||||
.bd-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.bd-bank {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.chip-w {
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--paper);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.chip-w:hover:not(:disabled) {
|
||||
border-color: var(--jade);
|
||||
}
|
||||
|
||||
.chip-w[data-used="1"] {
|
||||
opacity: 0.3;
|
||||
border-style: dashed;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.bd-slot .chip-w {
|
||||
background: var(--jade-soft);
|
||||
border-color: var(--jade);
|
||||
}
|
||||
|
||||
/* ── choice ──────────────────────────────────────────────────────── */
|
||||
|
||||
.ch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ch-q {
|
||||
font-size: 19px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ch-opts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.ch-opts button {
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--raise);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ch-opts button.ko {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.ch-opts button:hover:not(:disabled) {
|
||||
border-color: var(--jade);
|
||||
}
|
||||
|
||||
.ch-opts button[data-sel="1"] {
|
||||
background: var(--jade);
|
||||
border-color: var(--jade);
|
||||
color: var(--on-jade);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ti-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 5px;
|
||||
}
|
||||
.ti-row .q {
|
||||
min-width: 0;
|
||||
}
|
||||
.mt-cols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
152
app/src/ui/tutor/tutor.css
Normal file
152
app/src/ui/tutor/tutor.css
Normal file
@@ -0,0 +1,152 @@
|
||||
/* The lesson layout: chat on the left, word rail on the right. */
|
||||
|
||||
.lesson-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.55fr 1fr;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-log {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-height: calc(100dvh - 260px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.msg .who {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.msg.you {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.msg.you .bubble {
|
||||
background: var(--jade-soft);
|
||||
border-color: var(--jade);
|
||||
max-width: 82%;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 11px 13px;
|
||||
background: var(--raise);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 14.5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bubble p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bubble .gap {
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
/* An example line, not prose. */
|
||||
.bubble .kline {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.bubble .ok {
|
||||
color: var(--jade);
|
||||
margin-right: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bubble .no {
|
||||
color: var(--jeok);
|
||||
margin-right: 6px;
|
||||
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;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.bubble.dots i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: var(--ink3);
|
||||
animation: blink 1.2s 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;
|
||||
}
|
||||
.chat-log {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user