postext 1.60.0 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/__tests__/exercises/digital.test.js +38 -1
  2. package/dist/__tests__/exercises/digital.test.js.map +1 -1
  3. package/dist/__tests__/exports.test.js +16 -0
  4. package/dist/__tests__/exports.test.js.map +1 -1
  5. package/dist/__tests__/figureStyle.test.d.ts +2 -0
  6. package/dist/__tests__/figureStyle.test.d.ts.map +1 -0
  7. package/dist/__tests__/figureStyle.test.js +218 -0
  8. package/dist/__tests__/figureStyle.test.js.map +1 -0
  9. package/dist/__tests__/headingLetterSpacing.test.js +1 -1
  10. package/dist/__tests__/headingLetterSpacing.test.js.map +1 -1
  11. package/dist/__tests__/pipeline/paragraphStyleFaces.test.js +1 -1
  12. package/dist/__tests__/pipeline/paragraphStyleFaces.test.js.map +1 -1
  13. package/dist/canvas-backend/verticalText.d.ts.map +1 -1
  14. package/dist/canvas-backend/verticalText.js +5 -2
  15. package/dist/canvas-backend/verticalText.js.map +1 -1
  16. package/dist/configWarnings.d.ts.map +1 -1
  17. package/dist/configWarnings.js +18 -4
  18. package/dist/configWarnings.js.map +1 -1
  19. package/dist/defaults/bodyText.d.ts.map +1 -1
  20. package/dist/defaults/bodyText.js +8 -1
  21. package/dist/defaults/bodyText.js.map +1 -1
  22. package/dist/defaults/captionStyle.d.ts.map +1 -1
  23. package/dist/defaults/captionStyle.js +7 -2
  24. package/dist/defaults/captionStyle.js.map +1 -1
  25. package/dist/defaults/headerFooter.d.ts.map +1 -1
  26. package/dist/defaults/headerFooter.js +3 -1
  27. package/dist/defaults/headerFooter.js.map +1 -1
  28. package/dist/defaults/headings.d.ts.map +1 -1
  29. package/dist/defaults/headings.js +15 -3
  30. package/dist/defaults/headings.js.map +1 -1
  31. package/dist/defaults/paragraphStyles.d.ts.map +1 -1
  32. package/dist/defaults/paragraphStyles.js +4 -1
  33. package/dist/defaults/paragraphStyles.js.map +1 -1
  34. package/dist/defaults/tableStyle.d.ts.map +1 -1
  35. package/dist/defaults/tableStyle.js +5 -2
  36. package/dist/defaults/tableStyle.js.map +1 -1
  37. package/dist/design/elementKeys.d.ts +1 -0
  38. package/dist/design/elementKeys.d.ts.map +1 -1
  39. package/dist/design/elementKeys.js +1 -1
  40. package/dist/design/elementKeys.js.map +1 -1
  41. package/dist/exercises/digital.d.ts +7 -0
  42. package/dist/exercises/digital.d.ts.map +1 -1
  43. package/dist/exercises/digital.js +8 -1
  44. package/dist/exercises/digital.js.map +1 -1
  45. package/dist/exercises/expand.d.ts +4 -0
  46. package/dist/exercises/expand.d.ts.map +1 -1
  47. package/dist/exercises/expand.js +65 -4
  48. package/dist/exercises/expand.js.map +1 -1
  49. package/dist/exercises/record.d.ts +4 -0
  50. package/dist/exercises/record.d.ts.map +1 -1
  51. package/dist/exercises/runtime.d.ts +6 -0
  52. package/dist/exercises/runtime.d.ts.map +1 -1
  53. package/dist/exercises/runtime.js +432 -59
  54. package/dist/exercises/runtime.js.map +1 -1
  55. package/dist/exercises/styles.d.ts +1 -0
  56. package/dist/exercises/styles.d.ts.map +1 -1
  57. package/dist/exercises/styles.js +3 -0
  58. package/dist/exercises/styles.js.map +1 -1
  59. package/dist/fonts/featureFaces.d.ts +32 -0
  60. package/dist/fonts/featureFaces.d.ts.map +1 -0
  61. package/dist/fonts/featureFaces.js +166 -0
  62. package/dist/fonts/featureFaces.js.map +1 -0
  63. package/dist/fonts/figureStyle.d.ts +69 -0
  64. package/dist/fonts/figureStyle.d.ts.map +1 -0
  65. package/dist/fonts/figureStyle.js +196 -0
  66. package/dist/fonts/figureStyle.js.map +1 -0
  67. package/dist/fonts/freezeFeatures.d.ts +10 -0
  68. package/dist/fonts/freezeFeatures.d.ts.map +1 -0
  69. package/dist/fonts/freezeFeatures.js +372 -0
  70. package/dist/fonts/freezeFeatures.js.map +1 -0
  71. package/dist/fonts/glyphFallback.d.ts.map +1 -1
  72. package/dist/fonts/glyphFallback.js +6 -3
  73. package/dist/fonts/glyphFallback.js.map +1 -1
  74. package/dist/fonts/prepare.d.ts.map +1 -1
  75. package/dist/fonts/prepare.js +22 -4
  76. package/dist/fonts/prepare.js.map +1 -1
  77. package/dist/fonts/usage.d.ts +10 -0
  78. package/dist/fonts/usage.d.ts.map +1 -1
  79. package/dist/fonts/usage.js +97 -0
  80. package/dist/fonts/usage.js.map +1 -1
  81. package/dist/html-backend.d.ts.map +1 -1
  82. package/dist/html-backend.js +8 -5
  83. package/dist/html-backend.js.map +1 -1
  84. package/dist/index.d.ts +7 -2
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +4 -1
  87. package/dist/index.js.map +1 -1
  88. package/dist/pipeline/headingStyles.d.ts.map +1 -1
  89. package/dist/pipeline/headingStyles.js +3 -0
  90. package/dist/pipeline/headingStyles.js.map +1 -1
  91. package/dist/types.d.ts +45 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/worker/client.d.ts.map +1 -1
  94. package/dist/worker/client.js +13 -2
  95. package/dist/worker/client.js.map +1 -1
  96. package/dist/worker/fonts.d.ts +1 -1
  97. package/dist/worker/fonts.d.ts.map +1 -1
  98. package/dist/worker/fonts.js +1 -1
  99. package/dist/worker/fonts.js.map +1 -1
  100. package/dist/worker/protocol.d.ts +4 -0
  101. package/dist/worker/protocol.d.ts.map +1 -1
  102. package/package.json +1 -1
@@ -165,6 +165,32 @@ function exerciseRuntime() {
165
165
  }
166
166
  }
167
167
  }
168
+ /** The buttons an exercise's bar needs: a check where an answer is known
169
+ * for a slot the reader fills, the solution where there is one to show,
170
+ * a fresh start where there is anything to answer. */
171
+ function actionsOf(record) {
172
+ let anyMarkRight = false;
173
+ for (let i = 0; i < record.slots.length; i++)
174
+ if (record.slots[i].kind === 'mark' && (record.slots[i].correct || []).indexOf(1) >= 0)
175
+ anyMarkRight = true;
176
+ let check = false;
177
+ let solution = !!record.key || (!!record.solution && record.solution.length > 0);
178
+ let reset = false;
179
+ for (let i = 0; i < record.slots.length; i++) {
180
+ const slot = record.slots[i];
181
+ // Room on the printed page only.
182
+ if (slot.bare)
183
+ continue;
184
+ reset = true;
185
+ const known = slot.kind === 'mark' ? anyMarkRight : (!!slot.correct && slot.correct.length > 0) || (!!slot.accept && slot.accept.length > 0);
186
+ if (!known)
187
+ continue;
188
+ solution = true;
189
+ if (isGraded(slot, record))
190
+ check = true;
191
+ }
192
+ return { check, solution, reset };
193
+ }
168
194
  // --- Storage -----------------------------------------------------------------
169
195
  function load(key, persist) {
170
196
  let text = null;
@@ -215,28 +241,34 @@ function exerciseRuntime() {
215
241
  // --- The page ----------------------------------------------------------------
216
242
  const CSS = '.pt-ex-ui{pointer-events:auto;box-sizing:border-box;z-index:1;}' +
217
243
  '.pt-ex-field{position:absolute;display:block;}' +
218
- '.pt-ex-input{position:absolute;left:0;top:0;width:100%;height:100%;margin:0;padding:0 .15em;border:0;border-radius:.15em;background:transparent;color:inherit;font:inherit;line-height:1;text-align:center;outline-offset:1px;box-sizing:border-box;}' +
219
- '.pt-ex-input:focus{background:rgba(255,255,255,.9);outline:2px solid #1a5fb4;}' +
220
- 'textarea.pt-ex-input{text-align:start;resize:none;overflow:hidden;background:transparent;padding:0 .2em;}' +
244
+ '.pt-ex-input{position:absolute;left:0;top:0;width:100%;height:100%;margin:0;padding:0 .15em;border:0;border-radius:.15em;background:transparent;color:var(--pt-ex-text,#1d1d1f);font:inherit;line-height:1;text-align:center;box-sizing:border-box;}' +
245
+ // Focus rings, ticks and rings are drawn inside the slot: a page that
246
+ // clips at its edge (the Sandbox's HTML tab) cuts nothing.
247
+ '.pt-ex-input:focus{background:rgba(255,255,255,.9);outline:2px solid #1a5fb4;outline-offset:-2px;}' +
248
+ 'textarea.pt-ex-input{text-align:start;resize:none;overflow:hidden;background:transparent;padding:0 .2em;color:var(--pt-ex-text,#1d1d1f);}' +
221
249
  'textarea.pt-ex-input:focus{background:rgba(255,255,255,.35);}' +
222
250
  '.pt-ex-cell{text-transform:uppercase;}' +
223
251
  '.pt-ex-toggle{cursor:pointer;pointer-events:auto;}' +
224
- '.pt-ex-toggle:focus-visible{outline:2px solid #1a5fb4;outline-offset:2px;}' +
225
- '.pt-ex-on.pt-ex-ring,.pt-ex-on.pt-ex-mk-circle{outline:.12em solid currentColor;outline-offset:.04em;border-radius:999px;}' +
226
- '.pt-ex-on.pt-ex-mk-box{outline:.1em solid currentColor;}' +
227
- '.pt-ex-on.pt-ex-mk-underline{border-bottom:.12em solid currentColor;}' +
252
+ '.pt-ex-toggle:focus-visible{outline:2px solid #1a5fb4;outline-offset:-2px;}' +
253
+ '.pt-ex-on.pt-ex-ring,.pt-ex-on.pt-ex-mk-circle{box-shadow:inset 0 0 0 .1em var(--pt-ex-ring,currentColor);border-radius:999px;}' +
254
+ '.pt-ex-on.pt-ex-mk-box{box-shadow:inset 0 0 0 .09em var(--pt-ex-ring,currentColor);}' +
255
+ '.pt-ex-on.pt-ex-mk-underline{box-shadow:inset 0 -.12em 0 var(--pt-ex-ring,currentColor);}' +
228
256
  '.pt-ex-on.pt-ex-mk-highlight{background:rgba(255,214,0,.45);}' +
229
257
  '.pt-ex-on.pt-ex-mk-strike{background:linear-gradient(transparent 46%,currentColor 46%,currentColor 56%,transparent 56%);}' +
230
258
  '.pt-ex-on.pt-ex-tick::after{content:"\\2713";position:absolute;left:0;right:0;top:50%;transform:translateY(-55%);text-align:center;line-height:1;font-weight:700;}' +
231
259
  '.pt-ex-revealed,.pt-ex-revealed .pt-ex-input{color:var(--pt-ex-answer,#d6246e);}' +
232
- '.pt-ex-right{outline-color:var(--pt-ex-right,#1e8e3e)!important;}' +
233
- '.pt-ex-wrong{outline-color:var(--pt-ex-wrong,#c5221f)!important;}' +
260
+ '.pt-ex-right{--pt-ex-ring:var(--pt-ex-right,#1e8e3e);outline-color:var(--pt-ex-right,#1e8e3e)!important;}' +
261
+ '.pt-ex-wrong{--pt-ex-ring:var(--pt-ex-wrong,#c5221f);outline-color:var(--pt-ex-wrong,#c5221f)!important;}' +
234
262
  '.pt-ex-field.pt-ex-right .pt-ex-input,textarea.pt-ex-right{box-shadow:inset 0 -.12em 0 var(--pt-ex-right,#1e8e3e);}' +
235
263
  '.pt-ex-field.pt-ex-wrong .pt-ex-input,textarea.pt-ex-wrong{box-shadow:inset 0 -.12em 0 var(--pt-ex-wrong,#c5221f);}' +
236
- '.pt-ex-mark{position:absolute;right:-.55em;top:-.6em;font:700 .62em/1 system-ui,sans-serif;padding:.12em .2em;border-radius:999px;color:#fff;pointer-events:none;z-index:2;}' +
264
+ // A right or wrong mark stands on its slot's top corner, over the line
265
+ // gap rather than the next word, slid onto the page at its edge (see
266
+ // `placeMark`).
267
+ '.pt-ex-mark{position:absolute;left:100%;top:0;transform:translate(-55%,-60%);font:700 .62em/1 system-ui,sans-serif;padding:.12em .2em;border-radius:999px;color:#fff;pointer-events:none;z-index:2;white-space:nowrap;}' +
237
268
  '.pt-ex-mark.pt-ex-ok{background:var(--pt-ex-right,#1e8e3e);}' +
238
269
  '.pt-ex-mark.pt-ex-no{background:var(--pt-ex-wrong,#c5221f);}' +
239
270
  '.pt-ex-bar{position:absolute;right:0;top:100%;margin-top:.2em;display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:.35em;font:400 1em/1.2 system-ui,sans-serif;color:#1d1d1f;z-index:3;}' +
271
+ '.pt-ex-bar:not(.pt-ex-flow){padding:.15em;border-radius:999px;background:rgba(255,255,255,.85);}' +
240
272
  '.pt-ex-bar button{font:inherit;margin:0;padding:.18em .6em;border:1px solid rgba(0,0,0,.35);border-radius:999px;background:#fff;color:#1d1d1f;cursor:pointer;}' +
241
273
  '.pt-ex-bar button:hover{background:#f1f1f3;}' +
242
274
  '.pt-ex-bar button:focus-visible{outline:2px solid #1a5fb4;outline-offset:2px;}' +
@@ -248,15 +280,26 @@ function exerciseRuntime() {
248
280
  '.pt-ex-menu{position:absolute;left:0;top:100%;margin-top:.2em;display:flex;flex-direction:column;min-width:8em;padding:.2em;background:#fff;border:1px solid rgba(0,0,0,.25);border-radius:.3em;box-shadow:0 .2em .6em rgba(0,0,0,.18);font:400 .8em/1.2 system-ui,sans-serif;z-index:5;}' +
249
281
  '.pt-ex-menu button{font:inherit;text-align:start;padding:.3em .5em;border:0;background:none;color:#1d1d1f;cursor:pointer;border-radius:.2em;}' +
250
282
  '.pt-ex-menu button:hover,.pt-ex-menu button:focus{background:#eef2fb;}' +
251
- '.pt-ex-tag{position:absolute;left:50%;top:100%;transform:translate(-50%,.1em);font:600 .55em/1 system-ui,sans-serif;white-space:nowrap;padding:.1em .3em;border-radius:.3em;background:#eef2fb;color:#1d1d1f;pointer-events:none;}' +
283
+ // A sort exercise: a word sorted fades in the bank and stands in its
284
+ // column; words are dragged from the bank to a column and back.
285
+ '.pt-ex-drag{touch-action:none;user-select:none;-webkit-user-select:none;}' +
286
+ // The word is painted under its slot: a veil fades it.
287
+ '.pt-ex-sorted{background:rgba(255,255,255,.62);border-radius:.25em;}' +
288
+ '.pt-ex-col{position:absolute;left:0;top:0;width:100%;height:100%;box-sizing:border-box;padding:0 .2em;color:var(--pt-ex-text,#1d1d1f);border-radius:.2em;}' +
289
+ '.pt-ex-placed{display:table;cursor:grab;white-space:nowrap;}' +
290
+ '.pt-ex-placed.pt-ex-right{color:var(--pt-ex-right,#1e8e3e);}' +
291
+ '.pt-ex-placed.pt-ex-wrong{color:var(--pt-ex-wrong,#c5221f);}' +
292
+ '.pt-ex-col.pt-ex-revealed,.pt-ex-col.pt-ex-revealed .pt-ex-placed{color:var(--pt-ex-answer,#d6246e);cursor:default;}' +
293
+ '.pt-ex-drop{background:rgba(26,95,180,.08);box-shadow:inset 0 0 0 2px rgba(26,95,180,.55);}' +
252
294
  '.pt-ex-svg{position:absolute;left:0;top:0;overflow:visible;}' +
253
295
  '.pt-ex-layer{position:absolute;left:0;top:0;transform-origin:0 0;}' +
254
296
  // The controls of a mirrored flow (a right-to-left page of the HTML
255
297
  // viewer) turn back about their own centres, as its text does (#1244).
256
298
  '.pt-flow-mirrored .pt-ex-ui:not(svg){transform:scaleX(-1);}' +
257
- '.pt-ex-dot{fill:transparent;stroke:transparent;cursor:pointer;pointer-events:all;}' +
258
- '.pt-ex-dot.pt-ex-pending{stroke:#1a5fb4;stroke-width:2;}' +
259
- '.pt-ex-dot:focus{outline:none;stroke:#1a5fb4;stroke-width:2;}' +
299
+ '.pt-ex-dot{fill:transparent;stroke:transparent;cursor:pointer;pointer-events:all;touch-action:none;user-select:none;-webkit-user-select:none;}' +
300
+ '.pt-ex-dot:hover{fill:rgba(26,95,180,.12);}' +
301
+ '.pt-ex-dot.pt-ex-pending{fill:rgba(26,95,180,.2);stroke:#1a5fb4;stroke-width:2.5;}' +
302
+ '.pt-ex-dot:focus{outline:none;stroke:#1a5fb4;stroke-width:2.5;}' +
260
303
  '.pt-ex-bar.pt-ex-flow{position:static;justify-content:flex-start;margin-top:.6em;font-size:.8em;}' +
261
304
  '.pt-ex-panel.pt-ex-flow{position:static;margin-top:.5em;box-shadow:none;font-size:.9em;}' +
262
305
  '.pt-ex-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;}';
@@ -388,7 +431,7 @@ function exerciseRuntime() {
388
431
  const persist = opts.persist !== undefined ? opts.persist : data.persist !== false;
389
432
  const instant = (opts.check || data.check) === 'instant';
390
433
  const words = data.words || {};
391
- const look = (data.styles && data.styles[rec.style]) || { correct: '#1e8e3e', wrong: '#c5221f', answer: '#d6246e', marker: 'none', trueFalse: words.trueFalse || ['T', 'F'] };
434
+ const look = (data.styles && data.styles[rec.style]) || { correct: '#1e8e3e', wrong: '#c5221f', answer: '#d6246e', ink: 'currentColor', marker: 'none', trueFalse: words.trueFalse || ['T', 'F'] };
392
435
  const tf = look.trueFalse;
393
436
  const exName = (words.exercise || 'Exercise') + ' ' + rec.number;
394
437
  const key = base + ':' + (rec.anchor || '#' + rec.ordinal + '.' + rec.id);
@@ -455,6 +498,8 @@ function exerciseRuntime() {
455
498
  return e;
456
499
  }
457
500
  function colours(e) {
501
+ if (data.text)
502
+ e.style.setProperty('--pt-ex-text', data.text);
458
503
  e.style.setProperty('--pt-ex-right', look.correct);
459
504
  e.style.setProperty('--pt-ex-wrong', look.wrong);
460
505
  e.style.setProperty('--pt-ex-answer', look.answer);
@@ -493,6 +538,24 @@ function exerciseRuntime() {
493
538
  const m = el('span', 'pt-ex-mark ' + (ok ? 'pt-ex-ok' : 'pt-ex-no'), holder);
494
539
  m.setAttribute('aria-hidden', 'true');
495
540
  m.textContent = ok ? '\u2713' : '\u2717';
541
+ placeMark(m, holder);
542
+ }
543
+ /** A mark slid onto the page where its corner place would cross the
544
+ * page's edge (a page that clips, as the Sandbox's does). */
545
+ function placeMark(m, holder) {
546
+ const page = typeof holder.closest === 'function' ? holder.closest('.pt-page') : null;
547
+ if (!page || typeof m.getBoundingClientRect !== 'function')
548
+ return;
549
+ const pr = page.getBoundingClientRect();
550
+ const r = m.getBoundingClientRect();
551
+ if (r.width === 0)
552
+ return;
553
+ // Screen pixels per CSS pixel of the page (a page shown scaled).
554
+ const k = m.offsetWidth > 0 ? r.width / m.offsetWidth : 1;
555
+ const dx = r.right > pr.right - 1 ? pr.right - 1 - r.right : r.left < pr.left + 1 ? pr.left + 1 - r.left : 0;
556
+ const dy = r.top < pr.top + 1 ? pr.top + 1 - r.top : 0;
557
+ if (dx !== 0 || dy !== 0)
558
+ m.style.transform = 'translate(calc(-55% + ' + (dx / k).toFixed(1) + 'px),calc(-60% + ' + (dy / k).toFixed(1) + 'px))';
496
559
  }
497
560
  const widgets = [];
498
561
  let blankN = 0;
@@ -648,12 +711,136 @@ function exerciseRuntime() {
648
711
  const v = state.revealed ? solutionValue(w.slot, rec) : state.v[w.slot.key];
649
712
  const at = typeof v === 'string' ? categories.indexOf(v) : -1;
650
713
  if (at >= 0 && w.word)
651
- lists[at].push(w.word);
714
+ lists[at].push(w);
652
715
  }
653
716
  for (let i = 0; i < columnWidgets.length; i++)
654
- columnWidgets[i].show((lists[i] || []).join('\n'));
717
+ columnWidgets[i].show(lists[i] || []);
718
+ }
719
+ /** The category of the column under a point of the screen: `undefined`
720
+ * off every column. The words set above a column's area (its heading)
721
+ * count as the column. */
722
+ function columnAt(x, y) {
723
+ for (let i = 0; i < columnWidgets.length; i++) {
724
+ const r = columnWidgets[i].col.getBoundingClientRect();
725
+ const above = Math.min(r.height, body * 3 * (r.height / (columnWidgets[i].col.offsetHeight || r.height || 1)));
726
+ if (x >= r.left && x <= r.right && y >= r.top - above && y <= r.bottom)
727
+ return i;
728
+ }
729
+ return undefined;
730
+ }
731
+ /** Dragging a word of a sort exercise (from the bank or from a column)
732
+ * onto a column sorts it there; a word dragged out of its column goes
733
+ * back to the bank. A press with no drag is left to the click. Returns
734
+ * whether the click that follows a drag is to be ignored. */
735
+ function dragWord(t, slot, word, fromColumn) {
736
+ let start = null;
737
+ let ghost = null;
738
+ let over = -1;
739
+ let dragged = false;
740
+ // Where the ghost's left and top of 0 stand on the screen, and screen
741
+ // pixels per CSS pixel: the document may be zoomed.
742
+ let origin = [0, 0];
743
+ let scale = 1;
744
+ addClass(t, 'pt-ex-drag');
745
+ const highlight = (i) => {
746
+ if (i === over)
747
+ return;
748
+ if (over >= 0 && columnWidgets[over])
749
+ columnWidgets[over].col.classList.remove('pt-ex-drop');
750
+ over = i;
751
+ if (over >= 0 && columnWidgets[over])
752
+ columnWidgets[over].col.classList.add('pt-ex-drop');
753
+ };
754
+ const end = () => {
755
+ start = null;
756
+ if (ghost && ghost.parentNode)
757
+ ghost.parentNode.removeChild(ghost);
758
+ ghost = null;
759
+ highlight(-1);
760
+ };
761
+ const down = (e) => {
762
+ if (state.revealed || e.button > 0)
763
+ return;
764
+ start = [e.clientX, e.clientY];
765
+ dragged = false;
766
+ // The pointer's moves come here wherever it goes.
767
+ if (typeof t.setPointerCapture === 'function') {
768
+ try {
769
+ t.setPointerCapture(e.pointerId);
770
+ }
771
+ catch { /* a pointer gone */ }
772
+ }
773
+ };
774
+ const move = (e) => {
775
+ if (!start)
776
+ return;
777
+ if (!ghost) {
778
+ if (Math.abs(e.clientX - start[0]) + Math.abs(e.clientY - start[1]) < 6)
779
+ return;
780
+ // The word follows the pointer, set at the size the page shows it
781
+ // (the page may be scaled, and the ghost lives outside it).
782
+ const r = t.getBoundingClientRect();
783
+ ghost = doc.createElement('span');
784
+ ghost.textContent = word;
785
+ ghost.setAttribute('aria-hidden', 'true');
786
+ const st = ghost.style;
787
+ st.position = 'fixed';
788
+ st.zIndex = '2147483647';
789
+ st.pointerEvents = 'none';
790
+ st.transform = 'translate(-50%,-50%)';
791
+ st.padding = '.1em .4em';
792
+ st.borderRadius = '.3em';
793
+ st.border = '1px solid rgba(0,0,0,.35)';
794
+ st.background = '#fff';
795
+ st.color = data.text || '#1d1d1f';
796
+ st.boxShadow = '0 .2em .6em rgba(0,0,0,.25)';
797
+ st.whiteSpace = 'nowrap';
798
+ st.fontFamily = textFont || 'system-ui,sans-serif';
799
+ st.left = '0';
800
+ st.top = '0';
801
+ (doc.body || doc.documentElement).appendChild(ghost);
802
+ const g0 = ghost.getBoundingClientRect();
803
+ st.left = '100px';
804
+ const g1 = ghost.getBoundingClientRect();
805
+ scale = (g1.left - g0.left) / 100 || 1;
806
+ origin = [g0.left + g0.width / 2, g0.top + g0.height / 2];
807
+ st.fontSize = px(Math.max(12, r.height * (fromColumn ? 0.75 : 0.62)) / scale);
808
+ dragged = true;
809
+ }
810
+ e.preventDefault();
811
+ ghost.style.left = px((e.clientX - origin[0]) / scale);
812
+ ghost.style.top = px((e.clientY - origin[1]) / scale);
813
+ const at = columnAt(e.clientX, e.clientY);
814
+ highlight(at === undefined ? -1 : at);
815
+ };
816
+ const up = (e) => {
817
+ if (!start)
818
+ return;
819
+ const was = !!ghost;
820
+ const at = was ? columnAt(e.clientX, e.clientY) : undefined;
821
+ end();
822
+ if (!was)
823
+ return;
824
+ if (at !== undefined)
825
+ changed(slot, categories[at]);
826
+ else if (fromColumn)
827
+ changed(slot, null);
828
+ };
829
+ t.addEventListener('pointerdown', down);
830
+ t.addEventListener('pointermove', move);
831
+ t.addEventListener('pointerup', up);
832
+ t.addEventListener('pointercancel', end);
833
+ cleanups.push(() => {
834
+ end();
835
+ t.removeEventListener('pointerdown', down);
836
+ t.removeEventListener('pointermove', move);
837
+ t.removeEventListener('pointerup', up);
838
+ t.removeEventListener('pointercancel', end);
839
+ });
840
+ return () => { const d = dragged; dragged = false; return d; };
655
841
  }
656
- /** A word of a sort exercise's bank: a chooser of the categories. */
842
+ /** A word of a sort exercise's bank: dragged to a column, or a chooser
843
+ * of the categories. */
657
844
  function chooser(slot, t) {
658
845
  const word = chipText(t);
659
846
  setAttr(t, 'role', 'button');
@@ -663,8 +850,7 @@ function exerciseRuntime() {
663
850
  addClass(t, 'pt-ex-toggle');
664
851
  addClass(t, 'pt-ex-ui');
665
852
  colours(t);
666
- const tag = own(el('span', 'pt-ex-tag', t));
667
- tag.hidden = true;
853
+ const wasDrag = dragWord(t, slot, word, false);
668
854
  let menu = null;
669
855
  // A click anywhere else closes the menu.
670
856
  const outside = (e) => { if (menu && !t.contains(e.target))
@@ -712,33 +898,78 @@ function exerciseRuntime() {
712
898
  open();
713
899
  }
714
900
  };
715
- const onClick = (e) => { if (e.target === t || e.target === tag)
716
- open(); };
901
+ const onClick = (e) => {
902
+ if (wasDrag())
903
+ return;
904
+ if (!menu || !menu.contains(e.target))
905
+ open();
906
+ };
717
907
  t.addEventListener('click', onClick);
718
908
  t.addEventListener('keydown', onKey);
719
909
  cleanups.push(() => { close(); t.removeEventListener('click', onClick); t.removeEventListener('keydown', onKey); });
720
- widgets.push({
910
+ const w = {
721
911
  slot,
722
912
  word,
913
+ ok: null,
723
914
  paint(v) {
724
915
  const at = typeof v === 'string' ? categories.indexOf(v) : -1;
725
- tag.hidden = at < 0;
726
- tag.textContent = at >= 0 ? categories[at] : '';
727
- t.classList.toggle('pt-ex-on', at >= 0);
916
+ // Sorted: the word stands in its column, and fades in the bank.
917
+ t.classList.toggle('pt-ex-sorted', at >= 0 && columnWidgets.length > 0);
918
+ t.setAttribute('aria-label', (word || lineText(t)) + (at >= 0 ? ', ' + categories[at] : ''));
728
919
  refreshColumns();
729
920
  },
730
- lock(on) { t.classList.toggle('pt-ex-revealed', on); tag.classList.toggle('pt-ex-revealed', on); },
731
- mark(ok) { badge(t, ok); },
732
- });
921
+ lock(on) { t.classList.toggle('pt-ex-revealed', on); },
922
+ mark(ok) { w.ok = ok; badge(t, ok); refreshColumns(); },
923
+ };
924
+ widgets.push(w);
925
+ }
926
+ /** A sort exercise's column over its writing area: the words sorted in
927
+ * it, one a line, each dragged to another column or back to the bank
928
+ * (or sent back with a click). */
929
+ function sortColumn(slot, box) {
930
+ const name = categories[columnWidgets.length];
931
+ const col = own(el('div', 'pt-ex-col pt-ex-ui', box));
932
+ col.setAttribute('role', 'list');
933
+ col.setAttribute('aria-label', (name !== undefined ? name + ', ' : '') + exName);
934
+ const line = (slot.linePt || 0) * ppt || body * 1.35;
935
+ col.style.fontSize = px(body);
936
+ col.style.lineHeight = px(line);
937
+ col.style.paddingTop = px(Math.max(0, line * 0.12));
938
+ if (textFont)
939
+ col.style.fontFamily = textFont;
940
+ colours(col);
941
+ const w = {
942
+ slot,
943
+ col,
944
+ show(list) {
945
+ while (col.firstChild)
946
+ col.removeChild(col.firstChild);
947
+ for (let i = 0; i < list.length; i++) {
948
+ const src = list[i];
949
+ const p = el('span', 'pt-ex-placed', col);
950
+ p.setAttribute('role', 'listitem');
951
+ p.textContent = src.word;
952
+ if (state.revealed)
953
+ continue;
954
+ if (src.ok !== null && src.ok !== undefined)
955
+ p.classList.add(src.ok ? 'pt-ex-right' : 'pt-ex-wrong');
956
+ const wasDrag = dragWord(p, src.slot, src.word, true);
957
+ p.addEventListener('click', () => { if (!wasDrag())
958
+ changed(src.slot, null); });
959
+ }
960
+ },
961
+ paint() { },
962
+ lock(on) { col.classList.toggle('pt-ex-revealed', on); },
963
+ mark() { },
964
+ };
965
+ columnWidgets.push(w);
966
+ widgets.push(w);
733
967
  }
734
968
  /** A text area on a writing area: an open answer, a sentence put in
735
- * order; a sort exercise's column, filled from the words sorted. */
969
+ * order. */
736
970
  function area(slot, box) {
737
- const sortColumn = rec.kind === 'sort';
738
971
  const ta = own(el('textarea', 'pt-ex-input pt-ex-ui', box));
739
- // A sort exercise's column is named by its category.
740
- const column = sortColumn ? categories[columnWidgets.length] : undefined;
741
- ta.setAttribute('aria-label', column !== undefined ? column + ', ' + exName : (words.blank || 'blank') + ' ' + (++blankN) + ', ' + exName);
972
+ ta.setAttribute('aria-label', (words.blank || 'blank') + ' ' + (++blankN) + ', ' + exName);
742
973
  ta.setAttribute('spellcheck', 'false');
743
974
  const line = (slot.linePt || 0) * ppt || body * 1.35;
744
975
  ta.style.fontSize = px(body);
@@ -747,20 +978,11 @@ function exerciseRuntime() {
747
978
  if (textFont)
748
979
  ta.style.fontFamily = textFont;
749
980
  colours(ta);
750
- if (sortColumn) {
751
- ta.readOnly = true;
752
- ta.setAttribute('tabindex', '-1');
753
- ta.setAttribute('aria-live', 'polite');
754
- }
755
- else
756
- ta.addEventListener('input', () => changed(slot, ta.value));
981
+ ta.addEventListener('input', () => changed(slot, ta.value));
757
982
  const w = {
758
983
  slot,
759
- show(text) { ta.value = text; },
760
- paint(v) { if (!sortColumn)
761
- ta.value = typeof v === 'string' ? v : ''; },
762
- lock(on) { if (!sortColumn)
763
- ta.readOnly = on; ta.classList.toggle('pt-ex-revealed', on); },
984
+ paint(v) { ta.value = typeof v === 'string' ? v : ''; },
985
+ lock(on) { ta.readOnly = on; ta.classList.toggle('pt-ex-revealed', on); },
764
986
  mark(ok) {
765
987
  ta.classList.remove('pt-ex-right', 'pt-ex-wrong');
766
988
  const old = box.querySelector(':scope > .pt-ex-mark');
@@ -774,10 +996,9 @@ function exerciseRuntime() {
774
996
  m.textContent = ok ? '\u2713' : '\u2717';
775
997
  m.style.fontSize = px(body * 0.62);
776
998
  colours(m);
999
+ placeMark(m, box);
777
1000
  },
778
1001
  };
779
- if (sortColumn)
780
- columnWidgets.push(w);
781
1002
  widgets.push(w);
782
1003
  }
783
1004
  /** One-character inputs over the cells of a crossword or an operation. */
@@ -828,13 +1049,18 @@ function exerciseRuntime() {
828
1049
  });
829
1050
  }
830
1051
  /** The dots of a match: a left dot then a right one (or the other way
831
- * round) join them with a line. */
1052
+ * round) join them with a line, clicked one after the other or dragged
1053
+ * from one to the other. */
832
1054
  function matchDots(box, grid, slots) {
833
1055
  const w = parseFloat(box.style.width) || 0;
834
1056
  const h = parseFloat(box.style.height) || 0;
835
1057
  const s = own(svg('svg', { class: 'pt-ex-svg pt-ex-ui', width: w, height: h, viewBox: '0 0 ' + w + ' ' + h }, box));
836
1058
  const lines = svg('g', {}, s);
837
1059
  const marks = svg('g', {}, s);
1060
+ const ink = look.ink || 'currentColor';
1061
+ const stroke = Math.max(2.5, body * 0.13);
1062
+ // The line being drawn: from the dot picked to the pointer.
1063
+ const rubber = svg('line', { stroke: ink, 'stroke-width': stroke, 'stroke-linecap': 'round', 'stroke-dasharray': stroke * 1.5 + ' ' + stroke * 1.5, 'pointer-events': 'none', visibility: 'hidden' }, s);
838
1064
  // Mirrored on a right-to-left page: the left items stand on the right.
839
1065
  const lx = grid.rtl ? w - grid.dotsPt[0] * ppt : grid.dotsPt[0] * ppt;
840
1066
  const rx = grid.rtl ? w - grid.dotsPt[1] * ppt : grid.dotsPt[1] * ppt;
@@ -849,7 +1075,50 @@ function exerciseRuntime() {
849
1075
  leftDots[i].classList.toggle('pt-ex-pending', i === pendingLeft);
850
1076
  for (let i = 0; i < rightDots.length; i++)
851
1077
  rightDots[i].classList.toggle('pt-ex-pending', i === pendingRight);
1078
+ if (pendingLeft < 0 && pendingRight < 0)
1079
+ rubber.setAttribute('visibility', 'hidden');
1080
+ };
1081
+ /** A point of the screen in the drawing's own units. */
1082
+ const local = (x, y) => {
1083
+ const m = typeof s.getScreenCTM === 'function' ? s.getScreenCTM() : null;
1084
+ if (!m)
1085
+ return [x, y];
1086
+ const p = new DOMPoint(x, y).matrixTransform(m.inverse());
1087
+ return [p.x, p.y];
1088
+ };
1089
+ const follow = (x, y) => {
1090
+ const from = pendingLeft >= 0 ? [lx, rows[pendingLeft]] : pendingRight >= 0 ? [rx, rows[pendingRight]] : null;
1091
+ if (!from)
1092
+ return;
1093
+ const to = local(x, y);
1094
+ rubber.setAttribute('x1', String(from[0]));
1095
+ rubber.setAttribute('y1', String(from[1]));
1096
+ rubber.setAttribute('x2', String(to[0]));
1097
+ rubber.setAttribute('y2', String(to[1]));
1098
+ rubber.setAttribute('visibility', 'visible');
852
1099
  };
1100
+ /** The dot of a side nearest a screen point, if the point is on it. */
1101
+ const dotAt = (side, x, y) => {
1102
+ const p = local(x, y);
1103
+ const dx = side === 0 ? lx : rx;
1104
+ let best = -1;
1105
+ let bestD = radius * 1.8;
1106
+ const n = side === 0 ? leftDots.length : rightDots.length;
1107
+ for (let i = 0; i < n; i++) {
1108
+ const d = Math.hypot(p[0] - dx, p[1] - rows[i]);
1109
+ if (d < bestD) {
1110
+ best = i;
1111
+ bestD = d;
1112
+ }
1113
+ }
1114
+ return best;
1115
+ };
1116
+ const onMove = (e) => { if (!state.revealed)
1117
+ follow(e.clientX, e.clientY); };
1118
+ s.addEventListener('pointermove', onMove);
1119
+ let dragFrom = null;
1120
+ let dragging = false;
1121
+ let skipClick = false;
853
1122
  const join = (li, ri) => {
854
1123
  pendingLeft = -1;
855
1124
  pendingRight = -1;
@@ -875,6 +1144,7 @@ function exerciseRuntime() {
875
1144
  join(i, pendingRight);
876
1145
  else {
877
1146
  pendingLeft = pendingLeft === i ? -1 : i;
1147
+ pendingRight = -1;
878
1148
  refresh();
879
1149
  }
880
1150
  }
@@ -882,10 +1152,61 @@ function exerciseRuntime() {
882
1152
  join(pendingLeft, i);
883
1153
  else {
884
1154
  pendingRight = pendingRight === i ? -1 : i;
1155
+ pendingLeft = -1;
1156
+ refresh();
1157
+ }
1158
+ };
1159
+ // A drag from a dot to one of the other side joins them; a press
1160
+ // with no drag is a click.
1161
+ dot.addEventListener('pointerdown', (e) => {
1162
+ if (state.revealed || e.button > 0)
1163
+ return;
1164
+ dragFrom = [e.clientX, e.clientY];
1165
+ dragging = false;
1166
+ if (typeof dot.setPointerCapture === 'function') {
1167
+ try {
1168
+ dot.setPointerCapture(e.pointerId);
1169
+ }
1170
+ catch { /* a pointer gone */ }
1171
+ }
1172
+ });
1173
+ dot.addEventListener('pointermove', (e) => {
1174
+ if (!dragFrom)
1175
+ return;
1176
+ if (!dragging) {
1177
+ if (Math.abs(e.clientX - dragFrom[0]) + Math.abs(e.clientY - dragFrom[1]) < 6)
1178
+ return;
1179
+ dragging = true;
1180
+ pendingLeft = side === 0 ? i : -1;
1181
+ pendingRight = side === 0 ? -1 : i;
1182
+ refresh();
1183
+ }
1184
+ e.preventDefault();
1185
+ follow(e.clientX, e.clientY);
1186
+ });
1187
+ const drop = (e) => {
1188
+ if (!dragFrom)
1189
+ return;
1190
+ dragFrom = null;
1191
+ if (!dragging)
1192
+ return;
1193
+ dragging = false;
1194
+ skipClick = true;
1195
+ const other = e.type === 'pointerup' ? dotAt(1 - side, e.clientX, e.clientY) : -1;
1196
+ if (other >= 0)
1197
+ join(side === 0 ? i : other, side === 0 ? other : i);
1198
+ else {
1199
+ pendingLeft = -1;
1200
+ pendingRight = -1;
885
1201
  refresh();
886
1202
  }
887
1203
  };
888
- dot.addEventListener('click', pick);
1204
+ dot.addEventListener('pointerup', drop);
1205
+ dot.addEventListener('pointercancel', drop);
1206
+ dot.addEventListener('click', () => { if (skipClick) {
1207
+ skipClick = false;
1208
+ return;
1209
+ } pick(); });
889
1210
  dot.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') {
890
1211
  e.preventDefault();
891
1212
  pick();
@@ -905,7 +1226,7 @@ function exerciseRuntime() {
905
1226
  line = null;
906
1227
  }
907
1228
  if (typeof v === 'number' && rows[v - 1] !== undefined) {
908
- line = svg('line', { x1: lx, y1: rows[li], x2: rx, y2: rows[v - 1], stroke: 'currentColor', 'stroke-width': Math.max(1.5, body * 0.09), 'stroke-linecap': 'round' }, lines);
1229
+ line = svg('line', { x1: lx, y1: rows[li], x2: rx, y2: rows[v - 1], stroke: ink, 'stroke-width': stroke, 'stroke-linecap': 'round' }, lines);
909
1230
  if (state.revealed)
910
1231
  line.setAttribute('stroke', look.answer);
911
1232
  }
@@ -917,7 +1238,7 @@ function exerciseRuntime() {
917
1238
  mark = null;
918
1239
  }
919
1240
  if (line)
920
- line.setAttribute('stroke', ok === null ? (state.revealed ? look.answer : 'currentColor') : ok ? look.correct : look.wrong);
1241
+ line.setAttribute('stroke', ok === null ? (state.revealed ? look.answer : ink) : ok ? look.correct : look.wrong);
921
1242
  if (ok === null)
922
1243
  return;
923
1244
  mark = svg('text', { x: lx + radius * (grid.rtl ? -2.2 : 2.2), y: rows[li] - body * 0.25, 'font-size': body * 0.8, 'font-weight': 700, fill: ok ? look.correct : look.wrong, 'text-anchor': 'middle', 'aria-hidden': 'true' }, marks);
@@ -1077,7 +1398,9 @@ function exerciseRuntime() {
1077
1398
  }
1078
1399
  else if (slot.kind === 'text') {
1079
1400
  const box = slot.box !== undefined ? boxEls[String(slot.box)] : null;
1080
- if (box)
1401
+ if (box && rec.kind === 'sort')
1402
+ sortColumn(slot, box);
1403
+ else if (box && !slot.bare)
1081
1404
  area(slot, box);
1082
1405
  }
1083
1406
  else if (slot.kind === 'cell') {
@@ -1101,8 +1424,15 @@ function exerciseRuntime() {
1101
1424
  if (g.kind === 'wordsearch' && wordSlots.length > 0)
1102
1425
  wordSearch(box, g, wordSlots);
1103
1426
  }
1427
+ // An exercise with nothing to check, show or answer (a talk with a
1428
+ // classmate) has no bar.
1429
+ const actions = actionsOf(rec);
1430
+ const checkable = actions.check;
1431
+ const solvable = actions.solution;
1432
+ const resettable = actions.reset && widgets.length > 0;
1433
+ const hasHints = !!rec.hints && rec.hints.length > 0;
1104
1434
  // The bar: on the last part of the exercise's outer box.
1105
- const outer = outers.length > 0 ? outers[outers.length - 1] : null;
1435
+ const outer = outers.length > 0 && (resettable || solvable || hasHints) ? outers[outers.length - 1] : null;
1106
1436
  let scoreEl = null;
1107
1437
  let panel = null;
1108
1438
  let solutionBtn = null;
@@ -1124,18 +1454,47 @@ function exerciseRuntime() {
1124
1454
  scoreEl = el('span', 'pt-ex-score', bar);
1125
1455
  scoreEl.setAttribute('aria-live', 'polite');
1126
1456
  scoreEl.setAttribute('role', 'status');
1127
- if (rec.hints && rec.hints.length > 0)
1457
+ if (hasHints)
1128
1458
  hintBtn = button(words.hint || 'Hint', () => showPanel(panel && panel.getAttribute('data-show') === 'hint' ? null : 'hint'));
1129
- if (!instant)
1459
+ if (!instant && checkable)
1130
1460
  button(words.check || 'Check', () => { state.checked = true; persistNow(); feedback(); });
1131
- solutionBtn = button(words.showSolution || 'Show solution', () => reveal(!state.revealed));
1132
- button(words.reset || 'Try again', reset);
1461
+ if (solvable)
1462
+ solutionBtn = button(words.showSolution || 'Show solution', () => reveal(!state.revealed));
1463
+ if (resettable)
1464
+ button(words.reset || 'Try again', reset);
1133
1465
  panel = el('div', 'pt-ex-panel pt-ex-ui' + (flow ? ' pt-ex-flow' : ''), outer);
1134
1466
  own(panel);
1135
1467
  panel.hidden = true;
1136
1468
  if (!flow)
1137
1469
  panel.style.fontSize = px(Math.max(body * 0.72, 10));
1138
1470
  colours(panel);
1471
+ if (!flow) {
1472
+ liftOntoPage(bar);
1473
+ const view = doc.defaultView;
1474
+ // Again once the fonts have set the bar's size.
1475
+ if (view && view.requestAnimationFrame)
1476
+ view.requestAnimationFrame(() => liftOntoPage(bar));
1477
+ }
1478
+ }
1479
+ /** Lifts a control laid under the exercise back onto its page where it
1480
+ * would cross the page's foot (a page that clips, as the Sandbox's
1481
+ * short screen pages do). */
1482
+ function liftOntoPage(e) {
1483
+ e.style.transform = '';
1484
+ const page = typeof e.closest === 'function' ? e.closest('.pt-page') : null;
1485
+ if (!page || typeof e.getBoundingClientRect !== 'function')
1486
+ return;
1487
+ const pr = page.getBoundingClientRect();
1488
+ const r = e.getBoundingClientRect();
1489
+ if (r.height === 0)
1490
+ return;
1491
+ const k = e.offsetHeight > 0 ? r.height / e.offsetHeight : 1;
1492
+ let up = r.bottom - (pr.bottom - 2);
1493
+ // Never above the page's head.
1494
+ if (up > r.top - pr.top)
1495
+ up = r.top - pr.top;
1496
+ if (up > 0)
1497
+ e.style.transform = 'translateY(' + (-up / k).toFixed(1) + 'px)';
1139
1498
  }
1140
1499
  void hintBtn;
1141
1500
  function showPanel(what) {
@@ -1157,6 +1516,11 @@ function exerciseRuntime() {
1157
1516
  for (let i = 0; i < sol.length; i++)
1158
1517
  el('p', '', panel).textContent = sol[i];
1159
1518
  }
1519
+ // A solution shown in its slots alone needs no panel.
1520
+ if (!panel.firstChild)
1521
+ panel.hidden = true;
1522
+ if (!panel.hidden && !flow)
1523
+ liftOntoPage(panel);
1160
1524
  }
1161
1525
  function valueOf(w) {
1162
1526
  if (state.revealed)
@@ -1182,6 +1546,13 @@ function exerciseRuntime() {
1182
1546
  const s = rec.slots[i];
1183
1547
  if (!isGraded(s, rec))
1184
1548
  continue;
1549
+ // A word of a mark exercise not to be marked counts only when it is
1550
+ // marked: a wrong answer that keeps the score short of the total.
1551
+ if (s.kind === 'mark' && (s.correct || []).indexOf(1) < 0) {
1552
+ if (verdict(s) === false)
1553
+ total++;
1554
+ continue;
1555
+ }
1185
1556
  total++;
1186
1557
  if (verdict(s) === true)
1187
1558
  right++;
@@ -1273,7 +1644,7 @@ function exerciseRuntime() {
1273
1644
  solutionBtn.setAttribute('aria-expanded', 'false');
1274
1645
  return { record: rec, state, reveal, reset, feedback };
1275
1646
  }
1276
- return { normalizeAnswer, isGraded, checkSlot, solutionValue, attach };
1647
+ return { normalizeAnswer, isGraded, checkSlot, solutionValue, actionsOf, attach };
1277
1648
  }
1278
1649
  /* eslint-enable @typescript-eslint/no-explicit-any */
1279
1650
  const runtime = exerciseRuntime();
@@ -1285,6 +1656,8 @@ export const isGradedSlot = runtime.isGraded;
1285
1656
  * is no answer or not graded (`null`). `trueFalse` are the style's true
1286
1657
  * and false labels, for a typed true/false. */
1287
1658
  export const checkExerciseSlot = runtime.checkSlot;
1659
+ /** The buttons an exercise's bar offers: Check, Show solution, Try again. */
1660
+ export const exerciseActions = runtime.actionsOf;
1288
1661
  /** What a slot shows when the solution is shown. */
1289
1662
  export const exerciseSolutionValue = runtime.solutionValue;
1290
1663
  /** Makes the exercises of the HTML output under `root` answerable (see the