domma-cms 1.8.46 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/admin/js/templates/docs/usage-users-roles.html +4 -0
- package/bin/lib/left-free-tier.js +83 -0
- package/bin/update.js +24 -0
- package/docs/DommaFeatures.md +47 -7
- package/docs/plugin-development.md +7 -0
- package/package.json +1 -1
- package/plugins/_lib/games/names.js +6 -0
- package/plugins/_lib/public/games/motion.css +1 -0
- package/plugins/_lib/public/games/motion.js +28 -0
- package/plugins/free-tier.lock.json +1 -43
- package/server/routes/api/api-endpoints.js +5 -1
- package/server/routes/api/collections.js +4 -3
- package/server/routes/api/forms.js +22 -0
- package/server/routes/api/views.js +18 -0
- package/server/services/collectionAccess.js +97 -15
- package/server/services/permissionRegistry.js +39 -11
- package/plugins/crossword/CLAUDE.md +0 -37
- package/plugins/crossword/admin/css/index.css +0 -18
- package/plugins/crossword/admin/templates/crossword.html +0 -1
- package/plugins/crossword/admin/views/crossword.js +0 -449
- package/plugins/crossword/collections/crossword-plays.json +0 -20
- package/plugins/crossword/collections/crossword-puzzles.json +0 -17
- package/plugins/crossword/config.js +0 -6
- package/plugins/crossword/docs/guide.md +0 -53
- package/plugins/crossword/inject/body.html +0 -1
- package/plugins/crossword/lib/crossword.js +0 -170
- package/plugins/crossword/lib/store.js +0 -56
- package/plugins/crossword/plugin.js +0 -274
- package/plugins/crossword/plugin.json +0 -79
- package/plugins/crossword/public/crossword.css +0 -36
- package/plugins/crossword/public/crossword.js +0 -519
- package/plugins/crossword/public/lib/layout.js +0 -199
- package/plugins/quiz/CLAUDE.md +0 -43
- package/plugins/quiz/admin/css/index.css +0 -13
- package/plugins/quiz/admin/templates/quiz.html +0 -1
- package/plugins/quiz/admin/views/quiz.js +0 -530
- package/plugins/quiz/collections/quiz-plays.json +0 -20
- package/plugins/quiz/collections/quiz-quizzes.json +0 -17
- package/plugins/quiz/config.js +0 -6
- package/plugins/quiz/docs/guide.md +0 -61
- package/plugins/quiz/inject/body.html +0 -1
- package/plugins/quiz/lib/engine.js +0 -115
- package/plugins/quiz/lib/quiz.js +0 -225
- package/plugins/quiz/lib/store.js +0 -56
- package/plugins/quiz/plugin.js +0 -275
- package/plugins/quiz/plugin.json +0 -79
- package/plugins/quiz/public/quiz.css +0 -36
- package/plugins/quiz/public/quiz.js +0 -372
|
@@ -17,6 +17,19 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import {canReadView, createView, deleteView, executeView, executeViewConfig, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
|
|
19
19
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
20
|
+
import {pluginDenialFor} from '../../services/collectionAccess.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sends 403 and answers true when a pipeline reads (as its source or through a
|
|
24
|
+
* $lookup) a plugin's collection the caller may not read through the
|
|
25
|
+
* Collections API (collectionAccess.js).
|
|
26
|
+
*/
|
|
27
|
+
async function refusedPluginSources(request, reply, pipeline) {
|
|
28
|
+
const denial = await pluginDenialFor([pipeline?.source, ...lookupSources(pipeline?.stages || [])], request.user);
|
|
29
|
+
if (!denial) return false;
|
|
30
|
+
reply.status(403).send({error: denial});
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
20
33
|
|
|
21
34
|
/**
|
|
22
35
|
* Every collection a pipeline's $lookup stages read, as slugs (`cms_` stripped - Mongo-authored views
|
|
@@ -78,6 +91,7 @@ export async function viewsRoutes(fastify) {
|
|
|
78
91
|
});
|
|
79
92
|
|
|
80
93
|
fastify.post('/views', canCreate, async (request, reply) => {
|
|
94
|
+
if (await refusedPluginSources(request, reply, request.body?.pipeline)) return;
|
|
81
95
|
try {
|
|
82
96
|
const view = await createView(request.body || {}, request.user?.id || null);
|
|
83
97
|
return reply.status(201).send(view);
|
|
@@ -119,6 +133,7 @@ export async function viewsRoutes(fastify) {
|
|
|
119
133
|
});
|
|
120
134
|
|
|
121
135
|
fastify.put('/views/:slug', canUpdate, async (request, reply) => {
|
|
136
|
+
if (await refusedPluginSources(request, reply, request.body?.pipeline)) return;
|
|
122
137
|
try {
|
|
123
138
|
return await updateView(request.params.slug, request.body || {});
|
|
124
139
|
} catch (err) {
|
|
@@ -143,6 +158,8 @@ export async function viewsRoutes(fastify) {
|
|
|
143
158
|
|
|
144
159
|
fastify.get('/views/:slug/execute', canRead, async (request, reply) => {
|
|
145
160
|
const { page, limit } = request.query;
|
|
161
|
+
const view = await getView(request.params.slug).catch(() => null);
|
|
162
|
+
if (view && await refusedPluginSources(request, reply, view.pipeline)) return;
|
|
146
163
|
try {
|
|
147
164
|
return await executeView(request.params.slug, {
|
|
148
165
|
page: parseInt(page, 10) || 1,
|
|
@@ -178,6 +195,7 @@ export async function viewsRoutes(fastify) {
|
|
|
178
195
|
const joined = await getCollection(from).catch(() => null);
|
|
179
196
|
if (joined && !canSeeArtefact(request.user, joined)) return reply.status(403).send({error: 'Forbidden'});
|
|
180
197
|
}
|
|
198
|
+
if (await refusedPluginSources(request, reply, {source, stages})) return;
|
|
181
199
|
const n = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 50);
|
|
182
200
|
try {
|
|
183
201
|
return await executeViewConfig({connection, pipeline: {source, stages}, access: {rowLevel: null}},
|
|
@@ -13,15 +13,29 @@
|
|
|
13
13
|
*
|
|
14
14
|
* - A collection a plugin created (`schema.plugin`) belongs to that plugin.
|
|
15
15
|
* When the plugin registers permission resources (plugin.json
|
|
16
|
-
* `permissions`, or `resources` in its role files) the caller must
|
|
17
|
-
* of
|
|
16
|
+
* `permissions`, or `resources` in its role files) the caller must have
|
|
17
|
+
* full use of one of them: the action the resource names as its
|
|
18
|
+
* `collectionAction` (a game's `manage`), or else EVERY action it has. A
|
|
19
|
+
* read-only holder is kept out - the generic API shows whole rows, and a
|
|
20
|
+
* plugin's rows can hold what its own screens never show a reader (a
|
|
21
|
+
* quiz's answers, a puzzle's solution, a player's play token). A plugin
|
|
22
|
+
* that registers no resource falls back to `collections` alone. A plugin
|
|
23
|
+
* that is switched off (or failed to load) has nothing in the registry, so
|
|
24
|
+
* the permissions its plugin.json and role files declare still guard its
|
|
25
|
+
* collections - switching a game off must not open its answers.
|
|
26
|
+
*
|
|
27
|
+
* The same check guards every other way into a plugin's rows: a form whose
|
|
28
|
+
* submissions collection is the plugin's, a view reading or joining it, and
|
|
29
|
+
* an API Builder endpoint exposing it.
|
|
18
30
|
*
|
|
19
31
|
* A level-0 user keeps everything - the same bypass rowAccess.js gives them.
|
|
20
32
|
*/
|
|
21
33
|
import {USER_DATA_PRESET_SLUGS} from './presetCollections.js';
|
|
22
34
|
import {getPermissionsFor} from './roles.js';
|
|
23
35
|
import {getEffectiveLevel, getEffectiveRoles} from './userRoles.js';
|
|
24
|
-
import
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import {getEffectiveRegistry, normalisePluginResource} from './permissionRegistry.js';
|
|
25
39
|
|
|
26
40
|
/** Where each user-data preset keeps its owner - the field its Tool routes filter on. */
|
|
27
41
|
const OWNER_FIELD = Object.freeze({
|
|
@@ -81,35 +95,103 @@ export function holds(user, resource, action) {
|
|
|
81
95
|
return getEffectiveRoles(user).some(r => allowed.includes(r));
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
/** Plugin names as directories - anything else is not read from disk. */
|
|
99
|
+
const PLUGIN_NAME = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
100
|
+
|
|
84
101
|
/**
|
|
85
|
-
* The permission resources a plugin
|
|
86
|
-
* `permissions`
|
|
102
|
+
* The permission resources a plugin declares on disk (plugin.json
|
|
103
|
+
* `permissions` and `resources` in its role files), shaped like registry
|
|
104
|
+
* entries. Used when the registry holds none for it - the plugin is switched
|
|
105
|
+
* off or failed to load - so its collections keep their gate.
|
|
87
106
|
*
|
|
88
107
|
* @param {string} plugin
|
|
89
|
-
* @param {
|
|
90
|
-
* @returns {string
|
|
108
|
+
* @param {string} [pluginsDir]
|
|
109
|
+
* @returns {Array<{key: string, plugin: string, actions: Array<{key: string}>, collectionAction?: string}>}
|
|
91
110
|
*/
|
|
92
|
-
export function
|
|
93
|
-
|
|
111
|
+
export function declaredPluginResources(plugin, pluginsDir = path.resolve('plugins')) {
|
|
112
|
+
if (typeof plugin !== 'string' || !PLUGIN_NAME.test(plugin)) return [];
|
|
113
|
+
const dir = path.join(pluginsDir, plugin);
|
|
114
|
+
const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
115
|
+
const declared = [];
|
|
116
|
+
const manifest = readJson(path.join(dir, 'plugin.json'));
|
|
117
|
+
if (Array.isArray(manifest?.permissions)) declared.push(...manifest.permissions);
|
|
118
|
+
let roleFiles = [];
|
|
119
|
+
try { roleFiles = fs.readdirSync(path.join(dir, 'roles')).filter(f => f.endsWith('.json')); } catch { /* none */ }
|
|
120
|
+
for (const file of roleFiles) {
|
|
121
|
+
const def = readJson(path.join(dir, 'roles', file));
|
|
122
|
+
if (Array.isArray(def?.resources)) declared.push(...def.resources);
|
|
123
|
+
}
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
return declared.filter(r => typeof r?.key === 'string' && r.key && !seen.has(r.key) && seen.add(r.key))
|
|
126
|
+
.map(r => {
|
|
127
|
+
const {actions, collectionAction} = normalisePluginResource(r);
|
|
128
|
+
return {key: r.key, plugin, actions, ...(collectionAction && {collectionAction})};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The permissions that admit a user to a plugin's collections through the
|
|
134
|
+
* generic API, one list per resource the plugin registered (or, switched off,
|
|
135
|
+
* declares on disk): `[resource.action]` when the resource declares a
|
|
136
|
+
* `collectionAction`, otherwise every action it has. Holding every entry of
|
|
137
|
+
* any one list admits. A resource with no actions asks for one nobody but
|
|
138
|
+
* root holds, rather than admitting any action.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} plugin
|
|
141
|
+
* @param {Array<{key: string, plugin?: string, actions?: Array<{key: string}>, collectionAction?: string}>} [registry] - injectable for tests
|
|
142
|
+
* @param {(plugin: string) => Array<object>} [onDisk] - injectable for tests
|
|
143
|
+
* @returns {Array<Array<[string, string]>>} [resource, action] pairs
|
|
144
|
+
*/
|
|
145
|
+
export function pluginCollectionGates(plugin, registry = getEffectiveRegistry(), onDisk = declaredPluginResources) {
|
|
146
|
+
let own = registry.filter(r => r.plugin === plugin);
|
|
147
|
+
if (!own.length) own = onDisk(plugin);
|
|
148
|
+
return own.map(r => {
|
|
149
|
+
if (r.collectionAction) return [[r.key, r.collectionAction]];
|
|
150
|
+
const actions = (r.actions || []).map(a => a?.key).filter(Boolean);
|
|
151
|
+
return actions.length ? actions.map(action => [r.key, action]) : [[r.key, '(root only)']];
|
|
152
|
+
});
|
|
94
153
|
}
|
|
95
154
|
|
|
96
155
|
/**
|
|
97
156
|
* Why `user` may not touch a plugin's collection through the generic API, or
|
|
98
157
|
* null when they may: the collection is nobody's, the plugin registered no
|
|
99
|
-
* permission, the user
|
|
158
|
+
* permission, the user has full use of one of them (see pluginCollectionGates),
|
|
159
|
+
* or the user is root.
|
|
100
160
|
*
|
|
101
161
|
* @param {object} schema
|
|
102
162
|
* @param {object|null} user
|
|
103
163
|
* @param {Array<{key: string, plugin?: string}>} [registry] - injectable for tests
|
|
164
|
+
* @param {(plugin: string) => Array<object>} [onDisk] - injectable for tests
|
|
104
165
|
* @returns {string|null}
|
|
105
166
|
*/
|
|
106
|
-
export function pluginDenial(schema, user, registry) {
|
|
167
|
+
export function pluginDenial(schema, user, registry, onDisk) {
|
|
107
168
|
const plugin = schema?.plugin;
|
|
108
169
|
if (!plugin || isRoot(user)) return null;
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
111
|
-
if (
|
|
112
|
-
|
|
170
|
+
const gates = pluginCollectionGates(plugin, registry, onDisk);
|
|
171
|
+
if (!gates.length) return null;
|
|
172
|
+
if (gates.some(gate => gate.every(([resource, action]) => holds(user, resource, action)))) return null;
|
|
173
|
+
const needs = gates.map(gate => gate.map(([r, a]) => `${r}.${a}`).join(' + ')).join(' or ');
|
|
174
|
+
return `This collection belongs to the ${plugin} plugin - it needs ${needs}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* pluginDenial for collections named by slug - a form's submissions
|
|
179
|
+
* collection, a view's source and joins, an endpoint's collection. A slug
|
|
180
|
+
* with no collection is nobody's.
|
|
181
|
+
*
|
|
182
|
+
* @param {Iterable<string>} slugs
|
|
183
|
+
* @param {object|null} user
|
|
184
|
+
* @returns {Promise<string|null>} the first denial, or null
|
|
185
|
+
*/
|
|
186
|
+
export async function pluginDenialFor(slugs, user) {
|
|
187
|
+
if (isRoot(user)) return null;
|
|
188
|
+
const {getCollection} = await import('./collections.js');
|
|
189
|
+
for (const slug of new Set(slugs)) {
|
|
190
|
+
if (typeof slug !== 'string' || !slug) continue;
|
|
191
|
+
const denial = pluginDenial(await getCollection(slug).catch(() => null), user);
|
|
192
|
+
if (denial) return denial;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
113
195
|
}
|
|
114
196
|
|
|
115
197
|
/**
|
|
@@ -353,7 +353,7 @@ export const REGISTRY = [
|
|
|
353
353
|
* Plugin-contributed resources registered at runtime via registerPluginResource().
|
|
354
354
|
* Cleared on teardown via unregisterPluginResourcesByPlugin().
|
|
355
355
|
*
|
|
356
|
-
* @type {Array<{key:string,label:string,description:string,icon:string,group:string,plugin:string,actions:{key:string,label:string,description:string}[]}>}
|
|
356
|
+
* @type {Array<{key:string,label:string,description:string,icon:string,group:string,plugin:string,actions:{key:string,label:string,description:string}[],collectionAction?:string}>}
|
|
357
357
|
*/
|
|
358
358
|
const _pluginContributed = [];
|
|
359
359
|
|
|
@@ -361,13 +361,23 @@ const _pluginContributed = [];
|
|
|
361
361
|
* Register a resource contributed by a plugin.
|
|
362
362
|
* Idempotent - if the key already exists in the effective registry it is skipped.
|
|
363
363
|
*
|
|
364
|
-
* @param {{key:string,label:string,description?:string,icon?:string,group?:string,actions?:{key:string,label:string,description?:string}[]}} resource
|
|
364
|
+
* @param {{key:string,label:string,description?:string,icon?:string,group?:string,actions?:{key:string,label:string,description?:string}[],collectionAction?:string}} resource
|
|
365
365
|
* @param {string} plugin - Plugin name that owns this resource
|
|
366
366
|
*/
|
|
367
367
|
export function registerPluginResource(resource, plugin) {
|
|
368
368
|
if (!resource || typeof resource.key !== 'string' || !resource.key) return;
|
|
369
|
-
const
|
|
370
|
-
|
|
369
|
+
const {actions, collectionAction} = normalisePluginResource(resource);
|
|
370
|
+
const existing = getEffectiveRegistry().find(r => r.key === resource.key);
|
|
371
|
+
if (existing) {
|
|
372
|
+
// The same plugin registering the key again (a role file or
|
|
373
|
+
// hooks.registerRole() before the manifest) must not lose the
|
|
374
|
+
// manifest's collectionAction - it is a security setting.
|
|
375
|
+
if (existing.plugin === plugin && collectionAction && !existing.collectionAction
|
|
376
|
+
&& existing.actions.some(a => a.key === collectionAction)) {
|
|
377
|
+
existing.collectionAction = collectionAction;
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
371
381
|
|
|
372
382
|
_pluginContributed.push({
|
|
373
383
|
key: resource.key,
|
|
@@ -376,16 +386,34 @@ export function registerPluginResource(resource, plugin) {
|
|
|
376
386
|
icon: resource.icon || 'box',
|
|
377
387
|
group: resource.group || 'Plugins',
|
|
378
388
|
plugin,
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
actions: Array.isArray(resource.actions) && resource.actions.length
|
|
382
|
-
? resource.actions.map(a => typeof a === 'string'
|
|
383
|
-
? {key: a, label: a, description: a}
|
|
384
|
-
: {key: a.key, label: a.label || a.key, description: a.description || a.label || a.key})
|
|
385
|
-
: ACTIONS.map(key => ({key, label: key, description: key}))
|
|
389
|
+
actions,
|
|
390
|
+
...(collectionAction && {collectionAction})
|
|
386
391
|
});
|
|
387
392
|
}
|
|
388
393
|
|
|
394
|
+
/**
|
|
395
|
+
* A plugin resource's actions and `collectionAction`, as they are registered.
|
|
396
|
+
* Actions may be objects or bare keys (`["read", "create"]`) - the short form
|
|
397
|
+
* is what plugin role files have always shipped; an action without a key is
|
|
398
|
+
* dropped, and none left means the default CRUD set. `collectionAction` is the
|
|
399
|
+
* action the generic Collections API asks for on the plugin's collections
|
|
400
|
+
* (collectionAccess.js), kept only when it names one of those actions.
|
|
401
|
+
*
|
|
402
|
+
* @param {{actions?: Array<string|{key: string, label?: string, description?: string}>, collectionAction?: string}} resource
|
|
403
|
+
* @returns {{actions: {key: string, label: string, description: string}[], collectionAction: string|null}}
|
|
404
|
+
*/
|
|
405
|
+
export function normalisePluginResource(resource) {
|
|
406
|
+
const listed = (Array.isArray(resource?.actions) ? resource.actions : [])
|
|
407
|
+
.map(a => typeof a === 'string'
|
|
408
|
+
? {key: a, label: a, description: a}
|
|
409
|
+
: {key: a?.key, label: a?.label || a?.key, description: a?.description || a?.label || a?.key})
|
|
410
|
+
.filter(a => typeof a.key === 'string' && a.key);
|
|
411
|
+
const actions = listed.length ? listed : ACTIONS.map(key => ({key, label: key, description: key}));
|
|
412
|
+
const collectionAction = typeof resource?.collectionAction === 'string'
|
|
413
|
+
&& actions.some(a => a.key === resource.collectionAction) ? resource.collectionAction : null;
|
|
414
|
+
return {actions, collectionAction};
|
|
415
|
+
}
|
|
416
|
+
|
|
389
417
|
/**
|
|
390
418
|
* Remove all resources registered by a given plugin.
|
|
391
419
|
*
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# crossword - AI Assistant Guide
|
|
2
|
-
|
|
3
|
-
## Purpose
|
|
4
|
-
|
|
5
|
-
Crosswords on the public site, built from a word + clue list, timed by the server, with a fastest-solver
|
|
6
|
-
leaderboard. 1.1.0 (2026-10-21), `tier: free`, disabled on arrival. Sibling of `quiz`; shares `plugins/_lib/games`.
|
|
7
|
-
|
|
8
|
-
## Files
|
|
9
|
-
|
|
10
|
-
- `public/lib/layout.js` - PURE, isomorphic, SERVED: `generate(words, {seed})` (greedy, deterministic from the
|
|
11
|
-
seed, clues never affect the layout), `cleanAnswer` (letters + enumeration), `solutionGrid`, `shapeOf`. The admin
|
|
12
|
-
builder imports it over HTTP for the live preview; the server runs the same function on save, so a layout is
|
|
13
|
-
never taken from the browser. It holds no puzzle, which is why it may live under `public/`.
|
|
14
|
-
- `lib/crossword.js` - PURE: `cleanPuzzle`, `isOpen`, `publicPuzzle` (shape + clues, no letters), `readCells`,
|
|
15
|
-
`compare`, `catchUp`, `finish`, `view`.
|
|
16
|
-
- `lib/store.js` - `crossword-puzzles` + `plays = new PlayStore('crossword-plays', {order: 'time'})`.
|
|
17
|
-
- `plugin.js` - routes (header comment), shortcodes, the `fastest` notification source.
|
|
18
|
-
- `public/crossword.js` - the player: the screens are domma-reactive; the grid is a `Board` class over real
|
|
19
|
-
`<input>`s (keyboard rules in its header - all focus and keystrokes, which bindings do not reach), letters
|
|
20
|
-
remembered per play in storage. Exports `mount(el, {test})`, which the admin's Test screen uses.
|
|
21
|
-
- `admin/views/crossword.js` - list, editor with live grid, test and plays screens, all domma-reactive through the
|
|
22
|
-
admin kit (`_lib/admin/games/kit.js`). The preview grid is a data-each over cells.
|
|
23
|
-
|
|
24
|
-
## Contracts
|
|
25
|
-
|
|
26
|
-
- **No letters before solving.** `/public/crossword/:slug` gives only the size; the clues come with the play (so
|
|
27
|
-
nobody studies them off the clock); `solution` is in `/result` only when `status === 'finished'`. The tests
|
|
28
|
-
assert no answer string appears in the play payload - keep that test.
|
|
29
|
-
- **Time = server elapsed + `state.penaltyMs`.** Check adds `checkPenalty` seconds (403 when `checks` is off); a
|
|
30
|
-
FULL but wrong submit adds `wrongPenalty`; a grid with empties only reports the count.
|
|
31
|
-
- Cells travel as `height` strings of `width` chars, `A-Z` or space; anything else is 400.
|
|
32
|
-
- The grid uses fixed ink/paper colours (`--cw-ink`, `--cw-paper`) whatever the theme - a themed grid inverted
|
|
33
|
-
under dark themes.
|
|
34
|
-
- Same shortcode prefix trap as quiz: `crossword-leaderboard` is registered with `{priority: 5}`.
|
|
35
|
-
- **Test plays** (1.1.0): `GET/POST /puzzles/:id/test` (manage), as in quiz's CLAUDE.md - the saved crossword
|
|
36
|
-
whatever its status, never on a board, not in the plays list, no notification, cleared by the next test.
|
|
37
|
-
`/result` answers `test: true` and `wouldPlace`.
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/* Crossword admin - the word list and the live grid. Shared pieces are in /plugins/_lib/admin/games/games.css. */
|
|
2
|
-
.cwa-words { display: flex; flex-direction: column; gap: .4rem; }
|
|
3
|
-
.cwa-word { display: grid; grid-template-columns: 1.6rem minmax(7rem, 12rem) 3.5rem minmax(0, 1fr) auto; gap: .4rem; align-items: center; }
|
|
4
|
-
.cwa-n { color: var(--dm-text-muted); font-size: var(--dm-text-sm); text-align: right; }
|
|
5
|
-
.cwa-answer { text-transform: uppercase; font-family: var(--dm-font-mono, monospace); letter-spacing: .04em; }
|
|
6
|
-
.cwa-enum { color: var(--dm-text-muted); font-size: var(--dm-text-xs); white-space: nowrap; }
|
|
7
|
-
@media (max-width: 700px) { .cwa-word { grid-template-columns: 1.4rem 1fr auto; } .cwa-enum { display: none; } .cwa-clue { grid-column: 2 / 4; } }
|
|
8
|
-
|
|
9
|
-
.cwa-side { position: sticky; top: .5rem; }
|
|
10
|
-
.cwa-preview { overflow: auto; max-height: 70vh; }
|
|
11
|
-
/* Fixed ink and paper, as on the public grid: a themed grid came out inverted under dark themes. */
|
|
12
|
-
.cwa-grid { --cwa-ink: #111827; --cwa-paper: #ffffff; display: grid; gap: 0; width: max-content; border: 2px solid var(--cwa-ink); background: var(--cwa-ink); }
|
|
13
|
-
.cwa-cell { position: relative; width: 1.7rem; height: 1.7rem; display: grid; place-items: center; font-weight: 700; font-size: .85rem;
|
|
14
|
-
background: var(--cwa-paper); color: var(--cwa-ink); border: 1px solid color-mix(in srgb, var(--cwa-ink) 35%, transparent); box-sizing: border-box; }
|
|
15
|
-
.cwa-cell.is-block { background: var(--cwa-ink); border-color: var(--cwa-ink); }
|
|
16
|
-
.cwa-num { position: absolute; top: 1px; left: 2px; font-size: .5rem; font-weight: 600; line-height: 1; color: #4b5563; }
|
|
17
|
-
.cwa-unplaced { margin: 0; padding: .5rem .75rem; border-radius: 6px; font-size: var(--dm-text-sm);
|
|
18
|
-
background: color-mix(in srgb, var(--dm-warning) 15%, transparent); color: color-mix(in srgb, var(--dm-warning) 55%, var(--dm-text)); }
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<div class="gma cwa" data-crossword-admin></div>
|