@khorsheed/dsh-library 0.0.0-stage → 0.1.1

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 (130) hide show
  1. package/CHANGELOG.md +19 -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 +66334 -0
  7. package/lib/index.js +3318 -0
  8. package/lib/profiles-CFrPQdfO.js +1692 -0
  9. package/lib/tool.js +1597 -0
  10. package/lib/typert.host.d.ts +3 -0
  11. package/lib/typert.host.js +3811 -0
  12. package/lib/typert.remote-client.d.ts +137 -0
  13. package/lib/typert.remote-client.js +3077 -0
  14. package/lib/types/cards.d.ts +41 -0
  15. package/lib/types/cards.js +111 -0
  16. package/lib/types/client/LibraryPane.d.ts +26 -0
  17. package/lib/types/client/LibraryPane.js +129 -0
  18. package/lib/types/client/bind/BindChip.d.ts +33 -0
  19. package/lib/types/client/bind/BindChip.js +102 -0
  20. package/lib/types/client/contract.d.ts +167 -0
  21. package/lib/types/client/contract.js +1 -0
  22. package/lib/types/client/covers.d.ts +36 -0
  23. package/lib/types/client/covers.js +66 -0
  24. package/lib/types/client/definition.d.ts +27 -0
  25. package/lib/types/client/definition.js +32 -0
  26. package/lib/types/client/icons-local.d.ts +41 -0
  27. package/lib/types/client/icons-local.js +25 -0
  28. package/lib/types/client/icons.d.ts +27 -0
  29. package/lib/types/client/icons.js +11 -0
  30. package/lib/types/client/index.d.ts +35 -0
  31. package/lib/types/client/index.js +231 -0
  32. package/lib/types/client/judge.d.ts +70 -0
  33. package/lib/types/client/judge.js +262 -0
  34. package/lib/types/client/locales.d.ts +553 -0
  35. package/lib/types/client/locales.js +1076 -0
  36. package/lib/types/client/look.d.ts +63 -0
  37. package/lib/types/client/look.js +64 -0
  38. package/lib/types/client/math.d.ts +24 -0
  39. package/lib/types/client/math.js +47 -0
  40. package/lib/types/client/mistakes.d.ts +21 -0
  41. package/lib/types/client/mistakes.js +28 -0
  42. package/lib/types/client/preset-visibility.d.ts +109 -0
  43. package/lib/types/client/preset-visibility.js +123 -0
  44. package/lib/types/client/quiz-drafts.d.ts +29 -0
  45. package/lib/types/client/quiz-drafts.js +50 -0
  46. package/lib/types/client/reveal.d.ts +21 -0
  47. package/lib/types/client/reveal.js +31 -0
  48. package/lib/types/client/store.d.ts +111 -0
  49. package/lib/types/client/store.js +48 -0
  50. package/lib/types/client/titles.d.ts +17 -0
  51. package/lib/types/client/titles.js +23 -0
  52. package/lib/types/client/ui.d.ts +96 -0
  53. package/lib/types/client/ui.js +124 -0
  54. package/lib/types/client/views/book.d.ts +57 -0
  55. package/lib/types/client/views/book.js +164 -0
  56. package/lib/types/client/views/cards.d.ts +17 -0
  57. package/lib/types/client/views/cards.js +53 -0
  58. package/lib/types/client/views/home.d.ts +26 -0
  59. package/lib/types/client/views/home.js +263 -0
  60. package/lib/types/client/views/lesson.d.ts +42 -0
  61. package/lib/types/client/views/lesson.js +146 -0
  62. package/lib/types/client/views/path.d.ts +32 -0
  63. package/lib/types/client/views/path.js +64 -0
  64. package/lib/types/client/views/quiz.d.ts +42 -0
  65. package/lib/types/client/views/quiz.js +366 -0
  66. package/lib/types/client/views/station-quiz.d.ts +43 -0
  67. package/lib/types/client/views/station-quiz.js +203 -0
  68. package/lib/types/client/views/station.d.ts +18 -0
  69. package/lib/types/client/views/station.js +142 -0
  70. package/lib/types/image-refs.d.ts +35 -0
  71. package/lib/types/image-refs.js +71 -0
  72. package/lib/types/index.d.ts +51 -0
  73. package/lib/types/index.js +31 -0
  74. package/lib/types/mastery.d.ts +81 -0
  75. package/lib/types/mastery.js +110 -0
  76. package/lib/types/profiles.d.ts +31 -0
  77. package/lib/types/profiles.js +25 -0
  78. package/lib/types/remote.d.ts +355 -0
  79. package/lib/types/remote.js +332 -0
  80. package/lib/types/service/assess.d.ts +144 -0
  81. package/lib/types/service/assess.js +773 -0
  82. package/lib/types/service/bindings.d.ts +92 -0
  83. package/lib/types/service/bindings.js +198 -0
  84. package/lib/types/service/cards.d.ts +85 -0
  85. package/lib/types/service/cards.js +320 -0
  86. package/lib/types/service/core.d.ts +124 -0
  87. package/lib/types/service/core.js +169 -0
  88. package/lib/types/service/pages.d.ts +60 -0
  89. package/lib/types/service/pages.js +125 -0
  90. package/lib/types/service/roadmap.d.ts +99 -0
  91. package/lib/types/service/roadmap.js +284 -0
  92. package/lib/types/service/rules.d.ts +63 -0
  93. package/lib/types/service/rules.js +245 -0
  94. package/lib/types/service/shelf.d.ts +136 -0
  95. package/lib/types/service/shelf.js +422 -0
  96. package/lib/types/service.d.ts +42 -0
  97. package/lib/types/service.js +41 -0
  98. package/lib/types/store.d.ts +257 -0
  99. package/lib/types/store.js +1376 -0
  100. package/lib/types/tool-definition.d.ts +49 -0
  101. package/lib/types/tool-definition.js +49 -0
  102. package/lib/types/tool.d.ts +71 -0
  103. package/lib/types/tool.js +174 -0
  104. package/lib/types/tools/assess.d.ts +11 -0
  105. package/lib/types/tools/assess.js +82 -0
  106. package/lib/types/tools/binding.d.ts +11 -0
  107. package/lib/types/tools/binding.js +46 -0
  108. package/lib/types/tools/cards.d.ts +11 -0
  109. package/lib/types/tools/cards.js +100 -0
  110. package/lib/types/tools/kit.d.ts +215 -0
  111. package/lib/types/tools/kit.js +281 -0
  112. package/lib/types/tools/notes.d.ts +11 -0
  113. package/lib/types/tools/notes.js +40 -0
  114. package/lib/types/tools/page.d.ts +11 -0
  115. package/lib/types/tools/page.js +59 -0
  116. package/lib/types/tools/project.d.ts +11 -0
  117. package/lib/types/tools/project.js +67 -0
  118. package/lib/types/tools/quiz.d.ts +11 -0
  119. package/lib/types/tools/quiz.js +74 -0
  120. package/lib/types/tools/read.d.ts +11 -0
  121. package/lib/types/tools/read.js +127 -0
  122. package/lib/types/tools/resource.d.ts +11 -0
  123. package/lib/types/tools/resource.js +147 -0
  124. package/lib/types/tools/roadmap.d.ts +11 -0
  125. package/lib/types/tools/roadmap.js +102 -0
  126. package/lib/types/types.d.ts +752 -0
  127. package/lib/types/types.js +159 -0
  128. package/locale/en.json +6 -0
  129. package/locale/zh.json +6 -0
  130. package/package.json +135 -3
@@ -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