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,167 @@
1
+ /** The pure part of Messages - admin/lib/messages.js. */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {
5
+ ago, arrangeThreads, attachmentUrl, buildBubbles, cleanAttachments, cleanDrafts, dayLabel, filterCounts,
6
+ indexByThread, initials, messageRights, threadTitle, unreadBadge, UNREAD_AFTER_MS, unreadKey, unreadNotices,
7
+ CALL_PICK_MAX, callGroupName, callTarget, callTargetText} from '../admin/lib/messages.js';
8
+
9
+ const me = 'u1';
10
+ const T = (id, extra = {}) => ({id, kind: 'group-adhoc', name: id.toUpperCase(), participants: [{id: me, name: 'Me'}, {id: 'u2', name: 'Ann'}], unread: 0, lastMessageAt: '2026-09-20T10:00:00Z', ...extra});
11
+
12
+ test('titles: a DM is the other person, a group its name', () => {
13
+ assert.equal(threadTitle({kind: 'dm', participants: [{id: me, name: 'Me'}, {id: 'u2', name: 'Ann Lee'}]}, me), 'Ann Lee');
14
+ assert.equal(threadTitle({kind: 'dm', participants: [me, 'u9']}, me), 'u9');
15
+ assert.equal(threadTitle({kind: 'group-adhoc', name: ''}, me), 'Unnamed group');
16
+ assert.equal(initials('Ann Lee'), 'AL');
17
+ assert.equal(initials(''), '?');
18
+ });
19
+
20
+ test('arrange: newest first; inbox hides archived; unread and archived filters; search', () => {
21
+ const list = [T('a', {lastMessageAt: '2026-09-01T00:00:00Z', unread: 2}), T('b', {archivedBy: [me]}), T('c', {lastMessageAt: '2026-09-24T00:00:00Z', lastMessagePreview: 'budget'})];
22
+ assert.deepEqual(arrangeThreads(list, {myId: me}).map(t => t.id), ['c', 'a']);
23
+ assert.deepEqual(arrangeThreads(list, {myId: me, filter: 'unread'}).map(t => t.id), ['a']);
24
+ assert.deepEqual(arrangeThreads(list, {myId: me, filter: 'archived'}).map(t => t.id), ['b']);
25
+ assert.deepEqual(arrangeThreads(list, {myId: me, query: 'BUDGET'}).map(t => t.id), ['c']);
26
+ assert.deepEqual(filterCounts(list, me), {inbox: 2, unread: 1, archived: 1});
27
+ // Someone else's archive is not mine.
28
+ assert.equal(arrangeThreads([T('x', {archivedBy: ['u2']})], {myId: me}).length, 1);
29
+ });
30
+
31
+ test('rights: edit only mine, inside the window; delete mine, or as admin or thread creator', () => {
32
+ const now = Date.parse('2026-09-25T12:00:00Z');
33
+ const m = {senderId: me, createdAt: '2026-09-25T11:50:00Z'};
34
+ assert.deepEqual([messageRights(m, {myId: me, now}).canEdit, messageRights(m, {myId: me, now}).editLeftMinutes], [true, 5]);
35
+ assert.equal(messageRights({...m, createdAt: '2026-09-25T11:00:00Z'}, {myId: me, now}).canEdit, false);
36
+ const theirs = {senderId: 'u2', createdAt: m.createdAt};
37
+ assert.equal(messageRights(theirs, {myId: me, now}).canDelete, false);
38
+ assert.equal(messageRights(theirs, {myId: me, now, isAdmin: true}).canDelete, true);
39
+ assert.equal(messageRights(theirs, {myId: me, now, threadCreator: me}).canDelete, true);
40
+ assert.equal(messageRights({...m, deletedAt: 'x'}, {myId: me, now}).canDelete, false);
41
+ });
42
+
43
+ test('attachments: the address is always rebuilt - a stored url is never trusted', () => {
44
+ assert.equal(attachmentUrl({mediaId: 'a b.png'}), '/media/a%20b.png');
45
+ assert.equal(attachmentUrl({mediaId: 'x.png', url: 'javascript:alert(1)'}), '/media/x.png');
46
+ assert.equal(attachmentUrl({url: '/media/y.png'}), '/media/y.png');
47
+ const clean = cleanAttachments([{mediaId: 'ok.pdf', filename: 'Ok', url: 'https://evil'}, {mediaId: '../etc/passwd'}, {filename: 'no id'}]);
48
+ assert.deepEqual(clean, [{mediaId: 'ok.pdf', filename: 'Ok', mime: '', size: 0, url: '/media/ok.pdf'}]);
49
+ });
50
+
51
+ test('drafts: whitelisted, empty ones dropped', () => {
52
+ const d = cleanDrafts({t1: {text: 'hi', attachments: [{mediaId: 'a.png', filename: 'a'}, {nope: 1}]}, t2: {text: ' ', attachments: []}, t3: 'junk'});
53
+ assert.deepEqual(Object.keys(d), ['t1']);
54
+ assert.equal(d.t1.attachments.length, 1);
55
+ assert.deepEqual(cleanDrafts(null), {});
56
+ });
57
+
58
+ test('bubbles: a day separator on each new day; the sender only in groups, only when it changes', () => {
59
+ const now = Date.parse('2026-09-25T12:00:00Z');
60
+ const msgs = [
61
+ {id: '1', senderId: 'u2', body: 'hi', createdAt: '2026-09-24T09:00:00Z'},
62
+ {id: '2', senderId: 'u2', body: 'again', createdAt: '2026-09-24T09:01:00Z'},
63
+ {id: '3', senderId: me, body: 'yo', createdAt: '2026-09-25T09:00:00Z', editedAt: 'x'},
64
+ {id: '4', senderId: 'u2', body: '', deletedAt: 'x', createdAt: '2026-09-25T09:05:00Z', attachments: [{mediaId: 'a.png', mime: 'image/png'}]}
65
+ ];
66
+ const b = buildBubbles(msgs, {myId: me, isGroup: true, nameOf: () => 'Ann', now});
67
+ assert.deepEqual(b.map(x => x.isDay ? `[${x.dayLabel}]` : x.id), ['[Yesterday]', '1', '2', '[Today]', '3', '4']);
68
+ assert.deepEqual(b.filter(x => !x.isDay).map(x => x.showSender), [true, false, false, true]);
69
+ assert.match(b[4].meta, /edited/);
70
+ assert.equal(b[5].bodyText, 'message deleted');
71
+ assert.equal(b[5].attachments.length, 0, 'a deleted message shows no attachments');
72
+ assert.equal(buildBubbles(msgs, {myId: me, isGroup: false, now}).some(x => x.showSender), false);
73
+ assert.equal(dayLabel('nonsense', now), '');
74
+ // 1.6: a line typed in the conversation's call says so beside the time.
75
+ const inCall = buildBubbles([{id: '5', senderId: 'u2', body: 'from the call', createdAt: '2026-09-25T10:00:00Z', callChat: {id: 'x', who: 'w'}}], {myId: me, now});
76
+ assert.match(inCall.find(x => x.id === '5').meta, /in the call/);
77
+ });
78
+
79
+ test('badge: unread in conversations not archived; plain text popover', () => {
80
+ const now = Date.parse('2026-09-25T12:00:00Z');
81
+ const b = unreadBadge([T('a', {unread: 2}), T('b', {unread: 5, archivedBy: [me]}), T('c', {unread: 1, name: '<b>x</b>'})], me, now);
82
+ assert.equal(b.count, 3);
83
+ assert.equal(b.label, '3 unread messages in 2 conversations');
84
+ assert.equal(b.tone, 'info');
85
+ assert.deepEqual(b.details.rows.map(r => r.value), ['3', '2', '1']);
86
+ assert.ok(b.details.items.some(i => i.text === '<b>x</b>'), 'names pass through as text');
87
+ assert.match(b.details.items.find(i => i.text === 'A').meta, /^2 unread · /);
88
+ assert.equal(unreadBadge([], me, now).count, 0);
89
+ assert.equal(unreadBadge([], me, now).showZero, true);
90
+ assert.equal(b.showZero, true);
91
+ });
92
+
93
+ test('unread notices: waiting 10 minutes, one per conversation, to the recipient only', () => {
94
+ const now = Date.parse('2026-09-26T12:00:00Z');
95
+ const at = (mins) => new Date(now - mins * 60_000).toISOString();
96
+ const threads = [
97
+ {id: 't1', kind: 'dm', participants: ['a', 'b']},
98
+ {id: 't2', kind: 'group-adhoc', name: 'Site team', participants: ['a', 'b', 'c'], archivedBy: ['c']}
99
+ ];
100
+ const msgs = [
101
+ {threadId: 't1', senderId: 'a', body: 'Are you in today?', createdAt: at(12), readBy: ['a']},
102
+ {threadId: 't1', senderId: 'a', body: 'just now', createdAt: at(2), readBy: ['a']},
103
+ {threadId: 't2', senderId: 'b', body: 'Skip', createdAt: at(30), readBy: ['b', 'a']},
104
+ {threadId: 't2', senderId: 'b', body: 'old', createdAt: at(60 * 24 * 3), readBy: ['b']}
105
+ ];
106
+ const n = unreadNotices(threads, msgs, {a: 'Ann', b: 'Bob'}, now);
107
+ assert.equal(n.length, 1, 'b in the DM; a read the group; c archived it; the 3-day-old one is left alone');
108
+ assert.equal(n[0].title, 'Ann sent you a message');
109
+ assert.equal(n[0].body, 'Are you in today?');
110
+ assert.deepEqual(n[0].audience, {userIds: ['b']});
111
+ assert.equal(n[0].dedupeKey, unreadKey('t1', 'b'));
112
+ assert.ok(UNREAD_AFTER_MS === 600_000);
113
+ });
114
+
115
+ test('older dates read dd/mm/yyyy: the list stamp and the day separator', () => {
116
+ const now = new Date(2026, 8, 27, 12, 0).getTime();
117
+ const old = new Date(2026, 8, 3, 9, 30).toISOString();
118
+ assert.equal(ago(old, now), '03/09/2026');
119
+ assert.equal(dayLabel(old, now), 'Thu 03/09/2026');
120
+ });
121
+
122
+ test('Call…: which conversation a call with the people picked goes to', () => {
123
+ const P = (...ids) => ids.map(id => ({id, name: id}));
124
+ const threads = [
125
+ {id: 'dm-ann', kind: 'dm', participants: P(me, 'u2'), lastMessageAt: '2026-09-20T10:00:00Z'},
126
+ {id: 'dm-bob-archived', kind: 'dm', participants: [me, 'u3'], archivedBy: [me]},
127
+ {id: 'g-old', kind: 'group-adhoc', name: 'Old', participants: P(me, 'u2', 'u3'), lastMessageAt: '2026-09-01T10:00:00Z'},
128
+ {id: 'g-new', kind: 'group-adhoc', name: 'New', participants: P('u3', me, 'u2'), lastMessageAt: '2026-09-25T10:00:00Z'},
129
+ {id: 'g-big', kind: 'group-adhoc', name: 'Big', participants: P(me, 'u2', 'u3', 'u4')},
130
+ {id: 'g-gone', kind: 'group-adhoc', name: 'Gone', participants: P(me, 'u4', 'u5'), archivedBy: [me]},
131
+ {id: 'm-team', kind: 'group-managed', name: 'Team', participants: P(me, 'u5', 'u6')},
132
+ {id: 'g-without-me', kind: 'group-adhoc', name: 'Them', participants: P('u6', 'u7')}
133
+ ];
134
+ assert.equal(callTarget(threads, me, ['u2']).id, 'dm-ann', 'one person: your DM');
135
+ assert.equal(callTarget(threads, me, ['u3']).id, 'dm-bob-archived', 'a DM is one per pair, archived or not');
136
+ assert.deepEqual(callTarget(threads, me, ['u9']), {create: {kind: 'dm', participants: ['u9']}});
137
+ assert.equal(callTarget(threads, me, ['u3', 'u2']).id, 'g-new', 'exactly those people, the latest used');
138
+ assert.equal(callTarget(threads, me, ['u2', 'u3', 'u2', me]).id, 'g-new', 'repeats and yourself are ignored');
139
+ assert.deepEqual(callTarget(threads, me, ['u4', 'u5']), {create: {kind: 'group-adhoc', participants: ['u4', 'u5']}}, 'not one you archived');
140
+ assert.ok(callTarget(threads, me, ['u5', 'u6']).create, 'a managed group is not taken for an ad-hoc call');
141
+ assert.ok(callTarget(threads, me, ['u6', 'u7']).create, 'nor one you are not in');
142
+ assert.equal(callTarget(threads, me, []), null);
143
+ assert.equal(CALL_PICK_MAX, 5);
144
+ assert.ok(callTarget(threads, me, ['a', 'b', 'c', 'd', 'e']).create);
145
+ assert.equal(callTarget(threads, me, ['a', 'b', 'c', 'd', 'e', 'f']), null, 'a call holds 6, you included');
146
+
147
+ assert.equal(callGroupName([{name: 'Alex Carter'}, {name: 'Priya Shah'}, {name: 'Sam Patel'}]), 'Alex, Priya and Sam');
148
+ assert.equal(callGroupName([{name: 'Alex Carter'}, {name: 'Priya'}]), 'Alex and Priya');
149
+ assert.equal(callGroupName([]), 'Call');
150
+ assert.ok(callGroupName(Array.from({length: 30}, () => ({name: 'Bartholomew'}))).length <= 80);
151
+
152
+ assert.equal(callTargetText(callTarget(threads, me, ['u2']), me), 'Calls in your conversation with u2.');
153
+ assert.equal(callTargetText(callTarget(threads, me, ['u2', 'u3']), me), 'Calls in your group "New".');
154
+ assert.equal(callTargetText(callTarget(threads, me, ['u8', 'u9']), me), 'Starts a new group for these people and calls.');
155
+ assert.equal(callTargetText(null, me), '');
156
+ });
157
+
158
+ test('indexByThread: one list per conversation, in collection order; entries with no thread are left out', () => {
159
+ const E = (id, threadId, extra = {}) => ({id, data: {threadId, ...extra}});
160
+ const idx = indexByThread([E('m1', 't1'), E('m2', 't2'), E('m3', 't1', {deletedAt: 'x'}), {id: 'odd', data: {}}, null]);
161
+ assert.deepEqual([...idx.keys()], ['t1', 't2']);
162
+ assert.deepEqual(idx.get('t1').map(e => e.id), ['m1', 'm3']); // deleted ones stay in: the routes decide what to skip
163
+ assert.deepEqual(idx.get('t2').map(e => e.id), ['m2']);
164
+ assert.equal(idx.get('t9'), undefined);
165
+ assert.equal(indexByThread([]).size, 0);
166
+ assert.equal(indexByThread(undefined).size, 0);
167
+ });
@@ -0,0 +1,43 @@
1
+ # quiz - AI Assistant Guide
2
+
3
+ ## Purpose
4
+
5
+ Timed quizzes on the public site with name-only players and leaderboards. 1.1.0 (2026-10-21), `tier: free`,
6
+ disabled on arrival. Sibling of `crossword`; both sit in the sidebar's Tools > Games folder (`"group": "Games"`)
7
+ and share `plugins/_lib/games` (server) + `_lib/public/games` (browser) + `_lib/admin/games` (admin kit).
8
+
9
+ ## Files
10
+
11
+ - `lib/quiz.js` - PURE rules: `cleanQuiz` (the only gate on what the admin saves), `isOpen`, `planPlay` (question
12
+ order, option order, `draw`, fixed at start), `publicQuestion` (never `correct`), `mark`, `foldAnswer`.
13
+ - `lib/engine.js` - PURE clock: `catchUp`, `serve`, `answer`, `end`, `view`. Each mutates the play and says if it
14
+ changed; the route saves through `plays.change()` (locked read-modify-write).
15
+ - `lib/store.js` - `quiz-quizzes` collection + `plays = new PlayStore('quiz-plays', {order: 'score'})`.
16
+ - `plugin.js` - routes (header comment lists them), shortcodes, the `top-scores` notification source.
17
+ - `public/quiz.js` - the player (domma-reactive bindings), injected on every page by `inject/body.html`. Exports
18
+ `mount(el, {test})`, which the admin's Test screen uses.
19
+ - `admin/views/quiz.js` - list, editor, test and plays screens, all domma-reactive through the admin kit
20
+ (`mountScreen`, `wireRows`, `sortableRows`, `testScreen`, `playsScreen` in `_lib/admin/games/kit.js`). Templates
21
+ are strings in the JS (a templateUrl goes through `_.render`, which eats data-each `{{ }}`); the questions are an
22
+ `observableArray` of question models with their answers nested as `{{#each options key=id}}`.
23
+ - Tests: `tests/plugins/games-pure.test.js`, `tests/plugins/games-api.test.js` in the CMS repo.
24
+
25
+ ## Contracts
26
+
27
+ - **The server keeps time and marks.** A question's clock starts at `serve()` - the GET of `/public/play/:id` -
28
+ and only then can it be answered (`answer()` refuses an unserved one with `code: 'not-current'`). So the player
29
+ must GET the next question after each answer, not use the one in the answer response. `GRACE_MS` (3s) covers
30
+ latency; late answers are marked wrong, never refused.
31
+ - **The play token** (`X-Play-Token`) is returned once by start and compared with `timingSafeEqual`.
32
+ `PlayStore.adminView` strips it and the state from everything the admin sees.
33
+ - **Leaderboards** rank finished, unhidden plays, best per `nameKey`, score then time (`_lib/games/ranking.js`).
34
+ - **Shortcode prefix trap:** the engine's plugin-shortcode regex is a PREFIX match, so `[quiz` also matches
35
+ `[quiz-leaderboard`. `quiz-leaderboard` is registered with `{priority: 5}` so it runs first. Keep it that way.
36
+ - **Domma bindings:** a `data-on-submit` handler must return `false` synchronously - an async one returns a
37
+ Promise and the form submits natively (page reload).
38
+ - Collections' API verbs are all off: nothing here is readable through `/api/v1`.
39
+ - **Test plays** (1.1.0): `GET/POST /quizzes/:id/test` (manage) give the start screen and a play marked `test: true`,
40
+ for the SAVED quiz whatever its status. It is played through the ordinary `/public/play` routes. `PlayStore` leaves
41
+ tests out of `forGame` (so boards, places, goes-per-name and the admin list), `todayBadge` skips them,
42
+ `afterFinish` never notifies for one, and the next test of the game clears them (`clearTests`). `/result` answers
43
+ `test: true` and `wouldPlace` instead of a place. Tests: `games-api.test.js` "test plays".
@@ -1 +1,13 @@
1
- .qza-questions{display:flex;flex-direction:column;gap:.75rem}.qza-question{border:1px solid var(--gma-line);border-left:4px solid var(--dm-primary);border-radius:var(--dm-radius-md, 8px);padding:.85rem 1rem;display:flex;flex-direction:column;gap:.6rem;background:var(--dm-background, transparent)}.qza-question .form-select{width:auto}.qza-answers{display:flex;flex-direction:column;gap:.4rem}.qza-answer{display:flex;align-items:center;gap:.5rem;padding:.25rem .4rem;border-radius:6px}.qza-answer.is-right{background:color-mix(in srgb,var(--dm-success) 12%,transparent)}.qza-answer .form-input{flex:1;min-width:0}.qza-mark{display:inline-flex;align-items:center;cursor:pointer;padding:.2rem}.qza-mark input{width:1.1rem;height:1.1rem;accent-color:var(--dm-success)}.qza-fixed{flex:1;font-weight:500}.qza-more summary{cursor:pointer;color:var(--dm-text-muted);font-size:var(--dm-text-sm);margin-bottom:.5rem}
1
+ /* Quiz admin - the question editor. Shared pieces are in /plugins/_lib/admin/games/games.css. */
2
+ .qza-questions { display: flex; flex-direction: column; gap: .75rem; }
3
+ .qza-question { border: 1px solid var(--gma-line); border-left: 4px solid var(--dm-primary); border-radius: var(--dm-radius-md, 8px);
4
+ padding: .85rem 1rem; display: flex; flex-direction: column; gap: .6rem; background: var(--dm-background, transparent); }
5
+ .qza-question .form-select { width: auto; }
6
+ .qza-answers { display: flex; flex-direction: column; gap: .4rem; }
7
+ .qza-answer { display: flex; align-items: center; gap: .5rem; padding: .25rem .4rem; border-radius: 6px; }
8
+ .qza-answer.is-right { background: color-mix(in srgb, var(--dm-success) 12%, transparent); }
9
+ .qza-answer .form-input { flex: 1; min-width: 0; }
10
+ .qza-mark { display: inline-flex; align-items: center; cursor: pointer; padding: .2rem; }
11
+ .qza-mark input { width: 1.1rem; height: 1.1rem; accent-color: var(--dm-success); }
12
+ .qza-fixed { flex: 1; font-weight: 500; }
13
+ .qza-more summary { cursor: pointer; color: var(--dm-text-muted); font-size: var(--dm-text-sm); margin-bottom: .5rem; }