@lernapps/app-templates 0.0.1 → 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 (232) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +195 -2
  3. package/dist/appname-Bh23cUuz.mjs +12 -0
  4. package/dist/assessment-Dy359O1O.mjs +1696 -0
  5. package/dist/block-BK_ohsH1.mjs +82 -0
  6. package/dist/blocks-CxtCPAa5.mjs +149 -0
  7. package/dist/blocks.mjs +4 -0
  8. package/dist/check-CteIFN96.mjs +254 -0
  9. package/dist/content-DO6oEJxj.mjs +140 -0
  10. package/dist/contract-PONpxJtd.mjs +52 -0
  11. package/dist/e2e-CU0n_unV.mjs +89 -0
  12. package/dist/engine-CArxqizW.mjs +136 -0
  13. package/dist/explainer-e2e.mjs +1311 -0
  14. package/dist/explainer-islands.mjs +197 -0
  15. package/dist/explainer-plugin.mjs +2132 -0
  16. package/dist/explainer.mjs +1246 -0
  17. package/dist/html-mffkHGHh.mjs +89 -0
  18. package/dist/image-CspJVfyQ.mjs +21 -0
  19. package/dist/interactive-e2e.mjs +140 -0
  20. package/dist/interactive-plugin.mjs +242 -0
  21. package/dist/interactive.mjs +75 -0
  22. package/dist/load-RosvaPZf.mjs +16976 -0
  23. package/dist/needs-BzEahJ6X.mjs +97 -0
  24. package/dist/pages-9RaaomoO.mjs +81 -0
  25. package/dist/questions-e2e.mjs +1 -0
  26. package/dist/questions-engine.mjs +3 -0
  27. package/dist/questions.mjs +6 -0
  28. package/dist/quiz-DnW-xtCh.mjs +70 -0
  29. package/dist/quiz-e2e.mjs +174 -0
  30. package/dist/quiz-plugin.mjs +266 -0
  31. package/dist/quiz.mjs +246 -0
  32. package/dist/render-D5kwkNt1.mjs +507 -0
  33. package/dist/render-DqHOJyHK.mjs +146 -0
  34. package/dist/render-GS1Htlu8.mjs +545 -0
  35. package/dist/result-D_mmCVwN.mjs +180 -0
  36. package/dist/tasks-DADUYy52.mjs +74 -0
  37. package/dist/validate-BDwL7Su3.mjs +1076 -0
  38. package/dist/validate-Bxy9NfB8.mjs +146 -0
  39. package/dist/vue-DO1m6q1R.mjs +40 -0
  40. package/dist/vue-code-BkJjW_I_.mjs +320 -0
  41. package/docs/arc42/01-introduction-and-goals.arc42.md +82 -0
  42. package/docs/arc42/02-constraints.arc42.md +228 -0
  43. package/docs/arc42/03-system-context.arc42.md +118 -0
  44. package/docs/arc42/04-solution-strategy.arc42.md +47 -0
  45. package/docs/arc42/05-building-blocks.arc42.md +307 -0
  46. package/docs/arc42/06-runtime-view.arc42.md +98 -0
  47. package/docs/arc42/07-deployment-view.arc42.md +160 -0
  48. package/docs/arc42/08-concepts.arc42.md +123 -0
  49. package/docs/arc42/09-decisions.arc42.md +246 -0
  50. package/docs/arc42/10-quality-requirements.arc42.md +155 -0
  51. package/docs/arc42/11-risks.arc42.md +113 -0
  52. package/docs/arc42/12-glossary.arc42.md +162 -0
  53. package/docs/arc42/architecture-evidence.md +395 -0
  54. package/explainer/.arc42ignore +3 -0
  55. package/explainer/README.md +54 -0
  56. package/explainer/_gitignore +5 -0
  57. package/explainer/docs/arc42/03-explainer-system-context.arc42.md +35 -0
  58. package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +659 -0
  59. package/explainer/docs/arc42/06-explainer-runtime-view.arc42.md +105 -0
  60. package/explainer/docs/arc42/07-explainer-deployment-view.arc42.md +34 -0
  61. package/explainer/docs/arc42/08-explainer-concepts.arc42.md +17 -0
  62. package/explainer/docs/arc42/09-explainer-decisions.arc42.md +443 -0
  63. package/explainer/docs/arc42/10-explainer-quality-requirements.arc42.md +54 -0
  64. package/explainer/docs/arc42/11-explainer-risks.arc42.md +17 -0
  65. package/explainer/docs/arc42/12-explainer-glossary.arc42.md +185 -0
  66. package/explainer/e2e/explainer.e2e.ts +3 -0
  67. package/explainer/index.html +11 -0
  68. package/explainer/package.json +18 -0
  69. package/explainer/playwright.config.ts +1 -0
  70. package/explainer/src/app.yaml +20 -0
  71. package/explainer/src/components/ProzentFeld.test.ts +30 -0
  72. package/explainer/src/components/ProzentFeld.ts +121 -0
  73. package/explainer/src/components/ProzentFeld.vue +84 -0
  74. package/explainer/src/images/hunderterfeld-klammern.svg +105 -0
  75. package/explainer/src/main.ts +4 -0
  76. package/explainer/src/prozent/chapter.yaml +18 -0
  77. package/explainer/src/prozent/grundwert.yaml +115 -0
  78. package/explainer/src/prozent/prozentsatz.yaml +99 -0
  79. package/explainer/src/prozent/prozentwert.yaml +119 -0
  80. package/explainer/tsconfig.json +1 -0
  81. package/explainer/vite.config.ts +4 -0
  82. package/interactive/.arc42ignore +5 -0
  83. package/interactive/README.md +28 -0
  84. package/interactive/_gitignore +4 -0
  85. package/interactive/docs/arc42/05-interactive-building-blocks.arc42.md +518 -0
  86. package/interactive/docs/arc42/06-interactive-runtime-view.arc42.md +58 -0
  87. package/interactive/docs/arc42/07-interactive-deployment-view.arc42.md +35 -0
  88. package/interactive/docs/arc42/08-interactive-concepts.arc42.md +52 -0
  89. package/interactive/docs/arc42/09-interactive-decisions.arc42.md +130 -0
  90. package/interactive/docs/arc42/10-interactive-quality-requirements.arc42.md +45 -0
  91. package/interactive/docs/arc42/11-interactive-risks.arc42.md +20 -0
  92. package/interactive/docs/arc42/12-interactive-glossary.arc42.md +80 -0
  93. package/interactive/e2e/interactive.e2e.ts +3 -0
  94. package/interactive/index.html +11 -0
  95. package/interactive/package.json +18 -0
  96. package/interactive/playwright.config.ts +1 -0
  97. package/interactive/src/app.yaml +28 -0
  98. package/interactive/src/core/index.ts +101 -0
  99. package/interactive/src/data/lessons.json +4 -0
  100. package/interactive/src/env.d.ts +6 -0
  101. package/interactive/src/main.ts +6 -0
  102. package/interactive/src/messages/de.json +12 -0
  103. package/interactive/src/playthroughs/grundreihe.yaml +33 -0
  104. package/interactive/src/playthroughs/obere-reihe.yaml +14 -0
  105. package/interactive/src/ui/App.vue +85 -0
  106. package/interactive/tsconfig.json +1 -0
  107. package/interactive/vite.config.ts +4 -0
  108. package/package.json +143 -4
  109. package/quiz/README.md +31 -0
  110. package/quiz/_gitignore +5 -0
  111. package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +283 -0
  112. package/quiz/docs/arc42/06-quiz-runtime-view.arc42.md +21 -0
  113. package/quiz/docs/arc42/07-quiz-deployment-view.arc42.md +33 -0
  114. package/quiz/docs/arc42/09-quiz-decisions.arc42.md +36 -0
  115. package/quiz/docs/arc42/10-quiz-quality-requirements.arc42.md +53 -0
  116. package/quiz/docs/arc42/11-quiz-risks.arc42.md +19 -0
  117. package/quiz/docs/arc42/12-quiz-glossary.arc42.md +27 -0
  118. package/quiz/e2e/quiz.e2e.ts +3 -0
  119. package/quiz/index.html +11 -0
  120. package/quiz/package.json +18 -0
  121. package/quiz/playwright.config.ts +1 -0
  122. package/quiz/src/images/berge-ordnen.jpg +0 -0
  123. package/quiz/src/images/everest-hoehe.jpg +0 -0
  124. package/quiz/src/images/galapagos.jpg +0 -0
  125. package/quiz/src/images/grand-canyon.jpg +0 -0
  126. package/quiz/src/images/totes-meer.jpg +0 -0
  127. package/quiz/src/images/uluru.jpg +0 -0
  128. package/quiz/src/images/wasserfaelle-laender.jpg +0 -0
  129. package/quiz/src/main.ts +4 -0
  130. package/quiz/src/quiz.json +312 -0
  131. package/quiz/tsconfig.json +1 -0
  132. package/quiz/vite.config.ts +4 -0
  133. package/runtime/explainer/assessment-client.ts +478 -0
  134. package/runtime/explainer/assessment-page.ts +448 -0
  135. package/runtime/explainer/assessment.ts +393 -0
  136. package/runtime/explainer/check-page.ts +520 -0
  137. package/runtime/explainer/components/exercise.ts +409 -0
  138. package/runtime/explainer/components/props.ts +230 -0
  139. package/runtime/explainer/components/read.ts +428 -0
  140. package/runtime/explainer/content.ts +277 -0
  141. package/runtime/explainer/e2e.ts +1488 -0
  142. package/runtime/explainer/exercises.ts +326 -0
  143. package/runtime/explainer/explainer.css +1283 -0
  144. package/runtime/explainer/explainer.ts +838 -0
  145. package/runtime/explainer/explainer.v1.schema.json +1165 -0
  146. package/runtime/explainer/expression.ts +407 -0
  147. package/runtime/explainer/fraction.ts +107 -0
  148. package/runtime/explainer/generated/comparator.ts +31 -0
  149. package/runtime/explainer/generated/question.ts +165 -0
  150. package/runtime/explainer/generated/soundness.ts +280 -0
  151. package/runtime/explainer/generated/texts.ts +257 -0
  152. package/runtime/explainer/generated/types.ts +103 -0
  153. package/runtime/explainer/generated/values.ts +136 -0
  154. package/runtime/explainer/islands.ts +281 -0
  155. package/runtime/explainer/load.ts +457 -0
  156. package/runtime/explainer/messages/de.json +200 -0
  157. package/runtime/explainer/messages/en.json +200 -0
  158. package/runtime/explainer/messages.ts +13 -0
  159. package/runtime/explainer/needs.ts +122 -0
  160. package/runtime/explainer/open-task.ts +235 -0
  161. package/runtime/explainer/outline.ts +306 -0
  162. package/runtime/explainer/plugin.ts +488 -0
  163. package/runtime/explainer/position.ts +62 -0
  164. package/runtime/explainer/prompt.ts +75 -0
  165. package/runtime/explainer/render.ts +686 -0
  166. package/runtime/explainer/result.ts +238 -0
  167. package/runtime/explainer/routes.ts +119 -0
  168. package/runtime/explainer/schema.ts +140 -0
  169. package/runtime/explainer/scope.ts +87 -0
  170. package/runtime/explainer/stuck.ts +57 -0
  171. package/runtime/explainer/tasks.ts +109 -0
  172. package/runtime/explainer/tutor.ts +469 -0
  173. package/runtime/explainer/tutorpage.ts +669 -0
  174. package/runtime/explainer/video.ts +123 -0
  175. package/runtime/interactive/contract.ts +121 -0
  176. package/runtime/interactive/e2e.ts +190 -0
  177. package/runtime/interactive/interactive.css +82 -0
  178. package/runtime/interactive/interactive.ts +115 -0
  179. package/runtime/interactive/interactive.v1.schema.json +146 -0
  180. package/runtime/interactive/load.ts +188 -0
  181. package/runtime/interactive/messages/de.json +14 -0
  182. package/runtime/interactive/messages/en.json +14 -0
  183. package/runtime/interactive/messages.ts +11 -0
  184. package/runtime/interactive/playthrough.v1.schema.json +143 -0
  185. package/runtime/interactive/plugin.ts +266 -0
  186. package/runtime/interactive/render.ts +126 -0
  187. package/runtime/quiz/e2e.ts +233 -0
  188. package/runtime/quiz/engine.ts +242 -0
  189. package/runtime/quiz/messages/de.json +23 -0
  190. package/runtime/quiz/messages/en.json +23 -0
  191. package/runtime/quiz/plugin.ts +150 -0
  192. package/runtime/quiz/quiz.css +154 -0
  193. package/runtime/quiz/quiz.ts +56 -0
  194. package/runtime/quiz/quiz.v1.schema.json +72 -0
  195. package/runtime/quiz/render.ts +173 -0
  196. package/runtime/quiz/validate.ts +108 -0
  197. package/runtime/shared/appname.ts +16 -0
  198. package/runtime/shared/blocks/block.ts +162 -0
  199. package/runtime/shared/blocks/blocks.css +190 -0
  200. package/runtime/shared/blocks/blocks.v1.schema.json +432 -0
  201. package/runtime/shared/blocks/code.ts +26 -0
  202. package/runtime/shared/blocks/figures.ts +49 -0
  203. package/runtime/shared/blocks/image.ts +25 -0
  204. package/runtime/shared/blocks/index.ts +13 -0
  205. package/runtime/shared/blocks/messages/de.json +4 -0
  206. package/runtime/shared/blocks/messages/en.json +4 -0
  207. package/runtime/shared/blocks/render.ts +134 -0
  208. package/runtime/shared/blocks/validate.ts +165 -0
  209. package/runtime/shared/content.ts +197 -0
  210. package/runtime/shared/examples.ts +20 -0
  211. package/runtime/shared/html.ts +10 -0
  212. package/runtime/shared/languages.ts +94 -0
  213. package/runtime/shared/page.css +101 -0
  214. package/runtime/shared/pages.ts +114 -0
  215. package/runtime/shared/questions/e2e.ts +115 -0
  216. package/runtime/shared/questions/engine.ts +209 -0
  217. package/runtime/shared/questions/index.ts +39 -0
  218. package/runtime/shared/questions/messages/de.json +26 -0
  219. package/runtime/shared/questions/messages/en.json +26 -0
  220. package/runtime/shared/questions/question.ts +433 -0
  221. package/runtime/shared/questions/question.v1.schema.json +346 -0
  222. package/runtime/shared/questions/questions.css +192 -0
  223. package/runtime/shared/questions/render.ts +255 -0
  224. package/runtime/shared/questions/validate.ts +518 -0
  225. package/runtime/shared/schema.ts +44 -0
  226. package/runtime/shared/text/check.ts +68 -0
  227. package/runtime/shared/text/formula.ts +69 -0
  228. package/runtime/shared/text/markdown.ts +185 -0
  229. package/runtime/shared/text/speech.ts +39 -0
  230. package/runtime/shared/text/text.v1.schema.json +34 -0
  231. package/runtime/shared/vue-code.ts +395 -0
  232. package/runtime/shared/vue.ts +38 -0
@@ -0,0 +1,838 @@
1
+ // The client module of an explainer (@lernapps/app-templates/explainer), loaded by every page from the app's
2
+ // src/main.ts. Every page is complete without JavaScript; this module only adds: it marks the page as running with
3
+ // JavaScript (class explainer-js on <html>) and makes its exercises live. Every exercise is answered before its
4
+ // solution shows: a wrong answer gets its reason and may be tried again, and the solution shows after the right
5
+ // answer or "Lösung zeigen" (startQuestion of the shared questions with retry). A question of one of the question types gets
6
+ // the question's controls, "Prüfen" and its feedback; its "Lösung" for readers without JavaScript goes, as the question
7
+ // shows the same after the answer. A generated exercise gets a live task: from the seed in the address when the
8
+ // address names its task number (?<exercise>=<n>), else from a random one; its seed from the exercise's table
9
+ // of distinct tasks (tasks.ts), "Aufgabe Nr. <n>" above it; the question's controls, checker and feedback through its
10
+ // comparator, the message of a typical mistake, and "Neue Aufgabe", which moves to the next task number; the address
11
+ // always holds the task number of every exercise of the page. Its worked tasks, with their solutions, stay hidden with
12
+ // JavaScript. The page keeps in memory how far the learner got with each task (result.ts): a page a tutor opened
13
+ // (via=tutor in the address, beside any seed) gets "Zurück zum Tutor", which copies a result line with the address of
14
+ // the page's tutor page for the running chat; the tab stays open, and its button says where to paste. The learner
15
+ // switches a way to the tutor on once, on the start page or beside the title of a page: an assistant of the app, or copying the text; it is kept on the device.
16
+ // Two ways lead to the tutor, both naming the page's tutor page with where the learner is in its query (position.ts),
17
+ // the one source of what the tutor gets (tutorpage.ts). A: while a way is on, a bar beside the title of a content
18
+ // page, "Mit <name> lernen", opens the assistant with the start prompt (prompt.ts). B: the help button in the row of
19
+ // "Prüfen" and "Lösung" of every exercise, there only while a way is on, "Hilfe" and after a wrong answer "Warum?",
20
+ // gives the lines of that task (stuck.ts: the task, the input, the app's message word for word, the tutor page):
21
+ // without a chat (B1) it opens the way switched on with them; on a page a tutor opened (B2) it copies the lines and
22
+ // says where to paste them, and once the task is done it is "Zurück", which copies the result line. Copying instead
23
+ // of an assistant adds the tutor page's Markdown. Input and message leave the page only with the click. A video gets its
24
+ // button, which loads its player, and the choice "Videos immer direkt laden" (video.ts), kept on the device too. A
25
+ // self-assessment runs on its own page, offered on the start page or its chapter's page (assessment-client.ts); its
26
+ // way to the tutor is the one switched on, or one chosen there.
27
+ import { moveFocus } from "@lernapps/tooling/a11y";
28
+ import { createSettings, createStorage, type SettingScope } from "@lernapps/tooling/storage";
29
+ import { startQuestion } from "../shared/questions/engine.ts";
30
+ import { renderQuestion } from "../shared/questions/render.ts";
31
+ import { questionId } from "../shared/questions/question.ts";
32
+ import type { ExerciseData } from "./exercises.ts";
33
+ import { diagnose, questionFor } from "./generated/question.ts";
34
+ import { hintOf, stepsOf, taskText } from "./generated/texts.ts";
35
+ import { generate } from "./generated/values.ts";
36
+ import { message } from "./messages.ts";
37
+ import { answerShown, Progress, resultLine, sessionLines } from "./result.ts";
38
+ import { pointNames, startOpenTask } from "./open-task.ts";
39
+ import { stuckLines, stuckPrompt } from "./stuck.ts";
40
+ import { currentExercise, pageQuery, taskQuery, type PagePosition } from "./position.ts";
41
+ import {
42
+ isNeedsChoice,
43
+ NEEDS_KEY,
44
+ OTHER_LANGUAGE,
45
+ withLanguage,
46
+ withNeeds,
47
+ type Need,
48
+ type NeedsChoice,
49
+ } from "./needs.ts";
50
+ import {
51
+ addressOf,
52
+ NEW_TASK_ICON,
53
+ nextTaskNumber,
54
+ numberFromAddress,
55
+ randomTaskNumber,
56
+ SHOWN_EVENT,
57
+ taskNumber,
58
+ } from "./tasks.ts";
59
+ import { escape } from "../shared/html.ts";
60
+ import { startVideos } from "./video.ts";
61
+ import { startAssessment } from "./assessment-client.ts";
62
+
63
+ /** Makes a generated exercise live: a task from a seed, checked by the shared questions, and "Neue Aufgabe". */
64
+ function startGenerated(
65
+ element: HTMLElement,
66
+ data: Extract<ExerciseData, { kind: "generated" }>,
67
+ progress: Progress,
68
+ ): void {
69
+ const live = element.querySelector<HTMLElement>(".explainer-live");
70
+ if (!live) return;
71
+ const { spec, templates, lang, texts } = data;
72
+ // the worked tasks with their solutions: hidden with JavaScript (the learner gets new tasks)
73
+ for (const part of element.querySelectorAll<HTMLElement>(".explainer-worked-intro, .explainer-worked")) {
74
+ part.hidden = true;
75
+ }
76
+ const { seeds } = data;
77
+ const count = Math.max(seeds.length, 1);
78
+ const from = numberFromAddress(location.href, spec.id);
79
+ let number = from === undefined ? randomTaskNumber(count) : taskNumber(from, count);
80
+ // the address always names the task shown: it is the link to share
81
+ history.replaceState(history.state, "", addressOf(location.href, spec.id, number));
82
+
83
+ const show = (focus: boolean) => {
84
+ const task = generate(spec, seeds[number - 1] ?? number, data.data);
85
+ const question = questionFor(spec, task, lang, templates);
86
+ const hint = hintOf(spec, task, lang, templates);
87
+ const steps = stepsOf(spec, task, lang, templates);
88
+ live.innerHTML = [
89
+ `<p class="explainer-practice">${escape(texts.practice)}</p>`,
90
+ `<p class="explainer-task-number">${escape(texts.taskNumber.replace("{n}", String(number)))} <button type="button" class="explainer-new-task" aria-label="${escape(texts.newTask)}" title="${escape(texts.newTask)}">${NEW_TASK_ICON}</button></p>`,
91
+ renderQuestion(question, {
92
+ lang,
93
+ textHtml: taskText(spec, task, lang, templates),
94
+ ...(steps.length === 0
95
+ ? {}
96
+ : {
97
+ explanationHtml: `<ol class="explainer-steps">${steps.map((step) => `<li>${step}</li>`).join("")}</ol>`,
98
+ }),
99
+ }),
100
+ hint === undefined
101
+ ? ""
102
+ : `<details class="explainer-hint"><summary>${escape(texts.hint)}</summary><p>${hint}</p></details>`,
103
+ ].join("\n");
104
+ live.hidden = false;
105
+ const item = live.querySelector<HTMLElement>(`#${questionId(question)}`);
106
+ if (item) {
107
+ const shown = number;
108
+ startQuestion(item, question, {
109
+ lang,
110
+ retry: true,
111
+ diagnose: (answer) => diagnose(spec, task, question, answer, lang, templates),
112
+ onAnswer: (answer, right, said) =>
113
+ progress.answered(spec.id, shown, right, answerShown(question, answer, lang), said),
114
+ onReveal: () => progress.revealed(spec.id, shown),
115
+ });
116
+ const opened = live.querySelector<HTMLDetailsElement>("details.explainer-hint");
117
+ opened?.addEventListener("toggle", () => {
118
+ if (opened.open) progress.hinted(spec.id, shown);
119
+ });
120
+ const legend = item.querySelector<HTMLElement>("legend");
121
+ if (focus && legend) moveFocus(legend);
122
+ }
123
+ element.dispatchEvent(new CustomEvent(SHOWN_EVENT, { bubbles: true }));
124
+ live.querySelector(".explainer-new-task")?.addEventListener("click", () => {
125
+ // the tasks of the numbers are distinct: the next number is a new task
126
+ number = nextTaskNumber(number, count);
127
+ history.replaceState(history.state, "", addressOf(location.href, spec.id, number));
128
+ show(true);
129
+ });
130
+ };
131
+ show(false);
132
+ }
133
+
134
+ /** Whether a tutor opened the page: via=tutor in its address, exactly so. */
135
+ export const fromTutor = (href: string): boolean => new URL(href).searchParams.get("via") === "tutor";
136
+
137
+ /** The event an island sends for an exercise component's answer (islands.ts), with { correct, answer?, message? }. */
138
+ export const ANSWERED_EVENT = "explainer-answered";
139
+
140
+ /** How long the button says "kopiert" after a copy. */
141
+ const DONE_MS = 8000;
142
+
143
+ /** What the tutor section of a page gives the client module (render.ts, prompt.ts). */
144
+ interface TutorData {
145
+ /** The start prompt a provider link carries, with `query` on a content page. */
146
+ start: string;
147
+ base: string;
148
+ query: string;
149
+ /** The root of the language tree, relative to this page. */
150
+ root: string;
151
+ storage: string;
152
+ /** The content page, for the learner's position and the result line. */
153
+ page?: PagePosition & { title: string };
154
+ /** The page's tutor page, from the root of the language tree. */
155
+ tutor?: string;
156
+ /** The Markdown of the page's tutor page, from the root of the language tree, for copying. */
157
+ markdown?: string;
158
+ ways: { name: string; url: string; paste?: boolean }[];
159
+ /** The short message a way that needs pasting gets in its address. */
160
+ placeholder: string;
161
+ go: string;
162
+ /** The button after a copy: "kopiert", with `{name}`. */
163
+ done: string;
164
+ copy: string;
165
+ copied: string;
166
+ failed: string;
167
+ }
168
+
169
+ /** The data of the page's tutor section, if it has one. */
170
+ function tutorData(root: Document): TutorData | undefined {
171
+ const script = root.querySelector(".explainer-tutor script.explainer-tutor-prompt");
172
+ return script ? (JSON.parse(script.textContent ?? "{}") as TutorData) : undefined;
173
+ }
174
+
175
+ /** Whether `text` is now on the clipboard. */
176
+ async function copyText(text: string): Promise<boolean> {
177
+ try {
178
+ await navigator.clipboard.writeText(text);
179
+ return true;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ /** The absolute address of the page's tutor page with `query`, from the page at `href`. */
186
+ const tutorAddress = (href: string, data: TutorData, query: string): string =>
187
+ new URL(`${data.tutor ?? ""}${query}`, new URL(data.root, href)).href;
188
+
189
+ /**
190
+ * `prompt` with the Markdown of the page's tutor page after it, for an assistant that fetches nothing: the app's own
191
+ * static file; the prompt alone when it is not reachable, as it names the tutor page.
192
+ */
193
+ async function withMarkdown(prompt: string, data: TutorData, href: string): Promise<string> {
194
+ if (data.markdown === undefined) return prompt;
195
+ try {
196
+ const response = await fetch(new URL(data.markdown, new URL(data.root, href)).href);
197
+ if (response.ok) return `${prompt}\n\n${(await response.text()).trimEnd()}\n`;
198
+ } catch {
199
+ // not reachable: the prompt names the tutor page
200
+ }
201
+ return prompt;
202
+ }
203
+
204
+ /**
205
+ * The result line of the page at `href` (result.ts): of the exercise `id`, else of the one the learner answered last,
206
+ * else the one the anchor names, else the page's first; with the absolute address of the page's tutor page with the
207
+ * position.
208
+ */
209
+ function resultOf(href: string, lang: string, data: TutorData, progress: Progress, id?: string): string | undefined {
210
+ const { page, tutor } = data;
211
+ const exercise =
212
+ page === undefined
213
+ ? undefined
214
+ : (page.exercises.find((candidate) => candidate.id === id) ??
215
+ currentExercise(href, page, progress.last) ??
216
+ page.exercises[0]);
217
+ if (page === undefined || exercise === undefined || tutor === undefined) return undefined;
218
+ const n = exercise.numbered ? numberFromAddress(href, exercise.id) : undefined;
219
+ return resultLine(
220
+ {
221
+ page: page.title,
222
+ exercise: exercise.id,
223
+ ...(n === undefined ? {} : { number: n }),
224
+ state: progress.state(exercise.id, n),
225
+ session: progress.session(),
226
+ tutor: tutorAddress(href, data, taskQuery(href, page, exercise.id)),
227
+ },
228
+ lang,
229
+ );
230
+ }
231
+
232
+ /** The texts of copying for a running chat: the status once copied, and when copying fails. */
233
+ interface CopyTexts {
234
+ copied: string;
235
+ failed: string;
236
+ }
237
+
238
+ /**
239
+ * Copies `text` for the running chat with the tutor ("Zurück zum Tutor", "Hilfe" and "Zurück" on a page a tutor
240
+ * opened, via=tutor): the tab stays open, as closing it let the browser show some other tab; `button` says "✓ Kopiert"
241
+ * for a while, as the tutor bar does after copying, and `status`, on a line below it, where to paste it. When
242
+ * copying fails, the text is selected in `field` to copy by hand. The text goes to the clipboard only, never over the
243
+ * network.
244
+ */
245
+ function copyForChat(
246
+ root: Document,
247
+ text: string,
248
+ button: HTMLButtonElement,
249
+ status: HTMLElement,
250
+ field: HTMLTextAreaElement,
251
+ texts: CopyTexts,
252
+ ): void {
253
+ const lang = root.documentElement.lang;
254
+ status.textContent = "";
255
+ void copyText(text).then((copied) => {
256
+ if (!copied) {
257
+ field.value = text;
258
+ field.hidden = false;
259
+ field.focus();
260
+ field.select();
261
+ status.textContent = texts.failed;
262
+ return;
263
+ }
264
+ field.hidden = true;
265
+ status.textContent = texts.copied;
266
+ const before = [...button.childNodes];
267
+ button.dataset["done"] = "";
268
+ button.textContent = message(lang, "backToTutorDone");
269
+ setTimeout(() => {
270
+ if (button.dataset["done"] === undefined) return;
271
+ delete button.dataset["done"];
272
+ button.replaceChildren(...before);
273
+ }, DONE_MS);
274
+ });
275
+ }
276
+
277
+ /** A status line and the field for copying by hand, hidden until copying fails. */
278
+ function statusAndField(root: Document, label: string): { status: HTMLElement; field: HTMLTextAreaElement } {
279
+ const status = root.createElement("span");
280
+ status.setAttribute("role", "status");
281
+ status.setAttribute("aria-live", "polite");
282
+ const field = root.createElement("textarea");
283
+ field.className = "explainer-back-line";
284
+ field.readOnly = true;
285
+ field.rows = 3;
286
+ field.hidden = true;
287
+ field.setAttribute("aria-label", label);
288
+ return { status, field };
289
+ }
290
+
291
+ /**
292
+ * What "Zurück zum Tutor" copies on the page at `href`: the page's tutor page with the page's task numbers, and each
293
+ * task the learner did in this session with how it went (result.ts, sessionLines).
294
+ */
295
+ function sessionOf(href: string, lang: string, data: TutorData, progress: Progress): string | undefined {
296
+ const { page, tutor } = data;
297
+ if (page === undefined || tutor === undefined) return undefined;
298
+ return sessionLines(tutorAddress(href, data, pageQuery(href, page)), progress.tasks(), progress.session(), lang);
299
+ }
300
+
301
+ /**
302
+ * "Zurück zum Tutor" at the top and at the end of a page a tutor opened (via=tutor): it copies the page's tutor page
303
+ * and how each task of the session went, for the running chat (copyForChat).
304
+ */
305
+ function startBackToTutor(root: Document, main: HTMLElement, data: TutorData | undefined, progress: Progress): void {
306
+ const lang = root.documentElement.lang;
307
+ for (const at of ["top", "end"] as const) {
308
+ const box = root.createElement("p");
309
+ box.className = `explainer-back explainer-back-${at}`;
310
+ const button = root.createElement("button");
311
+ button.type = "button";
312
+ button.textContent = message(lang, "backToTutor");
313
+ const { status, field } = statusAndField(root, message(lang, "resultLabel"));
314
+ button.addEventListener("click", () => {
315
+ const lines = data === undefined ? undefined : sessionOf(root.location.href, lang, data, progress);
316
+ const name = chatName(data, lang);
317
+ if (lines === undefined) {
318
+ status.textContent = message(lang, "backToTutorOpen", { name });
319
+ return;
320
+ }
321
+ copyForChat(root, lines, button, status, field, {
322
+ copied: message(lang, "backToTutorCopied", { name }),
323
+ failed: message(lang, "backToTutorFailed", { name }),
324
+ });
325
+ });
326
+ box.append(button, " ", status, field);
327
+ if (at === "top") main.prepend(box);
328
+ else main.append(box);
329
+ }
330
+ }
331
+
332
+ /** The prompts of a way to the tutor: the one a provider link carries, and the one to copy. */
333
+ interface Prompts {
334
+ start: () => string;
335
+ copy: () => string | Promise<string>;
336
+ /** What a way that needs pasting gets in its address. */
337
+ placeholder: string;
338
+ }
339
+
340
+ /**
341
+ * Opens the assistant `provider` with the start prompt, when its address takes one; else copies the prompt to copy and
342
+ * opens the assistant (none for copying the text), and `say` tells whether copying worked. A way that needs pasting
343
+ * copies only the start prompt, at once, and its address gets only the placeholder message. The window opens at the click, which a browser
344
+ * allows; the address follows once the prompt is copied.
345
+ */
346
+ function openAssistant(
347
+ root: Document,
348
+ provider: { url?: string; paste?: boolean } | undefined,
349
+ prompts: Prompts,
350
+ say: (copied: boolean | undefined) => void,
351
+ ): void {
352
+ const template = provider?.url;
353
+ const paste = provider?.paste === true;
354
+ if (template !== undefined && template.includes("{prompt}") && !paste) {
355
+ say(undefined);
356
+ root.defaultView?.open(template.split("{prompt}").join(encodeURIComponent(prompts.start())), "_blank", "noopener");
357
+ return;
358
+ }
359
+ // a way that needs pasting copies the start prompt, which is there at once: the copy starts in the click, while the
360
+ // page has the focus, before the new window takes it (Edge refused a copy that came after the fetch of the Markdown)
361
+ const copied = paste ? copyText(prompts.start()) : undefined;
362
+ const opened = template === undefined ? null : root.defaultView?.open("", "_blank");
363
+ if (opened) opened.opener = null;
364
+ void Promise.resolve(copied ?? prompts.copy()).then(async (full) => {
365
+ say(typeof full === "boolean" ? full : await copyText(full));
366
+ if (template === undefined) return;
367
+ const address = paste ? template.split("{prompt}").join(encodeURIComponent(prompts.placeholder)) : template;
368
+ if (opened) opened.location.href = address;
369
+ else root.defaultView?.open(address, "_blank", "noopener");
370
+ });
371
+ }
372
+
373
+ /** The key the switched-on way to the tutor is kept under on the device: an assistant's name, or COPY; none when off. */
374
+ const WAY_KEY = "tutor-way";
375
+ const COPY = "copy";
376
+ const isText = (value: unknown): value is string => typeof value === "string";
377
+
378
+ /** A way to the tutor: an assistant of the app, or copying the prompt (no address). */
379
+ interface Way {
380
+ name: string;
381
+ url?: string;
382
+ /** The address takes only a placeholder message: the prompt is copied, and the learner pastes it. */
383
+ paste?: boolean;
384
+ }
385
+
386
+ /**
387
+ * The way to the tutor the learner switched on, kept on the device (@lernapps/tooling/storage), one per app: on the
388
+ * start page or under the title of a page. Whoever shows something of it listens, so that switching it on or off shows
389
+ * at once on the whole page.
390
+ */
391
+ class WayChoice {
392
+ readonly #storage: ReturnType<typeof createStorage>;
393
+ readonly #ways: readonly Way[];
394
+ readonly #listeners: (() => void)[] = [];
395
+ constructor(storage: string, ways: readonly Way[]) {
396
+ this.#storage = createStorage(storage);
397
+ this.#ways = ways;
398
+ }
399
+ /** The way switched on, if any: an assistant the app still offers, or copying. */
400
+ get(): Way | undefined {
401
+ const name = this.#storage.load(WAY_KEY, isText);
402
+ if (name === COPY) return { name: COPY };
403
+ return this.#ways.find((way) => way.name === name);
404
+ }
405
+ /** Switches the way `name` on (an assistant's name, or COPY), or the tutor off. */
406
+ set(name: string | undefined): void {
407
+ if (name === undefined) this.#storage.remove(WAY_KEY);
408
+ else this.#storage.save(WAY_KEY, name);
409
+ for (const listener of this.#listeners) listener();
410
+ }
411
+ listen(listener: () => void): void {
412
+ this.#listeners.push(listener);
413
+ }
414
+ }
415
+
416
+ /** The archetype whose settings the needs are: they hold for every explainer, or for one app (dec-tutor-needs). */
417
+ const ARCHETYPE = "explainer";
418
+
419
+ /** The needs the learner chose for the tutor (needs.ts), for this app or for every explainer, if any. */
420
+ const chosenNeeds = (storage: string): NeedsChoice | undefined =>
421
+ createSettings(storage, ARCHETYPE).load(NEEDS_KEY, isNeedsChoice)?.value;
422
+
423
+ /**
424
+ * The name of the running chat's assistant, as "zurück zu …" says it: the one the learner switched on, else "deinem
425
+ * KI-Tutor".
426
+ */
427
+ function chatName(data: { storage: string; ways: readonly Way[] } | undefined, lang: string): string {
428
+ const way = data === undefined ? undefined : new WayChoice(data.storage, data.ways).get();
429
+ return way?.url === undefined ? message(lang, "tutorSomeoneTo") : way.name;
430
+ }
431
+
432
+ /** The name of a way as the page says it: the assistant's, or "deinen KI-Tutor" for copying. */
433
+ const nameOf = (way: Way | undefined, lang: string): string =>
434
+ way?.url === undefined ? message(lang, "tutorSomeone") : way.name;
435
+
436
+ /** The symbol of a way: the first letter of the assistant's name in a circle, "?" for copying. */
437
+ function iconOf(root: Document, way: Way | undefined): HTMLElement {
438
+ const icon = root.createElement("span");
439
+ icon.className = "explainer-ki-icon";
440
+ icon.setAttribute("aria-hidden", "true");
441
+ icon.textContent = way?.url === undefined ? "?" : (way.name.trim()[0] ?? "?").toUpperCase();
442
+ return icon;
443
+ }
444
+
445
+ /**
446
+ * The tutor's help button in every exercise of the page, way B, there only while a way to the tutor is switched on or
447
+ * on a page a tutor opened (via=tutor). It sits in the row of "Prüfen" and "Lösung" (or below an exercise component,
448
+ * outside its island),
449
+ * with the assistant's symbol, and says what helps now: "Hilfe" before an answer and after the right one; "Warum?",
450
+ * standing out, after a wrong one, with a sentence that says what the click gives the assistant; on a page a tutor
451
+ * opened, "Zurück" once the task is right or its solution shown. "Hilfe" and "Warum?" give the lines of the task as the
452
+ * learner has it now (stuck.ts): the task, the input with the tries, the app's message word for word, the hint or
453
+ * solution opened, the page's tutor page with the task (taskQuery). B1, without a chat: they open the way switched
454
+ * on, the tutor page and the task in one sentence first. B2, on a page a tutor opened: they copy the lines, and the
455
+ * button says where to paste them (copyForChat), so the running chat goes on; "Zurück" copies the result line of the
456
+ * task the same way.
457
+ */
458
+ function startHelp(root: Document, data: TutorData, progress: Progress, choice: WayChoice): void {
459
+ const { page, tutor } = data;
460
+ if (page === undefined || tutor === undefined) return;
461
+ const lang = root.documentElement.lang;
462
+ const running = fromTutor(root.location.href);
463
+ const updates = new Map<string, () => void>();
464
+ for (const exercise of page.exercises) {
465
+ const element = root.getElementById(exercise.id);
466
+ if (!element) continue;
467
+ const button = root.createElement("button");
468
+ button.type = "button";
469
+ button.className = "explainer-help";
470
+ const label = root.createElement("span");
471
+ // below an exercise without the row of the shared questions (a component)
472
+ const own = root.createElement("p");
473
+ own.className = "explainer-help-row";
474
+ const note = root.createElement("p");
475
+ note.className = "explainer-help-note";
476
+ const text = root.createElement("small");
477
+ const { status, field } = statusAndField(root, message(lang, "stuckLabel"));
478
+ note.append(text, " ", status, field);
479
+ const place = () => {
480
+ // the row of the shared questions, or the one with "Prüfen" under an exercise component; never one in a
481
+ // component's island, whose DOM Vue owns: a button there makes the hydration mismatch, and each new render drops
482
+ // it (#83)
483
+ const row = element.classList.contains("explainer-component")
484
+ ? element.querySelector<HTMLElement>(":scope > form.explainer-component-form > .question-actions")
485
+ : element.querySelector<HTMLElement>(".question-actions");
486
+ if (row) {
487
+ row.append(" ", button);
488
+ own.remove();
489
+ } else {
490
+ own.append(button);
491
+ element.append(own);
492
+ }
493
+ element.append(note);
494
+ };
495
+ const n = () => (exercise.numbered ? numberFromAddress(root.location.href, exercise.id) : undefined);
496
+ let mode: "help" | "why" | "back" = "help";
497
+ const update = () => {
498
+ const way = choice.get();
499
+ const shown = running || way !== undefined;
500
+ button.hidden = !shown;
501
+ own.hidden = !shown;
502
+ const state = progress.state(exercise.id, n());
503
+ const done = state.rightAt !== undefined || state.revealed;
504
+ mode = running && done ? "back" : state.attempts > 0 && !done ? "why" : "help";
505
+ const name = running ? message(lang, "tutorSomeone") : nameOf(way, lang);
506
+ label.textContent = message(lang, mode === "back" ? "helpBack" : mode === "why" ? "helpWhy" : "help");
507
+ button.setAttribute(
508
+ "aria-label",
509
+ message(lang, mode === "back" ? "helpBackLabel" : mode === "why" ? "helpWhyLabel" : "helpLabel", { name }),
510
+ );
511
+ button.replaceChildren(iconOf(root, way), label);
512
+ button.classList.toggle("explainer-help-why", mode === "why");
513
+ button.classList.toggle("explainer-help-back", mode === "back");
514
+ text.textContent =
515
+ mode !== "why" ? "" : running ? message(lang, "stuckPrivacyCopy") : message(lang, "stuckPrivacy", { name });
516
+ note.hidden = !shown || (mode !== "why" && field.hidden && status.textContent === "");
517
+ };
518
+ const stuck = () => {
519
+ const href = root.location.href;
520
+ const number = n();
521
+ return {
522
+ exercise: exercise.id,
523
+ ...(number === undefined ? {} : { number }),
524
+ state: progress.state(exercise.id, number),
525
+ // a new chat gets the learner's needs; a running one has them (dec-tutor-needs)
526
+ tutor: tutorAddress(
527
+ href,
528
+ data,
529
+ running
530
+ ? taskQuery(href, page, exercise.id)
531
+ : withNeeds(taskQuery(href, page, exercise.id), chosenNeeds(data.storage)),
532
+ ),
533
+ };
534
+ };
535
+ button.addEventListener("click", () => {
536
+ note.hidden = false;
537
+ if (mode === "back") {
538
+ const name = chatName(data, lang);
539
+ const line = resultOf(root.location.href, lang, data, progress, exercise.id);
540
+ if (line === undefined) status.textContent = message(lang, "backToTutorOpen", { name });
541
+ else {
542
+ copyForChat(root, line, button, status, field, {
543
+ copied: message(lang, "backToTutorCopied", { name }),
544
+ failed: message(lang, "backToTutorFailed", { name }),
545
+ });
546
+ }
547
+ return;
548
+ }
549
+ if (running) {
550
+ const name = chatName(data, lang);
551
+ copyForChat(root, stuckLines(stuck(), lang), button, status, field, {
552
+ copied: message(lang, "stuckCopied", { name }),
553
+ failed: message(lang, "stuckFailed", { name }),
554
+ });
555
+ return;
556
+ }
557
+ const way = choice.get();
558
+ const prompt = () => withLanguage(stuckPrompt(stuck(), lang), chosenNeeds(data.storage), lang);
559
+ const copy = () => withMarkdown(prompt(), data, root.location.href);
560
+ openAssistant(root, way, { start: prompt, copy, placeholder: data.placeholder }, (copied) => {
561
+ status.textContent = copied === undefined ? "" : copied ? data.copied : data.failed;
562
+ });
563
+ });
564
+ place();
565
+ update();
566
+ // a generated exercise and a component show a new task in place: the button goes back into its row
567
+ element.addEventListener(SHOWN_EVENT, () => {
568
+ place();
569
+ update();
570
+ });
571
+ updates.set(exercise.id, update);
572
+ }
573
+ progress.listen((id) => updates.get(id)?.());
574
+ choice.listen(() => {
575
+ for (const update of updates.values()) update();
576
+ });
577
+ }
578
+
579
+ /** Shows in `hint` how the chosen option works when it says so (data-hint, a way that needs pasting); else hides it. */
580
+ function showHint(select: HTMLSelectElement, hint: HTMLElement | null): void {
581
+ if (!hint) return;
582
+ const text = select.selectedOptions[0]?.dataset["hint"] ?? "";
583
+ hint.textContent = text;
584
+ hint.hidden = text === "";
585
+ }
586
+
587
+ /**
588
+ * The choice of the learner's assistant on the start page: "Keiner", the app's assistants and copying the text, shown
589
+ * once the client module runs; a choice is kept on the device at once (WayChoice), "Keiner" switches the tutor off.
590
+ */
591
+ function startChoice(root: Document): void {
592
+ const select = root.querySelector<HTMLSelectElement>("select.explainer-tutor-choose");
593
+ const box = select?.closest<HTMLElement>(".explainer-tutor-choice");
594
+ if (!select || !box) return;
595
+ const storage = createStorage(select.dataset["storage"] ?? "explainer");
596
+ const stored = storage.load(WAY_KEY, isText);
597
+ const names = [...select.options].map((option) => option.value);
598
+ select.value = stored !== undefined && names.includes(stored) ? stored : "";
599
+ const hint = box.nextElementSibling instanceof HTMLElement ? box.nextElementSibling : null;
600
+ const explain = () => showHint(select, hint);
601
+ select.addEventListener("change", () => {
602
+ if (select.value === "") storage.remove(WAY_KEY);
603
+ else storage.save(WAY_KEY, select.value);
604
+ explain();
605
+ });
606
+ explain();
607
+ box.hidden = false;
608
+ }
609
+
610
+ /**
611
+ * The choice of the learner's needs on the start page (dec-tutor-needs), put into the page from its <template>:
612
+ * "Einstellungen" beside the choice of the assistant opens a dialog with a box per need, the first language once `andere-muttersprache` is ticked, and whether
613
+ * the choice holds for every explainer or for this app only; each change is kept on the device at once as a setting
614
+ * (createSettings), where the learner said.
615
+ */
616
+ function startNeeds(root: Document): void {
617
+ const template = root.querySelector<HTMLTemplateElement>("template.explainer-tutor-needs-template");
618
+ template?.replaceWith(root.importNode(template.content, true));
619
+ const box = root.querySelector<HTMLDialogElement>("dialog.explainer-tutor-needs");
620
+ const open = root.querySelector<HTMLButtonElement>("button.explainer-tutor-needs-open");
621
+ if (!box || !open) return;
622
+ open.addEventListener("click", () => box.showModal());
623
+ const settings = createSettings(box.dataset["storage"] ?? "explainer", ARCHETYPE);
624
+ const boxes = [...box.querySelectorAll<HTMLInputElement>("input.explainer-tutor-need")];
625
+ const scopes = [...box.querySelectorAll<HTMLInputElement>("input.explainer-tutor-needs-scope")];
626
+ const language = box.querySelector<HTMLSelectElement>("select.explainer-tutor-language");
627
+ const languageBox = language?.closest<HTMLElement>(".explainer-tutor-language-choice");
628
+ const stored = settings.load(NEEDS_KEY, isNeedsChoice);
629
+ for (const input of boxes) input.checked = stored?.value.needs.includes(input.value as Need) === true;
630
+ const where: SettingScope = stored?.scope === "app" ? "app" : "archetype";
631
+ for (const input of scopes) input.checked = input.value === where;
632
+ if (language) {
633
+ const tags = [...language.options].map((option) => option.value);
634
+ const tag = stored?.value.language;
635
+ language.value = tag !== undefined && tags.includes(tag) ? tag : "";
636
+ }
637
+ const showLanguage = () => {
638
+ if (languageBox) languageBox.hidden = !boxes.some((input) => input.checked && input.value === OTHER_LANGUAGE);
639
+ };
640
+ const save = () => {
641
+ const needs = boxes.filter((input) => input.checked).map((input) => input.value as Need);
642
+ const tag = language?.value ?? "";
643
+ const choice: NeedsChoice = { needs, ...(tag !== "" && needs.includes(OTHER_LANGUAGE) ? { language: tag } : {}) };
644
+ const scope: SettingScope = scopes.find((input) => input.checked)?.value === "app" ? "app" : "archetype";
645
+ settings.save(NEEDS_KEY, choice, scope);
646
+ showLanguage();
647
+ };
648
+ box.addEventListener("change", save);
649
+ showLanguage();
650
+ }
651
+
652
+ /**
653
+ * "Mit einem KI-Tutor lernen", way A, beside the title of a content page. While no way is switched on, it stays
654
+ * folded; unfolded, a select of the app's assistants and copying the text, and "Einschalten", which keeps the way on
655
+ * the device (WayChoice). Once a way is on, a bar takes its place: "Mit <name> lernen", with the assistant's symbol;
656
+ * the start page changes the way or switches it off. The bar's button does the way: it opens the assistant with the
657
+ * start prompt in its address, the page's tutor page with the page's task numbers (prompt.ts, pageQuery), for a
658
+ * tutor who leads through the page before it comes to a task; or it copies the prompt with the tutor page's Markdown, and opens
659
+ * an assistant whose address takes no prompt. On a page a tutor opened (via=tutor) the chat runs already: the section
660
+ * stays hidden. Nothing goes over the network but the click's own page, the tutor page's Markdown (the app's own
661
+ * static file, for copying) and the assistant that opens.
662
+ */
663
+ function startTutor(root: Document, data: TutorData, choice: WayChoice): void {
664
+ const section = root.querySelector<HTMLElement>(".explainer-tutor");
665
+ if (!section) return;
666
+ if (fromTutor(root.location.href)) {
667
+ section.hidden = true;
668
+ return;
669
+ }
670
+ const off = section.querySelector<HTMLDetailsElement>("details.explainer-tutor-off");
671
+ const bar = section.querySelector<HTMLElement>(".explainer-tutor-bar");
672
+ const way = section.querySelector<HTMLElement>(".explainer-tutor-way");
673
+ const select = section.querySelector<HTMLSelectElement>(".explainer-tutor-select");
674
+ const on = section.querySelector<HTMLButtonElement>(".explainer-tutor-on");
675
+ const go = section.querySelector<HTMLButtonElement>(".explainer-tutor-go");
676
+ const status = section.querySelector<HTMLElement>(".explainer-tutor-status");
677
+ if (!off || !bar || !way || !select || !on || !go) return;
678
+ let reset: ReturnType<typeof setTimeout> | undefined;
679
+ // the button's second text, in the button only while it says "kopiert"
680
+ const done = root.createElement("span");
681
+ done.className = "explainer-tutor-go-done";
682
+ const say = (text: string, state?: "ok" | "failed") => {
683
+ clearTimeout(reset);
684
+ bar.toggleAttribute("data-done", state === "ok");
685
+ if (state === "ok") go.append(done);
686
+ else done.remove();
687
+ if (!status) return;
688
+ status.textContent = text;
689
+ if (state === undefined) delete status.dataset["state"];
690
+ else status.dataset["state"] = state;
691
+ // the button says "kopiert" for a while: then it is the plain one again
692
+ if (state === "ok") reset = setTimeout(() => say(""), DONE_MS);
693
+ };
694
+ way.hidden = false;
695
+ const hint = section.querySelector<HTMLElement>(".explainer-tutor-hint");
696
+ select.addEventListener("change", () => showHint(select, hint));
697
+ showHint(select, hint);
698
+ const plain = section.querySelector<HTMLElement>(".explainer-tutor-plain");
699
+ if (plain) plain.hidden = true;
700
+ const show = () => {
701
+ const chosen = choice.get();
702
+ off.hidden = chosen !== undefined;
703
+ bar.hidden = chosen === undefined;
704
+ if (chosen === undefined) return;
705
+ const text = root.createElement("span");
706
+ text.className = "explainer-tutor-go-text";
707
+ text.textContent = chosen.url === undefined ? data.copy : data.go.replace("{name}", chosen.name);
708
+ done.textContent = data.done.replace("{name}", nameOf(chosen, root.documentElement.lang));
709
+ go.replaceChildren(iconOf(root, chosen), text);
710
+ };
711
+ show();
712
+ choice.listen(show);
713
+ on.addEventListener("click", () => {
714
+ off.open = false;
715
+ choice.set(select.value);
716
+ say("");
717
+ go.focus();
718
+ });
719
+
720
+ // the prompt's address starts at the root of the language tree, whichever page the section is on
721
+ const tree = new URL(data.root, root.location.href).href;
722
+ /** The start prompt: the page's tutor page with the page's task numbers and the learner's needs. */
723
+ const start = (): string => {
724
+ const text = data.start.split(data.base).join(tree);
725
+ const { page } = data;
726
+ const needs = chosenNeeds(data.storage);
727
+ const query = withNeeds(page === undefined ? "" : pageQuery(root.location.href, page), needs);
728
+ return withLanguage(text.split(data.query).join(query), needs, root.documentElement.lang);
729
+ };
730
+ const whole = () => withMarkdown(start(), data, root.location.href);
731
+ go.addEventListener("click", () => {
732
+ const chosen = choice.get();
733
+ openAssistant(root, chosen, { start, copy: whole, placeholder: data.placeholder }, (copied) => {
734
+ say(
735
+ copied === undefined ? "" : copied ? data.copied : data.failed,
736
+ copied === undefined ? undefined : copied ? "ok" : "failed",
737
+ );
738
+ });
739
+ });
740
+ }
741
+
742
+ /** Starts the explainer on the page. */
743
+ export function startExplainer(root: Document = document): void {
744
+ root.documentElement.classList.add("explainer-js");
745
+ const main = root.querySelector<HTMLElement>("main");
746
+ const tutor = tutorData(root);
747
+ const progress = new Progress();
748
+ // a self-assessment a tutor opened has its own "Zurück zum Tutor", with its result (assessment-client.ts)
749
+ const assessment = root.querySelector(".explainer-assessment-nojs") !== null;
750
+ if (main && fromTutor(root.location.href) && !assessment) startBackToTutor(root, main, tutor, progress);
751
+ startChoice(root);
752
+ startNeeds(root);
753
+ if (tutor !== undefined) {
754
+ const choice = new WayChoice(tutor.storage, tutor.ways);
755
+ startTutor(root, tutor, choice);
756
+ startHelp(root, tutor, progress, choice);
757
+ }
758
+ // an exercise component's answers, from its island
759
+ root.addEventListener(ANSWERED_EVENT, (event) => {
760
+ const element = event.target;
761
+ const detail = (event as CustomEvent<{ correct?: unknown; answer?: unknown; message?: unknown } | null>).detail;
762
+ if (!(element instanceof HTMLElement) || element.id === "" || detail === null) return;
763
+ const text = (value: unknown) => (typeof value === "string" ? value : undefined);
764
+ progress.answered(
765
+ element.id,
766
+ numberFromAddress(root.location.href, element.id),
767
+ detail.correct === true,
768
+ text(detail.answer),
769
+ text(detail.message),
770
+ );
771
+ });
772
+ startVideos(root);
773
+ startAssessment(root, {
774
+ chosen: (storage, ways) => new WayChoice(storage, ways).get(),
775
+ needs: chosenNeeds,
776
+ open: (way, prompts, say) => openAssistant(root, way, prompts, say),
777
+ hint: showHint,
778
+ running: fromTutor(root.location.href),
779
+ back: (text, button, place, storage, ways) => {
780
+ const lang = root.documentElement.lang;
781
+ const { status, field } = statusAndField(root, message(lang, "resultLabel"));
782
+ place.querySelector(":scope > .explainer-assessment-back-status")?.remove();
783
+ const line = root.createElement("p");
784
+ line.className = "explainer-assessment-back-status";
785
+ line.append(status, field);
786
+ place.append(line);
787
+ const name = chatName({ storage, ways }, lang);
788
+ copyForChat(root, text, button, status, field, {
789
+ copied: message(lang, "backToTutorCopied", { name }),
790
+ failed: message(lang, "backToTutorFailed", { name }),
791
+ });
792
+ },
793
+ });
794
+ for (const script of root.querySelectorAll<HTMLScriptElement>("script.explainer-exercise-data")) {
795
+ const element = script.parentElement;
796
+ if (!element) continue;
797
+ const data = JSON.parse(script.textContent ?? "{}") as ExerciseData;
798
+ if (data.kind === "question") {
799
+ const item = element.querySelector<HTMLElement>(".question");
800
+ if (!item) continue;
801
+ // the question shows the same as its "Lösung" once it is answered
802
+ const solution = element.querySelector<HTMLElement>(":scope > details.explainer-solution");
803
+ if (solution) solution.hidden = true;
804
+ const { rendered, question, lang } = data;
805
+ const id = element.id;
806
+ startQuestion(item, question, {
807
+ lang,
808
+ retry: true,
809
+ format: (text) => rendered[text] ?? escape(text),
810
+ onAnswer: (answer, right, said) =>
811
+ progress.answered(id, undefined, right, answerShown(question, answer, lang), said),
812
+ onReveal: () => progress.revealed(id, undefined),
813
+ });
814
+ } else if (data.kind === "open") {
815
+ const { id, lang, points } = data;
816
+ startOpenTask(element, data, {
817
+ onResult: (result, attempt) =>
818
+ progress.compared(
819
+ id,
820
+ result.ticked.length,
821
+ points.length,
822
+ pointNames(result.missed),
823
+ result.photo ? message(lang, "resultPhoto") : shortened(result.text ?? ""),
824
+ attempt,
825
+ ),
826
+ });
827
+ } else startGenerated(element, data, progress);
828
+ }
829
+ }
830
+
831
+ /** How much of an open answer a result line carries: the rest is the learner's to paste, if the tutor asks. */
832
+ const ANSWER_IN_LINE = 2000;
833
+
834
+ /** An open answer on one line, at most ANSWER_IN_LINE characters. */
835
+ const shortened = (text: string): string => {
836
+ const line = text.replace(/\s+/g, " ").trim();
837
+ return line.length <= ANSWER_IN_LINE ? line : `${line.slice(0, ANSWER_IN_LINE)} …`;
838
+ };