domma-cms 1.6.59 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +4344 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +2 -2
  7. package/admin/js/lib/draft-owner.js +1 -0
  8. package/admin/js/lib/effect-defs.js +1 -1
  9. package/admin/js/lib/effects-builder.js +2 -2
  10. package/admin/js/lib/page-editor-reference.js +5 -5
  11. package/admin/js/lib/shortcode-tags.js +1 -1
  12. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  13. package/admin/js/views/user-editor.js +1 -1
  14. package/docs/DommaFeatures.md +3005 -0
  15. package/docs/MultiTenant.md +176 -0
  16. package/docs/api-reference.md +1667 -0
  17. package/docs/cache.md +96 -0
  18. package/docs/configuration.md +1811 -0
  19. package/docs/editions-and-licences.md +190 -0
  20. package/docs/getting-started.md +201 -0
  21. package/docs/guides/form-triggers.md +260 -0
  22. package/docs/guides/mongodb-storage-adapter.md +282 -0
  23. package/docs/guides/multi-page-forms.md +121 -0
  24. package/docs/images/scribe-composer.png +0 -0
  25. package/docs/markdown-shortcodes.md +1738 -0
  26. package/docs/pentest-report-2026-04-03.md +181 -0
  27. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  28. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  29. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  30. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  31. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  32. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  34. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  35. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  37. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  38. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  39. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  40. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  42. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  43. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  44. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  45. package/docs/plugin-development.md +827 -0
  46. package/docs/recruitment-recipe.md +501 -0
  47. package/docs/scaffolding.md +312 -0
  48. package/docs/screenshots/calculator-floating.png +0 -0
  49. package/docs/screenshots/calculator-standard.png +0 -0
  50. package/docs/screenshots/email-settings.png +0 -0
  51. package/docs/screenshots/messages-count.png +0 -0
  52. package/docs/structure/user-types.md +107 -0
  53. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  54. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  55. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  56. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  58. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  59. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  60. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  61. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  62. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  63. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  64. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  65. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  66. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  67. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  68. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  69. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  70. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  71. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  72. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  73. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  74. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  75. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  76. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  77. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  78. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  79. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  80. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  81. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  82. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  83. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  84. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  85. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  86. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  87. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  89. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  90. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  91. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  92. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  93. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  95. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  96. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  97. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  98. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  99. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  100. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  101. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  102. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  103. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  106. package/docs/theming.md +320 -0
  107. package/package.json +2 -2
  108. package/plugins/_lib/admin/mail/keyboard.js +49 -0
  109. package/plugins/_lib/admin/mail/reader-view.js +4 -2
  110. package/public/js/celebrations/core/canvas.js +1 -1
  111. package/public/js/celebrations/index.js +1 -1
  112. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  113. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  114. package/public/js/celebrations/message-attrs.mjs +1 -0
  115. package/public/js/celebrations/themes/anniversary.js +1 -0
  116. package/public/js/celebrations/themes/autumn.js +1 -0
  117. package/public/js/celebrations/themes/birthday.js +1 -0
  118. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  119. package/public/js/celebrations/themes/calendar.js +1 -0
  120. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  121. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  122. package/public/js/celebrations/themes/christmas.js +1 -1
  123. package/public/js/celebrations/themes/easter.js +1 -0
  124. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  125. package/public/js/celebrations/themes/garden.js +1 -0
  126. package/public/js/celebrations/themes/graduation.js +1 -0
  127. package/public/js/celebrations/themes/greeting.js +1 -0
  128. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  129. package/public/js/celebrations/themes/money-rain.js +1 -0
  130. package/public/js/celebrations/themes/new-year.js +1 -0
  131. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  132. package/public/js/celebrations/themes/occasions.js +1 -0
  133. package/public/js/celebrations/themes/rain-control.js +1 -0
  134. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  135. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  136. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  137. package/public/js/celebrations/themes/summer-garden.js +1 -0
  138. package/public/js/celebrations/themes/winter.js +1 -0
  139. package/public/js/celebrations/trait-data.mjs +1 -1
  140. package/public/js/effects.js +1 -1
  141. package/scripts/build.js +4 -0
  142. package/server/services/markdown.js +5 -2
  143. package/server/templates/page.html +1 -1
@@ -0,0 +1,1577 @@
1
+ # Collection Context Menu - Sort, Group, Export & Copy - Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Extend the public-site right-click collection menu from filter-only to filter + sort + group + export + copy + print, with export permissions set in the collection schema and edited from the admin.
6
+
7
+ **Architecture:** A new shared ESM module `public/js/collection-export.mjs` owns the CSV/JSON/Markdown dialect and is imported by both the browser menu and the server's `exportEntries()`, so the two cannot diverge. `collection-context.js` keeps ownership of menu and display state, gaining a drill-in panel stack, a sort comparator, and grouping. Export permission is resolved server-side into the `data-ctx` payload and gated client-side.
8
+
9
+ **Tech Stack:** Node 20+ ESM, Fastify 5, `node:test` + `assert/strict`, Domma (`$`/`T`/`E`), no build step for `public/js`.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md`
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ | File | Responsibility |
18
+ |---|---|
19
+ | `public/js/collection-export.mjs` | **New.** Value coercion, CSV dialect, JSON/Markdown serialisation, download + clipboard sinks. The only place that knows how a value becomes text. |
20
+ | `public/js/collection-context.js` | Menu, panel stack, filter/sort/group state, per-display-type application. Grows; does not own serialisation. |
21
+ | `public/js/site.js` | Passes table export config from payload to wrapper. Single-line change. |
22
+ | `public/css/site.css` | Group headings, print stylesheet. |
23
+ | `server/services/markdown.js` | `buildCtxAttr()` third parameter; export-permission resolution; threaded through 8 call sites. |
24
+ | `server/services/collections.js` | `exportEntries()` delegates to the shared dialect. |
25
+ | `server/templates/page.html` | `collection-context.js` becomes `type="module"`; cache-buster bumps. |
26
+ | `admin/js/templates/collection-editor.html` | Tab rename; export rows. |
27
+ | `admin/js/views/collection-editor.js` | Render/read/save the `export` block. |
28
+ | `tests/services/csv-dialect.test.js` | **New.** The dialect's unit tests. |
29
+ | `tests/services/collections-export.test.js` | **New.** `exportEntries()` behaviour incl. the `[object Object]` regression. |
30
+ | `tests/markdown/collection-ctx-export.test.js` | **New.** Export config resolution in `buildCtxAttr`. |
31
+
32
+ **Why `.mjs`:** root `package.json` is `"type": "module"`, but `public/js/package.json` pins `"type": "commonjs"`, so a `.js` file there is CommonJS to Node. `.mjs` is ESM unconditionally - importable by Node tests, by `server/services/collections.js`, and by a browser `type="module"` script. `public/js/menu-decor.mjs` is the existing precedent.
33
+
34
+ ---
35
+
36
+ ## Task 1: The shared CSV dialect
37
+
38
+ **Files:**
39
+ - Create: `public/js/collection-export.mjs`
40
+ - Test: `tests/services/csv-dialect.test.js`
41
+
42
+ - [ ] **Step 1: Write the failing test**
43
+
44
+ Create `tests/services/csv-dialect.test.js`:
45
+
46
+ ```js
47
+ /**
48
+ * Shared CSV dialect - the single source of truth for turning collection
49
+ * values into CSV text, used by both the browser export menu and the
50
+ * server's exportEntries(). Locks in coercion, formula-injection
51
+ * sanitising, quoting, and the Excel BOM.
52
+ */
53
+ import {test} from 'node:test';
54
+ import assert from 'node:assert/strict';
55
+ import {coerce, sanitiseCsv, csvCell, toCsv, CSV_BOM} from '../../public/js/collection-export.mjs';
56
+
57
+ test('coerce: nullish becomes empty string', () => {
58
+ assert.equal(coerce(null), '');
59
+ assert.equal(coerce(undefined), '');
60
+ });
61
+
62
+ test('coerce: arrays join with semicolons', () => {
63
+ assert.equal(coerce(['news', 'tech']), 'news; tech');
64
+ assert.equal(coerce([]), '');
65
+ });
66
+
67
+ test('coerce: objects stringify rather than [object Object]', () => {
68
+ assert.equal(coerce({title: 'Hi'}), '{"title":"Hi"}');
69
+ });
70
+
71
+ test('coerce: dates become ISO 8601', () => {
72
+ assert.equal(coerce(new Date('2026-08-19T10:00:00Z')), '2026-08-19T10:00:00.000Z');
73
+ });
74
+
75
+ test('sanitiseCsv: formula leaders are neutralised', () => {
76
+ assert.equal(sanitiseCsv('=HYPERLINK("http://x")'), "'=HYPERLINK(\"http://x\")");
77
+ assert.equal(sanitiseCsv('@SUM(A1)'), "'@SUM(A1)");
78
+ assert.equal(sanitiseCsv('+1 555 0100'), "'+1 555 0100");
79
+ });
80
+
81
+ test('sanitiseCsv: finite numbers are exempt so -5 survives', () => {
82
+ assert.equal(sanitiseCsv('-5'), '-5');
83
+ assert.equal(sanitiseCsv('-12.75'), '-12.75');
84
+ });
85
+
86
+ test('sanitiseCsv: ordinary text is untouched', () => {
87
+ assert.equal(sanitiseCsv('Hello'), 'Hello');
88
+ assert.equal(sanitiseCsv(''), '');
89
+ });
90
+
91
+ test('csvCell: quotes always, escapes embedded quotes', () => {
92
+ assert.equal(csvCell('plain'), '"plain"');
93
+ assert.equal(csvCell('say "hi"'), '"say ""hi"""');
94
+ assert.equal(csvCell('a,b'), '"a,b"');
95
+ assert.equal(csvCell('line1\nline2'), '"line1\nline2"');
96
+ });
97
+
98
+ test('csvCell: accented text passes through unchanged', () => {
99
+ assert.equal(csvCell('café'), '"café"');
100
+ });
101
+
102
+ test('toCsv: BOM, quoted header from labels, newline-separated rows', () => {
103
+ const columns = [{name: 'title', label: 'Title'}, {name: 'qty'}];
104
+ const rows = [{title: 'One', qty: 1}, {title: 'Two', qty: -2}];
105
+ const out = toCsv(rows, columns);
106
+ assert.ok(out.startsWith(CSV_BOM), 'output must start with the BOM');
107
+ assert.equal(out.slice(1), '"Title","qty"\n"One","1"\n"Two","-2"');
108
+ });
109
+
110
+ test('toCsv: missing values render as empty cells', () => {
111
+ const out = toCsv([{title: 'Only'}], [{name: 'title'}, {name: 'absent'}]);
112
+ assert.equal(out.slice(1), '"title","absent"\n"Only",""');
113
+ });
114
+ ```
115
+
116
+ - [ ] **Step 2: Run the test to verify it fails**
117
+
118
+ Run: `node --test tests/services/csv-dialect.test.js`
119
+ Expected: FAIL - `Cannot find module .../public/js/collection-export.mjs`
120
+
121
+ - [ ] **Step 3: Write the implementation**
122
+
123
+ Create `public/js/collection-export.mjs`:
124
+
125
+ ```js
126
+ /**
127
+ * Collection export - the shared serialisation dialect.
128
+ *
129
+ * Imported by BOTH the browser (public/js/collection-context.js, as a module
130
+ * script) and the server (server/services/collections.js). Keeping one copy is
131
+ * the whole point: the admin's export button and the public right-click export
132
+ * must produce byte-identical files for the same values.
133
+ *
134
+ * `.mjs` is deliberate - public/js/package.json pins "type": "commonjs", so a
135
+ * .js file here would be CommonJS to Node and unimportable from server ESM.
136
+ */
137
+
138
+ /** Excel needs a BOM to read UTF-8 correctly. */
139
+ export const CSV_BOM = '';
140
+
141
+ /**
142
+ * Turn any collection value into a string.
143
+ * Arrays join with '; ' (semicolons survive a CSV round-trip; commas do not).
144
+ * Objects stringify - String({}) would give the useless '[object Object]'.
145
+ */
146
+ export function coerce(v) {
147
+ if (v === null || v === undefined) return '';
148
+ if (Array.isArray(v)) return v.map(coerce).join('; ');
149
+ if (v instanceof Date) return v.toISOString();
150
+ if (typeof v === 'object') return JSON.stringify(v);
151
+ return String(v);
152
+ }
153
+
154
+ const FORMULA_LEAD = /^[=+\-@\t\r]/;
155
+
156
+ /**
157
+ * Neutralise spreadsheet formula injection. A cell beginning =, +, -, @, TAB
158
+ * or CR is executed as a formula by Excel and Sheets, and collection content
159
+ * is user-authored, so this is a live path.
160
+ *
161
+ * Finite numbers are exempt - without that exemption every negative number
162
+ * would export as '-5 rather than -5.
163
+ */
164
+ export function sanitiseCsv(s) {
165
+ if (!FORMULA_LEAD.test(s)) return s;
166
+ if (s !== '' && Number.isFinite(Number(s))) return s;
167
+ return "'" + s;
168
+ }
169
+
170
+ /** One CSV cell: coerced, sanitised, always quoted, inner quotes doubled. */
171
+ export function csvCell(v) {
172
+ return '"' + sanitiseCsv(coerce(v)).replace(/"/g, '""') + '"';
173
+ }
174
+
175
+ /**
176
+ * @param {object[]} rows - plain value objects
177
+ * @param {Array<{name: string, label?: string}>} columns
178
+ * @returns {string} CSV text, BOM-prefixed, LF-separated
179
+ */
180
+ export function toCsv(rows, columns) {
181
+ const header = columns.map(c => csvCell(c.label || c.name)).join(',');
182
+ const body = rows.map(r => columns.map(c => csvCell(r[c.name])).join(','));
183
+ return CSV_BOM + [header, ...body].join('\n');
184
+ }
185
+ ```
186
+
187
+ Line endings are LF, not RFC 4180's CRLF - this matches what `exportEntries()` already emits, and changing it would break operators' existing importers for no user-visible gain.
188
+
189
+ - [ ] **Step 4: Run the test to verify it passes**
190
+
191
+ Run: `node --test tests/services/csv-dialect.test.js`
192
+ Expected: PASS, 11 tests
193
+
194
+ - [ ] **Step 5: Commit**
195
+
196
+ ```bash
197
+ git add public/js/collection-export.mjs tests/services/csv-dialect.test.js
198
+ git commit -m "feat(export): shared CSV dialect with injection sanitising and BOM"
199
+ ```
200
+
201
+ ---
202
+
203
+ ## Task 2: JSON and Markdown serialisation
204
+
205
+ **Files:**
206
+ - Modify: `public/js/collection-export.mjs`
207
+ - Test: `tests/services/csv-dialect.test.js`
208
+
209
+ - [ ] **Step 1: Write the failing test**
210
+
211
+ Append to `tests/services/csv-dialect.test.js`:
212
+
213
+ ```js
214
+ import {serialise} from '../../public/js/collection-export.mjs';
215
+
216
+ const COLS = [{name: 'title', label: 'Title'}, {name: 'qty', label: 'Qty'}];
217
+ const ROWS = [{title: 'One', qty: 1}, {title: 'Two | pipe', qty: -2}];
218
+
219
+ test('serialise json: array of objects keyed by field name', () => {
220
+ assert.equal(serialise(ROWS, COLS, 'json'),
221
+ JSON.stringify([{title: 'One', qty: 1}, {title: 'Two | pipe', qty: -2}], null, 2));
222
+ });
223
+
224
+ test('serialise json: restricted to the given columns', () => {
225
+ const out = JSON.parse(serialise([{title: 'A', secret: 'x'}], COLS, 'json'));
226
+ assert.deepEqual(Object.keys(out[0]), ['title', 'qty']);
227
+ });
228
+
229
+ test('serialise markdown: pipe table with escaped pipes', () => {
230
+ assert.equal(serialise(ROWS, COLS, 'markdown'), [
231
+ '| Title | Qty |',
232
+ '| --- | --- |',
233
+ '| One | 1 |',
234
+ '| Two \\| pipe | -2 |'
235
+ ].join('\n'));
236
+ });
237
+
238
+ test('serialise csv: delegates to toCsv', () => {
239
+ assert.equal(serialise(ROWS, COLS, 'csv'), toCsv(ROWS, COLS));
240
+ });
241
+
242
+ test('serialise: unknown format throws rather than returning junk', () => {
243
+ assert.throws(() => serialise(ROWS, COLS, 'xml'), /Unknown export format/);
244
+ });
245
+ ```
246
+
247
+ - [ ] **Step 2: Run the test to verify it fails**
248
+
249
+ Run: `node --test tests/services/csv-dialect.test.js`
250
+ Expected: FAIL - `serialise` is not exported
251
+
252
+ - [ ] **Step 3: Write the implementation**
253
+
254
+ Append to `public/js/collection-export.mjs`:
255
+
256
+ ```js
257
+ /** Markdown table cells must not break the pipe grid. */
258
+ function mdCell(v) {
259
+ return coerce(v).replace(/\|/g, '\\|').replace(/\n/g, ' ');
260
+ }
261
+
262
+ /**
263
+ * Serialise rows to text. The column list doubles as the field allowlist -
264
+ * a field absent from `columns` cannot appear in the output.
265
+ *
266
+ * @param {object[]} rows
267
+ * @param {Array<{name: string, label?: string}>} columns
268
+ * @param {'csv'|'json'|'markdown'} format
269
+ * @returns {string}
270
+ */
271
+ export function serialise(rows, columns, format) {
272
+ if (format === 'csv') return toCsv(rows, columns);
273
+ if (format === 'json') {
274
+ return JSON.stringify(
275
+ // `?? null` is load-bearing: JSON.stringify DROPS undefined values,
276
+ // so a row missing a field would silently lose that key and the
277
+ // export would have ragged objects. null keeps every column present.
278
+ rows.map(r => Object.fromEntries(columns.map(c => [c.name, r[c.name] ?? null]))),
279
+ null, 2
280
+ );
281
+ }
282
+ if (format === 'markdown') {
283
+ return [
284
+ '| ' + columns.map(c => mdCell(c.label || c.name)).join(' | ') + ' |',
285
+ '| ' + columns.map(() => '---').join(' | ') + ' |',
286
+ ...rows.map(r => '| ' + columns.map(c => mdCell(r[c.name])).join(' | ') + ' |')
287
+ ].join('\n');
288
+ }
289
+ throw new Error(`Unknown export format: ${format}`);
290
+ }
291
+ ```
292
+
293
+ - [ ] **Step 4: Run the test to verify it passes**
294
+
295
+ Run: `node --test tests/services/csv-dialect.test.js`
296
+ Expected: PASS, 16 tests
297
+
298
+ All 16 were run against this exact implementation during planning, so a failure here means a
299
+ transcription slip rather than a design gap.
300
+
301
+ - [ ] **Step 5: Commit**
302
+
303
+ ```bash
304
+ git add public/js/collection-export.mjs tests/services/csv-dialect.test.js
305
+ git commit -m "feat(export): JSON and Markdown serialisation"
306
+ ```
307
+
308
+ ---
309
+
310
+ ## Task 3: Server `exportEntries()` adopts the dialect
311
+
312
+ Fixes the live `[object Object]` bug for object-valued fields such as `blog-posts.seo`.
313
+
314
+ **Files:**
315
+ - Modify: `server/services/collections.js:475-497`
316
+ - Test: `tests/services/collections-export.test.js`
317
+
318
+ - [ ] **Step 1: Write the failing test**
319
+
320
+ Create `tests/services/collections-export.test.js`:
321
+
322
+ ```js
323
+ /**
324
+ * exportEntries() - CSV/JSON export of a whole collection from the admin.
325
+ * Locks in the shared dialect, most importantly that object-valued fields
326
+ * no longer export as the literal string [object Object].
327
+ */
328
+ import {after, before, test} from 'node:test';
329
+ import assert from 'node:assert/strict';
330
+ import fs from 'fs/promises';
331
+ import path from 'path';
332
+ import {exportEntries} from '../../server/services/collections.js';
333
+ import {CSV_BOM} from '../../public/js/collection-export.mjs';
334
+
335
+ const SLUG = 'rt-export-audit';
336
+ const DIR = path.resolve('content/collections', SLUG);
337
+
338
+ before(async () => {
339
+ await fs.mkdir(DIR, {recursive: true});
340
+ await fs.writeFile(path.join(DIR, 'schema.json'), JSON.stringify({
341
+ slug: SLUG, name: 'Export Audit',
342
+ fields: [{name: 'title', type: 'text'}, {name: 'seo', type: 'json'}, {name: 'tags', type: 'text'}]
343
+ }));
344
+ await fs.writeFile(path.join(DIR, 'e1.json'), JSON.stringify({
345
+ id: 'e1',
346
+ data: {title: 'First', seo: {desc: 'A "quoted" desc'}, tags: ['a', 'b']},
347
+ meta: {createdAt: '2026-08-19T00:00:00.000Z'}
348
+ }));
349
+ await fs.writeFile(path.join(DIR, 'e2.json'), JSON.stringify({
350
+ id: 'e2',
351
+ data: {title: '=cmd|calc', seo: null, tags: []},
352
+ meta: {createdAt: '2026-08-19T00:00:01.000Z'}
353
+ }));
354
+ });
355
+
356
+ after(async () => { await fs.rm(DIR, {recursive: true, force: true}); });
357
+
358
+ test('csv: object fields stringify instead of [object Object]', async () => {
359
+ const csv = await exportEntries(SLUG, 'csv');
360
+ assert.ok(!csv.includes('[object Object]'), 'regression: object exported as [object Object]');
361
+ // The cell holds JSON, so its quotes are CSV-doubled. Assert the shape
362
+ // rather than the exact byte soup, which is unreadable in a literal.
363
+ assert.ok(csv.includes('""desc""'), 'nested object should appear as escaped JSON');
364
+ });
365
+
366
+ test('csv: arrays join with semicolons', async () => {
367
+ const csv = await exportEntries(SLUG, 'csv');
368
+ assert.ok(csv.includes('"a; b"'));
369
+ });
370
+
371
+ test('csv: formula-leading values are sanitised', async () => {
372
+ const csv = await exportEntries(SLUG, 'csv');
373
+ assert.ok(csv.includes(`"'=cmd|calc"`));
374
+ });
375
+
376
+ test('csv: starts with the BOM and keeps id/createdAt columns', async () => {
377
+ const csv = await exportEntries(SLUG, 'csv');
378
+ assert.ok(csv.startsWith(CSV_BOM));
379
+ const header = csv.slice(1).split('\n')[0];
380
+ assert.equal(header, '"id","title","seo","tags","createdAt"');
381
+ });
382
+
383
+ test('json export is unchanged', async () => {
384
+ const json = JSON.parse(await exportEntries(SLUG, 'json'));
385
+ assert.equal(json.length, 2);
386
+ assert.equal(json[0].data.title, 'First');
387
+ });
388
+ ```
389
+
390
+ - [ ] **Step 2: Run the test to verify it fails**
391
+
392
+ Run: `node --test tests/services/collections-export.test.js`
393
+ Expected: FAIL - the `[object Object]` assertion and the BOM assertion both fail
394
+
395
+ - [ ] **Step 3: Write the implementation**
396
+
397
+ In `server/services/collections.js`, add near the other imports:
398
+
399
+ ```js
400
+ import {toCsv} from '../../public/js/collection-export.mjs';
401
+ ```
402
+
403
+ Replace the `if (format === 'csv') { ... }` block inside `exportEntries` (currently `:482-493`) with:
404
+
405
+ ```js
406
+ if (format === 'csv') {
407
+ const columns = [
408
+ {name: 'id'},
409
+ ...schema.fields.map(f => ({name: f.name})),
410
+ {name: 'createdAt'}
411
+ ];
412
+ const rows = entries.map(e => ({
413
+ id: e.id,
414
+ ...Object.fromEntries(schema.fields.map(f => [f.name, e.data?.[f.name]])),
415
+ createdAt: e.meta?.createdAt || ''
416
+ }));
417
+ return toCsv(rows, columns);
418
+ }
419
+ ```
420
+
421
+ Note two deliberate output changes: the header row is now quoted like every other row, and the file is BOM-prefixed. Both are consequences of adopting the shared dialect.
422
+
423
+ - [ ] **Step 4: Run the test to verify it passes**
424
+
425
+ Run: `node --test tests/services/collections-export.test.js`
426
+ Expected: PASS, 5 tests
427
+
428
+ - [ ] **Step 5: Run the full suite for regressions**
429
+
430
+ Run: `npm test`
431
+ Expected: PASS. Any pre-existing test asserting an unquoted CSV header must be updated to the quoted form - that is the intended change, not a break.
432
+
433
+ - [ ] **Step 6: Commit**
434
+
435
+ ```bash
436
+ git add server/services/collections.js tests/services/collections-export.test.js
437
+ git commit -m "fix(export): object fields no longer export as [object Object]"
438
+ ```
439
+
440
+ ---
441
+
442
+ ## Task 4: Server resolves export permission into the ctx payload
443
+
444
+ **Files:**
445
+ - Modify: `server/services/markdown.js:186-197` and its 8 call sites
446
+ - Test: `tests/markdown/collection-ctx-export.test.js`
447
+
448
+ - [ ] **Step 1: Write the failing test**
449
+
450
+ Create `tests/markdown/collection-ctx-export.test.js`:
451
+
452
+ ```js
453
+ /**
454
+ * buildCtxAttr() export config - the data-ctx payload must carry enough for
455
+ * the browser to decide whether to offer export, without ever carrying a
456
+ * field the display did not render.
457
+ */
458
+ import {test} from 'node:test';
459
+ import assert from 'node:assert/strict';
460
+ import {resolveExportConfig} from '../../server/services/markdown.js';
461
+
462
+ test('absent schema block defaults to enabled and role-gated', () => {
463
+ const cfg = resolveExportConfig(undefined, {}, 'subscriber');
464
+ assert.equal(cfg.allowed, true);
465
+ assert.equal(cfg.access, 'subscriber');
466
+ assert.deepEqual(cfg.formats, ['csv', 'json']);
467
+ });
468
+
469
+ test('enabled:false wins over exportable="true"', () => {
470
+ const cfg = resolveExportConfig({enabled: false}, {exportable: 'true'}, 'subscriber');
471
+ assert.equal(cfg.allowed, false);
472
+ });
473
+
474
+ test('exportable="true" is grandfathered as a public opt-in', () => {
475
+ const cfg = resolveExportConfig(undefined, {exportable: 'true'}, 'subscriber');
476
+ assert.equal(cfg.allowed, true);
477
+ assert.equal(cfg.access, 'public');
478
+ });
479
+
480
+ test('explicit public access needs no shortcode attribute', () => {
481
+ const cfg = resolveExportConfig({access: 'public'}, {}, 'subscriber');
482
+ assert.equal(cfg.access, 'public');
483
+ });
484
+
485
+ test('formats are honoured and filtered to known values', () => {
486
+ const cfg = resolveExportConfig({formats: ['csv', 'pdf']}, {}, 'subscriber');
487
+ assert.deepEqual(cfg.formats, ['csv']);
488
+ });
489
+ ```
490
+
491
+ - [ ] **Step 2: Run the test to verify it fails**
492
+
493
+ Run: `node --test tests/markdown/collection-ctx-export.test.js`
494
+ Expected: FAIL - `resolveExportConfig` is not exported
495
+
496
+ - [ ] **Step 3: Write the implementation**
497
+
498
+ In `server/services/markdown.js`, above `buildCtxAttr`:
499
+
500
+ ```js
501
+ const EXPORT_FORMATS = ['csv', 'json'];
502
+
503
+ /**
504
+ * Decide whether a display offers export, and to whom. First match wins:
505
+ * 1. schema export.enabled === false → never (exportable="true" ignored)
506
+ * 2. exportable="true" on the shortcode → public (grandfathered: that is
507
+ * exactly what the attribute has always meant)
508
+ * 3. schema export.access → as configured
509
+ * 4. otherwise → the least-privileged role
510
+ *
511
+ * @param {object|undefined} schemaExport - schema.json `export` block
512
+ * @param {object} attrs - shortcode attributes
513
+ * @param {string} defaultRole - least-privileged role name
514
+ */
515
+ export function resolveExportConfig(schemaExport, attrs, defaultRole) {
516
+ const cfg = schemaExport || {};
517
+ if (cfg.enabled === false) return {allowed: false, access: null, formats: []};
518
+ const formats = (Array.isArray(cfg.formats) ? cfg.formats : EXPORT_FORMATS)
519
+ .filter(f => EXPORT_FORMATS.includes(f));
520
+ const access = attrs?.exportable === 'true' ? 'public' : (cfg.access || defaultRole);
521
+ return {allowed: true, access, formats};
522
+ }
523
+ ```
524
+
525
+ Change the `buildCtxAttr` signature and payload:
526
+
527
+ ```js
528
+ function buildCtxAttr(fields, entries, opts = {}) {
529
+ const names = [...new Set(fields.map(f => f?.name).filter(Boolean))];
530
+ if (!names.length || !entries.length) return '';
531
+ const payload = {
532
+ fields: names.map(n => ({name: n, label: fields.find(f => f.name === n)?.label || n})),
533
+ entries: entries.map(e => ({
534
+ id: String(e.id ?? ''),
535
+ data: Object.fromEntries(names.map(n => [n, e.data?.[n] ?? '']))
536
+ }))
537
+ };
538
+ if (opts.slug) payload.slug = opts.slug;
539
+ if (opts.export) payload.export = opts.export;
540
+ return ` data-ctx="${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')}"`;
541
+ }
542
+ ```
543
+
544
+ ### Threading it to the call sites - CORRECTED
545
+
546
+ The earlier draft of this plan claimed there were 8 call sites and that each "already has `schema`
547
+ and `attrs` in scope". Both claims are false. Verified facts:
548
+
549
+ **There are 7 call sites of `buildCtxAttr`:** `:289, 508, 535, 566, 604, 666, 759`.
550
+
551
+ **None of them has `schema` in scope.** They sit inside seven render helpers whose signatures take
552
+ neither `schema` nor `attrs`:
553
+
554
+ | Line | Enclosing render helper | Signature shape |
555
+ |---|---|---|
556
+ | 289 | `renderCollectionBlocks` (`:257`) | positional |
557
+ | 508 | `renderCollectionCards` (`:483`) | positional |
558
+ | 535 | `renderCollectionList` (`:511`) | positional |
559
+ | 566 | `renderCollectionAccordion` (`:547`) | positional |
560
+ | 604 | `renderCollectionTimeline` (`:587`) | `(entries, opts)` |
561
+ | 666 | `renderCollectionCarousel` (`:632`) | `(entries, opts)` |
562
+ | 759 | `renderCollectionListgroup` (`:719`) | `(entries, opts)` |
563
+
564
+ **Each helper is called from TWO dispatch sites**, which differ in what they know:
565
+
566
+ - `renderCollectionFragment(attrs, …)` (`:1281`) - the `[collection]` path. Dispatches at
567
+ `:1330-1392`. Has `attrs`, and has `schema` from `getCollection(slug)` at `:1305`. Straightforward.
568
+ - `processViewBlocks(markdown, tagSet)` (`:772`) - the `[view]` path. Dispatches at `:843-904`.
569
+ Has `attrs` and a **view** slug, and loads `viewConfig` via `getView(slug)` at `:821`. It does
570
+ **not** have the collection schema and must resolve it from the view config before it can
571
+ compute an export config.
572
+
573
+ **Approach:** give all seven helpers a trailing optional `ctxOpts = {}` parameter and pass it
574
+ straight through to `buildCtxAttr`. A trailing optional parameter is backwards-compatible, so the
575
+ three `(entries, opts)` helpers do not need their options object reshaped and no existing caller
576
+ breaks. Then compute the config once at each of the two dispatch sites and pass it down.
577
+
578
+ Resolve `defaultRole` from `userTypes.getRoleHierarchy()` - the entry with the highest level
579
+ number (level 0 is admin, so highest number is least privileged).
580
+
581
+ For the `[view]` path, resolve the underlying collection schema from `viewConfig` before computing
582
+ the export config. If the view's collection cannot be resolved, fall back to a **closed** config
583
+ (`{allowed: false}`) rather than an open one - failing closed is correct for a permission gate.
584
+
585
+ **Do not** hoist the export config into a module-level variable. `markdown.js` renders shortcodes recursively, so a `[collection]` nested inside a `[card]` would clobber the outer one's config.
586
+
587
+ - [ ] **Step 4: Run the test to verify it passes**
588
+
589
+ Run: `node --test tests/markdown/collection-ctx-export.test.js`
590
+ Expected: PASS, 5 tests
591
+
592
+ - [ ] **Step 5: Run the display tests for regressions**
593
+
594
+ Run: `node --test tests/services/collection-displays.test.js`
595
+ Expected: PASS - the payload gained keys but no display markup changed.
596
+
597
+ - [ ] **Step 6: Commit**
598
+
599
+ ```bash
600
+ git add server/services/markdown.js tests/markdown/collection-ctx-export.test.js
601
+ git commit -m "feat(collections): resolve export permission into the ctx payload"
602
+ ```
603
+
604
+ ---
605
+
606
+ ## Task 5: Table displays carry export config
607
+
608
+ **Files:**
609
+ - Modify: `server/services/markdown.js:473-474` (table payload)
610
+ - Modify: `public/js/site.js:764,814-826`
611
+
612
+ - [ ] **Step 1: Add export config to the table payload**
613
+
614
+ In `markdown.js` where the table payload object is built (around `:473`), alongside `sortable` and `exportable`:
615
+
616
+ ```js
617
+ exportCfg: resolveExportConfig(schema.export, attrs, defaultRole),
618
+ slug: schema.slug,
619
+ ```
620
+
621
+ - [ ] **Step 2: Surface it on the wrapper in site.js**
622
+
623
+ In `public/js/site.js`, extend the destructure at `:764`:
624
+
625
+ ```js
626
+ const {columns, rows, search, sortable, exportable, pageSize, empty, ctaConfig, exportCfg, slug} = payload;
627
+ ```
628
+
629
+ and extend the `wrapper._dmTable` object (`:814`) with the two new keys so the context menu can read them:
630
+
631
+ ```js
632
+ wrapper._dmTable = {
633
+ columns,
634
+ rows,
635
+ slug,
636
+ exportCfg,
637
+ render(rowSubset) { /* unchanged */ }
638
+ };
639
+ ```
640
+
641
+ - [ ] **Step 3: Verify by hand**
642
+
643
+ Run: `curl -s localhost:4096/<a-page-with-a-table-collection> | grep -o 'data-payload="[^"]\{0,60\}'`
644
+ Then decode one payload and confirm `exportCfg` is present:
645
+ `node -e "console.log(JSON.parse(Buffer.from(process.argv[1],'base64')).exportCfg)" <paste>`
646
+ Expected: an object with `allowed`, `access`, `formats`.
647
+
648
+ The dev server on port 4096 is always running and does **not** hot-reload. Clear the cache after server-side edits with `POST /api/cache/clear` using a JWT minted from `JWT_SECRET`.
649
+
650
+ - [ ] **Step 4: Commit**
651
+
652
+ ```bash
653
+ git add server/services/markdown.js public/js/site.js
654
+ git commit -m "feat(collections): carry export config on table displays"
655
+ ```
656
+
657
+ ---
658
+
659
+ ## Task 6: Comparator extraction and sort state
660
+
661
+ **Files:**
662
+ - Create: `public/js/collection-sort.mjs`
663
+ - Create: `tests/services/collection-sort.test.js`
664
+ - Modify: `server/templates/page.html:120`
665
+ - Modify: `public/js/collection-context.js:80-121`
666
+
667
+ ### Why a separate module, and why the module conversion moves here
668
+
669
+ The repo has **no jsdom or equivalent** - `tests/services/collection-browser-shell.test.js:8-10`
670
+ records the house convention that DOM behaviour is verified manually, not under `node:test`. Taken
671
+ literally that would ship the sort comparator with zero automated coverage, and the comparator is
672
+ the one piece of this feature that is pure logic with real edge cases: numeric versus date versus
673
+ string ordering, mixed types in one column, empty values.
674
+
675
+ So the comparator goes in its own `.mjs` - importable by `node:test` exactly as
676
+ `collection-export.mjs` is, and importable by the browser. Everything that touches the DOM stays
677
+ in `collection-context.js` and remains manually verified.
678
+
679
+ This requires `collection-context.js` to be a module, which the earlier draft deferred to Task 11.
680
+ Doing it here instead means Tasks 6-12 can all import shared pure helpers. The change is the same
681
+ either way - `page.html:120` gains `type="module"` and drops `defer` (module scripts defer by
682
+ default), matching `site.js` (`:119`) and `forms.js` (`:128`), which already do this. The file is
683
+ already an IIFE under `'use strict'`, so nothing else changes.
684
+
685
+ - [ ] **Step 1: Extract the comparison ladder into a testable module**
686
+
687
+ The type-detection ladder is currently trapped inside the `gt`/`lt` case of `matches()` (`:92-99`). Lift it out so filtering and sorting share one definition of "which value is larger":
688
+
689
+ ```js
690
+ /**
691
+ * Compare two values: numeric if both parse as numbers, then date, then
692
+ * string. Shared by gt/lt filtering and by sorting, so the two can never
693
+ * disagree about ordering.
694
+ */
695
+ function compareValues(a, b) {
696
+ const na = parseFloat(a), nb = parseFloat(b);
697
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
698
+ const da = Date.parse(a), db = Date.parse(b);
699
+ if (!Number.isNaN(da) && !Number.isNaN(db)) return da - db;
700
+ return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
701
+ }
702
+ ```
703
+
704
+ Rewrite the `gt`/`lt` case to use it:
705
+
706
+ ```js
707
+ case 'gt':
708
+ case 'lt': {
709
+ const c = compareValues(str, f.value);
710
+ return f.op === 'gt' ? c > 0 : c < 0;
711
+ }
712
+ ```
713
+
714
+ - [ ] **Step 2: Sort inside apply()**
715
+
716
+ Replace the first line of `apply()` (`:107`):
717
+
718
+ ```js
719
+ const sort = wrapper._dmCtxSort || null;
720
+ let allowedEntries = model.entries.filter(e => filters.every(f => matches(e, f)));
721
+ if (sort) {
722
+ allowedEntries = allowedEntries.slice().sort((x, y) => {
723
+ const c = compareValues(x.data?.[sort.field] ?? '', y.data?.[sort.field] ?? '');
724
+ return sort.dir === 'desc' ? -c : c;
725
+ });
726
+ }
727
+ const allowed = new Set(allowedEntries.map(e => e.id));
728
+ ```
729
+
730
+ `.slice()` is required - `model.entries` is the pristine source order and clearing the sort must be able to return to it.
731
+
732
+ Table, timeline and carousel now sort with no further change, because each takes its order from the array it is handed.
733
+
734
+ - [ ] **Step 3: Verify in a real browser**
735
+
736
+ Load a page with a `[collection display="table"]`, set `wrapper._dmCtxSort` from the console, call the module's `apply` via a right-click filter, and confirm row order changes. Per project convention, do not record sort as working until a real browser has shown it.
737
+
738
+ - [ ] **Step 4: Commit**
739
+
740
+ ```bash
741
+ git add public/js/collection-context.js
742
+ git commit -m "refactor(collections): extract compareValues; sort filtered entries"
743
+ ```
744
+
745
+ ---
746
+
747
+ ## Task 7: Reordering for DOM-toggle displays
748
+
749
+ Cards, list, listgroup, accordion and block hide in place rather than re-rendering, so they need explicit reordering.
750
+
751
+ **Files:**
752
+ - Modify: `public/js/collection-context.js` (the `else` branch of `apply()`)
753
+
754
+ - [ ] **Step 1: Reorder siblings after toggling visibility**
755
+
756
+ Replace the final `else` branch of `apply()`:
757
+
758
+ ```js
759
+ } else {
760
+ const els = Array.from(wrapper.querySelectorAll('[data-entry-id]'));
761
+ els.forEach(el => el.classList.toggle('dm-ctx-hidden', !allowed.has(el.dataset.entryId)));
762
+ // Declared at branch level, not inside the `if` below - Task 10's
763
+ // grouping code appends after the reorder and needs both.
764
+ const byId = new Map(els.map(el => [el.dataset.entryId, el]));
765
+ const parent = els.length ? els[0].parentNode : null;
766
+ if (sort && parent) {
767
+ // Reorder by appending in sorted order. appendChild MOVES nodes
768
+ // rather than cloning, so listeners and Domma instance
769
+ // references on each entry survive intact.
770
+ allowedEntries.forEach(e => {
771
+ const el = byId.get(e.id);
772
+ if (el && el.parentNode === parent) parent.appendChild(el);
773
+ });
774
+ }
775
+ }
776
+ ```
777
+
778
+ Entries hidden by filters keep their DOM position; only visible ones are reordered, which is invisible to the user and avoids touching nodes needlessly.
779
+
780
+ - [ ] **Step 2: Verify in a real browser**
781
+
782
+ Load a page with `[collection display="cards"]` and `display="accordion"`. Sort, and confirm order changes and accordion panels still open and close - proving listeners survived the move.
783
+
784
+ - [ ] **Step 3: Commit**
785
+
786
+ ```bash
787
+ git add public/js/collection-context.js
788
+ git commit -m "feat(collections): sort DOM-toggle displays by reordering entries"
789
+ ```
790
+
791
+ ---
792
+
793
+ ## Task 8: Drill-in panel stack
794
+
795
+ **Files:**
796
+ - Modify: `public/js/collection-context.js:290-380` (the menu section)
797
+
798
+ - [ ] **Step 1: Replace single-panel rendering with a stack**
799
+
800
+ `openMenu()` currently builds one flat panel. Introduce a panel stack so any row can push a
801
+ sub-panel into the same element, needing no hover-intent timers and no flyout collision maths.
802
+
803
+ **The viewport clamp must be re-run on every panel swap.** An earlier draft of this plan claimed
804
+ the existing clamp was reusable "unchanged". It is not. The clamp (now at `:383-386`, after Task 6
805
+ shifted the file) measures `getBoundingClientRect()` once, immediately after the menu is first
806
+ appended to the body. A drill-in changes the panel's height - the Sort panel renders two rows per
807
+ field and is typically the tallest - so a menu opened near the bottom of the viewport would
808
+ overflow off-screen with no correction.
809
+
810
+ Extract the clamp into a helper and call it after every `renderPanel`, retaining the original
811
+ click coordinates so the menu keeps its anchor rather than drifting across the screen:
812
+
813
+ ```js
814
+ function clamp(menu, x, y) {
815
+ const r = menu.getBoundingClientRect();
816
+ menu.style.left = Math.min(x, window.innerWidth - r.width - 8) + 'px';
817
+ menu.style.top = Math.min(y, window.innerHeight - r.height - 8) + 'px';
818
+ }
819
+ ```
820
+
821
+ Store `x`/`y` on the menu element when it opens so `renderPanel` can re-clamp without threading
822
+ coordinates through every push and pop.
823
+
824
+ ```js
825
+ // Panel stack - a menu is a list of builder functions. Pushing renders a
826
+ // new panel into the SAME element, so positioning and the outside-click
827
+ // handlers are set up once and never recomputed.
828
+ function renderPanel(menu, build, title) {
829
+ menu.replaceChildren();
830
+ if (title) {
831
+ const back = document.createElement('button');
832
+ back.type = 'button';
833
+ back.className = 'dm-ctx-row dm-ctx-back';
834
+ back.textContent = '‹ Back';
835
+ back.addEventListener('click', () => menu._dmPop());
836
+ const head = document.createElement('div');
837
+ head.className = 'dm-ctx-panel-head';
838
+ head.appendChild(back);
839
+ head.appendChild(heading(title));
840
+ menu.appendChild(head);
841
+ }
842
+ build(menu);
843
+ }
844
+
845
+ function makeStack(menu) {
846
+ const stack = [];
847
+ menu._dmPush = (build, title) => {
848
+ stack.push({build, title});
849
+ renderPanel(menu, build, title);
850
+ };
851
+ menu._dmPop = () => {
852
+ stack.pop();
853
+ const prev = stack[stack.length - 1];
854
+ if (prev) renderPanel(menu, prev.build, prev.title);
855
+ };
856
+ return menu;
857
+ }
858
+ ```
859
+
860
+ - [ ] **Step 2: Add the panel-head styles**
861
+
862
+ Append to the injected `style.textContent` array:
863
+
864
+ ```js
865
+ '.dm-ctx-panel-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem;'
866
+ + 'border-bottom: 1px solid var(--dm-border, #2a2a40); margin-bottom: .25rem; }',
867
+ '.dm-ctx-menu button.dm-ctx-back { width: auto; padding: .3rem .5rem; color: var(--dm-text-muted, #9a9aac); }',
868
+ '.dm-ctx-group-heading { font-size: .75rem; text-transform: uppercase; letter-spacing: .04em;'
869
+ + 'color: var(--dm-text-muted, #9a9aac); padding: .5rem 0 .25rem; grid-column: 1 / -1; }',
870
+ ```
871
+
872
+ `grid-column: 1 / -1` matters: card displays render into a Domma Grid, so a heading must span every column or it will sit in a cell.
873
+
874
+ Note this puts the group-heading rule in the injected stylesheet rather than `public/css/site.css`
875
+ as the spec's file table suggested. Deliberate: every other context-menu style is injected here, and
876
+ splitting them across two files would mean editing both for one visual change. The print stylesheet
877
+ stays in `site.css` (Task 12), because print rules target the page, not the menu.
878
+
879
+ - [ ] **Step 3: Convert openMenu to push a root panel**
880
+
881
+ Wrap the existing body of `openMenu` in a root builder and push it:
882
+
883
+ ```js
884
+ const menu = makeStack(document.createElement('div'));
885
+ menu.className = 'dm-ctx-menu';
886
+ _menu = menu;
887
+ menu._dmPush(m => buildRootPanel(m, wrapper, entry, clickedField, model), null);
888
+ ```
889
+
890
+ Move the existing filter rows and the search input into `buildRootPanel(menu, wrapper, entry, clickedField, model)` unchanged. The search input keeps its autofocus, and filter stays one click.
891
+
892
+ - [ ] **Step 4: Verify in a real browser**
893
+
894
+ Right-click a collection: the existing filter rows and search must behave exactly as before. This task is a refactor with no user-visible change.
895
+
896
+ - [ ] **Step 5: Commit**
897
+
898
+ ```bash
899
+ git add public/js/collection-context.js
900
+ git commit -m "refactor(collections): drill-in panel stack for the context menu"
901
+ ```
902
+
903
+ ---
904
+
905
+ ## Task 9: The Sort panel
906
+
907
+ **Files:**
908
+ - Modify: `public/js/collection-context.js`
909
+
910
+ - [ ] **Step 1: Add a type-aware direction labeller**
911
+
912
+ ```js
913
+ /** Direction wording follows the detected value type. */
914
+ function dirLabels(model, field) {
915
+ const sample = model.entries.map(e => e.data?.[field]).find(v => v !== '' && v != null);
916
+ const s = String(sample ?? '');
917
+ if (s !== '' && !Number.isNaN(parseFloat(s))) return ['low → high', 'high → low'];
918
+ if (!Number.isNaN(Date.parse(s))) return ['oldest → newest', 'newest → oldest'];
919
+ return ['A → Z', 'Z → A'];
920
+ }
921
+ ```
922
+
923
+ - [ ] **Step 2: Build the panel**
924
+
925
+ ```js
926
+ function buildSortPanel(menu, wrapper, model) {
927
+ model.fields.forEach(f => {
928
+ const [asc, desc] = dirLabels(model, f.name);
929
+ menu.appendChild(row(`${f.label || f.name} ${asc}`, () => {
930
+ wrapper._dmCtxSort = {field: f.name, label: f.label || f.name, dir: 'asc'};
931
+ apply(wrapper);
932
+ closeMenu();
933
+ }));
934
+ menu.appendChild(row(`${f.label || f.name} ${desc}`, () => {
935
+ wrapper._dmCtxSort = {field: f.name, label: f.label || f.name, dir: 'desc'};
936
+ apply(wrapper);
937
+ closeMenu();
938
+ }));
939
+ });
940
+ if (wrapper._dmCtxSort) {
941
+ menu.appendChild(row('Clear sort', () => {
942
+ wrapper._dmCtxSort = null;
943
+ apply(wrapper);
944
+ closeMenu();
945
+ }));
946
+ }
947
+ }
948
+ ```
949
+
950
+ - [ ] **Step 3: Add the root-panel row**
951
+
952
+ In `buildRootPanel`, in the display-scoped block:
953
+
954
+ ```js
955
+ menu.appendChild(row('Sort…', () => menu._dmPush(m => buildSortPanel(m, wrapper, model), 'Sort')));
956
+ ```
957
+
958
+ - [ ] **Step 4: Verify in a real browser**
959
+
960
+ Right-click each of the eight display types, drill into Sort, apply ascending and descending, then Clear sort and confirm the original order returns.
961
+
962
+ - [ ] **Step 5: Commit**
963
+
964
+ ```bash
965
+ git add public/js/collection-context.js
966
+ git commit -m "feat(collections): sort panel in the right-click menu"
967
+ ```
968
+
969
+ ---
970
+
971
+ ## Task 10: Grouping (DOM-toggle displays only)
972
+
973
+ **Files:**
974
+ - Modify: `public/js/collection-context.js`
975
+
976
+ - [ ] **Step 1: Add a strategy predicate**
977
+
978
+ Grouping needs entries as siblings with room for a heading between them. Table fights `T.create`'s pagination, `Progression.setItems()` takes items rather than headers, and grouped carousel slides are not meaningful.
979
+
980
+ ```js
981
+ /** True when the display filters by toggling [data-entry-id] siblings. */
982
+ function isDomToggle(wrapper) {
983
+ return !wrapper._dmTable && !wrapper._dmCtx?.instance?.setItems && !wrapper._dmCtxSlides;
984
+ }
985
+ ```
986
+
987
+ - [ ] **Step 2: Insert headings during apply()**
988
+
989
+ Inside the reorder branch added in Task 7, after the reorder loop:
990
+
991
+ ```js
992
+ wrapper.querySelectorAll('.dm-ctx-group-heading').forEach(h => h.remove());
993
+ const group = wrapper._dmCtxGroup;
994
+ if (group) {
995
+ let last = null;
996
+ allowedEntries.forEach(e => {
997
+ const val = String(e.data?.[group] ?? '') || '-';
998
+ if (val === last) return;
999
+ last = val;
1000
+ const h = document.createElement('div');
1001
+ h.className = 'dm-ctx-group-heading';
1002
+ h.textContent = val;
1003
+ const el = byId.get(e.id);
1004
+ if (el) parent.insertBefore(h, el);
1005
+ });
1006
+ }
1007
+ ```
1008
+
1009
+ Headings are rebuilt from scratch on every `apply()` - cheaper to reason about than diffing, and the entry counts here are page-sized.
1010
+
1011
+ - [ ] **Step 3: Build the panel and gate the row**
1012
+
1013
+ ```js
1014
+ function buildGroupPanel(menu, wrapper, model) {
1015
+ model.fields.forEach(f => {
1016
+ menu.appendChild(row(f.label || f.name, () => {
1017
+ wrapper._dmCtxGroup = f.name;
1018
+ // Grouping implies a primary sort by the group field.
1019
+ if (!wrapper._dmCtxSort || wrapper._dmCtxSort.field !== f.name) {
1020
+ wrapper._dmCtxSort = {field: f.name, label: f.label || f.name, dir: 'asc'};
1021
+ }
1022
+ apply(wrapper);
1023
+ closeMenu();
1024
+ }));
1025
+ });
1026
+ if (wrapper._dmCtxGroup) {
1027
+ menu.appendChild(row('Clear grouping', () => {
1028
+ wrapper._dmCtxGroup = null;
1029
+ apply(wrapper);
1030
+ closeMenu();
1031
+ }));
1032
+ }
1033
+ }
1034
+ ```
1035
+
1036
+ In `buildRootPanel`, render the row only where it can work - hidden, not disabled, because a greyed row invites "why?" on every open:
1037
+
1038
+ ```js
1039
+ if (isDomToggle(wrapper)) {
1040
+ menu.appendChild(row('Group by…', () => menu._dmPush(m => buildGroupPanel(m, wrapper, model), 'Group by')));
1041
+ }
1042
+ ```
1043
+
1044
+ - [ ] **Step 4: Verify in a real browser**
1045
+
1046
+ Confirm headings appear on cards, list, listgroup, accordion and block; confirm the `Group by…` row is absent on table, timeline and carousel; confirm a card-grid heading spans the full grid width.
1047
+
1048
+ - [ ] **Step 5: Commit**
1049
+
1050
+ ```bash
1051
+ git add public/js/collection-context.js
1052
+ git commit -m "feat(collections): group-by for sibling-rendered displays"
1053
+ ```
1054
+
1055
+ ---
1056
+
1057
+ ## Task 11: Export, copy, and the client-side gate
1058
+
1059
+ **Files:**
1060
+ - Modify: `public/js/collection-context.js`
1061
+ - Modify: `server/templates/page.html:120`
1062
+
1063
+ - [ ] **Step 1: Make the context menu a module**
1064
+
1065
+ `collection-context.js` must `import` the serialiser, so it can no longer be a classic script. In `server/templates/page.html:120`:
1066
+
1067
+ ```html
1068
+ <script src="/public/js/collection-context.js?v=20260819-sort-export" type="module"></script>
1069
+ ```
1070
+
1071
+ `defer` is dropped because module scripts defer by default. `site.js` (`:119`) and `forms.js` (`:128`) already use this exact form. Add the import at the top of `collection-context.js`, above the IIFE:
1072
+
1073
+ ```js
1074
+ import {serialise} from '/public/js/collection-export.mjs';
1075
+ ```
1076
+
1077
+ - [ ] **Step 2: Add the sinks**
1078
+
1079
+ ```js
1080
+ function download(text, filename, mime) {
1081
+ const url = URL.createObjectURL(new Blob([text], {type: mime}));
1082
+ const a = document.createElement('a');
1083
+ a.href = url;
1084
+ a.download = filename;
1085
+ document.body.appendChild(a);
1086
+ a.click();
1087
+ a.remove();
1088
+ setTimeout(() => URL.revokeObjectURL(url), 0);
1089
+ }
1090
+
1091
+ /**
1092
+ * Copy via a throwaway textarea, NOT navigator.clipboard.
1093
+ *
1094
+ * navigator.clipboard exists only in a secure context - HTTPS or localhost.
1095
+ * config/server.json binds 0.0.0.0 and this CMS is routinely reached over
1096
+ * plain HTTP on a LAN address, where navigator.clipboard is `undefined` and
1097
+ * every copy would throw TypeError. This mirrors the existing house
1098
+ * fallback at admin/js/lib/crud-tutorial.js:119-129, whose comment records
1099
+ * the same reasoning.
1100
+ */
1101
+ function toClipboard(text, note) {
1102
+ const ta = document.createElement('textarea');
1103
+ ta.value = text;
1104
+ ta.style.cssText = 'position:fixed;opacity:0;';
1105
+ document.body.appendChild(ta);
1106
+ ta.select();
1107
+ const ok = document.execCommand('copy');
1108
+ ta.remove();
1109
+ if (typeof E !== 'undefined' && E.toast) {
1110
+ E.toast(ok ? note : 'Copy failed', {type: ok ? 'success' : 'error'});
1111
+ }
1112
+ }
1113
+
1114
+ /** ISO date without the time part, for filenames. */
1115
+ function stamp() { return new Date().toISOString().slice(0, 10); }
1116
+ ```
1117
+
1118
+ - [ ] **Step 3: Add the gate**
1119
+
1120
+ ```js
1121
+ // One lookup per page, cached. The dataset already ships inside data-ctx,
1122
+ // so this gate stops a casual visitor being handed a download button - it
1123
+ // is not, and is not documented as, an enforcement boundary.
1124
+ //
1125
+ // Auth is a Bearer token from Domma storage, NOT a cookie: @fastify/cookie
1126
+ // is not registered and @fastify/jwt is registered with only {secret}
1127
+ // (server/server.js:98), so request.jwtVerify() reads the Authorization
1128
+ // header and nothing else. `credentials: 'same-origin'` would send no
1129
+ // credential at all and every visitor would look anonymous.
1130
+ // This mirrors the established public-site idiom at site.js:582 and
1131
+ // collection-browser.js:944.
1132
+ let _mePromise = null;
1133
+ function currentUser() {
1134
+ if (!_mePromise) {
1135
+ const token = (typeof S !== 'undefined' && S.get) ? S.get('auth_token') : null;
1136
+ // No token means anonymous - skip the request entirely.
1137
+ _mePromise = token
1138
+ ? fetch('/api/auth/me', {headers: {Authorization: `Bearer ${token}`}})
1139
+ .then(r => r.ok ? r.json() : null)
1140
+ .catch(() => null)
1141
+ : Promise.resolve(null);
1142
+ }
1143
+ return _mePromise;
1144
+ }
1145
+
1146
+ async function exportAllowed(cfg) {
1147
+ if (!cfg || !cfg.allowed) return false;
1148
+ if (cfg.access === 'public') return true;
1149
+ return Boolean(await currentUser());
1150
+ }
1151
+ ```
1152
+
1153
+ - [ ] **Step 4: Build the Export and Copy panels**
1154
+
1155
+ ```js
1156
+ function visibleRows(wrapper, model) {
1157
+ const filters = wrapper._dmCtxFilters || [];
1158
+ let rows = model.entries.filter(e => filters.every(f => matches(e, f)));
1159
+ const sort = wrapper._dmCtxSort;
1160
+ if (sort) {
1161
+ rows = rows.slice().sort((x, y) => {
1162
+ const c = compareValues(x.data?.[sort.field] ?? '', y.data?.[sort.field] ?? '');
1163
+ return sort.dir === 'desc' ? -c : c;
1164
+ });
1165
+ }
1166
+ return rows.map(e => e.data);
1167
+ }
1168
+
1169
+ function buildExportPanel(menu, wrapper, model, cfg) {
1170
+ const slug = model.slug || wrapper._dmTable?.slug || 'collection';
1171
+ cfg.formats.forEach(fmt => {
1172
+ menu.appendChild(row(fmt.toUpperCase(), () => {
1173
+ const text = serialise(visibleRows(wrapper, model), model.fields, fmt);
1174
+ download(text, `${slug}-${stamp()}.${fmt}`,
1175
+ fmt === 'csv' ? 'text/csv;charset=utf-8' : 'application/json');
1176
+ closeMenu();
1177
+ }));
1178
+ });
1179
+ }
1180
+
1181
+ function buildCopyPanel(menu, wrapper, model, entry, clickedField) {
1182
+ if (entry && clickedField) {
1183
+ menu.appendChild(row('This value', () => {
1184
+ toClipboard(String(entry.data?.[clickedField.name] ?? ''), 'Value copied');
1185
+ closeMenu();
1186
+ }));
1187
+ }
1188
+ if (entry) {
1189
+ menu.appendChild(row('This row (JSON)', () => {
1190
+ toClipboard(serialise([entry.data], model.fields, 'json'), 'Row copied');
1191
+ closeMenu();
1192
+ }));
1193
+ }
1194
+ menu.appendChild(row('Visible entries as a table', () => {
1195
+ toClipboard(serialise(visibleRows(wrapper, model), model.fields, 'markdown'), 'Table copied');
1196
+ closeMenu();
1197
+ }));
1198
+ }
1199
+ ```
1200
+
1201
+ - [ ] **Step 5: Add the root rows**
1202
+
1203
+ In `buildRootPanel`'s display-scoped block:
1204
+
1205
+ ```js
1206
+ menu.appendChild(row('Copy…', () =>
1207
+ menu._dmPush(m => buildCopyPanel(m, wrapper, model, entry, clickedField), 'Copy')));
1208
+
1209
+ const cfg = model.export || wrapper._dmTable?.exportCfg;
1210
+ exportAllowed(cfg).then(ok => {
1211
+ if (!ok || _menu !== menu) return;
1212
+ menu.appendChild(row('Export…', () =>
1213
+ menu._dmPush(m => buildExportPanel(m, wrapper, model, cfg), 'Export')));
1214
+ });
1215
+ ```
1216
+
1217
+ The `_menu !== menu` guard matters: the fetch resolves asynchronously, and without it a slow response would append a row to a menu the user already closed.
1218
+
1219
+ - [ ] **Step 6: Verify in a real browser**
1220
+
1221
+ Signed out, confirm no Export row on a default-config collection. Sign in, reload, confirm it appears. Export CSV and open the file in a spreadsheet: check accented characters render (BOM working) and that a value beginning `=` appears as text rather than a formula. Confirm Copy rows land on the clipboard.
1222
+
1223
+ - [ ] **Step 7: Commit**
1224
+
1225
+ ```bash
1226
+ git add public/js/collection-context.js server/templates/page.html
1227
+ git commit -m "feat(collections): export and copy from the right-click menu"
1228
+ ```
1229
+
1230
+ ---
1231
+
1232
+ ## Task 12: Print, copy-link, and URL state
1233
+
1234
+ **Files:**
1235
+ - Modify: `public/js/collection-context.js`
1236
+ - Modify: `public/css/site.css`
1237
+
1238
+ - [ ] **Step 1: Add the print stylesheet**
1239
+
1240
+ Append to `public/css/site.css`:
1241
+
1242
+ ```css
1243
+ /* Right-click "Print" - isolate one collection display for printing.
1244
+ body * hidden, then the marked subtree walked back to visible. */
1245
+ @media print {
1246
+ body.dm-ctx-printing * { visibility: hidden; }
1247
+ body.dm-ctx-printing [data-dm-print],
1248
+ body.dm-ctx-printing [data-dm-print] * { visibility: visible; }
1249
+ body.dm-ctx-printing [data-dm-print] {
1250
+ position: absolute; left: 0; top: 0; width: 100%;
1251
+ }
1252
+ /* Closed accordions would otherwise print as a stack of bare headers.
1253
+ The collapsible region is .accordion-content - verified against both the
1254
+ markup markdown.js emits (renderCollectionAccordion, :547) and the class
1255
+ list in the Domma bundle. There is no .accordion-panel; an earlier draft
1256
+ of this plan targeted that name and would have silently done nothing. */
1257
+ body.dm-ctx-printing [data-dm-print] .accordion-content { display: block !important; height: auto !important; }
1258
+ body.dm-ctx-printing .dm-ctx-hidden { display: none !important; }
1259
+ }
1260
+ ```
1261
+
1262
+ - [ ] **Step 2: Add the print and copy-link rows**
1263
+
1264
+ ```js
1265
+ menu.appendChild(row('Print', () => {
1266
+ wrapper.setAttribute('data-dm-print', '');
1267
+ document.body.classList.add('dm-ctx-printing');
1268
+ closeMenu();
1269
+ const cleanup = () => {
1270
+ wrapper.removeAttribute('data-dm-print');
1271
+ document.body.classList.remove('dm-ctx-printing');
1272
+ window.removeEventListener('afterprint', cleanup);
1273
+ };
1274
+ window.addEventListener('afterprint', cleanup);
1275
+ window.print();
1276
+ }));
1277
+
1278
+ menu.appendChild(row('Copy link to this view', () => {
1279
+ syncHash();
1280
+ toClipboard(location.href, 'Link copied');
1281
+ closeMenu();
1282
+ }));
1283
+ ```
1284
+
1285
+ - [ ] **Step 3: Extend the hash payload**
1286
+
1287
+ `syncHash()` currently stores a bare filter array per display index. Store an object instead:
1288
+
1289
+ ```js
1290
+ function syncHash() {
1291
+ const state = {};
1292
+ allWrappers().forEach((w, i) => {
1293
+ const entry = {};
1294
+ if (w._dmCtxFilters && w._dmCtxFilters.length) entry.f = w._dmCtxFilters;
1295
+ if (w._dmCtxSort) entry.s = w._dmCtxSort;
1296
+ if (w._dmCtxGroup) entry.g = w._dmCtxGroup;
1297
+ if (Object.keys(entry).length) state[i] = entry;
1298
+ });
1299
+ /* …rest unchanged… */
1300
+ }
1301
+ ```
1302
+
1303
+ And read both shapes in `restoreFromHash()`, so links shared before this change keep working:
1304
+
1305
+ ```js
1306
+ for (const [idx, raw] of Object.entries(state)) {
1307
+ const w = wrappers[Number(idx)];
1308
+ // Legacy links stored a bare filter array; current ones store {f,s,g}.
1309
+ const cfg = Array.isArray(raw) ? {f: raw} : (raw || {});
1310
+ if (!w) continue;
1311
+ /* …hydration check unchanged… */
1312
+ w._dmCtxFilters = (cfg.f || []).filter(f => f && f.field && f.op != null && 'value' in f);
1313
+ w._dmCtxSort = cfg.s || null;
1314
+ w._dmCtxGroup = cfg.g || null;
1315
+ apply(w);
1316
+ }
1317
+ ```
1318
+
1319
+ Note the `pending` guard above this loop must also stop skipping wrappers with no filters, since a display may now carry only a sort.
1320
+
1321
+ - [ ] **Step 4: Verify in a real browser**
1322
+
1323
+ Apply a filter, sort and group; copy the link; open it in a fresh tab and confirm all three restore. Take a `dmf=` link generated before this change and confirm it still restores its filters. Print and confirm only the display appears.
1324
+
1325
+ - [ ] **Step 5: Commit**
1326
+
1327
+ ```bash
1328
+ git add public/js/collection-context.js public/css/site.css
1329
+ git commit -m "feat(collections): print, copy-link, and sort/group in the URL hash"
1330
+ ```
1331
+
1332
+ ---
1333
+
1334
+ ## Task 13: Admin UI for the export block
1335
+
1336
+ **Files:**
1337
+ - Modify: `admin/js/templates/collection-editor.html:17`
1338
+ - Modify: `admin/js/views/collection-editor.js:218-249, 297-305, 432, 463, 480`
1339
+
1340
+ This file is one of seven admin views committed with mangled identifiers (see the spec). **Write new code with readable names** - a file that is 5% legible beats one that is 0%. Do not attempt to restore the file here; that is a separate task.
1341
+
1342
+ - [ ] **Step 1: Rename the tab**
1343
+
1344
+ In `admin/js/templates/collection-editor.html:17`:
1345
+
1346
+ ```html
1347
+ <button class="tab-item">API &amp; Export</button>
1348
+ ```
1349
+
1350
+ - [ ] **Step 2: Render the export rows**
1351
+
1352
+ At the end of the function that renders `#api-access-rows` (`:218-249`), after the verb loop:
1353
+
1354
+ ```js
1355
+ // Export row - schema-level permission for the public right-click export.
1356
+ // Mirrors the verb rows above; the Formats sub-row follows the existing
1357
+ // "Read fields" sub-row precedent.
1358
+ const exportCfg = schemaExport || {};
1359
+ const exportRow = document.createElement('div');
1360
+ exportRow.style.cssText = 'display:grid;grid-template-columns:140px 1fr 160px;gap:.75rem;align-items:center;padding:.6rem 0;border-top:2px solid var(--dm-border,#333);';
1361
+ const exportLabel = document.createElement('strong');
1362
+ exportLabel.textContent = 'Export';
1363
+ exportLabel.style.cssText = 'font-size:.9rem;';
1364
+ const exportToggle = document.createElement('label');
1365
+ exportToggle.style.cssText = 'display:flex;align-items:center;gap:.45rem;cursor:pointer;font-size:.875rem;';
1366
+ const exportEnabled = document.createElement('input');
1367
+ exportEnabled.type = 'checkbox';
1368
+ exportEnabled.id = 'export-enabled';
1369
+ exportEnabled.checked = exportCfg.enabled !== false;
1370
+ exportToggle.appendChild(exportEnabled);
1371
+ exportToggle.appendChild(document.createTextNode('Allow export'));
1372
+ const exportAccess = document.createElement('select');
1373
+ exportAccess.id = 'export-access';
1374
+ exportAccess.className = 'form-input';
1375
+ // `ae` (collection-editor.js:45) is the API access list and includes
1376
+ // "token". An API token is a machine credential for /api/v1 - it is
1377
+ // meaningless for a browser download, and the spec forbids it here. Filter
1378
+ // it out rather than reusing `ae` wholesale.
1379
+ ae.filter(roleName => roleName !== 'token').forEach(roleName => {
1380
+ const opt = document.createElement('option');
1381
+ opt.value = roleName;
1382
+ opt.textContent = roleName.charAt(0).toUpperCase() + roleName.slice(1);
1383
+ if (roleName === exportCfg.access) opt.selected = true;
1384
+ exportAccess.appendChild(opt);
1385
+ });
1386
+ exportRow.appendChild(exportLabel);
1387
+ exportRow.appendChild(exportToggle);
1388
+ exportRow.appendChild(exportAccess);
1389
+ t.appendChild(exportRow);
1390
+
1391
+ const formatsRow = document.createElement('div');
1392
+ formatsRow.style.cssText = 'display:grid;grid-template-columns:140px 1fr;gap:.75rem;align-items:center;padding:.6rem 0;border-bottom:1px solid var(--dm-border,#333);';
1393
+ const formatsLabel = document.createElement('label');
1394
+ formatsLabel.style.cssText = 'font-size:.85rem;color:var(--dm-text-muted,#aaa);';
1395
+ formatsLabel.textContent = 'Formats';
1396
+ const formatsWrap = document.createElement('div');
1397
+ formatsWrap.style.cssText = 'display:flex;gap:1rem;';
1398
+ const chosenFormats = Array.isArray(exportCfg.formats) ? exportCfg.formats : ['csv', 'json'];
1399
+ ['csv', 'json'].forEach(fmt => {
1400
+ const wrap = document.createElement('label');
1401
+ wrap.style.cssText = 'display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.875rem;';
1402
+ const box = document.createElement('input');
1403
+ box.type = 'checkbox';
1404
+ box.id = `export-format-${fmt}`;
1405
+ box.checked = chosenFormats.includes(fmt);
1406
+ wrap.appendChild(box);
1407
+ wrap.appendChild(document.createTextNode(fmt.toUpperCase()));
1408
+ formatsWrap.appendChild(wrap);
1409
+ });
1410
+ formatsRow.appendChild(formatsLabel);
1411
+ formatsRow.appendChild(formatsWrap);
1412
+ t.appendChild(formatsRow);
1413
+ ```
1414
+
1415
+ **Verified identifiers in this mangled file:** `ae` (`:45`) is
1416
+ `["public","token","subscriber","editor","manager","admin"]`; `ee` (`:46`) is
1417
+ `["create","read","update","delete"]`; the render function is `se(e, t)` at `:217`, called from
1418
+ `:380`; `t` is the rows container and `e` the existing `api` block. Pass the loaded schema's
1419
+ `export` block in as a third parameter `schemaExport`.
1420
+
1421
+ - [ ] **Step 3: Read the values on save**
1422
+
1423
+ Beside `readApiRows()` (`:297-305`):
1424
+
1425
+ ```js
1426
+ /** Read the export block back out of the API & Export tab. */
1427
+ function readExportBlock() {
1428
+ const formats = ['csv', 'json'].filter(fmt => document.getElementById(`export-format-${fmt}`)?.checked);
1429
+ return {
1430
+ enabled: document.getElementById('export-enabled')?.checked ?? true,
1431
+ access: document.getElementById('export-access')?.value || 'subscriber',
1432
+ formats
1433
+ };
1434
+ }
1435
+ ```
1436
+
1437
+ - [ ] **Step 4: Include it in the three save paths**
1438
+
1439
+ At `:432`, `:463` and `:480`, where the payload currently carries `api: b`, add:
1440
+
1441
+ ```js
1442
+ export: readExportBlock(),
1443
+ ```
1444
+
1445
+ - [ ] **Step 5: Load it when editing an existing collection**
1446
+
1447
+ At `:373`, where `r = l.api || r` restores the API block, add alongside:
1448
+
1449
+ ```js
1450
+ schemaExport = l.export || {},
1451
+ ```
1452
+
1453
+ and pass it into the render function.
1454
+
1455
+ - [ ] **Step 6: Verify in a real browser**
1456
+
1457
+ Open a collection in the admin, go to API & Export, untick CSV, save, reload, confirm the tick state persisted. Check the written `schema.json` on disk contains the `export` block.
1458
+
1459
+ - [ ] **Step 7: Commit**
1460
+
1461
+ ```bash
1462
+ git add admin/js/templates/collection-editor.html admin/js/views/collection-editor.js
1463
+ git commit -m "feat(admin): export permission controls in the collection editor"
1464
+ ```
1465
+
1466
+ ---
1467
+
1468
+ ## Task 14: Documentation
1469
+
1470
+ **Files:**
1471
+ - Modify: `docs/configuration.md`
1472
+ - Modify: `docs/markdown-shortcodes.md`
1473
+ - Modify: `CLAUDE.md`
1474
+
1475
+ - [ ] **Step 1: Document the schema block**
1476
+
1477
+ Add to `docs/configuration.md`, beside the `api` block reference:
1478
+
1479
+ ```markdown
1480
+ ### `export` (collection schema)
1481
+
1482
+ Controls the public right-click export menu.
1483
+
1484
+ | Key | Default | Meaning |
1485
+ |---|---|---|
1486
+ | `enabled` | `true` | `false` disables export entirely and overrides `exportable="true"` |
1487
+ | `access` | least-privileged role | `"public"`, or a role name |
1488
+ | `formats` | `["csv", "json"]` | Offered download formats |
1489
+
1490
+ `[collection exportable="true"]` is treated as an explicit public opt-in for that placement,
1491
+ preserving the behaviour that attribute has always had.
1492
+
1493
+ **This gate is a convenience affordance, not an enforcement boundary.** Collection entries are
1494
+ already embedded in the rendered page's `data-ctx` attribute so the browser can filter them
1495
+ without a round trip. Restricting `access` stops a visitor being handed a download button; it
1496
+ does not stop a determined one reading the page source. Collections whose contents must not
1497
+ reach a visitor at all should not be rendered on a page that visitor can load - set
1498
+ `page.visibility`, or gate the collection's `api.read.access`.
1499
+ ```
1500
+
1501
+ - [ ] **Step 2: Document the menu's per-display capabilities**
1502
+
1503
+ Add to `docs/markdown-shortcodes.md`, in the collection display section:
1504
+
1505
+ ```markdown
1506
+ #### Right-click menu capabilities by display type
1507
+
1508
+ | Display | Filter | Sort | Group | Export / copy |
1509
+ |---|---|---|---|---|
1510
+ | cards, list, listgroup, accordion, block | ✅ | ✅ | ✅ | ✅ |
1511
+ | table | ✅ | ✅ | - | ✅ |
1512
+ | timeline | ✅ | ✅ | - | ✅ |
1513
+ | carousel | ✅ | ✅ | - | ✅ |
1514
+
1515
+ Grouping needs entries rendered as siblings with room for a heading between them. Table
1516
+ displays are re-rendered by `T.create` (heading rows would fight its pagination), timelines
1517
+ are driven by `Progression.setItems()` which accepts items rather than section headers, and
1518
+ carousel slides are a sequence. The `Group by…` row is hidden on those three.
1519
+ ```
1520
+
1521
+ - [ ] **Step 3: Update the project guide**
1522
+
1523
+ In `CLAUDE.md`, extend the "Collection displays & public filtering" section to note that the
1524
+ menu now also sorts, groups, exports and copies, that export permission lives in the schema's
1525
+ `export` block, and that `public/js/collection-export.mjs` is shared with the server.
1526
+
1527
+ - [ ] **Step 4: Commit**
1528
+
1529
+ ```bash
1530
+ git add docs/configuration.md docs/markdown-shortcodes.md CLAUDE.md
1531
+ git commit -m "docs: collection export permissions and menu capabilities"
1532
+ ```
1533
+
1534
+ ---
1535
+
1536
+ ## Task 15: Full verification
1537
+
1538
+ - [ ] **Step 1: Run the whole suite**
1539
+
1540
+ Run: `npm test`
1541
+ Expected: PASS, with the three new test files included.
1542
+
1543
+ - [ ] **Step 2: Clear the dev server cache**
1544
+
1545
+ The port-4096 dev server is always running and does not hot-reload. Mint a JWT from `JWT_SECRET` and `POST /api/cache/clear`. Never start or kill that server.
1546
+
1547
+ - [ ] **Step 3: Browser pass**
1548
+
1549
+ Walk one page carrying all eight display types:
1550
+
1551
+ - Filter still applies in one click; search still autofocuses.
1552
+ - Sort works on all eight; Clear sort restores source order.
1553
+ - Group by is offered on five, hidden on three; card headings span the grid.
1554
+ - Export absent signed out, present signed in; CSV opens cleanly in a spreadsheet with accents intact and formula-leading values inert.
1555
+ - Copy value, copy row, copy table all reach the clipboard.
1556
+ - Print shows only the display, with accordions expanded.
1557
+ - A link copied from the menu restores filter + sort + group in a fresh tab.
1558
+ - A pre-existing `dmf=` link still restores its filters.
1559
+ - Long-press on a touch device opens the menu and Back works.
1560
+
1561
+ - [ ] **Step 4: Confirm the admin round-trip**
1562
+
1563
+ Set `export.enabled = false` on a collection, save, reload the public page, and confirm the Export row is gone even with `exportable="true"` on the shortcode.
1564
+
1565
+ - [ ] **Step 5: Final commit**
1566
+
1567
+ **Never `git add -A` in this repo.** Running `npm test` boots the server, which calls
1568
+ `seedCoreProject()` and rewrites `updatedAt` timestamps in
1569
+ `content/collections/projects/data.json` on every single run. `git add -A` would sweep that
1570
+ churn into the commit. Stage explicit paths only:
1571
+
1572
+ ```bash
1573
+ git status --porcelain # confirm nothing unexpected
1574
+ git checkout -- content/collections/projects/data.json # discard test-run churn
1575
+ git add <the specific files this task changed>
1576
+ git commit -m "feat(collections): sort, group, export and copy in the right-click menu"
1577
+ ```