domma-cms 1.8.32 → 1.8.34

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 (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,61 @@
1
+ ---
2
+ title: Guide
3
+ order: 1
4
+ ---
5
+
6
+ Run quizzes on your website. Visitors type their name, answer against the clock and land on a leaderboard. Quiz is free.
7
+
8
+ ## What it does
9
+
10
+ - **Four kinds of question:** one right answer, more than one right answer, true or false, and a typed answer (case, accents, punctuation and a leading "the" do not matter, and you can accept more than one spelling).
11
+ - **Timing kept by the server.** A time limit for the whole quiz, a default per question, or a time on any single question. The clock starts when the question is shown, so a slow connection or a reload does not cost or gain anyone time.
12
+ - **Marking on the server.** The right answers never reach the visitor's browser until they have answered.
13
+ - **Leaderboards** for today, this week, this month and all time. Each name appears once, with its best go. Equal scores are split by time.
14
+ - **Your choice of when to show answers:** after each question, at the end, or never.
15
+ - **Shuffling:** questions, answers, or a random draw of so many questions from a bigger bank.
16
+ - **Opening and closing times**, and a limit on goes per name.
17
+ - The sidebar badge shows how many quizzes have been finished today, and you get a notification when someone takes the top spot.
18
+
19
+ ## Getting started
20
+
21
+ 1. Switch Quiz on in **Tools**.
22
+ 2. Open **Tools > Games > Quiz** and choose **New quiz**.
23
+ 3. Give it a title and add your questions. Tick the right answer (or answers) on each one.
24
+ 4. Press **Test** and play it yourself (see below).
25
+ 5. Set the status to **Open** and save.
26
+ 6. Put it on any page:
27
+
28
+ ```
29
+ [quiz id="capitals" /]
30
+ ```
31
+
32
+ `id` is the quiz's short name, shown in the list and under the title in the editor.
33
+
34
+ ## Testing a quiz
35
+
36
+ **Test** (in the editor, on each quiz in the list, and on its Plays screen) plays the quiz right there in the admin, exactly as your players will see it: the same clock, the same marking, the same screens, and the answers shown when the quiz shows them.
37
+
38
+ - It plays the version you last saved. With unsaved changes the button says **Save and test**, and saves first.
39
+ - It works whatever the status, so you can try a draft before anyone can see it, or a closed quiz.
40
+ - A test go is never saved to the leaderboard. It is not in the Plays list, it does not count towards the goes each name is allowed, and it does not raise a notification. At the end it tells you where it would have come.
41
+ - Each new test clears away the one before.
42
+
43
+ ## A leaderboard on its own
44
+
45
+ The quiz shows its own leaderboard at the end. To show one somewhere else, for example on your home page:
46
+
47
+ ```
48
+ [quiz-leaderboard id="capitals" period="week" limit="10" title="This week's best" /]
49
+ ```
50
+
51
+ `period` is `today`, `week`, `month` or `all`. Visitors can switch period on the board.
52
+
53
+ ## Players and fair play
54
+
55
+ - Players only give a name: 2 to 24 letters, numbers, spaces and `. ' - _`. Rude names are refused.
56
+ - A play belongs to the browser that started it. Reloading carries on where they were.
57
+ - Running out of time on a question marks it wrong and moves on. Running out of time on the whole quiz ends it, and the time counted is the limit.
58
+
59
+ ## Looking after the board
60
+
61
+ Open a quiz's **Plays** to see every go (your own test goes are not shown). You can hide a play from the leaderboard (it stays in the list), delete it, or reset the whole leaderboard, for example before a new quiz night.
@@ -1 +1 @@
1
- <script type="module" src="/plugins/quiz/public/quiz.js?v=1.0.0"></script>
1
+ <script type="module" src="/plugins/quiz/public/quiz.js?v=1.1.0"></script>
@@ -6,6 +6,9 @@
6
6
  * GET /quizzes every quiz as a list row
7
7
  * GET /quizzes/:id · POST /quizzes · PUT /quizzes/:id · DELETE /quizzes/:id
8
8
  * POST /quizzes/:id/copy a copy, as a draft
9
+ * GET /quizzes/:id/test what the player's start screen shows, as if it were open
10
+ * POST /quizzes/:id/test a TEST play of it as saved (draft or closed too) -> {playId, token, name, info};
11
+ * played through the /public/play routes, never on a board (see _lib/games/plays.js)
9
12
  * GET /quizzes/:id/plays · DELETE /quizzes/:id/plays · PUT /plays/:id · DELETE /plays/:id
10
13
  *
11
14
  * Players (no sign-in), under /public - the server keeps the time and marks every answer:
@@ -21,7 +24,7 @@
21
24
  */
22
25
 
23
26
  import {cleanPlayerName} from '../_lib/games/names.js';
24
- import {boardQuery, fail, isBot, limited, mountPlayAdmin, todayBadge, tokenOf} from '../_lib/games/http.js';
27
+ import {boardQuery, fail, isBot, limited, mountPlayAdmin, testerName, todayBadge, tokenOf} from '../_lib/games/http.js';
25
28
  import {answerText, cleanQuiz, isOpen, maxScoreOf, planPlay, rightText, summary} from './lib/quiz.js';
26
29
  import {answer, catchUp, feedbackFor, serve, view} from './lib/engine.js';
27
30
  import {allQuizzes, ensureCollections, getQuiz, plays, quizBySlug, removeQuiz, saveQuiz, uniqueSlug} from './lib/store.js';
@@ -97,7 +100,7 @@ export default async function quizPlugin(fastify, options) {
97
100
 
98
101
  fastify.get('/quizzes', can('read'), async () => {
99
102
  const everyPlay = await plays.all();
100
- return {quizzes: (await allQuizzes()).map(q => summary(q, everyPlay.filter(p => p.gameId === q.id)))
103
+ return {quizzes: (await allQuizzes()).map(q => summary(q, everyPlay.filter(p => p.gameId === q.id && p.test !== true)))
101
104
  .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))};
102
105
  });
103
106
 
@@ -134,6 +137,29 @@ export default async function quizPlugin(fastify, options) {
134
137
 
135
138
  mountPlayAdmin(fastify, {store: plays, base: '/quizzes', can, exists: async (id) => Boolean(await getQuiz(id))});
136
139
 
140
+ // The admin tries the quiz from the Tool: the saved version, whatever its status or dates, through
141
+ // the same player routes as anyone. The play is marked `test`, so it never counts anywhere.
142
+ const testInfo = (quiz) => ({...publicInfo(quiz), open: true, reason: ''});
143
+
144
+ fastify.get('/quizzes/:id/test', can('manage'), async (request, reply) => {
145
+ const quiz = await getQuiz(request.params.id);
146
+ if (!quiz) return fail(reply, 404, 'That quiz is not here any more.');
147
+ return {info: testInfo(quiz)};
148
+ });
149
+
150
+ fastify.post('/quizzes/:id/test', can('manage'), async (request, reply) => {
151
+ const quiz = await getQuiz(request.params.id);
152
+ if (!quiz) return fail(reply, 404, 'That quiz is not here any more.');
153
+ if (!quiz.questions?.length) return fail(reply, 409, 'Add a question and save, then test it.', {code: 'empty'});
154
+ await plays.clearTests(quiz.id);
155
+ const plan = planPlay(quiz);
156
+ const name = testerName(request);
157
+ const play = await plays.start({gameId: quiz.id, name, maxScore: maxScoreOf(quiz, plan), state: plan,
158
+ userId: String(request.user?.id || ''), test: true});
159
+ return reply.code(201).send({playId: play.id, token: play.token, name, total: plan.order.length, maxScore: play.maxScore,
160
+ info: testInfo(quiz)});
161
+ });
162
+
137
163
  // ---- Players ----------------------------------------------------------------
138
164
 
139
165
  /** A quiz the public may see: anything but a draft. */
@@ -175,6 +201,9 @@ export default async function quizPlugin(fastify, options) {
175
201
  * The play behind a request, brought up to date with the clock (and saved
176
202
  * when that changed it). Null after answering with the reason.
177
203
  */
204
+ /** The play went while this request was on its way (a reset, a delete, or the next test clearing it). */
205
+ const gone = (reply) => fail(reply, 404, 'That game is not here any more. Start again.', {code: 'gone'});
206
+
178
207
  async function livePlay(request, reply, {serveNow = false} = {}) {
179
208
  const opened = await plays.open(request.params.id, tokenOf(request));
180
209
  if (!opened) { fail(reply, 404, 'That game is not here any more. Start again.', {code: 'gone'}); return null; }
@@ -186,6 +215,7 @@ export default async function quizPlugin(fastify, options) {
186
215
  const b = serveNow && serve(quiz, p, now);
187
216
  return a || b ? p : null;
188
217
  });
218
+ if (!play) { gone(reply); return null; }
189
219
  if (play.status === 'finished' && opened.status === 'playing') await afterFinish(quiz, play);
190
220
  return {quiz, play, now};
191
221
  }
@@ -193,7 +223,7 @@ export default async function quizPlugin(fastify, options) {
193
223
  /** A new top of the all-time board tells the admins. */
194
224
  async function afterFinish(quiz, play) {
195
225
  try {
196
- if (!quiz.leaderboard || await plays.placeOf(play, 'all') !== 1) return;
226
+ if (play.test || !quiz.leaderboard || await plays.placeOf(play, 'all') !== 1) return;
197
227
  hooks.notify?.({
198
228
  title: `New top score on "${quiz.title}"`,
199
229
  body: `${play.name}: ${play.score} of ${play.maxScore} in ${Math.round(play.timeMs / 1000)} seconds`,
@@ -217,6 +247,7 @@ export default async function quizPlugin(fastify, options) {
217
247
  outcome = answer(quiz, p, request.body?.q, request.body?.answer, Date.now());
218
248
  return outcome.error ? null : p;
219
249
  });
250
+ if (!play) return gone(reply);
220
251
  if (outcome?.error) return fail(reply, 409, outcome.error, {code: outcome.code || 'finished', play: view(quiz, play)});
221
252
  if (play.status === 'finished') await afterFinish(quiz, play);
222
253
  return {correct: outcome.correct, late: outcome.late, points: outcome.points, feedback: feedbackFor(quiz, outcome.q, outcome), play: view(quiz, play)};
@@ -231,8 +262,11 @@ export default async function quizPlugin(fastify, options) {
231
262
  name: play.name, score: play.score, maxScore: play.maxScore, timeMs: play.timeMs, timeUp: play.state.timeUp === true,
232
263
  correct: play.state.answers.filter(a => a.correct).length, total: play.state.order.length,
233
264
  hidden: play.hidden === true,
234
- place: quiz.leaderboard && !play.hidden ? await plays.placeOf(play, 'all') : 0,
235
- placeToday: quiz.leaderboard && !play.hidden ? await plays.placeOf(play, 'today') : 0,
265
+ test: play.test === true,
266
+ place: quiz.leaderboard && !play.hidden && !play.test ? await plays.placeOf(play, 'all') : 0,
267
+ placeToday: quiz.leaderboard && !play.hidden && !play.test ? await plays.placeOf(play, 'today') : 0,
268
+ // A test is on no board; this is where it would have come.
269
+ wouldPlace: quiz.leaderboard && play.test ? await plays.placeOf(play, 'all') : 0,
236
270
  leaderboard: quiz.leaderboard,
237
271
  review: review(quiz, play)
238
272
  };
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "quiz",
3
3
  "displayName": "Quiz",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "tier": "free",
6
+ "minCmsVersion": "1.8.33",
6
7
  "description": "Make quizzes in the admin and put them on any page. Players type a name, answer against the clock, and see where they came on the leaderboard. The server keeps the time and marks every answer, so the browser never holds the answers.",
7
8
  "author": "Domma CMS",
8
- "date": "2026-10-20",
9
+ "date": "2026-10-21",
9
10
  "icon": "help-circle",
10
11
  "permissions": [
11
12
  {
@@ -70,7 +71,7 @@
70
71
  ],
71
72
  "views": {
72
73
  "plugin-quiz": {
73
- "entry": "quiz/admin/views/quiz.js?v=5be1f180",
74
+ "entry": "quiz/admin/views/quiz.js?v=1.1.0",
74
75
  "exportName": "quizView"
75
76
  }
76
77
  }
@@ -1 +1,36 @@
1
- .qz-q{font-size:1.35rem;line-height:1.35;margin:0 0 1rem;outline:none}.qz-image{display:block;max-width:100%;max-height:320px;border-radius:8px;margin:0 0 1rem}.qz-hint{margin:-.5rem 0 .75rem}.qz-options{display:grid;gap:.6rem}@media(min-width:640px){.qz-options{grid-template-columns:1fr 1fr}}.qz-option{display:flex;align-items:center;gap:.75rem;width:100%;text-align:left;font:inherit;cursor:pointer;padding:.8rem 1rem;border-radius:10px;border:2px solid var(--gm-line);background:var(--gm-soft);color:inherit;transition:border-color .12s,transform .12s}.qz-option:hover:not(:disabled){border-color:var(--gm-accent);transform:translateY(-1px)}.qz-option:focus-visible{outline:2px solid var(--gm-accent);outline-offset:2px}.qz-option:disabled{cursor:default;opacity:.7}.qz-option.is-picked{border-color:var(--gm-accent);background:color-mix(in srgb,var(--gm-accent) 15%,transparent)}.qz-letter{flex:none;display:inline-grid;place-items:center;width:1.8rem;height:1.8rem;border-radius:50%;background:var(--gm-accent);color:#fff;font-weight:700;font-size:.9rem}.qz-typed{display:flex;flex-direction:column;gap:.35rem;max-width:420px}.qz-verdict{font-size:1.8rem;margin:0 0 .5rem;outline:none}.qz-verdict.is-right{color:var(--gm-good)}.qz-verdict.is-wrong{color:var(--gm-bad)}.qz-review{margin-top:1.5rem}.qz-review h3{margin:0 0 .5rem;font-size:1.1rem}.qz-review-list{margin:0;padding-left:0;list-style:none;display:grid;gap:.5rem}.qz-review-list li{padding:.6rem .8rem;border-radius:8px;border-left:4px solid var(--gm-line);background:var(--gm-soft)}.qz-review-list li.is-right{border-left-color:var(--gm-good)}.qz-review-list li.is-wrong{border-left-color:var(--gm-bad)}.qz-review-q{margin:0 0 .25rem;font-weight:600}.qz-review-a{margin:0}.qz-live{position:absolute;left:-9999px}
1
+ /* Quiz - the player. Shared pieces (cards, clock, leaderboard) are in /plugins/_lib/public/games/games.css. */
2
+
3
+ .qz-q { font-size: 1.35rem; line-height: 1.35; margin: 0 0 1rem; outline: none; }
4
+ .qz-image { display: block; max-width: 100%; max-height: 320px; border-radius: 8px; margin: 0 0 1rem; }
5
+ .qz-hint { margin: -.5rem 0 .75rem; }
6
+
7
+ .qz-options { display: grid; gap: .6rem; }
8
+ @media (min-width: 640px) { .qz-options { grid-template-columns: 1fr 1fr; } }
9
+ .qz-option {
10
+ display: flex; align-items: center; gap: .75rem; width: 100%; text-align: left; font: inherit; cursor: pointer;
11
+ padding: .8rem 1rem; border-radius: 10px; border: 2px solid var(--gm-line); background: var(--gm-soft); color: inherit;
12
+ transition: border-color .12s, transform .12s;
13
+ }
14
+ .qz-option:hover:not(:disabled) { border-color: var(--gm-accent); transform: translateY(-1px); }
15
+ .qz-option:focus-visible { outline: 2px solid var(--gm-accent); outline-offset: 2px; }
16
+ .qz-option:disabled { cursor: default; opacity: .7; }
17
+ .qz-option.is-picked { border-color: var(--gm-accent); background: color-mix(in srgb, var(--gm-accent) 15%, transparent); }
18
+ .qz-letter {
19
+ flex: none; display: inline-grid; place-items: center; width: 1.8rem; height: 1.8rem; border-radius: 50%;
20
+ background: var(--gm-accent); color: #fff; font-weight: 700; font-size: .9rem;
21
+ }
22
+ .qz-typed { display: flex; flex-direction: column; gap: .35rem; max-width: 420px; }
23
+
24
+ .qz-verdict { font-size: 1.8rem; margin: 0 0 .5rem; outline: none; }
25
+ .qz-verdict.is-right { color: var(--gm-good); }
26
+ .qz-verdict.is-wrong { color: var(--gm-bad); }
27
+
28
+ .qz-review { margin-top: 1.5rem; }
29
+ .qz-review h3 { margin: 0 0 .5rem; font-size: 1.1rem; }
30
+ .qz-review-list { margin: 0; padding-left: 0; list-style: none; display: grid; gap: .5rem; }
31
+ .qz-review-list li { padding: .6rem .8rem; border-radius: 8px; border-left: 4px solid var(--gm-line); background: var(--gm-soft); }
32
+ .qz-review-list li.is-right { border-left-color: var(--gm-good); }
33
+ .qz-review-list li.is-wrong { border-left-color: var(--gm-bad); }
34
+ .qz-review-q { margin: 0 0 .25rem; font-weight: 600; }
35
+ .qz-review-a { margin: 0; }
36
+ .qz-live { position: absolute; left: -9999px; }
@@ -1,7 +1,32 @@
1
- import{Countdown as C,addStyles as N,celebrate as Y,configOf as Q,fmtDuration as $,memory as f,mountBoards as W,mountLeaderboard as B,ordinal as I,request as h,uid as D}from"/plugins/_lib/public/games/games.js";const s=window.M,c="/api/plugins/quiz/public",A="games.name",z=n=>`quiz.play.${n}`,F=`
1
+ /**
2
+ * Quiz - the player, in the browser.
3
+ *
4
+ * Mounts on every `[data-quiz]` placeholder the [quiz] shortcode wrote, and
5
+ * every `[data-quiz-board]` the [quiz-leaderboard] one wrote. The quiz runs on
6
+ * the SERVER: this asks for one question at a time (never with its answer),
7
+ * sends each answer to be marked, and shows the clock the server keeps. A play
8
+ * in progress is remembered for the tab's next load, so a reload carries on
9
+ * where it was - its clock never stopped.
10
+ *
11
+ * domma-reactive (window.M): one template per mount, one M.applyBindings, and
12
+ * text from the server only through bindings.
13
+ *
14
+ * `mount(el, {test})` is the same player for an admin testing the quiz from
15
+ * its Tool: `test.info()` and `test.start()` go through the admin's own API,
16
+ * the play is a test one (never on a board), and nothing is remembered.
17
+ */
18
+
19
+ import {Countdown, addStyles, celebrate, configOf, fmtDuration, memory, mountBoards, mountLeaderboard, ordinal, request, uid} from '/plugins/_lib/public/games/games.js';
20
+
21
+ const M = window.M;
22
+ const API = '/api/plugins/quiz/public';
23
+ const NAME_KEY = 'games.name';
24
+ const playKey = (slug) => `quiz.play.${slug}`;
25
+
26
+ const TEMPLATE = `
2
27
  <p class="gm-muted qz-live" role="status" aria-live="polite" data-bind-text="live.value"></p>
3
28
 
4
- <section class="gm-card" hidden data-bind-hidden="screen.value !== 'loading'"><p class="gm-muted">Loading the quiz\u2026</p></section>
29
+ <section class="gm-card" hidden data-bind-hidden="screen.value !== 'loading'"><p class="gm-muted">Loading the quiz…</p></section>
5
30
 
6
31
  <section class="gm-card" hidden data-bind-hidden="screen.value !== 'error'">
7
32
  <p class="gm-error" data-bind-text="problem.value"></p>
@@ -13,13 +38,14 @@ import{Countdown as C,addStyles as N,celebrate as Y,configOf as Q,fmtDuration as
13
38
  <ul class="gm-facts" data-each="facts.value key=k"><li data-bind-text="k"></li></ul>
14
39
  <p class="gm-note" hidden data-bind-hidden="info.value.open" data-bind-text="info.value.reason"></p>
15
40
  <form class="gm-start" hidden data-bind-hidden="!info.value.open" data-on-submit="start" novalidate>
16
- <div class="gm-field">
41
+ <p class="gm-note gm-test-note" hidden data-bind-hidden="!testing">A test go: it plays exactly as your players see it, and it is not saved to the leaderboard.</p>
42
+ <div class="gm-field" hidden data-bind-hidden="testing">
17
43
  <label for="%U%-name">Your name</label>
18
44
  <input id="%U%-name" class="gm-input" maxlength="24" autocomplete="nickname" data-model="name.value"
19
45
  data-bind-aria-invalid="problem.value ? 'true' : false" aria-describedby="%U%-err">
20
46
  </div>
21
47
  <div class="gm-hp" aria-hidden="true"><label>Website <input tabindex="-1" autocomplete="off" data-model="website.value"></label></div>
22
- <button type="submit" class="btn btn-primary" data-bind-disabled="busy.value">Start the quiz</button>
48
+ <button type="submit" class="btn btn-primary" data-bind-disabled="busy.value" data-bind-text="testing ? 'Start the test' : 'Start the quiz'">Start the quiz</button>
23
49
  </form>
24
50
  <p class="gm-error" id="%U%-err" hidden data-bind-hidden="!problem.value" data-bind-text="problem.value"></p>
25
51
  </section>
@@ -62,7 +88,7 @@ import{Countdown as C,addStyles as N,celebrate as Y,configOf as Q,fmtDuration as
62
88
  <p class="gm-muted" data-bind-text="res.value.detail"></p>
63
89
  <p class="gm-result-place" hidden data-bind-hidden="!res.value.placeText" data-bind-text="res.value.placeText"></p>
64
90
  <div class="gm-actions">
65
- <button type="button" class="btn btn-primary" data-on-click="again">Play again</button>
91
+ <button type="button" class="btn btn-primary" data-on-click="again" data-bind-text="testing ? 'Test again' : 'Play again'">Play again</button>
66
92
  </div>
67
93
  <div class="qz-review" hidden data-bind-hidden="!review.value.length">
68
94
  <h3>Your answers</h3>
@@ -77,4 +103,270 @@ import{Countdown as C,addStyles as N,celebrate as Y,configOf as Q,fmtDuration as
77
103
  </div>
78
104
  </section>
79
105
 
80
- <div class="qz-board-slot"></div>`,H="ABCDEFGH";function K(n){const l=[`${n.questions} question${n.questions===1?"":"s"}`];return n.timeLimit&&l.push(`${$(n.timeLimit*1e3)} to finish`),n.questionSeconds?l.push(`${n.questionSeconds} seconds a question`):n.perQuestionTimes&&l.push("Some questions are timed"),n.attemptsPerName===1?l.push("One go each"):n.attemptsPerName>1&&l.push(`${n.attemptsPerName} goes each`),n.leaderboard&&l.push("Fastest top score wins"),l.map(g=>({k:g}))}function G(n){const l=Q(n,"data-quiz");if(!l?.slug||!s)return;N(["/plugins/quiz/public/quiz.css?v=1.0.0"]);const g=D("qz");n.innerHTML=F.replaceAll("%U%",g);const t={screen:s.observable("loading"),live:s.observable(""),problem:s.observable(""),busy:s.observable(!1),info:s.observable({title:"",intro:"",open:!1,reason:""}),facts:s.observable([]),name:s.observable(String(f.get(A)||"")),website:s.observable(""),progress:s.observable(""),q:s.observable({text:"",kind:"single",image:""}),options:s.observable([]),typed:s.observable(""),clockText:s.observable(""),clockShown:s.observable(!1),clockLow:s.observable(!1),trackWidth:s.observable("0%"),fb:s.observable({}),res:s.observable({}),review:s.observable([])};let r=f.get(z(l.slug))||null,k=null,d=null,u=null,m=null;const b=e=>setTimeout(()=>n.querySelector(`#${g}-${e}`)?.focus({preventScroll:!1}),30),q=e=>{t.live.value="",setTimeout(()=>{t.live.value=e},20)};function T(){const e=u?.left.peek()??0,i=m?.left.peek()??0,a=d?.questionLeftMs!=null&&(d?.totalLeftMs==null||e<=i),o=a?e:i,M=a?(d?.question?.seconds||0)*1e3:(t.info.peek().timeLimit||0)*1e3;t.clockShown.value=d?.questionLeftMs!=null||d?.totalLeftMs!=null,t.clockText.value=$(o),t.clockLow.value=o>0&&o<=5e3,t.trackWidth.value=M?`${Math.max(0,Math.min(100,o/M*100)).toFixed(1)}%`:`${(d?.index||0)/(d?.total||1)*100}%`}function y(){u?.stop(),m?.stop()}u=new C(()=>{t.screen.peek()==="question"&&v(null,{timeout:!0})}),m=new C(()=>{["question","feedback"].includes(t.screen.peek())&&setTimeout(p,600)});const R=s.effect(()=>{u.left.value,m.left.value,T()});function x(){f.remove(z(l.slug)),r=null}async function L(){const e=await h(`${c}/quiz/${encodeURIComponent(l.slug)}`);return t.info.value=e,t.facts.value=K(e),e.leaderboard&&!k&&(k=B(n.querySelector(".qz-board-slot"),{url:`${c}/quiz/${encodeURIComponent(l.slug)}/leaderboard`,limit:e.leaderboardSize,title:"Leaderboard"})),e}async function p(){if(!r){t.screen.value="intro";return}try{const e=await h(`${c}/play/${encodeURIComponent(r.playId)}`,{token:r.token});if(d=e,e.status!=="playing")return y(),w();E(e)}catch(e){if(e.code==="gone"||e.status===404){x(),t.screen.value="intro";return}t.problem.value=e.message,t.screen.value="error"}}function E(e){const i=e.question;t.q.value={text:i.text,kind:i.kind,image:i.image||""},t.options.value=i.options.map((a,o)=>({id:a.id,text:a.text,letter:H[o]||"",picked:!1})),t.typed.value="",t.progress.value=`Question ${e.index+1} of ${e.total}`,t.busy.value=!1,t.screen.value="question",u.start(e.questionLeftMs??0),m.start(e.totalLeftMs??0),T(),i.kind==="text"?b("typed"):b("q"),q(`Question ${e.index+1} of ${e.total}. ${i.text}`)}async function v(e,{timeout:i=!1}={}){if(!(t.busy.peek()||!d?.question)){t.busy.value=!0,u.stop();try{const a=await h(`${c}/play/${encodeURIComponent(r.playId)}/answer`,{method:"POST",token:r.token,body:{q:d.question.id,answer:e}});d=a.play;const o=a.play.status!=="playing";if(a.feedback){t.fb.value={correct:a.correct,title:a.correct?"Right!":i||a.late?"Out of time":"Not quite",rightAnswer:a.feedback.rightAnswer,explanation:a.feedback.explanation,nextLabel:o?"See your score":"Next question"},t.screen.value="feedback",q(t.fb.peek().title),b("fb");return}if(o)return y(),w();await p()}catch(a){if(a.code==="not-current"||a.code==="finished")return p();t.busy.value=!1,t.live.value=a.message}}}async function w(){y();try{const e=await h(`${c}/play/${encodeURIComponent(r.playId)}/result`,{token:r.token}),i=e.place?`You came ${I(e.place)} of all time${e.placeToday&&e.placeToday!==e.place?`, ${I(e.placeToday)} today`:""}.`:"";t.res.value={title:e.timeUp?`Time's up, ${e.name}!`:`Well done, ${e.name}!`,scoreText:`${e.score} / ${e.maxScore}`,detail:`${e.correct} of ${e.total} right in ${$(e.timeMs)}.`,placeText:i},t.review.value=(e.review||[]).map((a,o)=>({k:`r${o}`,text:`${o+1}. ${a.text}`,correct:a.correct,right:a.right,explanation:a.explanation,mark:a.correct?"\u2713":"\u2717",yoursText:a.yours?`You said: ${a.yours}`:a.late?"Out of time":"No answer"})),t.screen.value="result",q(`${t.res.peek().title} ${t.res.peek().detail} ${i}`),b("res"),k?.refresh(r.playId),e.place===1&&Y()}catch(e){if(e.status===404){x(),t.screen.value="intro";return}t.problem.value=e.message,t.screen.value="error"}}async function O(){if(!t.busy.peek()){t.problem.value="",t.busy.value=!0;try{const e=await h(`${c}/quiz/${encodeURIComponent(l.slug)}/start`,{method:"POST",body:{name:t.name.peek(),website:t.website.peek()}});f.set(A,e.name),r={playId:e.playId,token:e.token},f.set(z(l.slug),r),await p()}catch(e){t.problem.value=e.message,t.busy.value=!1,b("name")}}}const U={start(){return O(),!1},choose(e){if(!t.busy.peek()){if(t.q.peek().kind==="multiple"){t.options.value=t.options.peek().map(i=>i.id===e.id?{...i,picked:!i.picked}:i);return}v(e.id)}},sendPicked(){if(t.q.peek().kind==="text")return v(t.typed.peek());v(t.options.peek().filter(e=>e.picked).map(e=>e.id))},sendTyped(){return v(t.typed.peek()),!1},next(){if(d?.status!=="playing")return w();p()},async again(){x(),t.problem.value="",t.busy.value=!1,await L().catch(()=>{}),t.screen.value="intro",b("name")}};s.applyBindings({...t,...U},n,{methods:U}),L().then(p).catch(e=>{t.problem.value=e.status===404?"This quiz is not available.":e.message,t.screen.value="error"});const S=new MutationObserver(()=>{n.isConnected||(y(),R?.(),S.disconnect())});S.observe(document.body,{childList:!0,subtree:!0})}function P(){for(const n of document.querySelectorAll("[data-quiz]"))n.dataset.gmMounted||(n.dataset.gmMounted="1",G(n));W("data-quiz-board",n=>`${c}/quiz/${encodeURIComponent(n)}/leaderboard`)}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",P):P();
106
+ <div class="qz-board-slot"></div>`;
107
+
108
+ const LETTERS = 'ABCDEFGH';
109
+
110
+ /** The server's words: the public request's Error, or the admin's (its reply is on `err.body`). */
111
+ const errText = (err) => err?.body?.error || err?.message || String(err);
112
+
113
+ function facts(info) {
114
+ const out = [`${info.questions} question${info.questions === 1 ? '' : 's'}`];
115
+ if (info.timeLimit) out.push(`${fmtDuration(info.timeLimit * 1000)} to finish`);
116
+ if (info.questionSeconds) out.push(`${info.questionSeconds} seconds a question`);
117
+ else if (info.perQuestionTimes) out.push('Some questions are timed');
118
+ if (info.attemptsPerName === 1) out.push('One go each');
119
+ else if (info.attemptsPerName > 1) out.push(`${info.attemptsPerName} goes each`);
120
+ if (info.leaderboard) out.push('Fastest top score wins');
121
+ return out.map(k => ({k}));
122
+ }
123
+
124
+ /**
125
+ * @param {HTMLElement} el
126
+ * @param {{test?: {info: () => Promise<object>, start: () => Promise<{playId: string, token: string, name: string, info: object}>}}} [opts]
127
+ */
128
+ export function mount(el, {test = null} = {}) {
129
+ const cfg = test ? {slug: 'test'} : configOf(el, 'data-quiz');
130
+ if (!cfg?.slug || !M) return;
131
+ addStyles(['/plugins/quiz/public/quiz.css?v=1.1.0']);
132
+ const U = uid('qz');
133
+ el.innerHTML = TEMPLATE.replaceAll('%U%', U);
134
+
135
+ const s = {
136
+ testing: Boolean(test),
137
+ screen: M.observable('loading'),
138
+ live: M.observable(''),
139
+ problem: M.observable(''),
140
+ busy: M.observable(false),
141
+ info: M.observable({title: '', intro: '', open: false, reason: ''}),
142
+ facts: M.observable([]),
143
+ name: M.observable(String(memory.get(NAME_KEY) || '')),
144
+ website: M.observable(''),
145
+ progress: M.observable(''),
146
+ q: M.observable({text: '', kind: 'single', image: ''}),
147
+ options: M.observable([]),
148
+ typed: M.observable(''),
149
+ clockText: M.observable(''),
150
+ clockShown: M.observable(false),
151
+ clockLow: M.observable(false),
152
+ trackWidth: M.observable('0%'),
153
+ fb: M.observable({}),
154
+ res: M.observable({}),
155
+ review: M.observable([])
156
+ };
157
+
158
+ let play = test ? null : memory.get(playKey(cfg.slug)) || null; // {playId, token}
159
+ let board = null;
160
+ let current = null; // the play's last view from the server
161
+ let qClock = null;
162
+ let totalClock = null;
163
+
164
+ const focus = (id) => setTimeout(() => el.querySelector(`#${U}-${id}`)?.focus({preventScroll: false}), 30);
165
+ const say = (t) => { s.live.value = ''; setTimeout(() => { s.live.value = t; }, 20); };
166
+
167
+ function showClock() {
168
+ const qLeft = qClock?.left.peek() ?? 0;
169
+ const tLeft = totalClock?.left.peek() ?? 0;
170
+ const useQ = current?.questionLeftMs != null && (current?.totalLeftMs == null || qLeft <= tLeft);
171
+ const left = useQ ? qLeft : tLeft;
172
+ const whole = useQ ? (current?.question?.seconds || 0) * 1000 : (s.info.peek().timeLimit || 0) * 1000;
173
+ s.clockShown.value = current?.questionLeftMs != null || current?.totalLeftMs != null;
174
+ s.clockText.value = fmtDuration(left);
175
+ s.clockLow.value = left > 0 && left <= 5000;
176
+ s.trackWidth.value = whole ? `${Math.max(0, Math.min(100, (left / whole) * 100)).toFixed(1)}%` : `${((current?.index || 0) / (current?.total || 1)) * 100}%`;
177
+ }
178
+
179
+ function stopClocks() {
180
+ qClock?.stop();
181
+ totalClock?.stop();
182
+ }
183
+
184
+ qClock = new Countdown(() => { if (s.screen.peek() === 'question') send(null, {timeout: true}); });
185
+ totalClock = new Countdown(() => { if (['question', 'feedback'].includes(s.screen.peek())) setTimeout(load, 600); });
186
+ const stopTicks = M.effect(() => { qClock.left.value; totalClock.left.value; showClock(); });
187
+
188
+ function forget() {
189
+ if (!test) memory.remove(playKey(cfg.slug));
190
+ play = null;
191
+ }
192
+
193
+ async function loadInfo() {
194
+ const info = test ? await test.info() : await request(`${API}/quiz/${encodeURIComponent(cfg.slug)}`);
195
+ s.info.value = info;
196
+ s.facts.value = facts(info);
197
+ if (info.leaderboard && !board && !test) {
198
+ board = mountLeaderboard(el.querySelector('.qz-board-slot'), {url: `${API}/quiz/${encodeURIComponent(cfg.slug)}/leaderboard`, limit: info.leaderboardSize, title: 'Leaderboard'});
199
+ }
200
+ return info;
201
+ }
202
+
203
+ /** Where the play is now, from the server; draws the screen that goes with it. */
204
+ async function load() {
205
+ if (!play) { s.screen.value = 'intro'; return; }
206
+ try {
207
+ const v = await request(`${API}/play/${encodeURIComponent(play.playId)}`, {token: play.token});
208
+ current = v;
209
+ if (v.status !== 'playing') { stopClocks(); return showResult(); }
210
+ drawQuestion(v);
211
+ } catch (err) {
212
+ if (err.code === 'gone' || err.status === 404) { forget(); s.screen.value = 'intro'; return; }
213
+ s.problem.value = err.message;
214
+ s.screen.value = 'error';
215
+ }
216
+ }
217
+
218
+ function drawQuestion(v) {
219
+ const q = v.question;
220
+ s.q.value = {text: q.text, kind: q.kind, image: q.image || ''};
221
+ s.options.value = q.options.map((o, i) => ({id: o.id, text: o.text, letter: LETTERS[i] || '', picked: false}));
222
+ s.typed.value = '';
223
+ s.progress.value = `Question ${v.index + 1} of ${v.total}`;
224
+ s.busy.value = false;
225
+ s.screen.value = 'question';
226
+ qClock.start(v.questionLeftMs ?? 0);
227
+ totalClock.start(v.totalLeftMs ?? 0);
228
+ showClock();
229
+ if (q.kind === 'text') focus('typed');
230
+ else focus('q');
231
+ say(`Question ${v.index + 1} of ${v.total}. ${q.text}`);
232
+ }
233
+
234
+ async function send(answer, {timeout = false} = {}) {
235
+ if (s.busy.peek() || !current?.question) return;
236
+ s.busy.value = true;
237
+ qClock.stop();
238
+ try {
239
+ const r = await request(`${API}/play/${encodeURIComponent(play.playId)}/answer`, {
240
+ method: 'POST', token: play.token, body: {q: current.question.id, answer}
241
+ });
242
+ current = r.play;
243
+ const done = r.play.status !== 'playing';
244
+ if (r.feedback) {
245
+ s.fb.value = {
246
+ correct: r.correct,
247
+ title: r.correct ? 'Right!' : timeout || r.late ? 'Out of time' : 'Not quite',
248
+ rightAnswer: r.feedback.rightAnswer, explanation: r.feedback.explanation,
249
+ nextLabel: done ? 'See your score' : 'Next question'
250
+ };
251
+ s.screen.value = 'feedback';
252
+ say(s.fb.peek().title);
253
+ focus('fb');
254
+ return;
255
+ }
256
+ if (done) { stopClocks(); return showResult(); }
257
+ // The next question's clock starts when the server hands it over, so ask for it.
258
+ await load();
259
+ } catch (err) {
260
+ if (err.code === 'not-current' || err.code === 'finished') return load();
261
+ s.busy.value = false;
262
+ s.live.value = err.message;
263
+ }
264
+ }
265
+
266
+ async function showResult() {
267
+ stopClocks();
268
+ try {
269
+ const r = await request(`${API}/play/${encodeURIComponent(play.playId)}/result`, {token: play.token});
270
+ const placeText = r.test
271
+ ? `A test go, so it is not on the leaderboard.${r.wouldPlace ? ` It would have come ${ordinal(r.wouldPlace)} of all time.` : ''}`
272
+ : r.place ? `You came ${ordinal(r.place)} of all time${r.placeToday && r.placeToday !== r.place ? `, ${ordinal(r.placeToday)} today` : ''}.` : '';
273
+ s.res.value = {
274
+ title: r.timeUp ? `Time's up, ${r.name}!` : `Well done, ${r.name}!`,
275
+ scoreText: `${r.score} / ${r.maxScore}`,
276
+ detail: `${r.correct} of ${r.total} right in ${fmtDuration(r.timeMs)}.`,
277
+ placeText
278
+ };
279
+ s.review.value = (r.review || []).map((x, i) => ({
280
+ k: `r${i}`, text: `${i + 1}. ${x.text}`, correct: x.correct, right: x.right, explanation: x.explanation,
281
+ mark: x.correct ? '✓' : '✗', yoursText: x.yours ? `You said: ${x.yours}` : x.late ? 'Out of time' : 'No answer'
282
+ }));
283
+ s.screen.value = 'result';
284
+ say(`${s.res.peek().title} ${s.res.peek().detail} ${placeText}`);
285
+ focus('res');
286
+ board?.refresh(play.playId);
287
+ if (r.place === 1 && !r.test) celebrate();
288
+ } catch (err) {
289
+ if (err.status === 404) { forget(); s.screen.value = 'intro'; return; }
290
+ s.problem.value = err.message;
291
+ s.screen.value = 'error';
292
+ }
293
+ }
294
+
295
+ async function begin() {
296
+ if (s.busy.peek()) return;
297
+ s.problem.value = '';
298
+ s.busy.value = true;
299
+ try {
300
+ const r = test ? await test.start() : await request(`${API}/quiz/${encodeURIComponent(cfg.slug)}/start`, {
301
+ method: 'POST', body: {name: s.name.peek(), website: s.website.peek()}
302
+ });
303
+ play = {playId: r.playId, token: r.token};
304
+ if (test) {
305
+ if (r.info) { s.info.value = r.info; s.facts.value = facts(r.info); }
306
+ } else {
307
+ memory.set(NAME_KEY, r.name);
308
+ memory.set(playKey(cfg.slug), play);
309
+ }
310
+ await load();
311
+ } catch (err) {
312
+ s.problem.value = errText(err);
313
+ s.busy.value = false;
314
+ if (!test) focus('name');
315
+ }
316
+ }
317
+
318
+ const methods = {
319
+ // Form handlers answer false at once (no native submit, no reload); the work runs after.
320
+ start() { begin(); return false; },
321
+ choose(opt) {
322
+ if (s.busy.peek()) return;
323
+ if (s.q.peek().kind === 'multiple') {
324
+ s.options.value = s.options.peek().map(o => (o.id === opt.id ? {...o, picked: !o.picked} : o));
325
+ return;
326
+ }
327
+ send(opt.id);
328
+ },
329
+ sendPicked() {
330
+ if (s.q.peek().kind === 'text') return send(s.typed.peek());
331
+ send(s.options.peek().filter(o => o.picked).map(o => o.id));
332
+ },
333
+ sendTyped() { send(s.typed.peek()); return false; },
334
+ next() {
335
+ if (current?.status !== 'playing') return showResult();
336
+ load();
337
+ },
338
+ async again() {
339
+ forget();
340
+ s.problem.value = '';
341
+ s.busy.value = false;
342
+ await loadInfo().catch(() => {});
343
+ s.screen.value = 'intro';
344
+ focus('name');
345
+ }
346
+ };
347
+
348
+ const bindings = M.applyBindings({...s, ...methods}, el, {methods});
349
+
350
+ loadInfo().then(load).catch((err) => {
351
+ s.problem.value = err.status === 404 ? 'This quiz is not available.' : errText(err);
352
+ s.screen.value = 'error';
353
+ });
354
+
355
+ // Gone from the page (an SPA swap): stop the clocks.
356
+ const dispose = () => { stopClocks(); stopTicks?.(); board?.dispose(); bindings?.dispose?.(); watch.disconnect(); };
357
+ const watch = new MutationObserver(() => { if (!el.isConnected) dispose(); });
358
+ watch.observe(document.body, {childList: true, subtree: true});
359
+ return {dispose};
360
+ }
361
+
362
+ function mountAll() {
363
+ for (const el of document.querySelectorAll('[data-quiz]')) {
364
+ if (el.dataset.gmMounted) continue;
365
+ el.dataset.gmMounted = '1';
366
+ mount(el);
367
+ }
368
+ mountBoards('data-quiz-board', (slug) => `${API}/quiz/${encodeURIComponent(slug)}/leaderboard`);
369
+ }
370
+
371
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAll);
372
+ else mountAll();
@@ -0,0 +1,65 @@
1
+ # Theme Roller
2
+
3
+ Makes custom themes - a built-in Domma theme plus the `--dm-*` tokens changed on top - and offers them to
4
+ the CMS with `hooks.registerThemes` (domma-cms 0.73+). The CMS does the rest: they are listed under
5
+ Custom themes in Content › Theme and applied as `dm-theme-<base> dm-custom-<id>` (core
6
+ `server/services/themeSettings.js`, "Custom themes"). This plugin only stores them and offers them.
7
+
8
+ ## Files
9
+
10
+ - `plugin.js` - API + provider. Themes live in `data/themes/<id>.json` (a site's own, declared in
11
+ `contentPaths`, never shipped); an in-memory Map mirrors them so the provider - called on every page
12
+ render - never touches disk. Writes are tmp+rename. "Use" goes through the core's `saveThemeConfig`.
13
+ - `admin/lib/themes.js` - PURE, shared by server and browser: slug/unique ids (never a built-in id),
14
+ cleaning tokens (same safe-value rule as the core), `usageOf`, `withoutTheme` (delete in use -> base),
15
+ `themeCss` (export).
16
+ - `admin/views/theme-roller-view.js`, `admin/templates/theme-roller.html`, `admin/css/theme-roller.css`.
17
+ - **The Custom CSS tab** edits the site-wide `content/custom.css` (inlined into every public page after the
18
+ theme) through CORE's `GET/PUT /api/settings/custom-css` - not this plugin's API - so it needs the
19
+ `settings.update` permission (Site Settings > Edit); without it the tab cannot load or save. The box stays
20
+ locked until the file has loaded (saving an empty box would replace the whole file); Ctrl+S saves.
21
+ - `tests/themes.test.js` (pure), `tests/api.test.js` (API against the real core theme service -
22
+ snapshots and restores the CMS checkout's config/theme.json and site.json).
23
+
24
+ A stored theme is `{id, label, baseTheme, changes, createdAt, updatedAt}`. Files from 1.0 (`name`, no
25
+ label) load as id = label = name.
26
+
27
+ ## Gotchas (the ThemeRoller component, from Domma's tools bundle)
28
+
29
+ - It re-themes the WHOLE admin while it runs (theme.set + a preview `<style>` on `body, :root` with
30
+ `!important`). The view takes the admin's `dm-custom-*` class off `<body>` while it runs, and on
31
+ leaving destroys the roller and re-applies the admin theme via the core's `admin-theme.js`.
32
+ - There is NO `getBaseTheme()` (1.0 called it on Save and threw - that was "it doesn't save", along with
33
+ `data/themes/` never being created). The tools bundle has its OWN theme engine, so the roller's
34
+ dropdown and `Domma.theme.get()` can disagree: the base is `#theme-select`'s value (`currentBase`).
35
+ - `loadPreset()` / `_refreshInputs()` wipe the changes. `loadInto` sets the base, builds a fresh
36
+ roller, puts `_changes` in place and re-renders its inputs (`_render` + accordion + `_bindEvents`),
37
+ falling back to `setAll` if those privates disappear. `_render` reads `_changes[var] || computed`.
38
+ - E.prompt/E.confirm render HTML (sanitised): labels in the message go in `esc()`d. Never give a prompt its starting
39
+ value through `inputValue` (a `"` cuts it short) - use `promptWith` (admin/lib/prompt.js); a test bans it.
40
+ - The public theme switcher cannot offer custom themes (the core refuses them there): a visitor can only switch
41
+ between built-in themes. Say so to a site owner who expects their custom theme in the switcher.
42
+
43
+ ## 1.2.0 (2026-10-15 Tool Upgrade, second pass)
44
+
45
+ - Saved themes: All / In use / Not in use with counts, a search box, and core's skeleton on the first load
46
+ (`loading.js` at run time; `.trx-loading` keeps its text on an older core). `load()` checks `here()` after awaits.
47
+ - **Saves never swallow newer edits**: `save()` compares what it sent (`base|changesKey`) with the roller after the
48
+ PUT and `saveCss()` the box with what it sent - anything changed meanwhile stays unsaved (and is drafted on leave).
49
+ - **Drafts know their age**: the theme draft carries `was` (the theme's `updatedAt` when editing began) and the CSS
50
+ draft `cssWas` (the file as loaded or last saved). `staleNote()` (admin/lib/view.js, tests/view.test.js) adds to the
51
+ restore question when the theme was saved again or deleted since, or custom.css changed.
52
+
53
+ ## 1.2.1 - unsaved-work sweep
54
+
55
+ The draft is per user (`theme-roller.drafts.<uid>`; the old shared `theme-roller.draft` is removed unread). Restore asks
56
+ "Throw away the changes you left unsaved to …?" (OK throws away, Cancel/Escape keeps; `restoreQuestion` in
57
+ admin/lib/view.js). A CSS draft whose file cannot be loaded is kept for next time, never dropped - `pendingCss` carries it
58
+ into the leave write so unsaved theme changes cannot overwrite it. Row Enter and every menu action run a task later, so
59
+ the Enter cannot answer the confirm they open.
60
+
61
+ ## 1.3.0 (2026-10-21): free
62
+
63
+ `tier: free`, no `closedSource` - no licence is needed or checked any more (the engine only checks a
64
+ `closedSource` plugin). Included in every hosted plan and free to self-host; not `bundle: false`, so the next
65
+ `make sync-free-tier` ships it in the engine.