@khorsheed/dsh-library 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.en.md +49 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +48 -2
  5. package/cordis.patch.yml +17 -0
  6. package/lib/client.js +66276 -0
  7. package/lib/index.js +3318 -0
  8. package/lib/profiles-BXM8xNUf.js +1608 -0
  9. package/lib/profiles-BrbeM-bi.js +1635 -0
  10. package/lib/profiles-CFrPQdfO.js +1692 -0
  11. package/lib/profiles-DxtzIUvd.js +1632 -0
  12. package/lib/profiles-O0ddEit3.js +1631 -0
  13. package/lib/profiles-Oj-zBAY9.js +1692 -0
  14. package/lib/profiles-gIEviEuO.js +1610 -0
  15. package/lib/store-CIcZgTsg.js +1456 -0
  16. package/lib/store-DfYOmS44.js +1418 -0
  17. package/lib/tool.js +1597 -0
  18. package/lib/typert.host.d.ts +3 -0
  19. package/lib/typert.host.js +3811 -0
  20. package/lib/typert.remote-client.d.ts +137 -0
  21. package/lib/typert.remote-client.js +3077 -0
  22. package/lib/types/cards.d.ts +41 -0
  23. package/lib/types/cards.js +111 -0
  24. package/lib/types/client/LibraryPane.d.ts +26 -0
  25. package/lib/types/client/LibraryPane.js +117 -0
  26. package/lib/types/client/bind/BindChip.d.ts +33 -0
  27. package/lib/types/client/bind/BindChip.js +102 -0
  28. package/lib/types/client/contract.d.ts +164 -0
  29. package/lib/types/client/contract.js +1 -0
  30. package/lib/types/client/covers.d.ts +36 -0
  31. package/lib/types/client/covers.js +66 -0
  32. package/lib/types/client/definition.d.ts +27 -0
  33. package/lib/types/client/definition.js +32 -0
  34. package/lib/types/client/icons-local.d.ts +41 -0
  35. package/lib/types/client/icons-local.js +25 -0
  36. package/lib/types/client/icons.d.ts +27 -0
  37. package/lib/types/client/icons.js +11 -0
  38. package/lib/types/client/index.d.ts +35 -0
  39. package/lib/types/client/index.js +219 -0
  40. package/lib/types/client/judge.d.ts +70 -0
  41. package/lib/types/client/judge.js +262 -0
  42. package/lib/types/client/locales.d.ts +553 -0
  43. package/lib/types/client/locales.js +1076 -0
  44. package/lib/types/client/look.d.ts +63 -0
  45. package/lib/types/client/look.js +64 -0
  46. package/lib/types/client/math.d.ts +24 -0
  47. package/lib/types/client/math.js +47 -0
  48. package/lib/types/client/mistakes.d.ts +21 -0
  49. package/lib/types/client/mistakes.js +28 -0
  50. package/lib/types/client/preset-visibility.d.ts +109 -0
  51. package/lib/types/client/preset-visibility.js +123 -0
  52. package/lib/types/client/quiz-drafts.d.ts +29 -0
  53. package/lib/types/client/quiz-drafts.js +50 -0
  54. package/lib/types/client/store.d.ts +109 -0
  55. package/lib/types/client/store.js +44 -0
  56. package/lib/types/client/titles.d.ts +17 -0
  57. package/lib/types/client/titles.js +23 -0
  58. package/lib/types/client/ui.d.ts +96 -0
  59. package/lib/types/client/ui.js +124 -0
  60. package/lib/types/client/views/book.d.ts +57 -0
  61. package/lib/types/client/views/book.js +164 -0
  62. package/lib/types/client/views/cards.d.ts +17 -0
  63. package/lib/types/client/views/cards.js +53 -0
  64. package/lib/types/client/views/home.d.ts +26 -0
  65. package/lib/types/client/views/home.js +263 -0
  66. package/lib/types/client/views/lesson.d.ts +42 -0
  67. package/lib/types/client/views/lesson.js +146 -0
  68. package/lib/types/client/views/path.d.ts +32 -0
  69. package/lib/types/client/views/path.js +64 -0
  70. package/lib/types/client/views/quiz.d.ts +42 -0
  71. package/lib/types/client/views/quiz.js +366 -0
  72. package/lib/types/client/views/station-quiz.d.ts +43 -0
  73. package/lib/types/client/views/station-quiz.js +203 -0
  74. package/lib/types/client/views/station.d.ts +18 -0
  75. package/lib/types/client/views/station.js +142 -0
  76. package/lib/types/image-refs.d.ts +35 -0
  77. package/lib/types/image-refs.js +71 -0
  78. package/lib/types/index.d.ts +51 -0
  79. package/lib/types/index.js +31 -0
  80. package/lib/types/mastery.d.ts +81 -0
  81. package/lib/types/mastery.js +110 -0
  82. package/lib/types/profiles.d.ts +31 -0
  83. package/lib/types/profiles.js +25 -0
  84. package/lib/types/remote.d.ts +355 -0
  85. package/lib/types/remote.js +332 -0
  86. package/lib/types/service/assess.d.ts +144 -0
  87. package/lib/types/service/assess.js +773 -0
  88. package/lib/types/service/bindings.d.ts +92 -0
  89. package/lib/types/service/bindings.js +198 -0
  90. package/lib/types/service/cards.d.ts +85 -0
  91. package/lib/types/service/cards.js +320 -0
  92. package/lib/types/service/core.d.ts +124 -0
  93. package/lib/types/service/core.js +169 -0
  94. package/lib/types/service/pages.d.ts +60 -0
  95. package/lib/types/service/pages.js +125 -0
  96. package/lib/types/service/roadmap.d.ts +99 -0
  97. package/lib/types/service/roadmap.js +284 -0
  98. package/lib/types/service/rules.d.ts +63 -0
  99. package/lib/types/service/rules.js +245 -0
  100. package/lib/types/service/shelf.d.ts +136 -0
  101. package/lib/types/service/shelf.js +422 -0
  102. package/lib/types/service.d.ts +42 -0
  103. package/lib/types/service.js +41 -0
  104. package/lib/types/store.d.ts +257 -0
  105. package/lib/types/store.js +1376 -0
  106. package/lib/types/tool-definition.d.ts +49 -0
  107. package/lib/types/tool-definition.js +49 -0
  108. package/lib/types/tool.d.ts +71 -0
  109. package/lib/types/tool.js +174 -0
  110. package/lib/types/tools/assess.d.ts +11 -0
  111. package/lib/types/tools/assess.js +82 -0
  112. package/lib/types/tools/binding.d.ts +11 -0
  113. package/lib/types/tools/binding.js +46 -0
  114. package/lib/types/tools/cards.d.ts +11 -0
  115. package/lib/types/tools/cards.js +100 -0
  116. package/lib/types/tools/kit.d.ts +215 -0
  117. package/lib/types/tools/kit.js +281 -0
  118. package/lib/types/tools/notes.d.ts +11 -0
  119. package/lib/types/tools/notes.js +40 -0
  120. package/lib/types/tools/page.d.ts +11 -0
  121. package/lib/types/tools/page.js +59 -0
  122. package/lib/types/tools/project.d.ts +11 -0
  123. package/lib/types/tools/project.js +67 -0
  124. package/lib/types/tools/quiz.d.ts +11 -0
  125. package/lib/types/tools/quiz.js +74 -0
  126. package/lib/types/tools/read.d.ts +11 -0
  127. package/lib/types/tools/read.js +127 -0
  128. package/lib/types/tools/resource.d.ts +11 -0
  129. package/lib/types/tools/resource.js +147 -0
  130. package/lib/types/tools/roadmap.d.ts +11 -0
  131. package/lib/types/tools/roadmap.js +102 -0
  132. package/lib/types/types.d.ts +752 -0
  133. package/lib/types/types.js +159 -0
  134. package/locale/en.json +6 -0
  135. package/locale/zh.json +6 -0
  136. package/package.json +135 -3
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A textbook's adopted path as a vertical lesson rail (proposal
3
+ * library-slimming §5, option A): one row per station with its full title,
4
+ * a disc that says where the learner stands, and its knowledge points as
5
+ * mastery dots. Rows group under the contents' parts. Nothing is locked
6
+ * (D6): the next station — the first not yet passed — is only highlighted
7
+ * and opens its card (开始学 / 接着学 / 去订正).
8
+ *
9
+ * @module @khorsheed/dsh-library/client/views/path
10
+ */
11
+ import type { ReactNode } from 'react';
12
+ import { type LibraryStationMastery } from '../../mastery.ts';
13
+ import { type LibraryResource, type LibraryRoadmap, type RoadmapNode } from '../../types.ts';
14
+ import { type ViewProps } from '../ui.tsx';
15
+ /** Where the learner stands on one station. */
16
+ export type StationStatus = 'done' | 'studying' | 'ready' | 'todo';
17
+ /**
18
+ * Passed (done) → 已过关; 讲义 written and the learner has been quizzed or has
19
+ * a weak point → 学习中; 讲义 written → 已备课; nothing yet → 未开始.
20
+ */
21
+ export declare function stationStatus(node: RoadmapNode, mastery: LibraryStationMastery): StationStatus;
22
+ /** The rail. */
23
+ export declare function Path({ p, projectId, resource, roadmap, onOpen, onLesson, onRetake }: {
24
+ p: ViewProps['p'];
25
+ projectId: string;
26
+ resource: LibraryResource;
27
+ roadmap: LibraryRoadmap;
28
+ onOpen: (node: RoadmapNode) => void;
29
+ onLesson: (node: RoadmapNode) => void;
30
+ onRetake: (node: RoadmapNode) => void;
31
+ }): ReactNode;
32
+ //# sourceMappingURL=path.d.ts.map
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { stationMastery } from "../../mastery.js";
3
+ import { coverKind, parseLocator } from "../../types.js";
4
+ import { IconCheckOutlineMedium } from "../icons.js";
5
+ import { Segments, useLoad, value } from "../ui.js";
6
+ import { stationTitle } from "../titles.js";
7
+ import css from '../LibraryPane.module.css';
8
+ /**
9
+ * Passed (done) → 已过关; 讲义 written and the learner has been quizzed or has
10
+ * a weak point → 学习中; 讲义 written → 已备课; nothing yet → 未开始.
11
+ */
12
+ export function stationStatus(node, mastery) {
13
+ if (node.state === 'done')
14
+ return 'done';
15
+ const tried = (node.quizStats?.attempts ?? 0) > 0 || mastery.open.length > 0 || mastery.points.some(point => point.state !== 'untested');
16
+ if (tried)
17
+ return 'studying';
18
+ return node.notes === undefined ? 'todo' : 'ready';
19
+ }
20
+ /** The contents part a station's first row sits under, when the contents has parts. */
21
+ function partOf(node, resource) {
22
+ const cover = node.covers.find(candidate => coverKind(candidate) === 'resource' && candidate.ref === resource.id && candidate.locator !== undefined);
23
+ const first = cover?.locator === undefined ? undefined : parseLocator(cover.locator)[0]?.from;
24
+ return first === undefined ? undefined : resource.toc?.find(row => row.n === first)?.part;
25
+ }
26
+ /** The station's rows as a short label (第 3 章 / 第 3–4 章). */
27
+ function rowsLabel(node, resource, t) {
28
+ const cover = node.covers.find(candidate => coverKind(candidate) === 'resource' && candidate.ref === resource.id && candidate.locator !== undefined);
29
+ return cover?.locator === undefined ? undefined : t('path.rows', { rows: cover.locator.replace(/-/g, '–') });
30
+ }
31
+ /** The rail. */
32
+ export function Path({ p, projectId, resource, roadmap, onOpen, onLesson, onRetake }) {
33
+ const { t, useStore } = p;
34
+ const rev = useStore(s => s.rev);
35
+ const assessments = useLoad(() => value(p.listAssessments(projectId)), [projectId, rev]);
36
+ const attempts = useLoad(() => value(p.listAttempts(projectId)), [projectId, rev]);
37
+ const rows = roadmap.nodes.map(node => {
38
+ const mastery = stationMastery(node, assessments.data?.assessments ?? [], attempts.data?.attempts ?? []);
39
+ return { node, mastery, status: stationStatus(node, mastery) };
40
+ });
41
+ const levels = rows.filter(row => row.node.gate !== 'read');
42
+ const passed = levels.filter(row => row.status === 'done').length;
43
+ const allPoints = rows.flatMap(row => row.mastery.points);
44
+ const mastered = allPoints.filter(point => point.state === 'mastered').length;
45
+ // The recommended next station: the first level not yet passed.
46
+ const next = levels.find(row => row.status !== 'done')?.node.id;
47
+ let lastPart;
48
+ let number = 0;
49
+ return (_jsxs("section", { className: css.section, children: [_jsxs("div", { className: css.railHead, children: [_jsxs("span", { className: css.railCounts, children: [_jsxs("span", { children: [_jsx("b", { children: passed }), " / ", levels.length, " ", t('path.stationsPassed')] }), allPoints.length > 0 && _jsxs("span", { children: [_jsx("b", { children: mastered }), " / ", allPoints.length, " ", t('path.pointsMastered')] })] }), _jsx(Segments, { done: passed, total: levels.length, label: t('path.progress', { done: String(passed), total: String(levels.length) }) })] }), _jsx("ol", { className: css.rail, children: rows.map(({ node, mastery, status }) => {
50
+ const part = partOf(node, resource);
51
+ const showPart = part !== undefined && part !== lastPart;
52
+ lastPart = part ?? lastPart;
53
+ const side = node.gate === 'read';
54
+ if (!side)
55
+ number += 1;
56
+ const isNext = node.id === next;
57
+ const best = node.quizStats?.bestRatio;
58
+ const open = mastery.open.length;
59
+ return (_jsxs("li", { className: css.railItem, "data-status": status, "data-side": side ? 'true' : undefined, "data-next": isNext ? 'true' : undefined, children: [showPart && _jsx("span", { className: css.railPart, children: part }), _jsxs("div", { className: css.railRow, children: [_jsx("button", { type: "button", className: css.railDisc, "data-status": status, "data-next": isNext ? 'true' : undefined, "aria-label": node.title, onClick: () => { onOpen(node); }, children: status === 'done' ? _jsx(IconCheckOutlineMedium, { size: 18 }) : side ? '·' : number }), _jsxs("div", { className: css.railBody, children: [_jsx("button", { type: "button", className: css.railTitle, onClick: () => { onOpen(node); }, children: stationTitle(node.title) }), _jsxs("span", { className: css.railMeta, children: [status === 'done' && _jsx("span", { className: css.railState, "data-status": "done", children: best === undefined ? t('path.done') : t('path.doneBest', { percent: String(Math.round(best * 100)) }) }), status === 'studying' && _jsx("span", { className: css.railState, "data-status": "studying", children: open > 0 ? t('path.studyingOpen', { count: String(open) }) : t('path.studying') }), status === 'ready' && _jsx("span", { className: css.railState, "data-status": "ready", children: t('path.ready') }), rowsLabel(node, resource, t) !== undefined && _jsx("span", { children: rowsLabel(node, resource, t) }), mastery.points.length > 0 && (_jsx("span", { className: css.railDots, "aria-label": t('path.dotsLabel'), children: mastery.points.map(point => _jsx("i", { className: css.lessonDot, "data-state": point.state, title: `${point.title} · ${t(`point.${point.state}`)}` }, point.id)) }))] }), isNext && (_jsxs("span", { className: css.railNext, children: [_jsx("span", { className: css.muted, children: t(status === 'todo' ? 'path.nextTodo' : 'path.nextHint') }), _jsxs("span", { className: css.btnRow, children: [open > 0 && _jsx("button", { type: "button", className: css.ghost, onClick: () => { onRetake(node); }, children: t('path.retake', { count: String(open) }) }), _jsx("button", { type: "button", className: css.primary, onClick: () => { if (node.notes === undefined)
60
+ onOpen(node);
61
+ else
62
+ onLesson(node); }, children: t(node.notes === undefined ? 'lesson.start' : 'path.continue') })] })] }))] })] })] }, node.id));
63
+ }) })] }));
64
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The chapter quiz in the sidebar, its result, and the daily review.
3
+ *
4
+ * A quiz is answered here: choice and fill questions are judged on the spot
5
+ * against the answer key; open questions are collected and handed to the
6
+ * coach, who judges them and ledgers the whole sitting (one attempt per
7
+ * sitting). A sitting with no open question is ledgered from here directly.
8
+ * 「这题有问题」 voids a question — it counts for nothing.
9
+ *
10
+ * Review is flashcards over the due cards: front, flip, self-rate. It moves
11
+ * only the schedule — never a node, never weakness.
12
+ *
13
+ * @module @khorsheed/dsh-library/client/views/quiz
14
+ */
15
+ import { type ReactNode } from 'react';
16
+ import { type LibraryAssessResult } from '../../types.ts';
17
+ import { type ViewProps } from '../ui.tsx';
18
+ /** The quiz screen: the whole paper on one scroll — every question numbered, judged on the spot, 交卷 once all are answered. The in-progress sitting persists locally (quiz-drafts) — leaving the paper never loses an answer. */
19
+ export declare function QuizView({ p, projectId, resourceId, quizId }: ViewProps & {
20
+ projectId: string;
21
+ resourceId: string | undefined;
22
+ quizId: string;
23
+ }): ReactNode;
24
+ /** The sitting's result: the score, what unlocked, how the cards moved — and the loop's next step, which rides the conversation from here (a retake of the misses or a fresh set), so the flow never dead-ends on the score. */
25
+ export declare function ResultView({ p, projectId, resourceId, result }: ViewProps & {
26
+ projectId: string;
27
+ resourceId: string | undefined;
28
+ result: LibraryAssessResult;
29
+ }): ReactNode;
30
+ /**
31
+ * Daily review as answering, not looking (M4): each due card is asked the
32
+ * way that fits it — a word as a cloze over its own sentence or a choice of
33
+ * its meaning against the learner's other glosses, a well-known word as a
34
+ * spelling, a concept as a one-line explanation checked against the answer.
35
+ * The pane judges what it can and maps it to the schedule (first try →
36
+ * recalled, after a hint → fuzzy, missed → forgot); concepts are self-rated.
37
+ * 「翻卡」 in the header falls back to the plain flashcard.
38
+ */
39
+ export declare function ReviewView({ p, projectId }: ViewProps & {
40
+ projectId?: string;
41
+ }): ReactNode;
42
+ //# sourceMappingURL=quiz.d.ts.map
@@ -0,0 +1,366 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * The chapter quiz in the sidebar, its result, and the daily review.
4
+ *
5
+ * A quiz is answered here: choice and fill questions are judged on the spot
6
+ * against the answer key; open questions are collected and handed to the
7
+ * coach, who judges them and ledgers the whole sitting (one attempt per
8
+ * sitting). A sitting with no open question is ledgered from here directly.
9
+ * 「这题有问题」 voids a question — it counts for nothing.
10
+ *
11
+ * Review is flashcards over the due cards: front, flip, self-rate. It moves
12
+ * only the schedule — never a node, never weakness.
13
+ *
14
+ * @module @khorsheed/dsh-library/client/views/quiz
15
+ */
16
+ import { useLayoutEffect, useMemo, useRef, useState } from 'react';
17
+ import { PanelHeader } from '@khorsheed/dsh-client-ui-panel-kit/src/client/index.ts';
18
+ import { LEITNER_INTERVAL_DAYS } from "../../types.js";
19
+ import { choiceIndex, choiceOptions, clozeOf, judgeFill, letterMarks, pickMode, spellMatches } from "../judge.js";
20
+ import { profileOf } from "../../profiles.js";
21
+ import { clearQuizDraft, loadQuizDraft, saveQuizDraft } from "../quiz-drafts.js";
22
+ import { MathText, Tex } from "../math.js";
23
+ import { Highlight, outcome, shortGloss, StateLine, useLoad, value } from "../ui.js";
24
+ import { IconCheckOutlineMedium } from "../icons.js";
25
+ import { IconChatOutlineMedium, IconFlagOutlineMedium } from "../icons-local.js";
26
+ import css from '../LibraryPane.module.css';
27
+ /** Judge a fill: a match is right; a miss is wrong only where the spelling is the point, otherwise the coach confirms. */
28
+ function judgeFillAnswer(profile, given, key) {
29
+ if (judgeFill(profile.fill, given, key))
30
+ return { given, pass: true };
31
+ return profile.fill === 'spelling' ? { given, pass: false } : { given, unsure: true };
32
+ }
33
+ /** The quiz screen: the whole paper on one scroll — every question numbered, judged on the spot, 交卷 once all are answered. The in-progress sitting persists locally (quiz-drafts) — leaving the paper never loses an answer. */
34
+ export function QuizView({ p, projectId, resourceId, quizId }) {
35
+ const { t, actions } = p;
36
+ const loaded = useLoad(async () => {
37
+ const result = await outcome(p.getAssessment(projectId, quizId));
38
+ return result.assessment;
39
+ }, [projectId, quizId]);
40
+ const [initialDraft] = useState(() => loadQuizDraft(quizId));
41
+ const [answers, setAnswers] = useState(() => ({ ...(initialDraft?.answers ?? {}) }));
42
+ const [drafts, setDrafts] = useState(() => ({ ...(initialDraft?.drafts ?? {}) }));
43
+ const [notice, setNotice] = useState();
44
+ const [busy, setBusy] = useState(false);
45
+ // Open answers go to the coach through the conversation: once handed over, 交卷 is done — not clickable again.
46
+ const [handedOver, setHandedOver] = useState(false);
47
+ const assessment = loaded.data;
48
+ // The project's profile decides how a fill is judged and whether answers preview math.
49
+ const project = useLoad(() => outcome(p.getProject(projectId)).then(detail => detail.project), [projectId]);
50
+ const profile = profileOf(project.data?.template);
51
+ const isRetake = assessment?.type === 'retake';
52
+ const quiz = assessment !== undefined && (assessment.type === 'quiz' || assessment.type === 'retake') && assessment.questions !== undefined
53
+ ? { ...assessment, questions: assessment.questions }
54
+ : undefined;
55
+ const total = quiz?.questions.length ?? 0;
56
+ const done = quiz === undefined ? 0 : quiz.questions.filter(item => answers[item.id] !== undefined).length;
57
+ const allAnswered = quiz !== undefined && done === total;
58
+ const record = (id, entry) => {
59
+ setAnswers(previous => {
60
+ const next = { ...previous, [id]: entry };
61
+ saveQuizDraft(quizId, { answers: next, drafts, at: new Date().toISOString() });
62
+ return next;
63
+ });
64
+ };
65
+ const recordDraft = (id, text) => {
66
+ setDrafts(previous => {
67
+ const next = { ...previous, [id]: text };
68
+ saveQuizDraft(quizId, { answers, drafts: next, at: new Date().toISOString() });
69
+ return next;
70
+ });
71
+ };
72
+ // 讲讲这题: a draft in the conversation input naming the question — before
73
+ // answering it asks for the approach (not the answer), after it asks why.
74
+ const explain = (question, number) => {
75
+ if (quiz === undefined)
76
+ return false;
77
+ void p.bindTarget(resourceId === undefined ? { projectId } : { projectId, resourceId });
78
+ const entry = answers[question.id];
79
+ const key = question.kind === 'choice'
80
+ ? (question.options ?? []).map((option, at) => `${String.fromCharCode(65 + at)}. ${option}`).join(' ')
81
+ : '';
82
+ const text = entry === undefined || entry.void === true
83
+ ? t('prime.explainHint', { quiz: quiz.id, number: String(number), prompt: question.prompt, options: key })
84
+ : t('prime.explainAfter', {
85
+ quiz: quiz.id,
86
+ number: String(number),
87
+ prompt: question.prompt,
88
+ options: key,
89
+ given: entry.given === '' ? t('quiz.explainBlank') : entry.given,
90
+ answer: question.answer,
91
+ verdict: entry.pass === true ? t('quiz.explainRight') : entry.pass === false ? t('quiz.explainWrong') : t('quiz.explainOpen'),
92
+ });
93
+ return p.primeConversation(text.replace(/\n\n+/g, '\n'));
94
+ };
95
+ const finish = async () => {
96
+ if (quiz === undefined)
97
+ return;
98
+ const open = quiz.questions.filter(item => answers[item.id]?.void !== true && answers[item.id]?.pass === undefined);
99
+ if (open.length > 0) {
100
+ // Open answers need the coach: hand over the whole sitting.
101
+ const lines = quiz.questions.map(item => {
102
+ const entry = answers[item.id];
103
+ const mark = entry?.void === true ? t('quiz.handVoid') : entry?.pass === true ? '✓' : entry?.pass === false ? '✗' : entry?.unsure === true ? t('quiz.handUnsure', { key: item.answer }) : t('quiz.handOpen');
104
+ return `- ${item.id}:${entry?.given ?? ''}(${mark})`;
105
+ });
106
+ const primed = p.primeConversation(t('prime.grade', { quiz: quiz.id, answers: lines.join('\n') }));
107
+ setNotice(primed ? t('quiz.handedOver') : t('notice.noConversation'));
108
+ if (primed)
109
+ setHandedOver(true);
110
+ return;
111
+ }
112
+ const verdicts = quiz.questions.map(item => {
113
+ const entry = answers[item.id];
114
+ return {
115
+ task: item.id,
116
+ criterion: entry?.overruled === true ? `${item.prompt.slice(0, 100)}(学员改判:与答案等价)` : item.prompt.slice(0, 120),
117
+ pass: entry?.pass === true,
118
+ evidence: item.evidence.quote ?? item.answer,
119
+ by: 'self',
120
+ ...(entry?.void === true ? { void: true } : {}),
121
+ };
122
+ });
123
+ setBusy(true);
124
+ try {
125
+ const result = await outcome(p.putAttempt(projectId, quiz.id, verdicts));
126
+ clearQuizDraft(quiz.id);
127
+ actions.replace({ view: 'result', projectId, ...(resourceId === undefined ? {} : { resourceId }), result });
128
+ }
129
+ catch (failure) {
130
+ setNotice(failure instanceof Error ? failure.message : String(failure));
131
+ }
132
+ finally {
133
+ setBusy(false);
134
+ }
135
+ };
136
+ return (_jsxs(_Fragment, { children: [_jsx(PanelHeader, { title: quiz === undefined ? t('node.quiz') : t(isRetake ? 'quiz.retakeProgress' : 'quiz.progress', { at: String(done), total: String(total) }), onBack: () => { actions.pop(); }, backLabel: t('action.back'), moreLabel: t('action.more') }), _jsxs("div", { className: css.body, children: [_jsx(StateLine, { loaded: loaded, t: t }), quiz !== undefined && (_jsx("div", { className: css.progress, "aria-hidden": "true", children: _jsx("i", { style: { width: `${total === 0 ? 0 : Math.round((done / total) * 100)}%` } }) })), quiz?.questions.map((question, at) => (_jsx(QuestionCard, { profile: profile, p: p, number: at + 1, question: question, answered: answers[question.id], draft: drafts[question.id] ?? '', onDraft: text => { recordDraft(question.id, text); }, onAnswer: entry => { record(question.id, entry); }, onExplain: () => explain(question, at + 1) }, question.id))), notice !== undefined && _jsx("div", { className: css.notice, children: notice }), quiz !== undefined && (_jsx("div", { className: css.actions, children: _jsx("button", { type: "button", className: css.primary, disabled: busy || handedOver || !allAnswered, onClick: () => { void finish(); }, children: handedOver ? t('quiz.handedOverButton') : busy ? t('quiz.finishing') : allAnswered ? t('quiz.finish') : t('quiz.remaining', { count: String(total - done) }) }) }))] })] }));
137
+ }
138
+ /** One question on the paper: its number, two tags, the answer control, the feedback. The evidence quote shows only after answering — quoted up front, it often IS the answer. */
139
+ function QuestionCard({ p, profile, question, number, answered, draft, onDraft, onAnswer, onExplain }) {
140
+ const { t } = p;
141
+ const [explained, setExplained] = useState();
142
+ const correct = question.kind === 'choice' ? choiceIndex(question.options ?? [], question.answer) : -1;
143
+ const evidenceTone = question.evidence.kind === 'photo' ? 'photo' : question.evidence.kind === 'web' ? 'web' : 'know';
144
+ // A choice or fill quote often IS the answer, so it waits for the feedback.
145
+ // An open question's quote frames the discussion but can hand the answer
146
+ // over just the same — it stays folded behind 参考原文 until peeked or answered.
147
+ const quoteUpfront = question.kind === 'free';
148
+ const [peek, setPeek] = useState(false);
149
+ // The answer box grows with what you type (up to a cap, then it scrolls).
150
+ const answerRef = useRef(null);
151
+ useLayoutEffect(() => {
152
+ const el = answerRef.current;
153
+ if (el === null)
154
+ return;
155
+ el.style.height = 'auto';
156
+ el.style.height = `${Math.min(el.scrollHeight, 240)}px`;
157
+ }, [draft, answered]);
158
+ return (_jsxs("section", { className: css.question, children: [_jsxs("div", { className: css.chips, children: [_jsx("span", { className: css.optionKey, "aria-hidden": "true", children: number }), _jsx("span", { className: css.chip, "data-tone": question.target.kind === 'asked' || question.target.kind === 'missed' ? 'weak' : undefined, children: t(`target.${question.target.kind}`) }), _jsx("span", { className: css.chip, "data-tone": evidenceTone, children: t(`evidence.${question.evidence.kind}`) })] }), _jsx("div", { className: css.prompt, children: _jsx(MathText, { text: question.prompt }) }), quoteUpfront && question.evidence.quote !== undefined && answered === undefined && (_jsx("div", { className: css.row, children: _jsx("button", { type: "button", className: css.textButton, "aria-expanded": peek, onClick: () => { setPeek(!peek); }, children: t(peek ? 'quiz.hideQuote' : 'quiz.showQuote') }) })), quoteUpfront && question.evidence.quote !== undefined && (peek || answered !== undefined) && (_jsx("blockquote", { className: css.quote, children: question.evidence.quote })), question.kind === 'choice' && (_jsx("div", { className: css.options, children: (question.options ?? []).map((option, at) => {
159
+ const chosen = answered?.given === option;
160
+ const state = answered === undefined || answered.void === true ? undefined : at === correct ? 'ok' : chosen ? 'bad' : undefined;
161
+ return (_jsxs("button", { type: "button", className: css.option, "data-state": state, disabled: answered !== undefined, onClick: () => { onAnswer({ given: option, pass: at === correct }); }, children: [_jsx("span", { className: css.optionKey, children: String.fromCharCode(65 + at) }), _jsx("span", { children: _jsx(MathText, { text: option }) })] }, option));
162
+ }) })), question.kind !== 'choice' && (_jsxs(_Fragment, { children: [_jsx("textarea", { ref: answerRef, className: css.answer, rows: question.kind === 'free' ? 4 : 1, value: answered?.given ?? draft, disabled: answered !== undefined, placeholder: t(question.kind === 'free' ? 'quiz.freePlaceholder' : /\$|\\[a-zA-Z]|[√∫∑∏∂∞±]/.test(question.prompt) ? 'quiz.fillMathPlaceholder' : 'quiz.fillPlaceholder'), onChange: event => { onDraft(event.target.value); } }), profile.math && _jsx(AnswerPreview, { text: answered?.given ?? draft, t: t })] })), answered !== undefined && answered.void !== true && (_jsxs("div", { className: css.feedback, children: [answered.pass === true && _jsx("strong", { className: css.ok, children: answered.overruled === true ? t('quiz.overruled') : t('quiz.right') }), answered.pass === false && _jsx("strong", { className: css.bad, children: _jsx(MathText, { text: t('quiz.wrong', { answer: correct >= 0 && question.options?.[correct] !== undefined ? `${String.fromCharCode(65 + correct)}. ${question.options[correct]}` : question.answer }) }) }), answered.pass === undefined && answered.unsure === true && (_jsxs(_Fragment, { children: [_jsx("strong", { children: t('quiz.unsure') }), _jsx("div", { className: css.muted, children: _jsx(MathText, { text: t('quiz.unsureKey', { answer: question.answer }) }) })] })), answered.pass === undefined && answered.unsure !== true && _jsx("strong", { children: t('quiz.coachJudges') }), question.explanation !== undefined && _jsx("div", { children: _jsx(MathText, { text: question.explanation }) }), !quoteUpfront && question.evidence.quote !== undefined && _jsx("blockquote", { className: css.quote, children: question.evidence.quote }), question.evidence.url !== undefined && _jsx("a", { className: css.link, href: question.evidence.url, target: "_blank", rel: "noreferrer", children: t('quiz.source') }), question.kind === 'fill' && (answered.pass === false || answered.unsure === true || answered.overruled === true) && (_jsx("div", { children: _jsx("button", { type: "button", className: css.textButton, onClick: () => { onAnswer(answered.overruled === true ? (profile.fill === 'spelling' ? { given: answered.given, pass: false } : { given: answered.given, unsure: true }) : { given: answered.given, pass: true, overruled: true }); }, children: answered.overruled === true ? t('quiz.overruleUndo') : t('quiz.overrule') }) }))] })), _jsxs("div", { className: css.questionTools, children: [question.kind !== 'choice' && answered === undefined && (_jsx("button", { type: "button", className: css.secondary, disabled: draft.trim() === '', onClick: () => { onAnswer(question.kind === 'fill' ? judgeFillAnswer(profile, draft, question.answer) : { given: draft }); }, children: t('quiz.submit') })), _jsx("span", { className: css.spacer }), _jsxs("button", { type: "button", className: css.toolChip, "data-done": explained === 'primed', title: t(answered === undefined || answered.void === true ? 'quiz.explainBeforeHint' : 'quiz.explainHint'), onClick: () => { setExplained(onExplain() ? 'primed' : 'failed'); }, children: [explained === 'primed' ? _jsx(IconCheckOutlineMedium, { size: 14 }) : _jsx(IconChatOutlineMedium, { size: 14 }), explained === 'primed' ? t('quiz.explainPrimed') : answered === undefined || answered.void === true ? t('quiz.explainBefore') : t('quiz.explain')] }), _jsxs("button", { type: "button", className: css.toolChip, "data-on": answered?.void === true, "aria-pressed": answered?.void === true, title: t('quiz.voidHint'), onClick: () => { onAnswer({ given: answered?.given ?? '', void: answered?.void !== true }); }, children: [_jsx(IconFlagOutlineMedium, { size: 14 }), answered?.void === true ? t('quiz.unvoid') : t('quiz.void')] })] }), explained === 'failed' && _jsx("div", { className: css.muted, children: t('notice.noConversation') })] }));
163
+ }
164
+ /** The answer box's live math preview: appears once the draft smells like math ($…$, a \command, or a plain ^ / _ like (x-2)^2); bare math previews as one inline formula. */
165
+ function AnswerPreview({ text, t }) {
166
+ if (!/\$|\\[a-zA-Z]|[\w)\]}][\^_]\{?[\w-]/.test(text))
167
+ return null;
168
+ return (_jsxs("div", { className: css.answerPreview, children: [_jsx("span", { className: css.muted, children: t('quiz.preview') }), _jsx("span", { className: css.answerPreviewBody, children: text.includes('$') ? _jsx(MathText, { text: text }) : _jsx(Tex, { tex: text }) })] }));
169
+ }
170
+ /** The sitting's result: the score, what unlocked, how the cards moved — and the loop's next step, which rides the conversation from here (a retake of the misses or a fresh set), so the flow never dead-ends on the score. */
171
+ export function ResultView({ p, projectId, resourceId, result }) {
172
+ const { t, actions } = p;
173
+ const [notice, setNotice] = useState();
174
+ const percent = Math.round(result.attempt.ratio * 100);
175
+ const passed = result.completed !== undefined;
176
+ // Flagged questions counted for nothing — say so once, right under the score.
177
+ const voidedCount = result.attempt.verdicts.filter(verdict => verdict.void === true).length;
178
+ const assessmentId = result.attempt.assessmentId;
179
+ // The paper itself, for the retake's question list; a gone one hides the asks.
180
+ const assessment = useLoad(() => assessmentId === undefined
181
+ ? Promise.resolve(undefined)
182
+ : outcome(p.getAssessment(projectId, assessmentId)).then(answer => answer.assessment, () => undefined), [projectId, assessmentId]);
183
+ const isRetake = assessment.data?.type === 'retake';
184
+ const questions = assessment.data?.type === 'quiz' || isRetake ? (assessment.data?.questions ?? []) : [];
185
+ const missedVerdicts = result.attempt.verdicts.filter(verdict => !verdict.pass && verdict.void !== true);
186
+ // What is still open from this sitting, as retake keys (a retake's question points back at its origin).
187
+ const missedKeys = missedVerdicts.flatMap(verdict => {
188
+ const question = questions.find(candidate => candidate.id === verdict.task);
189
+ if (question === undefined || assessmentId === undefined)
190
+ return [];
191
+ return [question.origin === undefined ? `${assessmentId}:${question.id}` : `${question.origin.assessmentId}:${question.origin.questionId}`];
192
+ });
193
+ const counted = result.attempt.verdicts.filter(verdict => verdict.void !== true);
194
+ const right = counted.filter(verdict => verdict.pass).length;
195
+ const stationId = assessment.data?.scope.nodeId;
196
+ const [retaking, setRetaking] = useState(false);
197
+ const prime = (text) => {
198
+ void p.bindTarget(resourceId === undefined ? { projectId } : { projectId, resourceId });
199
+ setNotice(p.primeConversation(text) ? t('notice.primed') : t('notice.noConversation'));
200
+ };
201
+ // 订正 in the sidebar: a retake paper over this sitting's misses (no coach needed).
202
+ const retake = () => {
203
+ if (missedKeys.length === 0 || retaking)
204
+ return;
205
+ setRetaking(true);
206
+ // A station's paper retakes on its station; a project-wide retake retakes across the project.
207
+ void outcome(p.createRetake(projectId, stationId === undefined ? undefined : resourceId, stationId, missedKeys)).then(created => {
208
+ actions.replace({ view: 'quiz', projectId, ...(resourceId === undefined ? {} : { resourceId }), quizId: created.assessment.id });
209
+ }, (failure) => { setNotice(failure instanceof Error ? failure.message : String(failure)); setRetaking(false); });
210
+ };
211
+ const again = () => {
212
+ if (assessmentId === undefined)
213
+ return;
214
+ prime(t('prime.result.again', { quiz: assessmentId, percent: String(percent) }));
215
+ };
216
+ const nextId = result.unlocked[0];
217
+ const canRetake = missedKeys.length > 0 && (isRetake || (stationId !== undefined && resourceId !== undefined));
218
+ const retakePrimary = (isRetake || !passed) && canRetake;
219
+ const nextPrimary = passed && nextId !== undefined;
220
+ const againPrimary = !retakePrimary && !nextPrimary;
221
+ return (_jsxs(_Fragment, { children: [_jsx(PanelHeader, { title: t('result.title'), onBack: () => { actions.pop(); }, backLabel: t('action.back'), moreLabel: t('action.more') }), _jsxs("div", { className: css.body, children: [isRetake ? (_jsx("section", { className: css.hero, children: _jsxs("div", { className: css.heroText, children: [_jsx("span", { className: css.big, children: t('result.retakeScore', { right: String(right), total: String(counted.length) }) }), _jsx("span", { className: css.muted, children: t('result.retakeNote') })] }) })) : (_jsxs("section", { className: css.hero, children: [_jsxs("div", { className: css.heroText, children: [_jsxs("span", { className: css.big, children: [percent, "%"] }), _jsx("span", { className: css.muted, children: t('result.line', { pass: String(Math.round(result.passRatio * 100)) }) })] }), _jsx("strong", { className: passed ? css.ok : css.bad, children: passed ? t('result.passed') : t('result.notYet') })] })), (result.pointChanges?.length ?? 0) > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('result.points') }), result.pointChanges.map(change => (_jsxs("div", { className: css.moveRow, children: [_jsx("span", { children: t('result.pointTo', { title: change.title, state: t(`point.${change.to}`) }) }), _jsxs("span", { className: change.to === 'mastered' ? css.ok : change.to === 'weak' ? css.bad : css.muted, children: [t(`point.${change.from}`), " \u2192 ", t(`point.${change.to}`)] })] }, change.pointId)))] })), voidedCount > 0 && _jsx("div", { className: css.muted, children: t('result.voided', { count: String(voidedCount) }) }), result.cardUpdates.length > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('result.cards') }), result.cardUpdates.map(update => (_jsxs("div", { className: css.moveRow, children: [_jsx("span", { children: update.term }), _jsx("span", { className: update.created === true ? css.bad : update.mastery.box > 0 ? css.ok : css.bad, children: update.created === true ? t('result.cardNew') : update.mastery.box > 0 ? t('result.cardUp') : t('result.cardBack') })] }, update.id)))] })), notice !== undefined && _jsx("div", { className: css.notice, children: notice }), _jsxs("div", { className: css.actions, children: [nextId !== undefined && resourceId !== undefined && (_jsx("button", { type: "button", className: nextPrimary ? css.primary : css.secondary, onClick: () => { actions.replace({ view: 'node', projectId, resourceId, nodeId: nextId, tab: 'guide' }); }, children: t('result.next') })), canRetake && (_jsx("button", { type: "button", className: retakePrimary ? css.primary : css.secondary, disabled: retaking, onClick: retake, children: t(isRetake ? 'result.retakeRest' : 'result.retake', { count: String(missedKeys.length) }) })), assessmentId !== undefined && !isRetake && (_jsx("button", { type: "button", className: againPrimary ? css.primary : css.secondary, onClick: again, children: t('result.again') })), isRetake && stationId !== undefined && resourceId !== undefined && (_jsx("button", { type: "button", className: retakePrimary ? css.secondary : css.primary, onClick: () => { actions.replace({ view: 'node', projectId, resourceId, nodeId: stationId, tab: 'quiz' }); }, children: t('result.toStation') })), resourceId !== undefined && (_jsx("button", { type: "button", className: css.ghost, onClick: () => { actions.replace({ view: 'resource', projectId, resourceId }); }, children: t('result.toPath') })), resourceId === undefined && (_jsx("button", { type: "button", className: css.ghost, onClick: () => { actions.replace({ view: 'project', projectId }); }, children: t('result.toPath') }))] })] })] }));
222
+ }
223
+ /** Days until the next review for a rating. */
224
+ function nextDays(card, rating) {
225
+ const box = rating === 'forgot' ? 0 : rating === 'fuzzy' ? card.mastery.box : Math.min(card.mastery.box + 1, 5);
226
+ return LEITNER_INTERVAL_DAYS[Math.min(box, LEITNER_INTERVAL_DAYS.length - 1)];
227
+ }
228
+ /**
229
+ * Daily review as answering, not looking (M4): each due card is asked the
230
+ * way that fits it — a word as a cloze over its own sentence or a choice of
231
+ * its meaning against the learner's other glosses, a well-known word as a
232
+ * spelling, a concept as a one-line explanation checked against the answer.
233
+ * The pane judges what it can and maps it to the schedule (first try →
234
+ * recalled, after a hint → fuzzy, missed → forgot); concepts are self-rated.
235
+ * 「翻卡」 in the header falls back to the plain flashcard.
236
+ */
237
+ export function ReviewView({ p, projectId }) {
238
+ const { t, actions } = p;
239
+ const loaded = useLoad(() => value(p.dueCards(projectId)), [projectId]);
240
+ const [queue, setQueue] = useState();
241
+ const [practice, setPractice] = useState(false);
242
+ const [at, setAt] = useState(0);
243
+ const [flip, setFlip] = useState(false);
244
+ const [combo, setCombo] = useState(0);
245
+ const [best, setBest] = useState(0);
246
+ const [outcomes, setOutcomes] = useState([]);
247
+ const [error, setError] = useState();
248
+ const due = loaded.data?.cards;
249
+ const cards = queue ?? due ?? [];
250
+ const card = cards[at];
251
+ // Distractors come from the same kind of card — a word's meaning against the
252
+ // other words' meanings — across the projects in this round, due or not.
253
+ const projectIds = useMemo(() => [...new Set((due ?? []).map(entry => entry.projectId))].slice(0, 3), [due]);
254
+ const words = useLoad(async () => {
255
+ const lists = await Promise.all(projectIds.map(id => value(p.listCards({ projectId: id, kind: 'word', limit: 60 }))));
256
+ return lists.flatMap(list => list.cards);
257
+ }, [projectIds.join(',')]);
258
+ const glossPool = useMemo(() => [...new Set([...(due ?? []), ...(words.data ?? [])].filter(entry => entry.kind === 'word').map(entry => shortGloss(entry.gloss)))], [due, words.data]);
259
+ // One rating per card: a double tap must not rate (and advance) twice.
260
+ const settling = useRef();
261
+ const settle = (rating) => {
262
+ // Keyed by the queue position: a stale second tap still carries the old position.
263
+ if (card === undefined || settling.current === at)
264
+ return;
265
+ settling.current = at;
266
+ const advance = () => {
267
+ const right = rating !== 'forgot';
268
+ const nextCombo = right ? combo + 1 : 0;
269
+ setCombo(nextCombo);
270
+ setBest(Math.max(best, nextCombo));
271
+ setOutcomes(previous => [...previous, { card, rating }]);
272
+ setAt(at + 1);
273
+ };
274
+ // A practice round (「再练错的」) never moves the schedule.
275
+ if (practice) {
276
+ advance();
277
+ return;
278
+ }
279
+ void outcome(p.reviewCard(card.projectId, card.id, rating)).then(advance, (failure) => { settling.current = undefined; setError(String(failure)); });
280
+ };
281
+ const again = () => {
282
+ settling.current = undefined;
283
+ const missed = outcomes.filter(entry => entry.rating === 'forgot').map(entry => entry.card);
284
+ setQueue(missed);
285
+ setPractice(true);
286
+ setAt(0);
287
+ setCombo(0);
288
+ setOutcomes([]);
289
+ };
290
+ return (_jsxs(_Fragment, { children: [_jsx(PanelHeader, { title: cards.length === 0 ? t('review.title') : card === undefined ? t('review.summaryTitle') : t(practice ? 'review.practiceProgress' : 'review.progress', { at: String(at + 1), total: String(cards.length) }), onBack: () => { actions.pop(); }, backLabel: t('action.back'), actions: [{ id: 'flip', label: t('review.flipMode'), icon: _jsx("span", { "aria-hidden": "true", children: "\u21C4" }), pressed: flip, onSelect: () => { setFlip(!flip); } }], moreLabel: t('action.more') }), _jsxs("div", { className: css.body, children: [_jsx(StateLine, { loaded: loaded, t: t }), card !== undefined && (_jsxs("div", { className: css.reviewHead, children: [_jsx("div", { className: css.progress, "aria-hidden": "true", children: _jsx("i", { style: { width: `${Math.round((at / cards.length) * 100)}%` } }) }), _jsx("span", { className: css.combo, "data-hot": combo >= 3 ? 'true' : 'false', children: t('review.combo', { count: String(combo) }) })] })), loaded.data !== undefined && cards.length === 0 && (_jsxs("div", { className: css.empty, children: [_jsx("div", { className: css.emptyTitle, children: t('review.nothing') }), _jsx("button", { type: "button", className: css.secondary, onClick: () => { actions.pop(); }, children: t('action.back') })] })), card !== undefined && (flip
291
+ ? _jsx(FlipCard, { p: p, card: card, onRate: settle }, card.id)
292
+ : _jsx(AskCard, { p: p, card: card, glossPool: glossPool, onRate: settle }, `${card.id}-${at}`)), loaded.data !== undefined && cards.length > 0 && card === undefined && (_jsx(Summary, { p: p, outcomes: outcomes, best: best, practice: practice, onAgain: again, onDone: () => { actions.pop(); } })), error !== undefined && _jsx("div", { className: css.error, children: error })] })] }));
293
+ }
294
+ /** Ask one card in the mode that fits it. */
295
+ function AskCard({ p, card, glossPool, onRate }) {
296
+ const { t } = p;
297
+ const others = glossPool.filter(gloss => gloss !== shortGloss(card.gloss));
298
+ const mode = pickMode(card, others.length);
299
+ const where = card.sources.find(source => source.type === 'photo')?.note;
300
+ const label = t(`review.mode.${mode}`) + (where === undefined ? '' : ` · ${where}`);
301
+ if (mode === 'choice')
302
+ return _jsx(ChoiceAsk, { p: p, card: card, others: others, label: label, onRate: onRate });
303
+ if (mode === 'explain')
304
+ return _jsx(ExplainAsk, { p: p, card: card, label: label, onRate: onRate });
305
+ return _jsx(TypeAsk, { p: p, card: card, mode: mode, label: label, onRate: onRate });
306
+ }
307
+ /** Cloze or spelling: type the word; one miss reveals more letters, a second shows it. */
308
+ function TypeAsk({ p, card, mode, label, onRate }) {
309
+ const { t } = p;
310
+ const blank = card.contexts.map(context => clozeOf(context.sentence, card.term, card.aliases)).find(found => found !== undefined);
311
+ const answer = blank?.answer ?? card.term;
312
+ const [input, setInput] = useState('');
313
+ const [misses, setMisses] = useState(0);
314
+ const [result, setResult] = useState();
315
+ const accepted = [answer, card.term, ...card.aliases];
316
+ const submit = () => {
317
+ if (spellMatches(input, accepted)) {
318
+ setResult('right');
319
+ return;
320
+ }
321
+ if (misses === 0) {
322
+ setMisses(1);
323
+ return;
324
+ }
325
+ setResult('wrong');
326
+ };
327
+ const rating = result === 'right' ? (misses === 0 ? 'recalled' : 'fuzzy') : 'forgot';
328
+ const variant = result === 'right' && input.trim().toLowerCase() !== answer.toLowerCase() && !accepted.some(word => word.toLowerCase() === input.trim().toLowerCase());
329
+ return (_jsxs("section", { className: css.qcard, children: [_jsx("span", { className: css.muted, children: label }), mode === 'spell' && _jsx("div", { className: css.askBig, children: card.gloss }), blank !== undefined && (_jsxs("div", { className: css.flashSentence, children: [blank.before, _jsx("span", { className: css.blank, "data-state": result, children: result === undefined ? '\u00a0' : answer }), blank.after] })), mode === 'cloze' && result === undefined && (_jsx("div", { className: css.muted, children: t('review.hint', { gloss: shortGloss(card.gloss), letters: answer.slice(0, misses + 1) }) })), mode === 'spell' && misses > 0 && result === undefined && _jsx("div", { className: css.muted, children: t('review.hintLetters', { letters: answer.slice(0, 2) }) }), result === undefined && (_jsxs("form", { className: css.row, onSubmit: event => { event.preventDefault(); submit(); }, children: [_jsx("input", { className: css.input, autoFocus: true, value: input, "aria-label": t('review.typeHere'), placeholder: t('review.typeHere'), onChange: event => { setInput(event.target.value); } }), _jsx("button", { type: "submit", className: css.secondary, disabled: input.trim() === '', children: t('quiz.submit') })] })), misses > 0 && result === undefined && _jsx("div", { className: css.letters, children: letterMarks(input, answer).map((ok, index) => _jsx("span", { "data-ok": ok ? 'true' : 'false', children: input[index] }, index)) }), result !== undefined && (_jsxs("div", { className: css.feedback, "data-state": result, children: [_jsx("strong", { className: result === 'right' ? css.ok : css.bad, children: result === 'right' ? t('review.right') : t('review.answerWas', { answer }) }), variant && _jsx("span", { className: css.muted, children: t('review.variant', { answer }) }), _jsx("span", { className: css.muted, children: t('review.nextIn', { days: String(nextDays(card, rating)) }) })] })), result !== undefined && _jsx("button", { type: "button", className: css.primary, onClick: () => { onRate(rating); }, children: t('quiz.next') })] }));
330
+ }
331
+ /** Which meaning in this sentence: the right gloss against three of the learner's own. */
332
+ function ChoiceAsk({ p, card, others, label, onRate }) {
333
+ const { t } = p;
334
+ const [{ options, correct }] = useState(() => choiceOptions(shortGloss(card.gloss), others));
335
+ const [picked, setPicked] = useState();
336
+ const sentence = card.contexts[card.mastery.streak % Math.max(card.contexts.length, 1)]?.sentence;
337
+ const right = picked === correct;
338
+ return (_jsxs("section", { className: css.qcard, children: [_jsx("span", { className: css.muted, children: label }), _jsx("div", { className: css.flashSentence, children: sentence === undefined ? card.term : _jsx(Highlight, { sentence: sentence, term: card.term }) }), _jsx("div", { className: css.muted, children: t('review.whichMeaning', { term: card.term }) }), _jsx("div", { className: css.options, children: options.map((option, index) => (_jsxs("button", { type: "button", className: css.option, disabled: picked !== undefined, "data-state": picked === undefined ? undefined : index === correct ? 'ok' : index === picked ? 'bad' : undefined, onClick: () => { setPicked(index); }, children: [_jsx("span", { className: css.optionKey, children: String.fromCharCode(65 + index) }), _jsx("span", { children: option })] }, option))) }), picked !== undefined && (_jsxs("div", { className: css.feedback, children: [_jsx("strong", { className: right ? css.ok : css.bad, children: right ? t('review.right') : t('review.meaningWas', { gloss: card.gloss }) }), _jsx("span", { className: css.muted, children: t('review.nextIn', { days: String(nextDays(card, right ? 'recalled' : 'forgot')) }) })] })), picked !== undefined && _jsx("button", { type: "button", className: css.primary, onClick: () => { onRate(right ? 'recalled' : 'forgot'); }, children: t('quiz.next') })] }));
339
+ }
340
+ /** A concept: say it in a sentence, then check against the answer (or hand it to the coach). */
341
+ function ExplainAsk({ p, card, label, onRate }) {
342
+ const { t } = p;
343
+ const [text, setText] = useState('');
344
+ const [shown, setShown] = useState(false);
345
+ const [sent, setSent] = useState(false);
346
+ const stuck = card.sources.slice().reverse().find(source => (source.type === 'chat' || source.type === 'quiz') && source.note !== undefined);
347
+ return (_jsxs("section", { className: css.qcard, children: [_jsxs("span", { className: css.muted, children: [label, stuck?.note === undefined ? '' : ` · ${t(stuck.type === 'quiz' ? 'review.youMissed' : 'review.youAsked', { note: stuck.note })}`] }), _jsx("div", { className: css.askBig, children: t('review.askConcept', { term: card.term }) }), _jsx("textarea", { className: css.answer, rows: 3, value: text, disabled: shown, "aria-label": t('review.typeHere'), placeholder: t('review.explainHere'), onChange: event => { setText(event.target.value); } }), !shown && _jsx("button", { type: "button", className: css.secondary, onClick: () => { setShown(true); }, children: t('review.check') }), shown && (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.feedback, children: [_jsx("strong", { children: t('review.reference') }), _jsx("span", { children: card.gloss })] }), _jsx("div", { className: css.rate, children: ['forgot', 'fuzzy', 'recalled'].map(rating => (_jsxs("button", { type: "button", className: css.rateButton, onClick: () => { onRate(rating); }, children: [_jsx("span", { children: t(`review.self.${rating}`) }), _jsx("span", { className: css.muted, children: t('review.inDays', { days: String(nextDays(card, rating)) }) })] }, rating))) }), text.trim() !== '' && !sent && (_jsx("button", { type: "button", className: css.textButton, onClick: () => { setSent(p.sendMessage(t('prime.judgeExplain', { term: card.term, answer: text.trim() }))); }, children: t('review.askCoachJudge') })), sent && _jsx("div", { className: css.muted, children: t('node.fixSent') })] }))] }));
348
+ }
349
+ /** The plain flashcard (「翻卡」): front, flip, self-rate. */
350
+ function FlipCard({ p, card, onRate }) {
351
+ const { t } = p;
352
+ const [flipped, setFlipped] = useState(false);
353
+ const sentence = card.contexts.length === 0 ? undefined : card.contexts[card.mastery.streak % card.contexts.length].sentence;
354
+ return (_jsxs("section", { className: css.flash, children: [_jsxs("div", { className: css.flashFront, children: [_jsx("div", { className: css.flashSentence, children: sentence === undefined ? card.term : _jsx(Highlight, { sentence: sentence, term: card.term }) }), _jsx("div", { className: css.muted, children: card.kind === 'word' ? t('review.askWord', { term: card.term }) : t('review.askConcept', { term: card.term }) })] }), flipped && _jsxs("div", { className: css.flashBack, children: [_jsx("strong", { children: card.gloss }), card.userGloss !== undefined && _jsx("div", { className: css.muted, children: t('cards.userGloss', { gloss: card.userGloss }) })] }), !flipped
355
+ ? _jsx("button", { type: "button", className: css.secondary, onClick: () => { setFlipped(true); }, children: t('review.flip') })
356
+ : (_jsx("div", { className: css.rate, children: ['forgot', 'fuzzy', 'recalled'].map(rating => (_jsxs("button", { type: "button", className: css.rateButton, onClick: () => { onRate(rating); }, children: [_jsx("span", { children: t(`review.${rating}`) }), _jsx("span", { className: css.muted, children: t('review.inDays', { days: String(nextDays(card, rating)) }) })] }, rating))) }))] }));
357
+ }
358
+ /** The round's summary: facts only — no score, no XP. */
359
+ function Summary({ p, outcomes, best, practice, onAgain, onDone }) {
360
+ const { t } = p;
361
+ const right = outcomes.filter(entry => entry.rating !== 'forgot');
362
+ const up = outcomes.filter(entry => entry.rating === 'recalled');
363
+ const steady = outcomes.filter(entry => entry.rating === 'fuzzy');
364
+ const missed = outcomes.filter(entry => entry.rating === 'forgot');
365
+ return (_jsxs("section", { className: css.summary, children: [_jsxs("div", { className: css.row, children: [_jsxs("span", { className: css.big, children: [right.length, "/", outcomes.length] }), _jsx("span", { className: css.muted, children: t('review.summaryLine', { best: String(best) }) })] }), practice && _jsx("div", { className: css.muted, children: t('review.practiceNote') }), missed.length > 0 && (_jsxs("div", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t(practice ? 'review.stillMissed' : 'review.backTomorrow', { count: String(missed.length) }) }), _jsx("div", { className: css.chips, children: missed.map(entry => _jsx("span", { className: css.chip, "data-tone": "weak", children: entry.card.term }, entry.card.id)) })] })), !practice && up.length > 0 && (_jsxs("div", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('review.movedUp', { count: String(up.length) }) }), _jsx("div", { className: css.chips, children: up.slice(0, 12).map(entry => _jsx("span", { className: css.chip, "data-tone": "photo", children: entry.card.term }, entry.card.id)) })] })), !practice && steady.length > 0 && (_jsxs("div", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('review.steady', { count: String(steady.length) }) }), _jsx("div", { className: css.chips, children: steady.slice(0, 12).map(entry => _jsx("span", { className: css.chip, children: entry.card.term }, entry.card.id)) })] })), _jsxs("div", { className: css.actions, children: [missed.length > 0 && _jsx("button", { type: "button", className: css.primary, onClick: onAgain, children: t('review.again', { count: String(missed.length) }) }), _jsx("button", { type: "button", className: css.secondary, onClick: onDone, children: t('review.done.back') })] })] }));
366
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A station's gate tab: 章节考 on a quiz / read station (the paper card, the
3
+ * points, the ask settings with the pending ask kept per station, the
4
+ * station's own mistakes folded into one card, earlier papers), and the
5
+ * acceptance sheet on an acceptance station.
6
+ *
7
+ * @module @khorsheed/dsh-library/client/views/station-quiz
8
+ */
9
+ import { type ReactNode } from 'react';
10
+ import { type LibraryQuizFocus, type LibraryQuizQuestion, type RoadmapNode } from '../../types.ts';
11
+ import { type ViewProps } from '../ui.tsx';
12
+ /**
13
+ * 章节考 on a quiz / read station, one line of travel (proposal
14
+ * 2026-10-19-library-point-mastery): the pass line, the points map (tap a
15
+ * point for why and what to do), then ONE primary action by state — an
16
+ * untaken paper → 开始作答; open misses → 开始订正 (a retake paper, judged
17
+ * here, never counted toward passing); else → 出题, its settings folded into
18
+ * a one-line summary beside the button. Earlier papers fold at the bottom.
19
+ * The settings and the pending ask live in the pane store per station.
20
+ */
21
+ export declare function QuizTab({ p, projectId, resourceId, node, passRatio, onPrime, onReplace }: {
22
+ p: ViewProps['p'];
23
+ projectId: string;
24
+ resourceId: string | undefined;
25
+ node: RoadmapNode;
26
+ passRatio: number;
27
+ onPrime: (focus: LibraryQuizFocus, context?: {
28
+ weak?: readonly string[];
29
+ points?: readonly string[];
30
+ kinds?: readonly LibraryQuizQuestion['kind'][];
31
+ count?: number | undefined;
32
+ }) => void;
33
+ onReplace: (prompts: string[]) => void;
34
+ }): ReactNode;
35
+ /** An acceptance station's gate tab: the rubric, the submit gesture, the history. */
36
+ export declare function AcceptanceTab({ p, projectId, node, passRatio, onPrime }: {
37
+ p: ViewProps['p'];
38
+ projectId: string;
39
+ node: RoadmapNode;
40
+ passRatio: number;
41
+ onPrime: () => void;
42
+ }): ReactNode;
43
+ //# sourceMappingURL=station-quiz.d.ts.map