feat(tutor): answer mode — the question, the field and the way forward
With a phone keyboard up there are about 350px left. Below 840px, focusing
an exercise's answer now strips the lesson back to what is being answered,
as the reworked artifact does: the nav, the header, the roadmap, the quick
replies and the earlier messages go, and an answer bar takes the composer's
place — ✕ · 2 / 4 · 가 · 한 · ↑ ↓ · 제출. Leaving the fields leaves the mode.
The 한글 keyboard follows the field touched last, message box or answer,
each with its own composer, so a half-built syllable stays in the field it
was typed in. The exercise picks the keyboard — up for recall, down for
translate, so one is not left up from the last — until he switches it
himself; that choice holds for the rest of the exercise. With it up, fields
ask for inputmode="none", and a focused field is refocused so the change
takes effect. 가 parks the field and opens the word list over the answer;
closing the list, or Back, returns to the field.
iOS ignores interactive-widget=resizes-content and lets the keyboard cover
the page; there the shell takes the visual viewport's height instead.
Also, from the plan's list:
· a send that gets nothing back takes the message out of the transcript
and puts a typed one back in the box; an exercise keeps its answers.
Stopped with nothing received, the message is withdrawn too. The answer
counts toward the unit once the tutor has it — counted before, a failed
and resent answer counted twice.
· Skip is local: the exercise steps aside and nothing is sent.
· a choice, tapped again, stays chosen.
· Enter moves to the next answer and submits from the last.
· an unreachable server says so, not "Failed to fetch".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -190,12 +190,23 @@ export async function editMeta(db: Db, k: string, v: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A turn the learner sent, or a reply he received. */
|
/** A turn the learner sent, or a reply he received. */
|
||||||
export async function editChatTurn(db: Db, role: string, body: string): Promise<void> {
|
export async function editChatTurn(db: Db, role: string, body: string): Promise<string> {
|
||||||
const t = now();
|
const t = now();
|
||||||
|
const id = uuidv7(t);
|
||||||
await db.run(
|
await db.run(
|
||||||
"INSERT INTO chat (id, role, body, created_at, updated_at, dirty, rev) VALUES (?, ?, ?, ?, ?, 1, 1)",
|
"INSERT INTO chat (id, role, body, created_at, updated_at, dirty, rev) VALUES (?, ?, ?, ?, ?, 1, 1)",
|
||||||
[uuidv7(t), role, body, t, t],
|
[id, role, body, t, t],
|
||||||
);
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take back one turn — a message whose reply never came. A deliberate
|
||||||
|
* deletion, so it is tombstoned: if a sync pushed the turn while the reply
|
||||||
|
* was pending, every other device drops it too.
|
||||||
|
*/
|
||||||
|
export async function editChatRemove(db: Db, id: string): Promise<void> {
|
||||||
|
await remove(db, "chat", "id = ?", [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -97,7 +97,10 @@ export function makeRemoteTutor(endpoint: TutorEndpoint): Sample {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (signal?.aborted) throw new SampleError("cancelled");
|
if (signal?.aborted) throw new SampleError("cancelled");
|
||||||
throw new SampleError("offline", err instanceof Error ? err.message : "Could not reach 선생님.");
|
// The browser's own words ("Failed to fetch", "Load failed") say
|
||||||
|
// nothing to a learner; the detail goes to the console.
|
||||||
|
console.warn("[tutor] request failed:", err);
|
||||||
|
throw new SampleError("offline", "선생님 could not be reached.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401) throw new SampleError("unauthorized", "The tutor token was rejected.");
|
if (res.status === 401) throw new SampleError("unauthorized", "The tutor token was rejected.");
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
Five destinations in a bottom bar on a phone, a rail on anything wider —
|
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. */
|
see shell/shell.css. The review screen is a layer over all of it. */
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
import { StoreProvider, type BootState } from "../state/store.js";
|
import { StoreProvider, type BootState } from "../state/store.js";
|
||||||
import { RouterProvider } from "./shell/router.js";
|
import { RouterProvider } from "./shell/router.js";
|
||||||
import { Nav } from "./shell/Nav.js";
|
import { Nav } from "./shell/Nav.js";
|
||||||
import { Routes } from "./routes.js";
|
import { Routes } from "./routes.js";
|
||||||
import { ReviewProvider } from "./review/useReview.js";
|
import { ReviewProvider } from "./review/useReview.js";
|
||||||
import { ReviewScreen } from "./review/ReviewScreen.js";
|
import { ReviewScreen } from "./review/ReviewScreen.js";
|
||||||
|
import { useKeyboardInset } from "./shell/useKeyboardInset.js";
|
||||||
import "../style/components.css";
|
import "../style/components.css";
|
||||||
import "./shell/shell.css";
|
import "./shell/shell.css";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
@@ -29,18 +31,29 @@ function Boot({ boot }: { boot: BootState }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Shell() {
|
||||||
|
const shell = useRef<HTMLDivElement>(null);
|
||||||
|
useKeyboardInset(shell);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="app" ref={shell}>
|
||||||
|
<main className="stage">
|
||||||
|
<Routes />
|
||||||
|
</main>
|
||||||
|
<Nav />
|
||||||
|
</div>
|
||||||
|
<ReviewScreen />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<StoreProvider fallback={(boot) => <Boot boot={boot} />}>
|
<StoreProvider fallback={(boot) => <Boot boot={boot} />}>
|
||||||
<RouterProvider>
|
<RouterProvider>
|
||||||
<ReviewProvider>
|
<ReviewProvider>
|
||||||
<div className="app">
|
<Shell />
|
||||||
<main className="stage">
|
|
||||||
<Routes />
|
|
||||||
</main>
|
|
||||||
<Nav />
|
|
||||||
</div>
|
|
||||||
<ReviewScreen />
|
|
||||||
</ReviewProvider>
|
</ReviewProvider>
|
||||||
</RouterProvider>
|
</RouterProvider>
|
||||||
</StoreProvider>
|
</StoreProvider>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
listens to it passively, so preventDefault() there does nothing. A
|
listens to it passively, so preventDefault() there does nothing. A
|
||||||
cancelled pointerdown still clicks; it only stops the focus change. */
|
cancelled pointerdown still clicks; it only stops the focus change. */
|
||||||
|
|
||||||
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Composer, KEYBOARD } from "@lib/hangul.js";
|
import { Composer, KEYBOARD } from "@lib/hangul.js";
|
||||||
import "./keyboard.css";
|
import "./keyboard.css";
|
||||||
|
|
||||||
@@ -68,22 +68,29 @@ export function useComposer(): ComposerHandle {
|
|||||||
export interface KeyboardProps {
|
export interface KeyboardProps {
|
||||||
composer: ComposerHandle;
|
composer: ComposerHandle;
|
||||||
/**
|
/**
|
||||||
* The field's setState, not a plain callback. Every key is applied through
|
* A functional update — a field's setState will do. Every key is applied
|
||||||
* the functional form so the composer always works from the CURRENT value:
|
* from the CURRENT value: reading a `value` prop instead would go stale
|
||||||
* reading a `value` prop instead would go stale between a fast pair of
|
* between a fast pair of taps, and the second key would compose against
|
||||||
* taps, and the second key would compose against the wrong text.
|
* the wrong text.
|
||||||
*/
|
*/
|
||||||
onChange: Dispatch<SetStateAction<string>>;
|
onChange: (fn: (prev: string) => string) => void;
|
||||||
/** Shown in the footer so it is obvious which field is being typed into. */
|
/** Shown in the footer so it is obvious which field is being typed into. */
|
||||||
target?: string;
|
target?: string;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
|
/** After each key: put the caret back in the field if it wandered off. */
|
||||||
|
onKey?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Keyboard({ composer, onChange, target, onDismiss }: KeyboardProps) {
|
export function Keyboard({ composer, onChange, target, onDismiss, onKey }: KeyboardProps) {
|
||||||
const [shift, setShift] = useState(false);
|
const [shift, setShift] = useState(false);
|
||||||
|
|
||||||
|
const apply = (fn: (prev: string) => string) => {
|
||||||
|
onChange(fn);
|
||||||
|
onKey?.();
|
||||||
|
};
|
||||||
|
|
||||||
const press = (jamo: string) => {
|
const press = (jamo: string) => {
|
||||||
onChange((prev) => composer.key(prev, jamo));
|
apply((prev) => composer.key(prev, jamo));
|
||||||
setShift(false); // shift is one-shot, like a real 두벌식 layout
|
setShift(false); // shift is one-shot, like a real 두벌식 layout
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,15 +122,15 @@ export function Keyboard({ composer, onChange, target, onDismiss }: KeyboardProp
|
|||||||
>
|
>
|
||||||
쌍자음 ⇧
|
쌍자음 ⇧
|
||||||
</button>
|
</button>
|
||||||
<button className="wide" onClick={() => onChange((prev) => composer.text(prev, " "))}>
|
<button className="wide" onClick={() => apply((prev) => composer.text(prev, " "))}>
|
||||||
space
|
space
|
||||||
</button>
|
</button>
|
||||||
{["?", "!", "."].map((t) => (
|
{["?", "!", "."].map((t) => (
|
||||||
<button key={t} onClick={() => onChange((prev) => composer.text(prev, t))}>
|
<button key={t} onClick={() => apply((prev) => composer.text(prev, t))}>
|
||||||
{t}
|
{t}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button className="wide" onClick={() => onChange((prev) => composer.back(prev))}>
|
<button className="wide" onClick={() => apply((prev) => composer.back(prev))}>
|
||||||
← delete
|
← delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
app/src/ui/shell/useKeyboardInset.ts
Normal file
41
app/src/ui/shell/useKeyboardInset.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/* The virtual keyboard, where the browser will not make room for it.
|
||||||
|
|
||||||
|
The viewport meta asks for interactive-widget=resizes-content, and
|
||||||
|
Chromium on Android honours it: the layout viewport shrinks and the svh
|
||||||
|
shell shrinks with it. iOS does not implement it — the keyboard simply
|
||||||
|
covers the bottom of the page, answer field and all. There the visual
|
||||||
|
viewport still reports what is left, so the shell takes exactly that
|
||||||
|
height, as the artifact does. */
|
||||||
|
|
||||||
|
import { useEffect, type RefObject } from "react";
|
||||||
|
|
||||||
|
/** Below this, a difference is browser chrome, not a keyboard. */
|
||||||
|
const KEYBOARD_PX = 60;
|
||||||
|
|
||||||
|
export function useKeyboardInset(shell: RefObject<HTMLElement | null>): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) return;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
frame = 0;
|
||||||
|
const el = shell.current;
|
||||||
|
if (!el) return;
|
||||||
|
const covered = Math.round(window.innerHeight - vv.height - vv.offsetTop);
|
||||||
|
el.style.height = covered > KEYBOARD_PX ? `${Math.round(vv.height)}px` : "";
|
||||||
|
};
|
||||||
|
const queue = () => {
|
||||||
|
if (!frame) frame = requestAnimationFrame(sync);
|
||||||
|
};
|
||||||
|
|
||||||
|
vv.addEventListener("resize", queue);
|
||||||
|
vv.addEventListener("scroll", queue);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener("resize", queue);
|
||||||
|
vv.removeEventListener("scroll", queue);
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [shell]);
|
||||||
|
}
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
/* The foot of the lesson: quick replies, the message field, the keyboard.
|
/* The foot of the lesson: quick replies, the message field, and — in
|
||||||
|
answer mode — the answer bar in their place.
|
||||||
|
|
||||||
The quick replies are the artifact's, and every one of them is a request
|
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.
|
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. */
|
시작 is always first; before the lesson has started it is the one to press. */
|
||||||
|
|
||||||
import { useLayoutEffect, useRef, type Dispatch, type SetStateAction } from "react";
|
import {
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
type Dispatch,
|
||||||
|
type PointerEvent,
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
type SetStateAction,
|
||||||
|
} from "react";
|
||||||
import type { FlatUnit } from "@lib/gate.js";
|
import type { FlatUnit } from "@lib/gate.js";
|
||||||
import { Keyboard, useComposer } from "../keyboard/Keyboard.js";
|
import { useComposer } from "../keyboard/Keyboard.js";
|
||||||
|
import { useFields, type KeyField } from "./fields.js";
|
||||||
|
|
||||||
export const startMessage = (u: FlatUnit) =>
|
export const startMessage = (u: FlatUnit) =>
|
||||||
`Let's start unit ${u.id} ${u.ko} (${u.name}). Give me the full introduction, then a first exercise.`;
|
`Let's start unit ${u.id} ${u.ko} (${u.name}). Give me the full introduction, then a first exercise.`;
|
||||||
@@ -39,13 +49,20 @@ export interface ComposerProps {
|
|||||||
draft: string;
|
draft: string;
|
||||||
setDraft: Dispatch<SetStateAction<string>>;
|
setDraft: Dispatch<SetStateAction<string>>;
|
||||||
keyboard: boolean;
|
keyboard: boolean;
|
||||||
setKeyboard: (on: boolean) => void;
|
onToggleKeyboard: () => void;
|
||||||
|
/** Kept pointing at the message field, the keyboard's target by default. */
|
||||||
|
messageField: RefObject<KeyField | null>;
|
||||||
/** Something to say instead of the hint: a retry, an error, the wait. */
|
/** Something to say instead of the hint: a retry, an error, the wait. */
|
||||||
note: string | null;
|
note: string | null;
|
||||||
onSend: (text: string) => void;
|
onSend: (text: string) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
|
/** Under the message field: the answer bar and the keyboard. */
|
||||||
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A button that must not take focus from the field being typed in. */
|
||||||
|
const keepFocus = (e: PointerEvent) => e.preventDefault();
|
||||||
|
|
||||||
export function Composer({
|
export function Composer({
|
||||||
unit,
|
unit,
|
||||||
busy,
|
busy,
|
||||||
@@ -53,13 +70,25 @@ export function Composer({
|
|||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
keyboard,
|
keyboard,
|
||||||
setKeyboard,
|
onToggleKeyboard,
|
||||||
|
messageField,
|
||||||
note,
|
note,
|
||||||
onSend,
|
onSend,
|
||||||
onStop,
|
onStop,
|
||||||
|
children,
|
||||||
}: ComposerProps) {
|
}: ComposerProps) {
|
||||||
const field = useRef<HTMLTextAreaElement>(null);
|
const field = useRef<HTMLTextAreaElement>(null);
|
||||||
const composer = useComposer();
|
const composer = useComposer();
|
||||||
|
const fields = useFields();
|
||||||
|
const self: KeyField = {
|
||||||
|
id: "message",
|
||||||
|
kind: "message",
|
||||||
|
script: "en",
|
||||||
|
apply: (fn) => setDraft(fn),
|
||||||
|
composer,
|
||||||
|
el: () => field.current,
|
||||||
|
};
|
||||||
|
messageField.current = self;
|
||||||
|
|
||||||
// Grow with the text, to the cap in the stylesheet.
|
// Grow with the text, to the cap in the stylesheet.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -103,6 +132,7 @@ export function Composer({
|
|||||||
aria-label="Answer or ask 선생님"
|
aria-label="Answer or ask 선생님"
|
||||||
// The on-screen keyboard is up: the system one stays down.
|
// The on-screen keyboard is up: the system one stays down.
|
||||||
inputMode={keyboard ? "none" : undefined}
|
inputMode={keyboard ? "none" : undefined}
|
||||||
|
onFocus={() => fields.onFocus(self)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
composer.onExternalInput();
|
composer.onExternalInput();
|
||||||
setDraft(e.target.value);
|
setDraft(e.target.value);
|
||||||
@@ -119,10 +149,8 @@ export function Composer({
|
|||||||
aria-pressed={keyboard}
|
aria-pressed={keyboard}
|
||||||
aria-label="한글 keyboard"
|
aria-label="한글 keyboard"
|
||||||
title="한글 keyboard"
|
title="한글 keyboard"
|
||||||
onClick={() => {
|
onPointerDown={keepFocus}
|
||||||
setKeyboard(!keyboard);
|
onClick={onToggleKeyboard}
|
||||||
field.current?.focus({ preventScroll: true });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
한
|
한
|
||||||
</button>
|
</button>
|
||||||
@@ -143,14 +171,7 @@ export function Composer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{keyboard && (
|
{children}
|
||||||
<Keyboard
|
|
||||||
composer={composer}
|
|
||||||
onChange={setDraft}
|
|
||||||
target="message"
|
|
||||||
onDismiss={() => setKeyboard(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="chat-note" data-say={note ? "1" : undefined} aria-live="polite">
|
<div className="chat-note" data-say={note ? "1" : undefined} aria-live="polite">
|
||||||
<span>{note ?? "Enter sends · Shift + Enter for a new line"}</span>
|
<span>{note ?? "Enter sends · Shift + Enter for a new line"}</span>
|
||||||
@@ -158,3 +179,75 @@ export function Composer({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnswerBarProps {
|
||||||
|
/** "2 / 4" on a field, "3 of 4" filled while none has focus. */
|
||||||
|
position: string;
|
||||||
|
keyboard: boolean;
|
||||||
|
wordsOpen: boolean;
|
||||||
|
canPrev: boolean;
|
||||||
|
canNext: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onDone: () => void;
|
||||||
|
onWords: () => void;
|
||||||
|
onKeyboard: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer mode's whole toolbar: the way out, the way forward, and help. */
|
||||||
|
export function AnswerBar(p: AnswerBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="answerbar" role="toolbar" aria-label="Answering">
|
||||||
|
{/* With the 한글 keyboard up, 한 closes it; a second button that does
|
||||||
|
the same has no room here. */}
|
||||||
|
{!p.keyboard && (
|
||||||
|
<button className="btn" aria-label="Close the keyboard" title="Close the keyboard" onClick={p.onDone}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="n tnum">{p.position}</span>
|
||||||
|
<button
|
||||||
|
className="btn ko"
|
||||||
|
aria-label="Word list"
|
||||||
|
title="Word list"
|
||||||
|
aria-pressed={p.wordsOpen}
|
||||||
|
onClick={p.onWords}
|
||||||
|
>
|
||||||
|
가
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn ko"
|
||||||
|
aria-label="한글 keyboard"
|
||||||
|
title="한글 keyboard"
|
||||||
|
aria-pressed={p.keyboard}
|
||||||
|
onPointerDown={keepFocus}
|
||||||
|
onClick={p.onKeyboard}
|
||||||
|
>
|
||||||
|
한
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
aria-label="Previous answer"
|
||||||
|
disabled={!p.canPrev}
|
||||||
|
onPointerDown={keepFocus}
|
||||||
|
onClick={p.onPrev}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
aria-label="Next answer"
|
||||||
|
disabled={!p.canNext}
|
||||||
|
onPointerDown={keepFocus}
|
||||||
|
onClick={p.onNext}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button className="cta ko" disabled={p.busy} onPointerDown={keepFocus} onClick={p.onSubmit}>
|
||||||
|
제출
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
and sent; 선생님 marks them. That is deliberate: the feedback is the
|
and sent; 선생님 marks them. That is deliberate: the feedback is the
|
||||||
lesson, and a client-side ✗ would pre-empt it. */
|
lesson, and a client-side ✗ would pre-empt it. */
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import type {
|
import type {
|
||||||
BuildTask,
|
BuildTask,
|
||||||
ChoiceTask,
|
ChoiceTask,
|
||||||
@@ -21,6 +21,8 @@ import type {
|
|||||||
} from "@lib/blocks.js";
|
} from "@lib/blocks.js";
|
||||||
import { answerText } from "@lib/blocks.js";
|
import { answerText } from "@lib/blocks.js";
|
||||||
import { recallLetterBlock } from "../../domain/letters.js";
|
import { recallLetterBlock } from "../../domain/letters.js";
|
||||||
|
import { useComposer } from "../keyboard/Keyboard.js";
|
||||||
|
import { useFields, type KeyField } from "./fields.js";
|
||||||
import "./task.css";
|
import "./task.css";
|
||||||
|
|
||||||
/* A shuffle seed from the turn's id — a string now, see db/ids.ts. */
|
/* A shuffle seed from the turn's id — a string now, see db/ids.ts. */
|
||||||
@@ -60,94 +62,147 @@ export interface TaskProps {
|
|||||||
/** Words the learner revealed in the rail, reported with the answer. */
|
/** Words the learner revealed in the rail, reported with the answer. */
|
||||||
lookups: string[];
|
lookups: string[];
|
||||||
onSubmit: (message: string) => void;
|
onSubmit: (message: string) => void;
|
||||||
onSkip: () => void;
|
/** Set to this exercise's submit while it is the one open — the answer
|
||||||
|
bar's 제출 presses it. */
|
||||||
|
submitRef?: RefObject<(() => void) | null>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Already answered: keep the exercise and the answers on screen, but
|
/** Already answered: keep the exercise and the answers on screen, but
|
||||||
show that it is finished rather than the submit controls. */
|
show that it is finished rather than the submit controls. */
|
||||||
spent?: boolean;
|
spent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── translate ───────────────────────────────────────────────────── */
|
/* ── the answer field ────────────────────────────────────────────── */
|
||||||
|
|
||||||
function Translate({ task, answers, setAnswers, disabled, onEnter }: {
|
/**
|
||||||
task: TranslateTask;
|
* One answer. It tells the lesson when it has focus, so the 한글 keyboard
|
||||||
answers: string[];
|
* types into it and answer mode can begin; with that keyboard up it asks
|
||||||
setAnswers: (a: string[]) => void;
|
* the system for none. Enter moves to the next answer, and on the last one
|
||||||
|
* submits.
|
||||||
|
*/
|
||||||
|
function AnswerField({
|
||||||
|
id,
|
||||||
|
script,
|
||||||
|
value,
|
||||||
|
update,
|
||||||
|
disabled,
|
||||||
|
placeholder,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
onEnter,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
script: KeyField["script"];
|
||||||
|
value: string;
|
||||||
|
update: (fn: (prev: string) => string) => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
onEnter: () => void;
|
onEnter: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const composer = useComposer();
|
||||||
|
const fields = useFields();
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const field: KeyField = { id, kind: "answer", script, apply: update, composer, el: () => ref.current };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
data-field={id}
|
||||||
|
className={className}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label={label}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
spellCheck={false}
|
||||||
|
enterKeyHint="next"
|
||||||
|
inputMode={fields.keyboard ? "none" : undefined}
|
||||||
|
onFocus={() => fields.onFocus(field)}
|
||||||
|
onBlur={() => fields.onBlur(field)}
|
||||||
|
onChange={(e) => {
|
||||||
|
composer.onExternalInput();
|
||||||
|
const v = e.target.value;
|
||||||
|
update(() => v);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
onEnter();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldsProps {
|
||||||
|
turnId: string;
|
||||||
|
answers: string[];
|
||||||
|
update: (i: number, fn: (prev: string) => string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
onEnter: (i: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── translate ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function Translate({ task, turnId, answers, update, disabled, onEnter }: FieldsProps & { task: TranslateTask }) {
|
||||||
|
const { answering, activeId } = useFields();
|
||||||
return (
|
return (
|
||||||
<div className="ti">
|
<div className="ti">
|
||||||
{task.items.map((it, i) => (
|
{task.items.map((it, i) => {
|
||||||
<div className="ti-row" key={i}>
|
const id = `${turnId}:${i}`;
|
||||||
<span className="q ko">{it.q}</span>
|
return (
|
||||||
<input
|
<div className="ti-row" key={i} data-active={answering && activeId === id ? "1" : undefined}>
|
||||||
type="text"
|
<span className="q ko">{it.q}</span>
|
||||||
value={answers[i] ?? ""}
|
<AnswerField
|
||||||
disabled={disabled}
|
id={id}
|
||||||
placeholder="…"
|
script="en"
|
||||||
aria-label={`Your answer for ${it.q}`}
|
value={answers[i] ?? ""}
|
||||||
onChange={(e) => {
|
update={(fn) => update(i, fn)}
|
||||||
const next = [...answers];
|
disabled={disabled}
|
||||||
next[i] = e.target.value;
|
placeholder="…"
|
||||||
setAnswers(next);
|
label={`Your answer for ${it.q}`}
|
||||||
}}
|
onEnter={() => onEnter(i)}
|
||||||
onKeyDown={(e) => {
|
/>
|
||||||
if (e.key === "Enter") {
|
</div>
|
||||||
e.preventDefault();
|
);
|
||||||
onEnter();
|
})}
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── recall ──────────────────────────────────────────────────────── */
|
/* ── recall ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* English prompt; he writes the 한글. The keyboard wiring and the
|
/* English prompt; he writes the 한글, so answer mode brings up the 한글
|
||||||
letter-level check arrive with the answer mode — this renders the task
|
keyboard for it. */
|
||||||
so it can be answered at all. */
|
function Recall({ task, turnId, answers, update, disabled, onEnter }: FieldsProps & { task: RecallTask }) {
|
||||||
function Recall({ task, answers, setAnswers, disabled, onEnter }: {
|
const { answering, activeId } = useFields();
|
||||||
task: RecallTask;
|
|
||||||
answers: string[];
|
|
||||||
setAnswers: (a: string[]) => void;
|
|
||||||
disabled: boolean;
|
|
||||||
onEnter: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="ti">
|
<div className="ti">
|
||||||
{task.items.map((it, i) => (
|
{task.items.map((it, i) => {
|
||||||
<div className="ti-row recall" key={i}>
|
const id = `${turnId}:${i}`;
|
||||||
<span className="q">
|
return (
|
||||||
{it.q}
|
<div className="ti-row recall" key={i} data-active={answering && activeId === id ? "1" : undefined}>
|
||||||
{it.hint && <span className="rc-hint"> · {it.hint}</span>}
|
<span className="q">
|
||||||
</span>
|
{it.q}
|
||||||
<input
|
{it.hint && <span className="rc-hint"> · {it.hint}</span>}
|
||||||
type="text"
|
</span>
|
||||||
className="ko"
|
<AnswerField
|
||||||
value={answers[i] ?? ""}
|
id={id}
|
||||||
disabled={disabled}
|
script="ko"
|
||||||
placeholder="한국어로…"
|
className="ko"
|
||||||
autoComplete="off"
|
value={answers[i] ?? ""}
|
||||||
spellCheck={false}
|
update={(fn) => update(i, fn)}
|
||||||
aria-label={`Write ${it.q} in Korean`}
|
disabled={disabled}
|
||||||
onChange={(e) => {
|
placeholder="한국어로…"
|
||||||
const next = [...answers];
|
label={`Write ${it.q} in Korean`}
|
||||||
next[i] = e.target.value;
|
onEnter={() => onEnter(i)}
|
||||||
setAnswers(next);
|
/>
|
||||||
}}
|
</div>
|
||||||
onKeyDown={(e) => {
|
);
|
||||||
if (e.key === "Enter") {
|
})}
|
||||||
e.preventDefault();
|
|
||||||
onEnter();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -350,8 +405,10 @@ function Choice({ task, picks, setPicks, disabled }: {
|
|||||||
data-sel={picks[i] === j ? "1" : undefined}
|
data-sel={picks[i] === j ? "1" : undefined}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
// Tapping your pick again keeps it. Untoggling on a second
|
||||||
|
// tap cleared answers that a double-tap meant to confirm.
|
||||||
const next = [...picks];
|
const next = [...picks];
|
||||||
next[i] = picks[i] === j ? null : j;
|
next[i] = j;
|
||||||
setPicks(next);
|
setPicks(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -373,11 +430,23 @@ export function TaskHost({
|
|||||||
turnId,
|
turnId,
|
||||||
lookups,
|
lookups,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onSkip,
|
submitRef,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
spent = false,
|
spent = false,
|
||||||
}: TaskProps) {
|
}: TaskProps) {
|
||||||
const [answers, setAnswers] = useState<string[]>([]);
|
const [answers, setAnswers] = useState<string[]>([]);
|
||||||
|
/* Skip is local, as in the artifact: the exercise steps aside and nothing
|
||||||
|
is sent. It used to send "Let's skip that one and just talk", which
|
||||||
|
spent a turn — and a round of the tutor's attention — on saying no. */
|
||||||
|
const [skipped, setSkipped] = useState(false);
|
||||||
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const update = (i: number, fn: (prev: string) => string) =>
|
||||||
|
setAnswers((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[i] = fn(prev[i] ?? "");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
const [done, setDone] = useState<Pairing[]>([]);
|
const [done, setDone] = useState<Pairing[]>([]);
|
||||||
const [selected, setSelected] = useState<number | null>(null);
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
const [placed, setPlaced] = useState<string[][]>(() =>
|
const [placed, setPlaced] = useState<string[][]>(() =>
|
||||||
@@ -425,8 +494,33 @@ export function TaskHost({
|
|||||||
onSubmit(text);
|
onSubmit(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Enter moves to the next answer; on the last one it submits. */
|
||||||
|
const onEnter = (i: number) => {
|
||||||
|
const fields = root.current?.querySelectorAll<HTMLInputElement>("input[data-field]") ?? [];
|
||||||
|
const next = fields[i + 1];
|
||||||
|
if (next) next.focus();
|
||||||
|
else submit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = !spent && !skipped;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!submitRef || !open) return;
|
||||||
|
submitRef.current = submit;
|
||||||
|
return () => {
|
||||||
|
if (submitRef.current === submit) submitRef.current = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (skipped) {
|
||||||
|
return (
|
||||||
|
<div className="task" data-spent="true">
|
||||||
|
<div className="task-spent">exercise skipped — ask for another whenever you like</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="task" data-spent={spent}>
|
<div className="task" data-spent={spent} ref={root}>
|
||||||
<div className="task-h">
|
<div className="task-h">
|
||||||
<span className="eyebrow">연습 · {task.type}</span>
|
<span className="eyebrow">연습 · {task.type}</span>
|
||||||
<span className="hint">{LABEL[task.type]}</span>
|
<span className="hint">{LABEL[task.type]}</span>
|
||||||
@@ -436,19 +530,21 @@ export function TaskHost({
|
|||||||
{task.type === "translate" && (
|
{task.type === "translate" && (
|
||||||
<Translate
|
<Translate
|
||||||
task={task}
|
task={task}
|
||||||
|
turnId={turnId}
|
||||||
answers={answers}
|
answers={answers}
|
||||||
setAnswers={setAnswers}
|
update={update}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onEnter={submit}
|
onEnter={onEnter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{task.type === "recall" && (
|
{task.type === "recall" && (
|
||||||
<Recall
|
<Recall
|
||||||
task={task}
|
task={task}
|
||||||
|
turnId={turnId}
|
||||||
answers={answers}
|
answers={answers}
|
||||||
setAnswers={setAnswers}
|
update={update}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onEnter={submit}
|
onEnter={onEnter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{task.type === "match" && (
|
{task.type === "match" && (
|
||||||
@@ -483,8 +579,8 @@ export function TaskHost({
|
|||||||
<span className="left tnum">
|
<span className="left tnum">
|
||||||
{filled.n} of {filled.of} filled in
|
{filled.n} of {filled.of} filled in
|
||||||
</span>
|
</span>
|
||||||
<button className="btn sm" onClick={onSkip} disabled={disabled}>
|
<button className="btn sm" onClick={() => setSkipped(true)} disabled={disabled}>
|
||||||
Skip · just talk
|
Skip
|
||||||
</button>
|
</button>
|
||||||
<button className="btn sm primary" onClick={submit} disabled={disabled}>
|
<button className="btn sm primary" onClick={submit} disabled={disabled}>
|
||||||
Submit answers
|
Submit answers
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
|
|
||||||
The layout is a column that fits the screen exactly: the roadmap strip,
|
The layout is a column that fits the screen exactly: the roadmap strip,
|
||||||
the conversation (the one thing that scrolls), and the composer. The
|
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. */
|
word list is a docked column at 840px and up, and a sheet below that.
|
||||||
|
Below 840px an answer field takes the screen over — see fields.tsx. */
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||||
import { useStore } from "../../state/store.js";
|
import { useStore } from "../../state/store.js";
|
||||||
import { editChatClear, editChatTurn, pruneChat, seedChatTurn } from "../../db/writes.js";
|
import { editChatClear, editChatRemove, editChatTurn, pruneChat, seedChatTurn } from "../../db/writes.js";
|
||||||
import { parseMessage } from "../../domain/gloss.js";
|
import { parseMessage } from "../../domain/gloss.js";
|
||||||
import {
|
import {
|
||||||
gateFor,
|
gateFor,
|
||||||
@@ -49,7 +50,9 @@ import { TaskHost } from "./TaskHost.js";
|
|||||||
import { RailPanel, collectWords, type RailWord } from "./WordRail.js";
|
import { RailPanel, collectWords, type RailWord } from "./WordRail.js";
|
||||||
import { WordSheet, type Detent } from "./WordSheet.js";
|
import { WordSheet, type Detent } from "./WordSheet.js";
|
||||||
import { RoadStrip } from "./RoadStrip.js";
|
import { RoadStrip } from "./RoadStrip.js";
|
||||||
import { Composer } from "./Composer.js";
|
import { AnswerBar, Composer } from "./Composer.js";
|
||||||
|
import { FieldsContext, liveAnswerFields, type Fields, type KeyField } from "./fields.js";
|
||||||
|
import { Keyboard } from "../keyboard/Keyboard.js";
|
||||||
import { RouteHead, useRouteActive } from "../shell/Route.js";
|
import { RouteHead, useRouteActive } from "../shell/Route.js";
|
||||||
import { useLayer } from "../shell/router.js";
|
import { useLayer } from "../shell/router.js";
|
||||||
import { Pop } from "../shell/Pop.js";
|
import { Pop } from "../shell/Pop.js";
|
||||||
@@ -183,8 +186,31 @@ export function TutorTab() {
|
|||||||
const log = useRef<HTMLDivElement>(null);
|
const log = useRef<HTMLDivElement>(null);
|
||||||
/** The log follows new text while this is set; see the follow effect. */
|
/** The log follows new text while this is set; see the follow effect. */
|
||||||
const stick = useRef(true);
|
const stick = useRef(true);
|
||||||
|
/** Where the log was last scrolled to; see onLogScroll. */
|
||||||
|
const lastTop = useRef(0);
|
||||||
const menuButton = useRef<HTMLButtonElement>(null);
|
const menuButton = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
/* ── the keyboard's field, and answer mode ── */
|
||||||
|
|
||||||
|
const [answering, setAnswering] = useState(false);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
/** The word list is up over an answer and no field has focus. */
|
||||||
|
const [parked, setParked] = useState(false);
|
||||||
|
/** The field the 한글 keyboard types into; the message box until another is touched. */
|
||||||
|
const fieldRef = useRef<KeyField | null>(null);
|
||||||
|
const messageField = useRef<KeyField | null>(null);
|
||||||
|
/** The word sheet is up over an answer: stay in answer mode, and go back to this field after. */
|
||||||
|
const keepAnswering = useRef(false);
|
||||||
|
const resumeField = useRef<KeyField | null>(null);
|
||||||
|
/** He switched the keyboard himself this exercise: the exercise no longer picks it. */
|
||||||
|
const kbManual = useRef(false);
|
||||||
|
const exitTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
/** The open exercise's submit, for the answer bar's 제출. */
|
||||||
|
const submitTask = useRef<(() => void) | null>(null);
|
||||||
|
/** Re-render the answer bar's count as answers are typed. */
|
||||||
|
const [, recount] = useReducer((n: number) => n + 1, 0);
|
||||||
|
const coarse = useMedia("(pointer: coarse)");
|
||||||
|
|
||||||
const unit = currentUnit(progress);
|
const unit = currentUnit(progress);
|
||||||
const unitId = progress.current;
|
const unitId = progress.current;
|
||||||
|
|
||||||
@@ -303,11 +329,30 @@ export function TutorTab() {
|
|||||||
/* ── sending ── */
|
/* ── sending ── */
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
async (body: string, { lookups = [] }: { lookups?: string[] } = {}) => {
|
async (
|
||||||
|
body: string,
|
||||||
|
{
|
||||||
|
lookups = [],
|
||||||
|
restore = false,
|
||||||
|
}: {
|
||||||
|
lookups?: string[];
|
||||||
|
/** Typed into the box: if nothing comes back, put it back there. */
|
||||||
|
restore?: boolean;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
if (inFlight.current) return;
|
if (inFlight.current) return;
|
||||||
inFlight.current = true;
|
inFlight.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setNote("선생님 is reading your answer…");
|
setNote("선생님 is reading your answer…");
|
||||||
|
// Out of answer mode, and the keyboard goes back to the message box:
|
||||||
|
// the exercise is done with.
|
||||||
|
clearTimeout(exitTimer.current);
|
||||||
|
keepAnswering.current = false;
|
||||||
|
resumeField.current = null;
|
||||||
|
setAnswering(false);
|
||||||
|
setParked(false);
|
||||||
|
fieldRef.current = null;
|
||||||
|
setActiveId(null);
|
||||||
|
|
||||||
// What he looked up belongs to THIS answer; a typed message has none.
|
// What he looked up belongs to THIS answer; a typed message has none.
|
||||||
lastLookups.current = lookups;
|
lastLookups.current = lookups;
|
||||||
@@ -316,12 +361,18 @@ export function TutorTab() {
|
|||||||
// the reply, so follow it again.
|
// the reply, so follow it again.
|
||||||
stick.current = true;
|
stick.current = true;
|
||||||
|
|
||||||
// Counted before the request, as the artifact does: the answer is
|
const turnId = await editChatTurn(db, "user", body);
|
||||||
// given whether or not the reply arrives.
|
|
||||||
await noteAnswer(db, progress, body);
|
|
||||||
await editChatTurn(db, "user", body);
|
|
||||||
await loadTurns();
|
await loadTurns();
|
||||||
|
|
||||||
|
/* Nothing came back. Take the message out again, so the thread does
|
||||||
|
not collect his turns with no replies between them, and put a typed
|
||||||
|
one back in the box. An exercise's answers are still in its fields. */
|
||||||
|
const withdraw = async () => {
|
||||||
|
await editChatRemove(db, turnId);
|
||||||
|
setTurns(await readTurns());
|
||||||
|
if (restore) setDraft((d) => d || body);
|
||||||
|
};
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abort.current = controller;
|
abort.current = controller;
|
||||||
|
|
||||||
@@ -346,6 +397,10 @@ export function TutorTab() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Counted once the tutor has it. Counted before the request, as the
|
||||||
|
// artifact does, a message that failed and was sent again counted twice.
|
||||||
|
await noteAnswer(db, progress, body);
|
||||||
|
|
||||||
/* Write first, then swap the placeholder for the real turn in a
|
/* Write first, then swap the placeholder for the real turn in a
|
||||||
single render. Clearing `streaming` before these awaits unmounted
|
single render. Clearing `streaming` before these awaits unmounted
|
||||||
the message, fell back to the typing dots, and remounted it once
|
the message, fell back to the typing dots, and remounted it once
|
||||||
@@ -375,23 +430,36 @@ export function TutorTab() {
|
|||||||
if (result.parsed.task) {
|
if (result.parsed.task) {
|
||||||
setRevealed(new Set());
|
setRevealed(new Set());
|
||||||
setDetent((d) => (d === "full" ? "peek" : d));
|
setDetent((d) => (d === "full" ? "peek" : d));
|
||||||
|
kbManual.current = false;
|
||||||
}
|
}
|
||||||
setRecent(applied.recent);
|
setRecent(applied.recent);
|
||||||
await refreshProgress();
|
await refreshProgress();
|
||||||
invalidate();
|
invalidate();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as SampleError;
|
const e = err as SampleError;
|
||||||
|
setStreaming(null);
|
||||||
if (e?.code === "cancelled") {
|
if (e?.code === "cancelled") {
|
||||||
if (e.text) {
|
if (e.text) {
|
||||||
|
// Stopped part-way: what arrived is kept, and so is the answer.
|
||||||
|
await noteAnswer(db, progress, body);
|
||||||
await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`);
|
await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`);
|
||||||
setTurns(await readTurns());
|
setTurns(await readTurns());
|
||||||
|
} else {
|
||||||
|
await withdraw();
|
||||||
}
|
}
|
||||||
setStreaming(null);
|
|
||||||
setBusy(false);
|
|
||||||
setNote(null);
|
setNote(null);
|
||||||
|
} else if (e?.text) {
|
||||||
|
// Cut off part-way by an error: what arrived is kept.
|
||||||
|
await noteAnswer(db, progress, body);
|
||||||
|
await editChatTurn(db, "assistant", e.text);
|
||||||
|
setTurns(await readTurns());
|
||||||
|
setNote(`${e.message ?? "The reply was cut off"} — send again to finish it.`);
|
||||||
} else {
|
} else {
|
||||||
setStreaming(null);
|
await withdraw();
|
||||||
setNote(e?.message ?? "The tutor could not be reached.");
|
const why = e?.message ?? "The tutor could not be reached";
|
||||||
|
setNote(
|
||||||
|
`${why.replace(/[.!]$/, "")} — ${restore ? "your message is back in the box" : "your answers are still there"}. Try again.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
abort.current = null;
|
abort.current = null;
|
||||||
@@ -516,7 +584,6 @@ export function TutorTab() {
|
|||||||
alone, that read as the learner scrolling away, and the log stopped
|
alone, that read as the learner scrolling away, and the log stopped
|
||||||
following mid-reply. Growing content never moves scrollTop up; a
|
following mid-reply. Growing content never moves scrollTop up; a
|
||||||
finger does. */
|
finger does. */
|
||||||
const lastTop = useRef(0);
|
|
||||||
|
|
||||||
const onLogScroll = useCallback(() => {
|
const onLogScroll = useCallback(() => {
|
||||||
const el = log.current;
|
const el = log.current;
|
||||||
@@ -552,6 +619,147 @@ export function TutorTab() {
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [follow]);
|
}, [follow]);
|
||||||
|
|
||||||
|
/* ── answer mode ── */
|
||||||
|
|
||||||
|
/** Bring an answer's row to the top of the log, where the keyboard cannot cover it. */
|
||||||
|
const showRow = useCallback((input: HTMLElement | null) => {
|
||||||
|
const el = log.current;
|
||||||
|
const row = input?.closest(".ti-row");
|
||||||
|
if (!el || !(row instanceof HTMLElement)) return;
|
||||||
|
stick.current = false;
|
||||||
|
el.scrollTop += row.getBoundingClientRect().top - el.getBoundingClientRect().top - 10;
|
||||||
|
lastTop.current = el.scrollTop;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const exitAnswering = useCallback(() => {
|
||||||
|
clearTimeout(exitTimer.current);
|
||||||
|
keepAnswering.current = false;
|
||||||
|
resumeField.current = null;
|
||||||
|
setAnswering(false);
|
||||||
|
setParked(false);
|
||||||
|
// The earlier messages come back; land at the exercise, the end of the log.
|
||||||
|
stick.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onFieldFocus = useCallback(
|
||||||
|
(field: KeyField) => {
|
||||||
|
clearTimeout(exitTimer.current);
|
||||||
|
fieldRef.current = field;
|
||||||
|
setActiveId(field.id);
|
||||||
|
if (field.kind !== "answer" || wide) return;
|
||||||
|
keepAnswering.current = false;
|
||||||
|
setParked(false);
|
||||||
|
setAnswering(true);
|
||||||
|
// The exercise picks the keyboard — 한글 for writing, none for
|
||||||
|
// translating, so one is not left up from the last — unless he has
|
||||||
|
// chosen for himself this exercise.
|
||||||
|
if (!kbManual.current) setKeyboard(field.script === "ko");
|
||||||
|
requestAnimationFrame(() => showRow(field.el()));
|
||||||
|
},
|
||||||
|
[showRow, wide],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFieldBlur = useCallback(
|
||||||
|
(field: KeyField) => {
|
||||||
|
if (field.kind !== "answer") return;
|
||||||
|
clearTimeout(exitTimer.current);
|
||||||
|
exitTimer.current = setTimeout(() => {
|
||||||
|
if (keepAnswering.current) return;
|
||||||
|
// A word looked up while answering is not leaving the answer.
|
||||||
|
if (document.querySelector(".pop")) return;
|
||||||
|
const a = document.activeElement;
|
||||||
|
if (a instanceof HTMLElement && a.matches("input[data-field]")) return;
|
||||||
|
exitAnswering();
|
||||||
|
}, 160);
|
||||||
|
},
|
||||||
|
[exitAnswering],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = useMemo<Fields>(
|
||||||
|
() => ({ keyboard, activeId, answering, onFocus: onFieldFocus, onBlur: onFieldBlur }),
|
||||||
|
[keyboard, activeId, answering, onFieldFocus, onFieldBlur],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleKeyboard = (manual: boolean) => {
|
||||||
|
if (manual) kbManual.current = true;
|
||||||
|
const on = !keyboard;
|
||||||
|
setKeyboard(on);
|
||||||
|
// The keyboard and the word sheet contend for the same space.
|
||||||
|
if (on) setDetent("closed");
|
||||||
|
if (on && !document.activeElement?.matches("input[data-field], .chat-in textarea")) {
|
||||||
|
(fieldRef.current ?? messageField.current)?.el()?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* inputmode is read when a field gains focus. Switching keyboards on a
|
||||||
|
focused field changes nothing until it is focused again. */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!coarse) return;
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (!(el instanceof HTMLElement) || !el.matches("input[data-field], .chat-in textarea")) return;
|
||||||
|
el.blur();
|
||||||
|
el.focus({ preventScroll: true });
|
||||||
|
}, [keyboard, coarse]);
|
||||||
|
|
||||||
|
/* 가 — the word list over an answer. The sheet and a keyboard contend for
|
||||||
|
the same space, so the field lets go while the list is up; answer mode
|
||||||
|
stays, and closing the list goes back to the field. */
|
||||||
|
const toggleWords = () => {
|
||||||
|
if (detent !== "closed") {
|
||||||
|
setDetent("closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const field = fieldRef.current?.kind === "answer" ? fieldRef.current : null;
|
||||||
|
resumeField.current = field;
|
||||||
|
keepAnswering.current = true;
|
||||||
|
setParked(true);
|
||||||
|
field?.el()?.blur();
|
||||||
|
setDetent("half");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (detent !== "closed" || !keepAnswering.current) return;
|
||||||
|
const field = resumeField.current;
|
||||||
|
keepAnswering.current = false;
|
||||||
|
resumeField.current = null;
|
||||||
|
setParked(false);
|
||||||
|
if (field) setTimeout(() => field.el()?.focus({ preventScroll: true }), 30);
|
||||||
|
else exitAnswering();
|
||||||
|
}, [detent, exitAnswering]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (answering && (wide || !active)) exitAnswering();
|
||||||
|
}, [active, answering, exitAnswering, wide]);
|
||||||
|
|
||||||
|
/* The shell hides the nav for it, so the flag goes where the shell can see it. */
|
||||||
|
useEffect(() => {
|
||||||
|
const flags = document.body.dataset;
|
||||||
|
if (answering) flags.answering = "1";
|
||||||
|
else delete flags.answering;
|
||||||
|
return () => {
|
||||||
|
delete flags.answering;
|
||||||
|
};
|
||||||
|
}, [answering]);
|
||||||
|
|
||||||
|
const step = (d: number) => {
|
||||||
|
const all = liveAnswerFields(log.current);
|
||||||
|
const to = all[all.findIndex((el) => el.dataset.field === activeId) + d];
|
||||||
|
if (!to) return;
|
||||||
|
to.focus({ preventScroll: true });
|
||||||
|
showRow(to);
|
||||||
|
};
|
||||||
|
|
||||||
|
const answerFields = answering ? liveAnswerFields(log.current) : [];
|
||||||
|
const at = answerFields.findIndex((el) => el.dataset.field === activeId);
|
||||||
|
const onField = at >= 0 && !parked;
|
||||||
|
const position = !answerFields.length
|
||||||
|
? ""
|
||||||
|
: onField
|
||||||
|
? `${at + 1} / ${answerFields.length}`
|
||||||
|
: `${answerFields.filter((el) => el.value.trim()).length} of ${answerFields.length}`;
|
||||||
|
|
||||||
|
const target = fieldRef.current ?? messageField.current;
|
||||||
|
|
||||||
/* ── rendering ── */
|
/* ── rendering ── */
|
||||||
|
|
||||||
const isLast = (i: number) => i === turns.length - 1;
|
const isLast = (i: number) => i === turns.length - 1;
|
||||||
@@ -648,128 +856,167 @@ export function TutorTab() {
|
|||||||
</div>
|
</div>
|
||||||
</Pop>
|
</Pop>
|
||||||
|
|
||||||
<div className="lesson-wrap">
|
<FieldsContext.Provider value={fields}>
|
||||||
<div
|
<div className="lesson-wrap">
|
||||||
className="chatcol"
|
<div
|
||||||
onFocusCapture={(e) => {
|
className="chatcol"
|
||||||
// Typing an answer brings the sheet down to peek: what is being
|
onFocusCapture={(e) => {
|
||||||
// answered is never behind it.
|
// Typing an answer brings the sheet down to peek: what is being
|
||||||
const t = e.target as HTMLElement;
|
// answered is never behind it.
|
||||||
if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
|
const t = e.target as HTMLElement;
|
||||||
setDetent("peek");
|
if (keepAnswering.current) return;
|
||||||
}
|
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.`,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
onInputCapture={() => {
|
||||||
|
if (answering) recount();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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}>
|
<div className="chat-log" ref={log} onScroll={onLogScroll}>
|
||||||
{parsedTurns.map(({ turn: t, parsed }, i) => {
|
{parsedTurns.map(({ turn: t, parsed }, i) => {
|
||||||
const you = t.role === "user";
|
const you = t.role === "user";
|
||||||
const body = parsed ? parsed.body : ownWords(t.body);
|
const body = parsed ? parsed.body : ownWords(t.body);
|
||||||
return (
|
return (
|
||||||
<div className={`msg${you ? " you" : ""}`} key={t.id}>
|
<div className={`msg${you ? " you" : ""}`} key={t.id}>
|
||||||
<span className="who ko">{you ? "나" : "선생님"}</span>
|
<span className="who ko">{you ? "나" : "선생님"}</span>
|
||||||
{/* A turn can be nothing but blocks — some models write no
|
{/* A turn can be nothing but blocks — some models write no
|
||||||
prose around an exercise at all. Rendering the bubble
|
prose around an exercise at all. Rendering the bubble
|
||||||
anyway left an empty box above it. */}
|
anyway left an empty box above it. */}
|
||||||
{(body.trim() || parsed?.gloss) && (
|
{(body.trim() || parsed?.gloss) && (
|
||||||
|
<div className="bubble">
|
||||||
|
<MessageBody text={body} />
|
||||||
|
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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}
|
||||||
|
words={parsed.words}
|
||||||
|
turnId={t.id}
|
||||||
|
lookups={[...revealed]}
|
||||||
|
disabled={busy || !isLast(i)}
|
||||||
|
spent={!isLast(i)}
|
||||||
|
onSubmit={(message) => void send(message, { lookups: [...revealed] })}
|
||||||
|
submitRef={isLast(i) ? submitTask : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* One slot for the turn in flight, keyed so the node survives
|
||||||
|
the change from waiting to streaming. As two sibling
|
||||||
|
conditionals the dots unmounted and the text mounted in
|
||||||
|
their place, which read as a blink at the moment the first
|
||||||
|
token arrived. */}
|
||||||
|
{busy && (
|
||||||
|
<div className="msg" key="pending">
|
||||||
|
<span className="who ko">선생님</span>
|
||||||
|
{/* Keep the dots up while the reply so far is only block
|
||||||
|
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" aria-label="선생님 is writing">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="bubble">
|
<div className="bubble">
|
||||||
<MessageBody text={body} />
|
<MessageBody text={streamingBody} />
|
||||||
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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}
|
|
||||||
words={parsed.words}
|
|
||||||
turnId={t.id}
|
|
||||||
lookups={[...revealed]}
|
|
||||||
disabled={busy || !isLast(i)}
|
|
||||||
spent={!isLast(i)}
|
|
||||||
onSubmit={(message) => void send(message, { lookups: [...revealed] })}
|
|
||||||
onSkip={() => void send("Let's skip that one and just talk.")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
{/* One slot for the turn in flight, keyed so the node survives
|
<Composer
|
||||||
the change from waiting to streaming. As two sibling
|
unit={unit}
|
||||||
conditionals the dots unmounted and the text mounted in
|
busy={busy}
|
||||||
their place, which read as a blink at the moment the first
|
notStarted={notStarted}
|
||||||
token arrived. */}
|
draft={draft}
|
||||||
{busy && (
|
setDraft={setDraft}
|
||||||
<div className="msg" key="pending">
|
keyboard={keyboard}
|
||||||
<span className="who ko">선생님</span>
|
onToggleKeyboard={() => toggleKeyboard(false)}
|
||||||
{/* Keep the dots up while the reply so far is only block
|
messageField={messageField}
|
||||||
markup: there is genuinely nothing to read yet, and an
|
note={note}
|
||||||
empty bubble reads as a failure rather than as waiting. */}
|
onSend={(text) => void send(text, { restore: true })}
|
||||||
{streamingBody.trim() === "" ? (
|
onStop={() => abort.current?.abort()}
|
||||||
<div className="bubble dots" aria-label="선생님 is writing">
|
>
|
||||||
<i />
|
{answering && (
|
||||||
<i />
|
<AnswerBar
|
||||||
<i />
|
position={position}
|
||||||
</div>
|
keyboard={keyboard}
|
||||||
) : (
|
wordsOpen={detent !== "closed"}
|
||||||
<div className="bubble">
|
canPrev={onField && at > 0}
|
||||||
<MessageBody text={streamingBody} />
|
canNext={onField && at < answerFields.length - 1}
|
||||||
</div>
|
busy={busy}
|
||||||
)}
|
onDone={() => {
|
||||||
</div>
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
)}
|
exitAnswering();
|
||||||
|
}}
|
||||||
|
onWords={toggleWords}
|
||||||
|
onKeyboard={() => toggleKeyboard(true)}
|
||||||
|
onPrev={() => step(-1)}
|
||||||
|
onNext={() => step(1)}
|
||||||
|
onSubmit={() => {
|
||||||
|
exitAnswering();
|
||||||
|
if (!kbManual.current) setKeyboard(false);
|
||||||
|
submitTask.current?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{keyboard && target && !parked && (
|
||||||
|
<Keyboard
|
||||||
|
composer={target.composer}
|
||||||
|
onChange={target.apply}
|
||||||
|
target={target.kind === "message" ? "message" : "answer"}
|
||||||
|
onKey={() => {
|
||||||
|
const el = target.el();
|
||||||
|
if (el && document.activeElement !== el) el.focus({ preventScroll: true });
|
||||||
|
}}
|
||||||
|
onDismiss={() => setKeyboard(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Composer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Composer
|
{wide && (
|
||||||
unit={unit}
|
<aside className="railcol" aria-label="Word list">
|
||||||
busy={busy}
|
<RailPanel {...railProps} />
|
||||||
notStarted={notStarted}
|
</aside>
|
||||||
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>
|
</div>
|
||||||
|
</FieldsContext.Provider>
|
||||||
{wide && (
|
|
||||||
<aside className="railcol" aria-label="Word list">
|
|
||||||
<RailPanel {...railProps} />
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!wide && active && (
|
{!wide && active && (
|
||||||
<WordSheet detent={detent} onDetent={setDetent}>
|
<WordSheet detent={detent} onDetent={setDetent}>
|
||||||
|
|||||||
54
app/src/ui/tutor/fields.tsx
Normal file
54
app/src/ui/tutor/fields.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/* The fields the 한글 keyboard types into, and answer mode.
|
||||||
|
|
||||||
|
The on-screen keyboard follows whichever field was touched last — the
|
||||||
|
message box or one of an exercise's answers. Each field brings its own
|
||||||
|
composer: a half-built syllable belongs to the field it was typed in.
|
||||||
|
|
||||||
|
Answer mode, below 840px: focusing an answer strips the lesson back to
|
||||||
|
the question, the field and the way forward. While a keyboard takes more
|
||||||
|
than half the screen, the nav, the roadmap, the quick replies and the
|
||||||
|
earlier messages are all noise. The lesson owns the state; the fields
|
||||||
|
report to it through this context. */
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { ComposerHandle } from "../keyboard/Keyboard.js";
|
||||||
|
|
||||||
|
export interface KeyField {
|
||||||
|
/** "message", or the answer's own id. */
|
||||||
|
id: string;
|
||||||
|
kind: "message" | "answer";
|
||||||
|
/** What the field is answered in. A recall answer wants 한글. */
|
||||||
|
script: "ko" | "en";
|
||||||
|
/** Change the value from what it is now — never from a stale copy. */
|
||||||
|
apply: (fn: (prev: string) => string) => void;
|
||||||
|
composer: ComposerHandle;
|
||||||
|
el: () => HTMLInputElement | HTMLTextAreaElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Fields {
|
||||||
|
/** The 한글 keyboard is up: fields ask the system for no keyboard. */
|
||||||
|
keyboard: boolean;
|
||||||
|
/** The field the keyboard types into. */
|
||||||
|
activeId: string | null;
|
||||||
|
answering: boolean;
|
||||||
|
onFocus: (field: KeyField) => void;
|
||||||
|
onBlur: (field: KeyField) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FieldsContext = createContext<Fields>({
|
||||||
|
keyboard: false,
|
||||||
|
activeId: null,
|
||||||
|
answering: false,
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useFields = (): Fields => useContext(FieldsContext);
|
||||||
|
|
||||||
|
/** The answer fields of the exercise still open in this log, in order. */
|
||||||
|
export function liveAnswerFields(log: HTMLElement | null): HTMLInputElement[] {
|
||||||
|
if (!log) return [];
|
||||||
|
return [
|
||||||
|
...log.querySelectorAll<HTMLInputElement>('.task:not([data-spent="true"]) input[data-field]'),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -251,6 +251,18 @@
|
|||||||
color: var(--on-jade);
|
color: var(--on-jade);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The answer being typed, in answer mode. */
|
||||||
|
.ti-row[data-active="1"] {
|
||||||
|
margin: 0 -8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--jade-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ti-row[data-active="1"] .q {
|
||||||
|
color: var(--jade-ink);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.ti-row {
|
.ti-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -260,8 +272,27 @@
|
|||||||
.ti-row .q {
|
.ti-row .q {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.mt-cols {
|
/* Thumb-sized. Matching keeps its two columns, as the artifact does:
|
||||||
grid-template-columns: 1fr;
|
stacked, every pairing is a scroll between the word and its meaning. */
|
||||||
|
.mt-chip {
|
||||||
|
padding: 11px 10px;
|
||||||
|
}
|
||||||
|
.chip-w {
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
.ch-opts button {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.task-f .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.task-f .left {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
.task-f {
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -328,3 +328,90 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── answer mode ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.answerbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 0 calc(8px + var(--safe-b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The keyboard sits below it and takes the safe area instead. */
|
||||||
|
.answerbar:has(+ .kb) {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-foot .kb {
|
||||||
|
padding-bottom: calc(10px + var(--safe-b));
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerbar .n {
|
||||||
|
margin-right: auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerbar .btn {
|
||||||
|
min-width: 42px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerbar .btn[aria-pressed="true"] {
|
||||||
|
border-color: var(--jade);
|
||||||
|
background: var(--jade);
|
||||||
|
color: var(--on-jade);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerbar .cta {
|
||||||
|
flex: none;
|
||||||
|
width: auto;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 379px) {
|
||||||
|
.answerbar {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.answerbar .btn {
|
||||||
|
min-width: 38px;
|
||||||
|
}
|
||||||
|
.answerbar .cta {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only the question, the field and the way forward. */
|
||||||
|
body[data-answering="1"] .nav,
|
||||||
|
body[data-answering="1"] #rt-lesson > .rhead,
|
||||||
|
body[data-answering="1"] .road-strip,
|
||||||
|
body[data-answering="1"] .road-ready,
|
||||||
|
body[data-answering="1"] .road-panel,
|
||||||
|
body[data-answering="1"] .chips-row,
|
||||||
|
body[data-answering="1"] .chat-in,
|
||||||
|
body[data-answering="1"] .chat-note {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-answering="1"] .chat-foot {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-answering="1"] .msg:not(:last-child) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-answering="1"] .chat-log {
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user