@octolearn/training-ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  var _a;
2
2
  import { html, svg, css, unsafeCSS, LitElement, nothing } from "lit";
3
3
  import { property, state, query } from "lit/decorators.js";
4
- import { g as getSpeechController, A as ANSWER_EVENT, D as DONT_KNOW_ANSWER } from "./core-DR1lxtEg.js";
4
+ import { g as getSpeechController, S as SpeechController, A as ANSWER_EVENT, D as DONT_KNOW_ANSWER } from "./core-rJUDTpGS.js";
5
5
  import { styleMap } from "lit/directives/style-map.js";
6
6
  import { repeat } from "lit/directives/repeat.js";
7
7
  import { live } from "lit/directives/live.js";
@@ -16,7 +16,8 @@ const paths = {
16
16
  },
17
17
  check: { box: 22, body: svg`<path d="M4 11.5l5 5 9-11"></path>` },
18
18
  cross: { box: 30, body: svg`<path d="M8 8l14 14M22 8L8 22"></path>` },
19
- arrow: { box: 22, body: svg`<path d="M4 11h14M12 5l6 6-6 6"></path>` }
19
+ arrow: { box: 22, body: svg`<path d="M4 11h14M12 5l6 6-6 6"></path>` },
20
+ zoom: { box: 24, body: svg`<circle cx="10.5" cy="10.5" r="6.5"></circle><path d="M15.5 15.5L21 21M10.5 7.5v6M7.5 10.5h6"></path>` }
20
21
  };
21
22
  function dIcon(name, size, strokeWidth = 3) {
22
23
  const { box, body } = paths[name];
@@ -49,6 +50,7 @@ const de = {
49
50
  stop: "Sprachausgabe stoppen",
50
51
  playHintMessage: "Dein Browser benötigt eine Interaktion, bevor Audio abgespielt werden kann.",
51
52
  playHintButton: "Abspielen",
53
+ noVoiceMessage: "Dein Gerät hat keine Stimme für {language}, deshalb klingt das Wort falsch. Füge in den Sprachausgabe-Einstellungen deines Geräts eine Stimme für {language} hinzu.",
52
54
  feedbackCorrect: "Richtige Antwort",
53
55
  feedbackWrong: "Falsche Antwort",
54
56
  yourAnswer: "Deine Antwort",
@@ -84,6 +86,8 @@ const de = {
84
86
  pictureAlt: "Bild zur Aufgabe",
85
87
  pictureCaptionVocabulary: "Bild zur Vokabelaufgabe",
86
88
  pictureCaptionRiddle: "Bilderrätsel: Was ist auf dem Bild zu sehen?",
89
+ enlargePicture: "Bild vergrößern",
90
+ closePicture: "Großes Bild schließen",
87
91
  enterPictureVocabulary: "Vokabel zu diesem Bild eingeben",
88
92
  enterPictureRiddle: "Eingeben, was auf dem Bild zu sehen ist",
89
93
  arithmeticHeading: "Rechenaufgabe",
@@ -219,6 +223,7 @@ const en = {
219
223
  stop: "Stop dictation",
220
224
  playHintMessage: "Your browser requires interaction before playing audio.",
221
225
  playHintButton: "Play",
226
+ noVoiceMessage: "Your device has no {language} voice, so the word sounds wrong. Add a {language} voice in your device's speech settings.",
222
227
  feedbackCorrect: "Correct answer",
223
228
  feedbackWrong: "Wrong answer",
224
229
  yourAnswer: "Your answer",
@@ -254,6 +259,8 @@ const en = {
254
259
  pictureAlt: "Picture for the task",
255
260
  pictureCaptionVocabulary: "Picture for the vocabulary task",
256
261
  pictureCaptionRiddle: "Picture riddle: what can you see in the picture?",
262
+ enlargePicture: "Enlarge picture",
263
+ closePicture: "Close the large picture",
257
264
  enterPictureVocabulary: "Enter the vocabulary word for this picture",
258
265
  enterPictureRiddle: "Enter what you see in the picture",
259
266
  arithmeticHeading: "Arithmetic task",
@@ -727,6 +734,83 @@ const baseStyles = [
727
734
  }
728
735
  `
729
736
  ];
737
+ const SHORT_VIEWPORT_PX = 520;
738
+ class ViewportWatcher {
739
+ constructor() {
740
+ this.listeners = /* @__PURE__ */ new Set();
741
+ this.viewport = null;
742
+ this.current = false;
743
+ this.onResize = () => {
744
+ const short = ViewportWatcher.measure(this.viewport);
745
+ if (short === this.current) {
746
+ return;
747
+ }
748
+ this.current = short;
749
+ for (const listener of [...this.listeners]) {
750
+ listener(short);
751
+ }
752
+ };
753
+ }
754
+ /** The visible area is short right now (false without a visual viewport, e.g. old browsers). */
755
+ get short() {
756
+ return this.listeners.size > 0 ? this.current : ViewportWatcher.measure(ViewportWatcher.visualViewport());
757
+ }
758
+ /** Calls `listener` whenever `short` flips; returns the function that ends it. */
759
+ subscribe(listener) {
760
+ if (this.listeners.size === 0) {
761
+ this.attach();
762
+ }
763
+ this.listeners.add(listener);
764
+ return () => {
765
+ this.listeners.delete(listener);
766
+ if (this.listeners.size === 0) {
767
+ this.detach();
768
+ }
769
+ };
770
+ }
771
+ attach() {
772
+ this.viewport = ViewportWatcher.visualViewport();
773
+ this.current = ViewportWatcher.measure(this.viewport);
774
+ this.viewport?.addEventListener("resize", this.onResize);
775
+ }
776
+ detach() {
777
+ this.viewport?.removeEventListener("resize", this.onResize);
778
+ this.viewport = null;
779
+ this.current = false;
780
+ }
781
+ static visualViewport() {
782
+ return typeof window === "undefined" ? null : window.visualViewport ?? null;
783
+ }
784
+ /** Height times scale: zooming in with two fingers makes the visual viewport smaller, but the page isn't short. */
785
+ static measure(viewport) {
786
+ if (!viewport || !(viewport.height > 0)) {
787
+ return false;
788
+ }
789
+ return viewport.height * (viewport.scale || 1) < SHORT_VIEWPORT_PX;
790
+ }
791
+ }
792
+ let defaultWatcher;
793
+ function getViewportWatcher() {
794
+ defaultWatcher ??= new ViewportWatcher();
795
+ return defaultWatcher;
796
+ }
797
+ function revealAtTop(element) {
798
+ element.scrollIntoView({ block: "start" });
799
+ const offset = typeof window === "undefined" ? 0 : window.visualViewport?.offsetTop ?? 0;
800
+ if (offset > 0) {
801
+ window.scrollBy(0, -offset);
802
+ }
803
+ }
804
+ function hasFocusInside(element) {
805
+ let active = document.activeElement;
806
+ while (active) {
807
+ if (active === element) {
808
+ return true;
809
+ }
810
+ active = active.shadowRoot?.activeElement ?? null;
811
+ }
812
+ return false;
813
+ }
730
814
  var __defProp$i = Object.defineProperty;
731
815
  var __decorateClass$i = (decorators, target, key, kind) => {
732
816
  var result = void 0;
@@ -737,6 +821,21 @@ var __decorateClass$i = (decorators, target, key, kind) => {
737
821
  return result;
738
822
  };
739
823
  const _OctolearnElement = class _OctolearnElement extends LitElement {
824
+ constructor() {
825
+ super(...arguments);
826
+ this.short = false;
827
+ }
828
+ connectedCallback() {
829
+ super.connectedCallback();
830
+ const watcher = getViewportWatcher();
831
+ this.endViewportWatch = watcher.subscribe((short) => this.short = short);
832
+ this.short = watcher.short;
833
+ }
834
+ disconnectedCallback() {
835
+ super.disconnectedCallback();
836
+ this.endViewportWatch?.();
837
+ this.endViewportWatch = void 0;
838
+ }
740
839
  get resolvedLanguage() {
741
840
  return this.language ? toTrainingLanguage(this.language) : resolveLanguage(this);
742
841
  }
@@ -761,6 +860,9 @@ __decorateClass$i([
761
860
  __decorateClass$i([
762
861
  property({ reflect: true })
763
862
  ], OctolearnElement.prototype, "theme");
863
+ __decorateClass$i([
864
+ property({ type: Boolean, reflect: true })
865
+ ], OctolearnElement.prototype, "short");
764
866
  const dParts = css`
765
867
  :focus-visible {
766
868
  outline: 4px solid ${d.ink};
@@ -948,6 +1050,60 @@ const dParts = css`
948
1050
  }
949
1051
  }
950
1052
 
1053
+ :host([short]) .prompt {
1054
+ font-size: 18px;
1055
+ line-height: 1.15;
1056
+ }
1057
+
1058
+ :host([short]) .field {
1059
+ height: 52px;
1060
+ padding: 0 6px 0 14px;
1061
+ border-radius: 16px;
1062
+ box-shadow: 0 0 0 4px ${d.cream};
1063
+ }
1064
+
1065
+ :host([short]) .field input {
1066
+ font-size: 24px;
1067
+ }
1068
+
1069
+ /* the field's label as placeholder ("Schreib das Wort"): readable, not answer-sized */
1070
+ :host([short]) .field input::placeholder {
1071
+ font-size: 14px;
1072
+ font-weight: 600;
1073
+ letter-spacing: 0;
1074
+ }
1075
+
1076
+ :host([short]) .pill {
1077
+ min-height: 40px;
1078
+ padding: 0 14px;
1079
+ font-size: 14px;
1080
+ }
1081
+
1082
+ :host([short]) .listen {
1083
+ width: 48px;
1084
+ height: 48px;
1085
+ }
1086
+
1087
+ :host([short]) .listen.medium {
1088
+ width: 44px;
1089
+ height: 44px;
1090
+ }
1091
+
1092
+ :host([short]) .listen.small {
1093
+ width: 40px;
1094
+ height: 40px;
1095
+ }
1096
+
1097
+ :host([short]) .listen .d-icon {
1098
+ width: 24px;
1099
+ height: 24px;
1100
+ }
1101
+
1102
+ :host([short]) .listen.small .d-icon {
1103
+ width: 20px;
1104
+ height: 20px;
1105
+ }
1106
+
951
1107
  kbd {
952
1108
  padding: 2px 8px;
953
1109
  border: 1.5px solid ${d.openStar};
@@ -1053,13 +1209,39 @@ const questionStyles = css`
1053
1209
  font-size: 22px;
1054
1210
  }
1055
1211
  }
1212
+
1213
+ /* a phone's keyboard is open */
1214
+ :host([short]) .question {
1215
+ gap: 10px;
1216
+ }
1217
+
1218
+ :host([short]) .row {
1219
+ gap: 10px;
1220
+ }
1221
+
1222
+ :host([short]) .big {
1223
+ font-size: 30px;
1224
+ }
1225
+
1226
+ :host([short]) .big.sentence {
1227
+ font-size: 18px;
1228
+ }
1229
+
1230
+ :host([short]) .instruction {
1231
+ font-size: 15px;
1232
+ }
1233
+
1234
+ :host([short]) .play-hint {
1235
+ padding: 8px 12px;
1236
+ font-size: 14px;
1237
+ }
1056
1238
  `;
1057
1239
  const _QuestionElement = class _QuestionElement extends OctolearnElement {
1058
1240
  constructor() {
1059
1241
  super(...arguments);
1060
1242
  this.noKeys = false;
1243
+ this.noAutofocus = false;
1061
1244
  this.needsGesture = false;
1062
- this.autofocusAnswer = false;
1063
1245
  this.speech = getSpeechController();
1064
1246
  }
1065
1247
  connectedCallback() {
@@ -1092,6 +1274,9 @@ const _QuestionElement = class _QuestionElement extends OctolearnElement {
1092
1274
  </div>` : nothing;
1093
1275
  }
1094
1276
  updated(changed) {
1277
+ if (this.short && (changed.has("short") || changed.has("content"))) {
1278
+ void this.revealWhenFocused();
1279
+ }
1095
1280
  if (!changed.has("content")) {
1096
1281
  return;
1097
1282
  }
@@ -1103,11 +1288,30 @@ const _QuestionElement = class _QuestionElement extends OctolearnElement {
1103
1288
  "octolearn-tile-answer, octolearn-true-false, octolearn-flashcard"
1104
1289
  );
1105
1290
  if (field) {
1106
- if (this.autofocusAnswer) {
1291
+ if (!this.noAutofocus) {
1107
1292
  void field.updateComplete.then(() => field.focus());
1108
1293
  }
1109
1294
  }
1110
1295
  }
1296
+ /** After the field took the focus (autofocus waits for the field's own update) and the layout settled. */
1297
+ async revealWhenFocused() {
1298
+ await this.updateComplete;
1299
+ await new Promise((resolve) => requestAnimationFrame(resolve));
1300
+ if (this.short && this.isConnected && hasFocusInside(this)) {
1301
+ revealAtTop(this.closestCard() ?? this);
1302
+ }
1303
+ }
1304
+ /** The `<octolearn-card>` around the question, across shadow roots. */
1305
+ closestCard() {
1306
+ let node = this.parentNode;
1307
+ while (node) {
1308
+ if (node instanceof Element && node.localName === "octolearn-card") {
1309
+ return node;
1310
+ }
1311
+ node = node.parentNode ?? (node instanceof ShadowRoot ? node.host : null);
1312
+ }
1313
+ return null;
1314
+ }
1111
1315
  /** Puts the focus on the answer: the field, the first tile or the first button (e.g. when an overlay opens). */
1112
1316
  focusAnswer() {
1113
1317
  this.renderRoot.querySelector(
@@ -1126,6 +1330,9 @@ __decorateClass$h([
1126
1330
  __decorateClass$h([
1127
1331
  property({ attribute: false })
1128
1332
  ], QuestionElement.prototype, "revealFlashcard");
1333
+ __decorateClass$h([
1334
+ property({ type: Boolean, attribute: "no-autofocus" })
1335
+ ], QuestionElement.prototype, "noAutofocus");
1129
1336
  __decorateClass$h([
1130
1337
  state()
1131
1338
  ], QuestionElement.prototype, "needsGesture");
@@ -1315,6 +1522,21 @@ _OctolearnArithmeticQuestion.styles = [
1315
1522
  font-size: 56px;
1316
1523
  }
1317
1524
  }
1525
+
1526
+ /* a phone's keyboard is open */
1527
+ :host([short]) .task {
1528
+ font-size: 40px;
1529
+ }
1530
+
1531
+ :host([short]) .head-row {
1532
+ gap: 8px;
1533
+ }
1534
+
1535
+ :host([short]) .head-row .pill {
1536
+ min-height: 36px;
1537
+ padding: 0 12px;
1538
+ font-size: 13px;
1539
+ }
1318
1540
  `
1319
1541
  ];
1320
1542
  let OctolearnArithmeticQuestion = _OctolearnArithmeticQuestion;
@@ -1499,6 +1721,37 @@ _OctolearnCard.styles = [
1499
1721
  width: 72px;
1500
1722
  }
1501
1723
  }
1724
+
1725
+ /* the on-screen keyboard covers half the screen: only the question and the field; label and stars for screen readers */
1726
+ :host([short]) {
1727
+ scroll-margin-top: 8px;
1728
+ }
1729
+
1730
+ :host([short]) .card {
1731
+ --band-height: 8px;
1732
+ border-radius: 18px;
1733
+ }
1734
+
1735
+ :host([short]) .content {
1736
+ gap: 10px;
1737
+ padding: calc(var(--band-height) + 10px) 14px 12px;
1738
+ }
1739
+
1740
+ :host([short]) .head {
1741
+ position: absolute;
1742
+ width: 1px;
1743
+ height: 1px;
1744
+ margin: -1px;
1745
+ padding: 0;
1746
+ overflow: hidden;
1747
+ clip: rect(0, 0, 0, 0);
1748
+ white-space: nowrap;
1749
+ border: 0;
1750
+ }
1751
+
1752
+ :host([short]) .inkie {
1753
+ display: none;
1754
+ }
1502
1755
  `
1503
1756
  ];
1504
1757
  let OctolearnCard = _OctolearnCard;
@@ -1544,6 +1797,7 @@ const _OctolearnDictationQuestion = class _OctolearnDictationQuestion extends Qu
1544
1797
  super(...arguments);
1545
1798
  this.settings = false;
1546
1799
  this.noAutospeak = false;
1800
+ this.handleVoicesChanged = () => this.requestUpdate();
1547
1801
  }
1548
1802
  /** Where the keyboard focus belongs when the page's voice settings close. */
1549
1803
  focusSettings() {
@@ -1551,13 +1805,26 @@ const _OctolearnDictationQuestion = class _OctolearnDictationQuestion extends Qu
1551
1805
  }
1552
1806
  /** Reads the word aloud, as the listen button does. */
1553
1807
  speak(reason = "repeat") {
1554
- this.speakText(this.content?.question, reason, reason === "slow" ? { rate: SLOW_RATE } : {});
1808
+ const lang = this.wordLanguage;
1809
+ this.speakText(this.content?.question, reason, reason === "slow" ? { lang, rate: SLOW_RATE } : { lang });
1555
1810
  }
1556
1811
  stop() {
1557
1812
  if (this.emit("octolearn-stop", {}, { cancelable: true })) {
1558
1813
  this.speech.stop();
1559
1814
  }
1560
1815
  }
1816
+ connectedCallback() {
1817
+ super.connectedCallback();
1818
+ if (SpeechController.supported) {
1819
+ speechSynthesis.addEventListener("voiceschanged", this.handleVoicesChanged);
1820
+ }
1821
+ }
1822
+ disconnectedCallback() {
1823
+ super.disconnectedCallback();
1824
+ if (SpeechController.supported) {
1825
+ speechSynthesis.removeEventListener("voiceschanged", this.handleVoicesChanged);
1826
+ }
1827
+ }
1561
1828
  updated(changed) {
1562
1829
  super.updated(changed);
1563
1830
  if (changed.has("content") && !this.noAutospeak) {
@@ -1583,7 +1850,7 @@ const _OctolearnDictationQuestion = class _OctolearnDictationQuestion extends Qu
1583
1850
  const words = content.queryFormat === "WordTiles";
1584
1851
  return html`<section class="root question" lang=${this.resolvedLanguage} aria-labelledby="dictation-heading">
1585
1852
  <h2 id="dictation-heading" class="prompt">${words ? t.promptWordSalad : t.promptLetterSalad}</h2>
1586
- ${this.playHint(t)}
1853
+ ${this.playHint(t)} ${this.noVoiceHint(t)}
1587
1854
  <div class="row">
1588
1855
  <button type="button" class="listen medium repeat-button" aria-label=${t.listenWord} @click=${this.repeat}>
1589
1856
  ${dIcon("speaker", 36, 3.4)}
@@ -1597,7 +1864,7 @@ const _OctolearnDictationQuestion = class _OctolearnDictationQuestion extends Qu
1597
1864
  </section>`;
1598
1865
  }
1599
1866
  return html`<section class="root question" lang=${this.resolvedLanguage} aria-labelledby="dictation-heading">
1600
- ${this.playHint(t)}
1867
+ ${this.playHint(t)} ${this.noVoiceHint(t)}
1601
1868
  <div class="row">
1602
1869
  <button type="button" class="listen repeat-button" aria-label=${t.listenWord} @click=${this.repeat}>
1603
1870
  ${dIcon("speaker", 46, 3.4)}
@@ -1612,6 +1879,20 @@ const _OctolearnDictationQuestion = class _OctolearnDictationQuestion extends Qu
1612
1879
  ${answer}
1613
1880
  </section>`;
1614
1881
  }
1882
+ /** The word's language: the content's, else German (octolearn's dictations are German). */
1883
+ get wordLanguage() {
1884
+ return this.content?.sourceLanguage?.trim() || "de";
1885
+ }
1886
+ /** Shown once the browser's voice list is known and has no voice for the word's language. */
1887
+ noVoiceHint(t) {
1888
+ const lang = this.wordLanguage;
1889
+ if (this.speech.hasVoiceFor(lang) !== false) {
1890
+ return nothing;
1891
+ }
1892
+ return html`<div class="play-hint no-voice" role="status">
1893
+ <span>${format(t.noVoiceMessage, { language: languageName(lang, this.resolvedLanguage) })}</span>
1894
+ </div>`;
1895
+ }
1615
1896
  controls() {
1616
1897
  const t = this.t;
1617
1898
  return html`<button type="button" class="pill slow-button" @click=${this.slow}>${t.listenSlowly}</button> ${this.settings ? html`<button type="button" class="pill settings-button" aria-haspopup="dialog" @click=${this.openSettings}>${t.settings}</button>` : nothing}`;
@@ -1634,6 +1915,35 @@ _OctolearnDictationQuestion.styles = [
1634
1915
  flex-wrap: wrap;
1635
1916
  gap: 8px;
1636
1917
  }
1918
+
1919
+ /*
1920
+ * a phone's keyboard is open: speaker and prompt side by side, "Langsam anhören" and "Einstellungen" in one slim
1921
+ * row below them (next to the speaker there is no room for both)
1922
+ */
1923
+ :host([short]) .row {
1924
+ flex-wrap: wrap;
1925
+ row-gap: 8px;
1926
+ }
1927
+
1928
+ :host([short]) .stack {
1929
+ display: contents;
1930
+ }
1931
+
1932
+ :host([short]) .stack .prompt {
1933
+ flex: 1 1 0;
1934
+ min-width: 0;
1935
+ }
1936
+
1937
+ :host([short]) .controls {
1938
+ flex: 1 0 100%;
1939
+ gap: 6px;
1940
+ }
1941
+
1942
+ :host([short]) .controls .pill {
1943
+ min-height: 36px;
1944
+ padding: 0 12px;
1945
+ font-size: 13px;
1946
+ }
1637
1947
  `
1638
1948
  ];
1639
1949
  let OctolearnDictationQuestion = _OctolearnDictationQuestion;
@@ -3647,7 +3957,54 @@ __decorateClass$a([
3647
3957
  __decorateClass$a([
3648
3958
  state()
3649
3959
  ], OctolearnMultipleChoice.prototype, "chosen");
3960
+ var __defProp$9 = Object.defineProperty;
3961
+ var __decorateClass$9 = (decorators, target, key, kind) => {
3962
+ var result = void 0;
3963
+ for (var i = decorators.length - 1, decorator; i >= 0; i--)
3964
+ if (decorator = decorators[i])
3965
+ result = decorator(target, key, result) || result;
3966
+ if (result) __defProp$9(target, key, result);
3967
+ return result;
3968
+ };
3650
3969
  const _OctolearnPictureQuestion = class _OctolearnPictureQuestion extends QuestionElement {
3970
+ constructor() {
3971
+ super(...arguments);
3972
+ this.zoomed = false;
3973
+ }
3974
+ /** Shows the picture large (same as tapping it). */
3975
+ enlargePicture() {
3976
+ this.zoomed = true;
3977
+ }
3978
+ /** Closes the large picture. */
3979
+ closePicture() {
3980
+ this.zoomed = false;
3981
+ }
3982
+ willUpdate(changed) {
3983
+ super.willUpdate(changed);
3984
+ if (changed.has("content")) {
3985
+ this.zoomed = false;
3986
+ }
3987
+ }
3988
+ updated(changed) {
3989
+ super.updated(changed);
3990
+ const dialog = this.zoomDialog;
3991
+ if (!dialog) {
3992
+ return;
3993
+ }
3994
+ if (this.zoomed && !dialog.open) {
3995
+ if (typeof dialog.showModal === "function") {
3996
+ dialog.showModal();
3997
+ } else {
3998
+ dialog.setAttribute("open", "");
3999
+ }
4000
+ } else if (!this.zoomed && dialog.open) {
4001
+ if (typeof dialog.close === "function") {
4002
+ dialog.close();
4003
+ } else {
4004
+ dialog.removeAttribute("open");
4005
+ }
4006
+ }
4007
+ }
3651
4008
  render() {
3652
4009
  const content = this.content;
3653
4010
  if (!content) {
@@ -3657,8 +4014,14 @@ const _OctolearnPictureQuestion = class _OctolearnPictureQuestion extends Questi
3657
4014
  const lang = this.resolvedLanguage;
3658
4015
  const vocabulary = content.isItVocabulary === true;
3659
4016
  const choice = content.queryFormat === "MultipleChoice";
4017
+ const src = content.question ?? "";
4018
+ const alt = vocabulary ? t.pictureCaptionVocabulary : t.pictureCaptionRiddle;
3660
4019
  const picture = html`<figure class="frame">
3661
- <img src=${content.question ?? ""} alt=${vocabulary ? t.pictureCaptionVocabulary : t.pictureCaptionRiddle} />
4020
+ <button type="button" class="enlarge" aria-haspopup="dialog" title=${t.enlargePicture} @click=${this.enlargePicture}>
4021
+ <img src=${src} alt=${alt} />
4022
+ <span class="badge" aria-hidden="true">${dIcon("zoom", 22, 2.6)}</span>
4023
+ <span class="visually-hidden">${t.enlargePicture}</span>
4024
+ </button>
3662
4025
  </figure>`;
3663
4026
  const answer = answerSection({
3664
4027
  content,
@@ -3674,8 +4037,22 @@ const _OctolearnPictureQuestion = class _OctolearnPictureQuestion extends Questi
3674
4037
  return html`<section class="root question" lang=${lang} aria-labelledby="picture-heading">
3675
4038
  <h2 id="picture-heading" class="prompt">${vocabulary ? vocabularyPrompt(content, lang, t) : t.promptPicture}</h2>
3676
4039
  ${choice ? html`<div class="centered">${picture}</div>` : nothing} ${answer}
4040
+ <dialog class="zoom" aria-label=${alt} @click=${this.closePicture} @keydown=${this.onZoomKeydown} @close=${this.onZoomClosed}>
4041
+ ${this.zoomed ? html`<div class="stage"><img src=${src} alt=${alt} /></div>
4042
+ <button type="button" class="close" aria-label=${t.closePicture} title=${t.closePicture}>${dIcon("cross", 26, 3.4)}</button>` : nothing}
4043
+ </dialog>
3677
4044
  </section>`;
3678
4045
  }
4046
+ onZoomKeydown(event) {
4047
+ event.stopPropagation();
4048
+ if (event.key === "Escape") {
4049
+ event.preventDefault();
4050
+ this.zoomed = false;
4051
+ }
4052
+ }
4053
+ onZoomClosed() {
4054
+ this.zoomed = false;
4055
+ }
3679
4056
  };
3680
4057
  _OctolearnPictureQuestion.styles = [
3681
4058
  QuestionElement.styles,
@@ -3703,6 +4080,96 @@ _OctolearnPictureQuestion.styles = [
3703
4080
  border-radius: 14px;
3704
4081
  }
3705
4082
 
4083
+ .enlarge {
4084
+ position: relative;
4085
+ display: flex;
4086
+ align-items: center;
4087
+ justify-content: center;
4088
+ width: 100%;
4089
+ height: 100%;
4090
+ padding: 0;
4091
+ border: 0;
4092
+ border-radius: 14px;
4093
+ background: none;
4094
+ cursor: zoom-in;
4095
+ }
4096
+
4097
+ .enlarge .badge {
4098
+ position: absolute;
4099
+ right: 0;
4100
+ bottom: 0;
4101
+ display: flex;
4102
+ align-items: center;
4103
+ justify-content: center;
4104
+ width: 36px;
4105
+ height: 36px;
4106
+ border: 2px solid ${d.frameLine};
4107
+ border-radius: 999px;
4108
+ background: ${d.creamLight};
4109
+ color: ${d.ink};
4110
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
4111
+ }
4112
+
4113
+ .enlarge:hover .badge {
4114
+ background: ${d.cream};
4115
+ }
4116
+
4117
+ .zoom {
4118
+ position: fixed;
4119
+ inset: 0;
4120
+ width: 100%;
4121
+ height: 100%;
4122
+ max-width: none;
4123
+ max-height: none;
4124
+ margin: 0;
4125
+ padding: 0;
4126
+ border: 0;
4127
+ background: transparent;
4128
+ cursor: zoom-out;
4129
+ }
4130
+
4131
+ .zoom::backdrop {
4132
+ background: ${d.backdrop};
4133
+ -webkit-backdrop-filter: blur(4px);
4134
+ backdrop-filter: blur(4px);
4135
+ }
4136
+
4137
+ .zoom .stage {
4138
+ display: flex;
4139
+ align-items: center;
4140
+ justify-content: center;
4141
+ width: 100%;
4142
+ height: 100%;
4143
+ padding: 24px;
4144
+ }
4145
+
4146
+ .zoom img {
4147
+ display: block;
4148
+ max-width: min(92vw, 1100px);
4149
+ max-height: 86vh;
4150
+ object-fit: contain;
4151
+ border: 3px solid ${d.frameLine};
4152
+ border-radius: 24px;
4153
+ background: ${d.creamLight};
4154
+ }
4155
+
4156
+ .zoom .close {
4157
+ position: fixed;
4158
+ top: 16px;
4159
+ right: 16px;
4160
+ display: flex;
4161
+ align-items: center;
4162
+ justify-content: center;
4163
+ width: 52px;
4164
+ height: 52px;
4165
+ padding: 0;
4166
+ border: 2px solid ${d.ink};
4167
+ border-radius: 999px;
4168
+ background: ${d.paper};
4169
+ color: ${d.ink};
4170
+ cursor: pointer;
4171
+ }
4172
+
3706
4173
  .centered {
3707
4174
  display: flex;
3708
4175
  justify-content: center;
@@ -3725,16 +4192,47 @@ _OctolearnPictureQuestion.styles = [
3725
4192
  height: 180px;
3726
4193
  }
3727
4194
  }
4195
+
4196
+ /* a phone's keyboard is open: a small picture left of the field (a tap still shows it large) */
4197
+ :host([short]) .frame {
4198
+ align-self: center;
4199
+ width: 88px;
4200
+ height: 88px;
4201
+ padding: 4px;
4202
+ border-width: 2px;
4203
+ border-radius: 16px;
4204
+ }
4205
+
4206
+ :host([short]) .frame img,
4207
+ :host([short]) .enlarge {
4208
+ border-radius: 10px;
4209
+ }
4210
+
4211
+ :host([short]) .enlarge .badge {
4212
+ width: 26px;
4213
+ height: 26px;
4214
+ }
4215
+
4216
+ :host([short]) .enlarge .badge .d-icon {
4217
+ width: 16px;
4218
+ height: 16px;
4219
+ }
3728
4220
  `
3729
4221
  ];
3730
4222
  let OctolearnPictureQuestion = _OctolearnPictureQuestion;
3731
- var __defProp$9 = Object.defineProperty;
3732
- var __decorateClass$9 = (decorators, target, key, kind) => {
4223
+ __decorateClass$9([
4224
+ state()
4225
+ ], OctolearnPictureQuestion.prototype, "zoomed");
4226
+ __decorateClass$9([
4227
+ query("dialog.zoom")
4228
+ ], OctolearnPictureQuestion.prototype, "zoomDialog");
4229
+ var __defProp$8 = Object.defineProperty;
4230
+ var __decorateClass$8 = (decorators, target, key, kind) => {
3733
4231
  var result = void 0;
3734
4232
  for (var i = decorators.length - 1, decorator; i >= 0; i--)
3735
4233
  if (decorator = decorators[i])
3736
4234
  result = decorator(target, key, result) || result;
3737
- if (result) __defProp$9(target, key, result);
4235
+ if (result) __defProp$8(target, key, result);
3738
4236
  return result;
3739
4237
  };
3740
4238
  const _OctolearnProgress = class _OctolearnProgress extends OctolearnElement {
@@ -3834,22 +4332,22 @@ _OctolearnProgress.styles = [
3834
4332
  `
3835
4333
  ];
3836
4334
  let OctolearnProgress = _OctolearnProgress;
3837
- __decorateClass$9([
4335
+ __decorateClass$8([
3838
4336
  property({ type: Number })
3839
4337
  ], OctolearnProgress.prototype, "current");
3840
- __decorateClass$9([
4338
+ __decorateClass$8([
3841
4339
  property({ type: Number })
3842
4340
  ], OctolearnProgress.prototype, "total");
3843
- __decorateClass$9([
4341
+ __decorateClass$8([
3844
4342
  property({ attribute: false })
3845
4343
  ], OctolearnProgress.prototype, "results");
3846
- var __defProp$8 = Object.defineProperty;
3847
- var __decorateClass$8 = (decorators, target, key, kind) => {
4344
+ var __defProp$7 = Object.defineProperty;
4345
+ var __decorateClass$7 = (decorators, target, key, kind) => {
3848
4346
  var result = void 0;
3849
4347
  for (var i = decorators.length - 1, decorator; i >= 0; i--)
3850
4348
  if (decorator = decorators[i])
3851
4349
  result = decorator(target, key, result) || result;
3852
- if (result) __defProp$8(target, key, result);
4350
+ if (result) __defProp$7(target, key, result);
3853
4351
  return result;
3854
4352
  };
3855
4353
  const ELEMENT_BY_FORMAT = {
@@ -3903,6 +4401,7 @@ class OctolearnQuestion extends OctolearnElement {
3903
4401
  .content=${this.content}
3904
4402
  .strings=${this.strings}
3905
4403
  lang=${lang}
4404
+ ?no-autofocus=${this.noAutofocus}
3906
4405
  ?no-keys=${this.noKeys}
3907
4406
  ></octolearn-arithmetic-question>`;
3908
4407
  case "octolearn-dictation-question":
@@ -3912,6 +4411,7 @@ class OctolearnQuestion extends OctolearnElement {
3912
4411
  lang=${lang}
3913
4412
  ?settings=${this.settings}
3914
4413
  ?no-autospeak=${this.noAutospeak}
4414
+ ?no-autofocus=${this.noAutofocus}
3915
4415
  ?no-keys=${this.noKeys}
3916
4416
  ></octolearn-dictation-question>`;
3917
4417
  case "octolearn-picture-question":
@@ -3919,6 +4419,7 @@ class OctolearnQuestion extends OctolearnElement {
3919
4419
  .content=${this.content}
3920
4420
  .strings=${this.strings}
3921
4421
  lang=${lang}
4422
+ ?no-autofocus=${this.noAutofocus}
3922
4423
  ?no-keys=${this.noKeys}
3923
4424
  ></octolearn-picture-question>`;
3924
4425
  default:
@@ -3933,41 +4434,25 @@ class OctolearnQuestion extends OctolearnElement {
3933
4434
  }
3934
4435
  }
3935
4436
  }
3936
- __decorateClass$8([
4437
+ __decorateClass$7([
3937
4438
  property({ attribute: false })
3938
4439
  ], OctolearnQuestion.prototype, "content");
3939
- __decorateClass$8([
4440
+ __decorateClass$7([
3940
4441
  property({ type: Boolean })
3941
4442
  ], OctolearnQuestion.prototype, "settings");
3942
- __decorateClass$8([
4443
+ __decorateClass$7([
3943
4444
  property({ type: Boolean, attribute: "no-autospeak" })
3944
4445
  ], OctolearnQuestion.prototype, "noAutospeak");
3945
- __decorateClass$8([
4446
+ __decorateClass$7([
3946
4447
  property({ type: Boolean, attribute: "no-autofocus" })
3947
4448
  ], OctolearnQuestion.prototype, "noAutofocus");
3948
- __decorateClass$8([
4449
+ __decorateClass$7([
3949
4450
  property({ type: Boolean, attribute: "no-keys" })
3950
4451
  ], OctolearnQuestion.prototype, "noKeys");
3951
- __decorateClass$8([
4452
+ __decorateClass$7([
3952
4453
  property({ attribute: false })
3953
4454
  ], OctolearnQuestion.prototype, "revealFlashcard");
3954
- var __defProp$7 = Object.defineProperty;
3955
- var __decorateClass$7 = (decorators, target, key, kind) => {
3956
- var result = void 0;
3957
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
3958
- if (decorator = decorators[i])
3959
- result = decorator(target, key, result) || result;
3960
- if (result) __defProp$7(target, key, result);
3961
- return result;
3962
- };
3963
4455
  class OctolearnQuestionAnswer extends QuestionElement {
3964
- constructor() {
3965
- super(...arguments);
3966
- this.noAutofocus = false;
3967
- }
3968
- willUpdate() {
3969
- this.autofocusAnswer = !this.noAutofocus;
3970
- }
3971
4456
  render() {
3972
4457
  const content = this.content;
3973
4458
  if (!content) {
@@ -4030,9 +4515,6 @@ class OctolearnQuestionAnswer extends QuestionElement {
4030
4515
  }
4031
4516
  }
4032
4517
  }
4033
- __decorateClass$7([
4034
- property({ type: Boolean, attribute: "no-autofocus" })
4035
- ], OctolearnQuestionAnswer.prototype, "noAutofocus");
4036
4518
  var __defProp$6 = Object.defineProperty;
4037
4519
  var __decorateClass$6 = (decorators, target, key, kind) => {
4038
4520
  var result = void 0;
@@ -4061,13 +4543,19 @@ const _OctolearnTextAnswer = class _OctolearnTextAnswer extends OctolearnElement
4061
4543
  const t = this.t;
4062
4544
  const empty = this.value.trim() === "";
4063
4545
  const keyHint = t.pressKeyOr.split("{key}");
4546
+ const short = this.short;
4547
+ const placeholder = short && this.visibleLabel ? this.label : this.placeholder;
4548
+ const send = html`<button type="submit" class="send-round" aria-label=${t.done} title=${t.done} ?disabled=${empty}>
4549
+ ${dIcon("check", 22, 3.4)}
4550
+ </button>`;
4064
4551
  const input = html`<input
4065
4552
  id="answer"
4066
4553
  class=${this.variant === "box" ? "box" : ""}
4067
4554
  .type=${this.type}
4068
4555
  inputmode=${this.type === "number" ? "numeric" : "text"}
4556
+ enterkeyhint="done"
4069
4557
  .value=${live(this.value)}
4070
- placeholder=${this.placeholder || nothing}
4558
+ placeholder=${placeholder || nothing}
4071
4559
  lang=${this.inputLang ?? nothing}
4072
4560
  autocomplete="off"
4073
4561
  autocapitalize="off"
@@ -4075,30 +4563,35 @@ const _OctolearnTextAnswer = class _OctolearnTextAnswer extends OctolearnElement
4075
4563
  spellcheck="false"
4076
4564
  @input=${this.onInput}
4077
4565
  />`;
4078
- const label = html`<label for="answer" class=${this.visibleLabel ? "field-label" : "visually-hidden"}>${this.label}</label>`;
4566
+ const label = html`<label for="answer" class=${this.visibleLabel && !short ? "field-label" : "visually-hidden"}>${this.label}</label>`;
4567
+ const field = html`<div class="field">${input}${short ? send : nothing}</div>`;
4079
4568
  return html`<div class="root" lang=${this.resolvedLanguage}>
4080
4569
  <form novalidate @submit=${this.onSubmit}>
4081
4570
  ${this.renderHint()}
4082
- ${this.variant === "box" ? html`<div class="equation"><slot name="before"></slot>${label}${input}</div>` : this.variant === "media" ? html`<div class="with-media">
4571
+ ${this.variant === "box" ? html`<div class="equation"><slot name="before"></slot>${label}${input}${short ? send : nothing}</div>` : this.variant === "media" ? html`<div class="with-media">
4083
4572
  <slot name="media"></slot>
4084
- <div class="labelled grow">
4085
- ${label}
4086
- <div class="field">${input}</div>
4087
- </div>
4088
- </div>` : html`<div class="labelled">
4089
- ${label}
4090
- <div class="field">${input}</div>
4091
- </div>`}
4092
- <div class="actions">
4093
- ${this.noDontKnow ? nothing : html`<button type="button" class="pill dont-know" @click=${this.onDontKnow}>${t.dontKnow}</button>`}
4094
- <span class="send">
4095
- <span class="hint keys-only">${keyHint[0]}<kbd>Enter</kbd>${keyHint[1]}</span>
4096
- <button type="submit" class="pill primary" ?disabled=${empty}>${dIcon("check", 22)} ${t.done}</button>
4097
- </span>
4098
- </div>
4573
+ <div class="labelled grow">${label} ${field}</div>
4574
+ </div>` : html`<div class="labelled">${label} ${field}</div>`}
4575
+ ${short ? this.renderShortActions() : this.renderActions(empty, keyHint)}
4099
4576
  </form>
4100
4577
  </div>`;
4101
4578
  }
4579
+ renderActions(empty, keyHint) {
4580
+ const t = this.t;
4581
+ return html`<div class="actions">
4582
+ ${this.noDontKnow ? nothing : html`<button type="button" class="pill dont-know" @click=${this.onDontKnow}>${t.dontKnow}</button>`}
4583
+ <span class="send">
4584
+ <span class="hint keys-only">${keyHint[0]}<kbd>Enter</kbd>${keyHint[1]}</span>
4585
+ <button type="submit" class="pill primary" ?disabled=${empty}>${dIcon("check", 22)} ${t.done}</button>
4586
+ </span>
4587
+ </div>`;
4588
+ }
4589
+ /** Short: the send button is in the field; only "Weiß ich nicht", small, below it. */
4590
+ renderShortActions() {
4591
+ return this.noDontKnow ? nothing : html`<div class="actions">
4592
+ <button type="button" class="dont-know link" @click=${this.onDontKnow}>${this.t.dontKnow}</button>
4593
+ </div>`;
4594
+ }
4102
4595
  renderHint() {
4103
4596
  const letters2 = Array.from((this.hint ?? "").normalize("NFC"));
4104
4597
  if (letters2.length === 0) {
@@ -4265,6 +4758,96 @@ _OctolearnTextAnswer.styles = [
4265
4758
  height: 80px;
4266
4759
  font-size: 48px;
4267
4760
  }
4761
+
4762
+ /* "Weiß ich nicht" and "Fertig" share one row on a 320 px phone */
4763
+ .actions {
4764
+ gap: 8px;
4765
+ }
4766
+
4767
+ .actions .pill {
4768
+ padding: 0 14px;
4769
+ }
4770
+
4771
+ .actions .pill.primary {
4772
+ padding: 0 18px;
4773
+ }
4774
+ }
4775
+
4776
+ /* a phone's keyboard is open */
4777
+ :host([short]) form {
4778
+ gap: 6px;
4779
+ }
4780
+
4781
+ :host([short]) .with-media {
4782
+ flex-direction: row;
4783
+ align-items: center;
4784
+ gap: 10px;
4785
+ }
4786
+
4787
+ :host([short]) .equation {
4788
+ flex-wrap: nowrap;
4789
+ gap: 10px;
4790
+ }
4791
+
4792
+ :host([short]) .box {
4793
+ width: 88px;
4794
+ height: 56px;
4795
+ border-radius: 16px;
4796
+ box-shadow: 0 0 0 4px ${d.cream};
4797
+ font-size: 32px;
4798
+ }
4799
+
4800
+ :host([short]) .fragment {
4801
+ font-size: 26px;
4802
+ }
4803
+
4804
+ :host([short]) .fragment .gap {
4805
+ width: 18px;
4806
+ height: 24px;
4807
+ margin: 0 2px;
4808
+ border-width: 2px;
4809
+ border-radius: 6px;
4810
+ vertical-align: -3px;
4811
+ }
4812
+
4813
+ .send-round {
4814
+ display: inline-flex;
4815
+ flex: none;
4816
+ align-items: center;
4817
+ justify-content: center;
4818
+ width: 40px;
4819
+ height: 40px;
4820
+ padding: 0;
4821
+ border: 0;
4822
+ border-radius: 50%;
4823
+ background: ${d.accent};
4824
+ color: ${d.onAccent};
4825
+ }
4826
+
4827
+ .send-round:disabled {
4828
+ opacity: 0.5;
4829
+ }
4830
+
4831
+ .equation .send-round {
4832
+ width: 48px;
4833
+ height: 48px;
4834
+ }
4835
+
4836
+ :host([short]) .actions {
4837
+ justify-content: flex-start;
4838
+ gap: 0;
4839
+ }
4840
+
4841
+ .dont-know.link {
4842
+ min-height: 40px;
4843
+ padding: 0 4px;
4844
+ border: 0;
4845
+ background: none;
4846
+ color: ${d.inkSoft};
4847
+ font-size: 14px;
4848
+ font-weight: 600;
4849
+ text-decoration: underline;
4850
+ text-underline-offset: 3px;
4268
4851
  }
4269
4852
  `
4270
4853
  ];
@@ -5054,6 +5637,16 @@ _OctolearnTrainerOverlay.styles = [
5054
5637
  padding: 76px 8px 16px;
5055
5638
  }
5056
5639
  }
5640
+
5641
+ /* a phone's keyboard is open: the card at the top, no note over it */
5642
+ :host([short]) .stage {
5643
+ align-items: flex-start;
5644
+ padding: 8px;
5645
+ }
5646
+
5647
+ :host([short]) .status {
5648
+ display: none;
5649
+ }
5057
5650
  `
5058
5651
  ];
5059
5652
  let OctolearnTrainerOverlay = _OctolearnTrainerOverlay;
@@ -5479,13 +6072,16 @@ function defineTrainingElements(options = {}) {
5479
6072
  }
5480
6073
  export {
5481
6074
  AUTO_CONTINUE_MS as A,
5482
- parseArithmeticTask as B,
5483
- trainingElements as C,
6075
+ loadFonts as B,
6076
+ parseArithmeticTask as C,
6077
+ trainingElements as D,
5484
6078
  FALSE_ANSWER as F,
5485
6079
  KNOWN_ANSWER as K,
5486
6080
  NOT_KNOWN_ANSWER as N,
5487
6081
  OctolearnArithmeticQuestion as O,
6082
+ SHORT_VIEWPORT_PX as S,
5488
6083
  TRUE_ANSWER as T,
6084
+ ViewportWatcher as V,
5489
6085
  OctolearnCard as a,
5490
6086
  OctolearnDictationQuestion as b,
5491
6087
  OctolearnFeedback as c,
@@ -5509,7 +6105,7 @@ export {
5509
6105
  describeMatchingRound as u,
5510
6106
  describeQuestion as v,
5511
6107
  en as w,
5512
- languageName as x,
5513
- letterDiff as y,
5514
- loadFonts as z
6108
+ getViewportWatcher as x,
6109
+ languageName as y,
6110
+ letterDiff as z
5515
6111
  };