domma-cms 1.8.30 → 1.8.32

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 (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/docs/DommaFeatures.md +5 -4
  3. package/package.json +3 -1
  4. package/plugins/_lib/admin/games/games.css +1 -0
  5. package/plugins/_lib/admin/games/kit.js +241 -0
  6. package/plugins/_lib/games/http.js +82 -0
  7. package/plugins/_lib/games/names.js +42 -0
  8. package/plugins/_lib/games/plays.js +155 -0
  9. package/plugins/_lib/games/ranking.js +68 -0
  10. package/plugins/_lib/games/timing.js +27 -0
  11. package/plugins/_lib/public/games/format.js +38 -0
  12. package/plugins/_lib/public/games/games.css +1 -0
  13. package/plugins/_lib/public/games/games.js +204 -0
  14. package/plugins/crossword/admin/css/index.css +1 -0
  15. package/plugins/crossword/admin/templates/crossword.html +1 -0
  16. package/plugins/crossword/admin/views/crossword.js +317 -0
  17. package/plugins/crossword/collections/crossword-plays.json +20 -0
  18. package/plugins/crossword/collections/crossword-puzzles.json +17 -0
  19. package/plugins/crossword/config.js +6 -0
  20. package/plugins/crossword/inject/body.html +1 -0
  21. package/plugins/crossword/lib/crossword.js +170 -0
  22. package/plugins/crossword/lib/store.js +56 -0
  23. package/plugins/crossword/plugin.js +241 -0
  24. package/plugins/crossword/plugin.json +78 -0
  25. package/plugins/crossword/public/crossword.css +1 -0
  26. package/plugins/crossword/public/crossword.js +57 -0
  27. package/plugins/crossword/public/lib/layout.js +199 -0
  28. package/plugins/quiz/admin/css/index.css +1 -0
  29. package/plugins/quiz/admin/templates/quiz.html +1 -0
  30. package/plugins/quiz/admin/views/quiz.js +352 -0
  31. package/plugins/quiz/collections/quiz-plays.json +20 -0
  32. package/plugins/quiz/collections/quiz-quizzes.json +17 -0
  33. package/plugins/quiz/config.js +6 -0
  34. package/plugins/quiz/inject/body.html +1 -0
  35. package/plugins/quiz/lib/engine.js +115 -0
  36. package/plugins/quiz/lib/quiz.js +225 -0
  37. package/plugins/quiz/lib/store.js +56 -0
  38. package/plugins/quiz/plugin.js +241 -0
  39. package/plugins/quiz/plugin.json +78 -0
  40. package/plugins/quiz/public/quiz.css +1 -0
  41. package/plugins/quiz/public/quiz.js +80 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Games - who is top. PURE: plays in, leaderboard rows out.
3
+ *
4
+ * Only finished, visible plays count. A player (by name key) appears once,
5
+ * with their best play, so one person replaying cannot fill the board. Order:
6
+ *
7
+ * 'score' higher score first, then the faster time, then whoever got there first (Quiz)
8
+ * 'time' the faster time first, then whoever got there first (Crossword - every finisher solved it)
9
+ *
10
+ * Periods are calendar ones in the server's own day: today, this week (from
11
+ * Monday), this month, all time.
12
+ *
13
+ * @module _lib/games/ranking
14
+ */
15
+
16
+ import {cleanPeriod} from '../public/games/format.js';
17
+
18
+ /** The instant a period starts, or 0 for all time. */
19
+ export function periodStart(period, now = Date.now()) {
20
+ const d = new Date(now);
21
+ d.setHours(0, 0, 0, 0);
22
+ switch (cleanPeriod(period)) {
23
+ case 'today': return d.getTime();
24
+ case 'week': d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d.getTime();
25
+ case 'month': d.setDate(1); return d.getTime();
26
+ default: return 0;
27
+ }
28
+ }
29
+
30
+ /** a before b? - the board's order. */
31
+ export function comparePlays(a, b, order = 'score') {
32
+ if (order === 'score' && (b.score ?? 0) !== (a.score ?? 0)) return (b.score ?? 0) - (a.score ?? 0);
33
+ if ((a.timeMs ?? Infinity) !== (b.timeMs ?? Infinity)) return (a.timeMs ?? Infinity) - (b.timeMs ?? Infinity);
34
+ return String(a.finishedAt).localeCompare(String(b.finishedAt));
35
+ }
36
+
37
+ /**
38
+ * @param {object[]} plays - {id, name, nameKey, status, hidden, score, timeMs, finishedAt}
39
+ * @param {{order?: 'score'|'time', period?: string, now?: number, limit?: number, bestPerName?: boolean}} [opts]
40
+ * @returns {{id: string, rank: number, name: string, score: number, timeMs: number, finishedAt: string}[]}
41
+ */
42
+ export function rankPlays(plays, {order = 'score', period = 'all', now = Date.now(), limit = 10, bestPerName = true} = {}) {
43
+ const from = periodStart(period, now);
44
+ const counted = (plays || []).filter(p => p && p.status === 'finished' && p.hidden !== true
45
+ && Date.parse(p.finishedAt) >= from).sort((a, b) => comparePlays(a, b, order));
46
+ const seen = new Set();
47
+ const rows = [];
48
+ for (const p of counted) {
49
+ const key = p.nameKey || String(p.name).toLowerCase();
50
+ if (bestPerName && seen.has(key)) continue;
51
+ seen.add(key);
52
+ rows.push({id: p.id, rank: 0, name: p.name, score: p.score ?? 0, timeMs: p.timeMs ?? 0, finishedAt: p.finishedAt});
53
+ }
54
+ // Equal score and time share a rank ("=2nd"); the next is placed after them.
55
+ rows.forEach((r, i) => {
56
+ const prev = rows[i - 1];
57
+ r.rank = prev && (order === 'time' || prev.score === r.score) && prev.timeMs === r.timeMs ? prev.rank : i + 1;
58
+ });
59
+ return Math.max(0, Number(limit) || 0) ? rows.slice(0, Math.max(1, Math.min(100, Number(limit)))) : rows;
60
+ }
61
+
62
+ /** Where one play stands on its board for a period, counting it even when it is not the player's best. */
63
+ export function placeOf(play, plays, {order = 'score', period = 'all', now = Date.now()} = {}) {
64
+ if (!play || play.status !== 'finished') return 0;
65
+ const others = (plays || []).filter(p => p.id !== play.id);
66
+ const board = rankPlays([...others, play], {order, period, now, limit: 0, bestPerName: false});
67
+ return board.find(r => r.id === play.id)?.rank || 0;
68
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Games - how the server keeps time. PURE.
3
+ *
4
+ * Every clock that decides anything is the server's: when a play started, when
5
+ * a question was put in front of the player, when the answer arrived. The
6
+ * browser's countdown is only a picture of these. A little grace covers the
7
+ * network, so an answer sent in the last second still counts.
8
+ *
9
+ * @module _lib/games/timing
10
+ */
11
+
12
+ /** A late answer is still on time within this much, for the network. */
13
+ export const GRACE_MS = 3000;
14
+
15
+ /** A play still open this long after it started counts as abandoned. */
16
+ export const ABANDON_MS = 24 * 3600 * 1000;
17
+
18
+ /** Milliseconds left before `startIso + limitMs`, never below 0; null when there is no limit. */
19
+ export function leftOf(startIso, limitMs, now = Date.now()) {
20
+ if (!(limitMs > 0)) return null;
21
+ return Math.max(0, Date.parse(startIso) + limitMs - now);
22
+ }
23
+
24
+ /** Past `startIso + limitMs` by more than the grace? */
25
+ export function overdue(startIso, limitMs, now = Date.now()) {
26
+ return limitMs > 0 && now > Date.parse(startIso) + limitMs + GRACE_MS;
27
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Games - words and numbers shared by the server and the browser (Quiz,
3
+ * Crossword). Served from /plugins/_lib/public/games/format.js and imported by
4
+ * path on the server, so it must stay free of I/O and of DOM.
5
+ *
6
+ * @module _lib/public/games/format
7
+ */
8
+
9
+ /** "0:42", "3:05", "1:02:09" from milliseconds. Tenths only under a minute, when asked. */
10
+ export function fmtDuration(ms, {tenths = false} = {}) {
11
+ const n = Math.max(0, Number(ms) || 0);
12
+ if (tenths && n < 60000) return `${(Math.floor(n / 100) / 10).toFixed(1)}s`;
13
+ const total = Math.floor(n / 1000);
14
+ const h = Math.floor(total / 3600);
15
+ const m = Math.floor((total % 3600) / 60);
16
+ const s = String(total % 60).padStart(2, '0');
17
+ return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
18
+ }
19
+
20
+ /** "1st", "2nd", "3rd", "11th". */
21
+ export function ordinal(n) {
22
+ const v = Math.abs(Math.round(Number(n) || 0));
23
+ const tail = v % 100 >= 11 && v % 100 <= 13 ? 'th' : ({1: 'st', 2: 'nd', 3: 'rd'}[v % 10] || 'th');
24
+ return `${v}${tail}`;
25
+ }
26
+
27
+ /** The leaderboard periods, in the order the switcher shows them. */
28
+ export const PERIODS = [
29
+ {key: 'today', label: 'Today'},
30
+ {key: 'week', label: 'This week'},
31
+ {key: 'month', label: 'This month'},
32
+ {key: 'all', label: 'All time'}
33
+ ];
34
+
35
+ export const PERIOD_KEYS = PERIODS.map(p => p.key);
36
+
37
+ /** A period key, or 'all'. */
38
+ export const cleanPeriod = (p) => (PERIOD_KEYS.includes(String(p)) ? String(p) : 'all');
@@ -0,0 +1 @@
1
+ .gm-app{--gm-gap: 1rem;--gm-radius: var(--dm-radius, 10px);--gm-line: var(--dm-border, rgba(127, 127, 127, .3));--gm-soft: var(--dm-surface, rgba(127, 127, 127, .08));--gm-accent: var(--dm-primary, #3b82f6);--gm-good: var(--dm-success, #16a34a);--gm-bad: var(--dm-danger, #dc2626);--gm-warn: var(--dm-warning, #d97706);max-width:760px;margin:1.5rem auto;color:var(--dm-text, inherit)}.gm-app [hidden],.gm-board [hidden]{display:none!important}.gm-card{border:1px solid var(--gm-line);border-radius:var(--gm-radius);background:var(--dm-surface-raised, var(--dm-background, transparent));padding:1.25rem 1.5rem;margin-bottom:var(--gm-gap)}.gm-title{margin:0 0 .5rem;font-size:1.6rem;line-height:1.2}.gm-intro{margin:0 0 1rem;white-space:pre-line}.gm-muted{color:var(--dm-text-muted, #6b7280);margin:.25rem 0}.gm-facts{display:flex;flex-wrap:wrap;gap:.5rem 1.25rem;margin:0 0 1rem;padding:0;list-style:none;color:var(--dm-text-muted, #6b7280)}.gm-facts li:before{content:"\2022 "}.gm-facts li:first-child:before{content:""}.gm-start{display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end}.gm-field{display:flex;flex-direction:column;gap:.3rem;flex:1 1 220px}.gm-field label{font-weight:600}.gm-input{font:inherit;padding:.6rem .75rem;border:1px solid var(--gm-line);border-radius:8px;background:var(--dm-input-bg, var(--dm-background, #fff));color:inherit;width:100%;box-sizing:border-box}.gm-input:focus{outline:2px solid var(--gm-accent);outline-offset:1px}.gm-hp{position:absolute!important;left:-9999px!important;width:1px;height:1px;overflow:hidden}.gm-error{color:var(--gm-bad);margin:.5rem 0 0;font-weight:600}.gm-note{margin:.5rem 0 0}.gm-bar{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:1rem}.gm-progress{font-weight:600}.gm-clock{font-variant-numeric:tabular-nums;font-weight:700;padding:.2rem .65rem;border-radius:999px;background:var(--gm-soft);min-width:4.5rem;text-align:center}.gm-clock.is-low{background:var(--gm-bad);color:#fff}.gm-track{height:6px;border-radius:999px;background:var(--gm-soft);overflow:hidden;margin-bottom:1rem}.gm-track>span{display:block;height:100%;background:var(--gm-accent);transition:width .2s linear}.gm-actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:1rem}.gm-result-score{font-size:2.4rem;font-weight:800;margin:.25rem 0}.gm-result-place{font-size:1.15rem;margin:0 0 .75rem}.gm-board{border:1px solid var(--gm-line, rgba(127, 127, 127, .3));border-radius:var(--gm-radius, 10px);padding:1rem 1.25rem;margin:0 0 1rem}.gm-board-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.5rem}.gm-board-title{margin:0;font-size:1.2rem}.gm-periods{display:flex;flex-wrap:wrap;gap:.25rem}.gm-period{font:inherit;font-size:.85rem;padding:.2rem .6rem;border-radius:999px;cursor:pointer;border:1px solid var(--gm-line, rgba(127, 127, 127, .3));background:transparent;color:inherit}.gm-period.is-on{background:var(--dm-primary, #3b82f6);border-color:var(--dm-primary, #3b82f6);color:#fff}.gm-board-list{list-style:none;margin:0;padding:0}.gm-board-row{display:grid;grid-template-columns:3.2rem 1fr auto auto;gap:.75rem;align-items:center;padding:.45rem .5rem;border-bottom:1px solid var(--gm-line, rgba(127, 127, 127, .2))}.gm-board-row:last-child{border-bottom:0}.gm-board-row:nth-child(1) .gm-rank{color:#c9a227}.gm-board-row:nth-child(2) .gm-rank{color:#9aa3ad}.gm-board-row:nth-child(3) .gm-rank{color:#b0703a}.gm-board-row.is-mine{background:color-mix(in srgb,var(--dm-primary, #3b82f6) 14%,transparent);border-radius:6px;font-weight:700}.gm-rank{font-weight:800}.gm-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gm-score,.gm-time{font-variant-numeric:tabular-nums;text-align:right}.gm-score{font-weight:700}@media(max-width:520px){.gm-card{padding:1rem}.gm-board-row{grid-template-columns:2.6rem 1fr auto auto;gap:.5rem}}
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Games - what the Quiz and Crossword pages share in the browser: talking to
3
+ * the server, the leaderboard, the countdown, the player's remembered name,
4
+ * the stylesheet and a celebration for a top place.
5
+ *
6
+ * Served from /plugins/_lib/public/games/games.js. Domma is on every public
7
+ * page, so the screens are domma-reactive (window.M): observables, one
8
+ * M.applyBindings per screen, and text from the server reaches the page only
9
+ * through bindings (textContent), never as HTML.
10
+ *
11
+ * @module _lib/public/games/games
12
+ */
13
+
14
+ import {fmtDuration, ordinal, PERIODS} from './format.js';
15
+
16
+ export {fmtDuration, ordinal, PERIODS};
17
+
18
+ const M = window.M;
19
+
20
+ /** JSON in and out (`token`: the play's key); a failure is an Error carrying the server's words, `status` and `code`. */
21
+ export async function request(url, {method = 'GET', body, token} = {}) {
22
+ const headers = {Accept: 'application/json'};
23
+ if (body) headers['Content-Type'] = 'application/json';
24
+ // A play's key travels in a header, so it stays out of addresses and server logs.
25
+ if (token) headers['X-Play-Token'] = token;
26
+ const res = await fetch(url, {
27
+ method,
28
+ headers,
29
+ body: body ? JSON.stringify(body) : undefined,
30
+ credentials: 'same-origin'
31
+ });
32
+ let data = null;
33
+ try { data = await res.json(); } catch { /* not JSON */ }
34
+ if (!res.ok) {
35
+ const err = new Error(data?.error || (res.status === 429 ? 'Too many tries just now. Please wait a minute.' : 'Something went wrong. Please try again.'));
36
+ err.status = res.status;
37
+ err.code = data?.code || '';
38
+ throw err;
39
+ }
40
+ return data;
41
+ }
42
+
43
+ /** Remembered conveniences (the name last played under, a play in progress). Storage may be blocked. */
44
+ export const memory = {
45
+ get(k) { try { return window.S ? S.get(k) : null; } catch { return null; } },
46
+ set(k, v) { try { window.S?.set(k, v); } catch { /* a convenience */ } },
47
+ remove(k) { try { window.S?.remove(k); } catch { /* a convenience */ } }
48
+ };
49
+
50
+ let stylesAdded = false;
51
+ /** The shared stylesheet, once per page. */
52
+ export function addStyles(extra = []) {
53
+ const want = stylesAdded ? extra : ['/plugins/_lib/public/games/games.css', ...extra];
54
+ stylesAdded = true;
55
+ for (const href of want) {
56
+ if (document.querySelector(`link[href="${href}"]`)) continue;
57
+ const link = document.createElement('link');
58
+ link.rel = 'stylesheet';
59
+ link.href = href;
60
+ document.head.appendChild(link);
61
+ }
62
+ }
63
+
64
+ /** The JSON config a shortcode wrote on its placeholder, or null. */
65
+ export function configOf(el, attr) {
66
+ try { return JSON.parse(el.getAttribute(attr) || 'null'); } catch { return null; }
67
+ }
68
+
69
+ /** A short-lived unique prefix for ids inside one mount. */
70
+ let mounts = 0;
71
+ export const uid = (prefix) => `${prefix}${++mounts}${Math.random().toString(36).slice(2, 6)}`;
72
+
73
+ /** Ticker tape for a top place - skipped when the visitor asked for less motion. */
74
+ export function celebrate() {
75
+ try {
76
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
77
+ const fx = window.Domma?.effects;
78
+ if (typeof fx?.tickerTape === 'function') fx.tickerTape(null, {burst: true, burstCount: 160});
79
+ } catch { /* a celebration never breaks the page */ }
80
+ }
81
+
82
+ /**
83
+ * A countdown to a deadline the SERVER set: the browser only shows it. `left`
84
+ * is an observable of milliseconds; `onEnd` runs once when it reaches 0.
85
+ */
86
+ export class Countdown {
87
+ constructor(onEnd = () => {}) {
88
+ this.left = M.observable(0);
89
+ this.onEnd = onEnd;
90
+ this.timer = null;
91
+ this.deadline = 0;
92
+ }
93
+
94
+ /** `ms` from now; 0 or less stops it (no limit). */
95
+ start(ms) {
96
+ this.stop();
97
+ if (!(ms > 0)) { this.left.value = 0; return; }
98
+ this.deadline = performance.now() + ms;
99
+ const tick = () => {
100
+ const left = Math.max(0, this.deadline - performance.now());
101
+ this.left.value = left;
102
+ if (left <= 0) { this.stop(); this.onEnd(); }
103
+ };
104
+ tick();
105
+ this.timer = setInterval(tick, 200);
106
+ }
107
+
108
+ stop() {
109
+ if (this.timer) clearInterval(this.timer);
110
+ this.timer = null;
111
+ }
112
+ }
113
+
114
+ /** A running clock from a start the server gave (milliseconds already gone). */
115
+ export class Stopwatch {
116
+ constructor() {
117
+ this.shown = M.observable('0:00');
118
+ this.timer = null;
119
+ }
120
+
121
+ start(elapsedMs = 0) {
122
+ this.stop();
123
+ const origin = performance.now() - Math.max(0, elapsedMs);
124
+ const tick = () => { this.shown.value = fmtDuration(performance.now() - origin); };
125
+ tick();
126
+ this.timer = setInterval(tick, 500);
127
+ }
128
+
129
+ stop() {
130
+ if (this.timer) clearInterval(this.timer);
131
+ this.timer = null;
132
+ }
133
+ }
134
+
135
+ const BOARD = `
136
+ <div class="gm-board-head">
137
+ <h3 class="gm-board-title" data-bind-text="title.value"></h3>
138
+ <div class="gm-periods" role="group" aria-label="Period" data-each="periods key=key">
139
+ <button type="button" class="gm-period" data-bind-class="$root.period.value === key && 'is-on'"
140
+ data-bind-aria-pressed="$root.period.value === key ? 'true' : 'false'" data-on-click="$root.pick($data)" data-bind-text="label"></button>
141
+ </div>
142
+ </div>
143
+ <p class="gm-muted" hidden data-bind-hidden="!loading.value">Loading…</p>
144
+ <p class="gm-muted" hidden data-bind-hidden="loading.value || rows.value.length">No scores yet. Be the first!</p>
145
+ <ol class="gm-board-list" hidden data-bind-hidden="!rows.value.length" data-each="rows.value key=id">
146
+ <li class="gm-board-row" data-bind-class="mine && 'is-mine'">
147
+ <span class="gm-rank" data-bind-text="place"></span>
148
+ <span class="gm-name" data-bind-text="name"></span>
149
+ <span class="gm-score" data-if="showScore" data-bind-text="scoreText"></span>
150
+ <span class="gm-time" data-bind-text="timeText"></span>
151
+ </li>
152
+ </ol>`;
153
+
154
+ /**
155
+ * A live leaderboard on `el`, fetched from `url` (?period=&limit=).
156
+ *
157
+ * @param {HTMLElement} el
158
+ * @param {{url: string, period?: string, limit?: number, showScore?: boolean, title?: string}} opts
159
+ * @returns {{refresh: (mineId?: string) => Promise<void>, dispose: () => void}}
160
+ */
161
+ export function mountLeaderboard(el, {url, period = 'all', limit = 10, showScore = true, title = 'Leaderboard'}) {
162
+ if (!M) return {refresh: async () => {}, dispose() {}};
163
+ addStyles();
164
+ el.classList.add('gm-board');
165
+ el.innerHTML = BOARD;
166
+ const state = {
167
+ title: M.observable(title),
168
+ period: M.observable(PERIODS.some(p => p.key === period) ? period : 'all'),
169
+ periods: PERIODS,
170
+ rows: M.observable([]),
171
+ loading: M.observable(true),
172
+ pick: (p) => { state.period.value = p.key; refresh(); }
173
+ };
174
+ let mine = '';
175
+ async function refresh(mineId) {
176
+ if (mineId) mine = mineId;
177
+ state.loading.value = !state.rows.peek().length;
178
+ try {
179
+ const res = await request(`${url}?period=${encodeURIComponent(state.period.peek())}&limit=${encodeURIComponent(limit)}`);
180
+ state.rows.value = (res.rows || []).map(r => ({
181
+ id: r.id, name: r.name, mine: r.id === mine, showScore,
182
+ place: ordinal(r.rank), scoreText: `${r.score}`, timeText: fmtDuration(r.timeMs)
183
+ }));
184
+ } catch {
185
+ state.rows.value = [];
186
+ } finally {
187
+ state.loading.value = false;
188
+ }
189
+ }
190
+ const handle = M.applyBindings(state, el);
191
+ refresh();
192
+ return {refresh, dispose: () => handle.dispose()};
193
+ }
194
+
195
+ /** Every `[data-<kind>-board]` placeholder on the page (the leaderboard shortcodes). */
196
+ export function mountBoards(attr, urlFor, {showScore = true} = {}) {
197
+ for (const el of document.querySelectorAll(`[${attr}]`)) {
198
+ if (el.dataset.gmMounted) continue;
199
+ el.dataset.gmMounted = '1';
200
+ const cfg = configOf(el, attr);
201
+ if (!cfg?.id) continue;
202
+ mountLeaderboard(el, {url: urlFor(cfg.id), period: cfg.period, limit: cfg.limit, showScore, title: cfg.title || 'Leaderboard'});
203
+ }
204
+ }
@@ -0,0 +1 @@
1
+ .cwa-words{display:flex;flex-direction:column;gap:.4rem}.cwa-word{display:grid;grid-template-columns:1.6rem minmax(7rem,12rem) 3.5rem minmax(0,1fr) auto;gap:.4rem;align-items:center}.cwa-n{color:var(--dm-text-muted);font-size:var(--dm-text-sm);text-align:right}.cwa-answer{text-transform:uppercase;font-family:var(--dm-font-mono, monospace);letter-spacing:.04em}.cwa-enum{color:var(--dm-text-muted);font-size:var(--dm-text-xs);white-space:nowrap}@media(max-width:700px){.cwa-word{grid-template-columns:1.4rem 1fr auto}.cwa-enum{display:none}.cwa-clue{grid-column:2 / 4}}.cwa-side{position:sticky;top:.5rem}.cwa-preview{overflow:auto;max-height:70vh}.cwa-grid{display:grid;gap:0;width:max-content;border:2px solid var(--dm-text)}.cwa-cell{position:relative;width:1.7rem;height:1.7rem;display:grid;place-items:center;font-weight:700;font-size:.85rem;background:var(--dm-background, #fff);color:var(--dm-text);border:1px solid color-mix(in srgb,var(--dm-text) 35%,transparent);box-sizing:border-box}.cwa-cell.is-block{background:var(--dm-text);border-color:var(--dm-text)}.cwa-num{position:absolute;top:1px;left:2px;font-size:.5rem;font-weight:600;line-height:1;color:var(--dm-text-muted)}.cwa-unplaced{margin:0;padding:.5rem .75rem;border-radius:6px;font-size:var(--dm-text-sm);background:color-mix(in srgb,var(--dm-warning) 15%,transparent);color:color-mix(in srgb,var(--dm-warning) 55%,var(--dm-text))}
@@ -0,0 +1 @@
1
+ <div class="gma cwa" data-crossword-admin></div>