domma-cms 1.6.60 → 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 (137) 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/lib/effect-defs.js +1 -1
  6. package/admin/js/lib/effects-builder.js +2 -2
  7. package/admin/js/lib/page-editor-reference.js +5 -5
  8. package/admin/js/lib/shortcode-tags.js +1 -1
  9. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  10. package/docs/DommaFeatures.md +3005 -0
  11. package/docs/MultiTenant.md +176 -0
  12. package/docs/api-reference.md +1667 -0
  13. package/docs/cache.md +96 -0
  14. package/docs/configuration.md +1811 -0
  15. package/docs/editions-and-licences.md +190 -0
  16. package/docs/getting-started.md +201 -0
  17. package/docs/guides/form-triggers.md +260 -0
  18. package/docs/guides/mongodb-storage-adapter.md +282 -0
  19. package/docs/guides/multi-page-forms.md +121 -0
  20. package/docs/images/scribe-composer.png +0 -0
  21. package/docs/markdown-shortcodes.md +1738 -0
  22. package/docs/pentest-report-2026-04-03.md +181 -0
  23. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  24. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  25. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  26. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  27. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  28. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  29. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  30. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  31. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  32. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  33. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  34. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  35. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  36. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  37. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  38. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  39. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  40. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  41. package/docs/plugin-development.md +827 -0
  42. package/docs/recruitment-recipe.md +501 -0
  43. package/docs/scaffolding.md +312 -0
  44. package/docs/screenshots/calculator-floating.png +0 -0
  45. package/docs/screenshots/calculator-standard.png +0 -0
  46. package/docs/screenshots/email-settings.png +0 -0
  47. package/docs/screenshots/messages-count.png +0 -0
  48. package/docs/structure/user-types.md +107 -0
  49. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  50. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  51. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  52. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  53. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  54. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  55. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  56. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  57. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  58. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  59. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  60. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  61. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  62. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  63. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  64. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  65. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  66. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  67. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  68. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  69. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  70. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  71. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  72. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  73. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  74. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  75. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  76. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  77. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  78. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  79. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  80. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  81. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  82. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  83. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  84. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  85. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  86. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  87. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  88. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  89. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  90. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  91. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  92. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  93. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  94. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  95. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  96. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  97. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  98. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  99. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  100. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  101. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  102. package/docs/theming.md +320 -0
  103. package/package.json +2 -2
  104. package/public/js/celebrations/core/canvas.js +1 -1
  105. package/public/js/celebrations/index.js +1 -1
  106. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  107. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  108. package/public/js/celebrations/message-attrs.mjs +1 -0
  109. package/public/js/celebrations/themes/anniversary.js +1 -0
  110. package/public/js/celebrations/themes/autumn.js +1 -0
  111. package/public/js/celebrations/themes/birthday.js +1 -0
  112. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  113. package/public/js/celebrations/themes/calendar.js +1 -0
  114. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  115. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  116. package/public/js/celebrations/themes/christmas.js +1 -1
  117. package/public/js/celebrations/themes/easter.js +1 -0
  118. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  119. package/public/js/celebrations/themes/garden.js +1 -0
  120. package/public/js/celebrations/themes/graduation.js +1 -0
  121. package/public/js/celebrations/themes/greeting.js +1 -0
  122. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  123. package/public/js/celebrations/themes/money-rain.js +1 -0
  124. package/public/js/celebrations/themes/new-year.js +1 -0
  125. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  126. package/public/js/celebrations/themes/occasions.js +1 -0
  127. package/public/js/celebrations/themes/rain-control.js +1 -0
  128. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  129. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  130. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  131. package/public/js/celebrations/themes/summer-garden.js +1 -0
  132. package/public/js/celebrations/themes/winter.js +1 -0
  133. package/public/js/celebrations/trait-data.mjs +1 -1
  134. package/public/js/effects.js +1 -1
  135. package/scripts/build.js +4 -0
  136. package/server/services/markdown.js +5 -2
  137. package/server/templates/page.html +1 -1
@@ -0,0 +1,330 @@
1
+ # Collection context menu - sort, group, export & copy - design
2
+
3
+ Extends the public-site right-click menu (`public/js/collection-context.js`) from filter-only
4
+ to filter + sort + group + export + copy + print, with schema-level export permissions
5
+ configurable from the collection editor.
6
+
7
+ ## Goal
8
+
9
+ The right-click menu on `[collection]` / `[view]` displays currently offers filters and free-text
10
+ search. Add sorting, grouping, export, clipboard copy, print, and a shareable-view link - without
11
+ regressing the one-click speed of the filter path that makes the current menu good.
12
+
13
+ Secondary goal, surfaced during brainstorming: make export **coherent**. Two export surfaces
14
+ already exist and disagree with each other (see Prior art). This design unifies the dialect.
15
+
16
+ ## Prior art found during brainstorming
17
+
18
+ Three discoveries reshaped the design. Recorded here because none are visible from the feature's
19
+ own vantage point.
20
+
21
+ 1. **`exportable="true"` already exists.** `server/services/markdown.js:474` (tables) and
22
+ `:1122`/`:1231` (Collection Browser) already accept a per-placement export attribute, passed
23
+ through to `T.create` by `public/js/site.js:764`. A schema-level gate must not regress it.
24
+
25
+ 2. **`exportEntries()` already exists.** `server/services/collections.js:475` has served the
26
+ admin's CSV/JSON export since the collections feature landed. It quotes every cell and escapes
27
+ `"` as `""` - and nothing else. Object-valued fields export as the literal string
28
+ `[object Object]`; this is a live bug affecting e.g. `blog-posts.seo`.
29
+
30
+ 3. **Seven admin view files are committed mangled.** `collection-editor.js`, `form-editor.js`,
31
+ `block-editor.js`, `action-editor.js`, `page-editor.js`, `view-editor.js` and `navigation.js`
32
+ were readable at `35f0bdd` (2026-03-10) and mangled by `d492e38` (2026-03-12) - identifiers
33
+ reduced to single letters, JSDoc stripped. The other 81 files under `admin/js` are unaffected,
34
+ and `scripts/build.js` writes only to `_publish/`, so the current build is not the cause.
35
+ Restoration is out of scope here (see Deliberately deferred).
36
+
37
+ ## Decisions (from brainstorming)
38
+
39
+ 1. **Export permission lives in the collection schema**, as an `export` block alongside `api`.
40
+ 2. **Default on, but role-gated.** Absent config means signed-in users get export and anonymous
41
+ visitors do not. Public export is an explicit opt-in.
42
+ 3. **`exportable="true"` is grandfathered as an explicit public opt-in**, because that is exactly
43
+ what it means today. No existing page loses its CSV button on update.
44
+ 4. **Scope is the wide tier**: sort, group, export, copy, print, copy-link-to-view.
45
+ 5. **Menu structure is drill-in panels**, not hover flyouts or tabs.
46
+ 6. **The export gate is client-side and soft**, and is documented as such.
47
+ 7. **One shared CSV dialect** across the public export and the server's `exportEntries()`,
48
+ in one shared `.mjs` module imported by both. Fixes the `[object Object]` bug as a side effect.
49
+
50
+ ## Schema
51
+
52
+ New optional block in `content/collections/<slug>/schema.json`, parallel to `api`:
53
+
54
+ ```json
55
+ "export": {
56
+ "enabled": true,
57
+ "access": "subscriber",
58
+ "formats": ["csv", "json"]
59
+ }
60
+ ```
61
+
62
+ - `enabled` - default `true`.
63
+ - `access` - `"public"` or a role name. Default: the least-privileged role in
64
+ `userTypes.getRoleHierarchy()` (the highest level number), so the default is effectively
65
+ "any authenticated user". Never `"token"`; API tokens are not a browser concept.
66
+ - `formats` - subset of `["csv", "json"]`. Default: both.
67
+
68
+ ### Resolution order
69
+
70
+ Evaluated per display, first match wins:
71
+
72
+ 1. `export.enabled === false` → no export. `exportable="true"` is ignored.
73
+ 2. `exportable="true"` on the shortcode → export offered to everyone (grandfathering).
74
+ 3. `export.access === "public"` → offered to everyone.
75
+ 4. Otherwise → offered only when `GET /api/auth/me` resolves a user whose role level is at or
76
+ above `export.access`.
77
+
78
+ `schema.export` answers *may this collection be exported, and by whom*. `exportable="true"`
79
+ answers *does this placement render a visible button*. They are not redundant; both survive.
80
+
81
+ ### Why the gate is client-side
82
+
83
+ `server/routes/public.js:138-149` deliberately skips `jwtVerify()` when a page's visibility is
84
+ effectively public, and keys the response cache `role:anon` so all anonymous traffic shares one
85
+ entry. Server-rendering a per-visitor export flag would force JWT verification on every public
86
+ page request and fragment that cache.
87
+
88
+ This costs nothing, because the gate was never a security boundary: the full dataset already
89
+ ships inside the base64 `data-ctx` attribute. The gate stops a casual visitor being handed a
90
+ download button; it does not stop a determined one reading DevTools. **This must be stated in
91
+ `docs/configuration.md`** so no operator mistakes it for enforcement. A hard gate would require a
92
+ server export endpoint re-deriving rows from posted filter state - deferred.
93
+
94
+ ## Delivery to the client
95
+
96
+ Two routes, because tables already differ:
97
+
98
+ - **`data-ctx` displays** - `buildCtxAttr(fields, entries, opts)` gains a third parameter
99
+ carrying `{slug, export: {allowed, access, formats}}`. Threaded explicitly through all 8 call
100
+ sites (`markdown.js:289, 508, 535, 566, 604, 666, 759`). **Not** module-level state:
101
+ `markdown.js` renders shortcodes recursively, so a shared variable is clobbered by a
102
+ `[collection]` nested inside a `[card]`.
103
+ - **`[data-collection-table]`** - rides the existing base64 `payload` object that
104
+ `site.js:758` already decodes.
105
+
106
+ ## Menu structure
107
+
108
+ Drill-in panels: one panel whose content swaps, with a `‹ Back` row. Chosen over hover flyouts
109
+ because it reuses the existing viewport clamp (`collection-context.js:393-395`) unchanged, needs
110
+ no hover-intent timing, survives long-press on touch, and is keyboard-navigable for free.
111
+
112
+ Root panel, split by scope - `resolveEntry()` already distinguishes the two:
113
+
114
+ ```
115
+ ENTRY-SCOPED (heading = clicked field label)
116
+ Filter = "value"
117
+ Filter ≠ "value"
118
+ Filter > / < "value" (numeric/date values only, as today)
119
+ Copy value
120
+ DISPLAY-SCOPED
121
+ Sort…
122
+ Group by… (DOM-toggle display types only - see Grouping)
123
+ Copy…
124
+ Export… (only when resolution order permits)
125
+ Print
126
+ Copy link to this view
127
+ Clear all filters (n) (only when filters active, as today)
128
+ [ Search… ]
129
+ ```
130
+
131
+ The free-text search input stays pinned at the bottom of the root panel and keeps autofocus.
132
+ Filter remains one click. Sort becomes two.
133
+
134
+ ## Sort
135
+
136
+ State: `wrapper._dmCtxSort = {field, label, dir}` or `null`. Applied in `apply()` directly after
137
+ the existing filter step:
138
+
139
+ ```js
140
+ let allowedEntries = model.entries.filter(e => filters.every(f => matches(e, f)));
141
+ if (sort) allowedEntries = allowedEntries.slice().sort(comparator(sort));
142
+ ```
143
+
144
+ `.slice()` is required - `model.entries` is the pristine source order and clearing the sort must
145
+ return to it.
146
+
147
+ Three of the four existing strategies inherit sort with no further work, because each takes its
148
+ order from the array it is handed: table (`_dmTable.render`), timeline (`instance.setItems`),
149
+ carousel (rebuild from pristine slides). Only the DOM-toggle path needs new code: reorder the
150
+ `[data-entry-id]` siblings with `appendChild` in sorted order. Moving nodes rather than cloning
151
+ preserves event listeners and Domma instance references.
152
+
153
+ **Comparator.** Extract the type-detection ladder currently inlined in `matches()`
154
+ (`collection-context.js:92-99`) into a shared `compareValues(a, b)`: try `parseFloat`, then
155
+ `Date.parse`, then string compare. Both `gt`/`lt` filtering and sorting then share one definition
156
+ of "which value is larger", which otherwise drifts.
157
+
158
+ **Direction labels follow the detected type** - `A → Z` for strings, `newest → oldest` for dates,
159
+ `low → high` for numbers.
160
+
161
+ ## Grouping
162
+
163
+ Available **only on display types that use the DOM-toggle strategy**: cards, list, listgroup,
164
+ accordion, block. These render entries as siblings, so a heading element can be inserted between
165
+ groups.
166
+
167
+ The other three cannot take it, for three distinct reasons:
168
+
169
+ - **table** - `T.create` has no grouping API; injected heading rows would fight its pagination.
170
+ - **timeline** - `Progression.setItems()` accepts items, not section headers.
171
+ - **carousel** - slides are a sequence; grouped slides are not meaningful.
172
+
173
+ The `Group by…` row is **hidden** on those types, not disabled - a greyed row invites "why?" on
174
+ every open.
175
+
176
+ Implementation: group field becomes the primary sort; a `.dm-ctx-group-heading` div is inserted
177
+ before the first entry of each distinct value. An active sort becomes the secondary sort within
178
+ each group. Clearing the group leaves the sort intact.
179
+
180
+ ## Export, copy, print
181
+
182
+ One serialiser, several sinks:
183
+
184
+ ```js
185
+ serialise(entries, fields, format) // format: 'csv' | 'json' | 'markdown' → string
186
+ ```
187
+
188
+ | Menu row | Format | Sink |
189
+ |----------------------------|----------|-----------------|
190
+ | Export → CSV | csv | Blob download |
191
+ | Export → JSON | json | Blob download |
192
+ | Copy → this value | - | clipboard (raw) |
193
+ | Copy → this row | json | clipboard |
194
+ | Copy → visible as table | markdown | clipboard |
195
+
196
+ **Row set:** the filtered-and-sorted entries - exactly what is on screen. No "all vs visible"
197
+ choice; the chips above the display already state what has been filtered out. Grouping is
198
+ presentational and does not affect the row set.
199
+
200
+ **Column set:** `model.fields`, which is already the rendered-field allowlist produced by
201
+ `buildCtxAttr`. Export cannot emit a column the page did not render.
202
+
203
+ **Filename:** `<slug>-<YYYY-MM-DD>.<ext>`. Requires adding `slug` to the ctx payload.
204
+
205
+ **Print:** stamp `data-dm-print` on the clicked wrapper, apply a print stylesheet that hides
206
+ everything outside that subtree, call `window.print()`, then remove the attribute. Accordions
207
+ must be force-expanded in print CSS or the output is a stack of closed headers.
208
+
209
+ **Copy link to this view:** call `syncHash()`, then copy `location.href`. Confirmation via
210
+ `E.toast` when Domma is present, falling back to a transient inline note.
211
+
212
+ ## The shared CSV dialect
213
+
214
+ Both `serialise(..., 'csv')` (browser) and `exportEntries(slug, 'csv')` (server) must produce
215
+ byte-identical output for the same values. **They share one module** - verified during planning:
216
+ root `package.json` is `"type": "module"`, and although `public/js/package.json` pins
217
+ `"type": "commonjs"`, a `.mjs` file is ESM unconditionally and imports cleanly from server code.
218
+ `tests/services/menu-decor-client.test.js:7` is the existing precedent for the test side; a
219
+ `node -e` import from the server side was confirmed working.
220
+
221
+ So `public/js/collection-export.mjs` is the single source of truth, imported by
222
+ `server/services/collections.js` *and* served to the browser. No duplication, so no parity
223
+ fixture is needed - the dialect simply cannot diverge.
224
+
225
+ The **dialect** is shared. The **column set** legitimately differs: the admin export includes
226
+ `id` and `createdAt` and every schema field; the public export emits only the rendered allowlist.
227
+
228
+ Rules, in order:
229
+
230
+ 1. **Coercion.**
231
+ - `null` / `undefined` → `''`
232
+ - `Array` → elements coerced individually, joined with `'; '`
233
+ - `Date` → ISO 8601 string
234
+ - non-null non-array `object` → `JSON.stringify(v)`
235
+ - otherwise → `String(v)`
236
+ 2. **Formula-injection sanitising.** If the coerced string matches `/^[=+\-@\t\r]/` **and is not
237
+ a finite number**, prefix a single apostrophe. The numeric exemption is essential - without it
238
+ every negative number exports as `'-5`. Applied to `csv` only; `json` and `markdown` are
239
+ unaffected.
240
+ 3. **Quoting.** Every cell is wrapped in double quotes, with `"` escaped as `""`. Matches the
241
+ existing server behaviour; quoting unconditionally avoids a needs-quoting predicate.
242
+ 4. **Line ending:** `\n`. Deviates from RFC 4180's CRLF, but matches what `exportEntries()`
243
+ already emits - changing it risks breaking operators' existing importers for no user-visible
244
+ gain. Deliberate.
245
+ 5. **Byte order mark:** output is prefixed with `\uFEFF` (U+FEFF) so Excel reads UTF-8 correctly.
246
+
247
+ Rule 1 fixes the `[object Object]` bug. Rules 2 and 5 are new to both surfaces.
248
+
249
+ ## URL state
250
+
251
+ The `dmf=` hash payload currently maps display index → filter array. It becomes display index →
252
+ `{f, s, g}` (filters, sort, group). Back-compat is three lines in `readHash()`: if the per-index
253
+ value is an `Array`, treat it as a legacy filter list. Existing shared links keep working.
254
+
255
+ ## Admin UI
256
+
257
+ The collection editor (`admin/js/templates/collection-editor.html:13-18`) has tabs
258
+ Settings / Fields / API Access / Storage. Export joins the **API Access** panel as a fifth row
259
+ below the four verbs, with a Formats sub-row:
260
+
261
+ ```
262
+ Export ☑ Allow export [ subscriber ▾ ]
263
+ Formats ☑ CSV ☑ JSON
264
+ ```
265
+
266
+ The existing **Read fields** sub-row (`collection-editor.js:236-247`) is the precedent for a row
267
+ carrying an extra control, so Formats introduces no new pattern.
268
+
269
+ Wiring mirrors the existing shape: `readApiRows()` (`:297-305`) reads `api-<verb>-enabled` /
270
+ `api-<verb>-access` by DOM id; export reads `export-enabled`, `export-access`, and two format
271
+ checkboxes. The three save paths (`:432`, `:463`, `:480`) currently send `api: b` and gain
272
+ `export: <obj>` alongside.
273
+
274
+ The tab is renamed **"API & Export"** - a user hunting for an export setting will not open a tab
275
+ labelled "API Access".
276
+
277
+ New code in this file uses **readable identifiers** despite the surrounding mangled style. A file
278
+ that is 5% legible is better than one that is 0%.
279
+
280
+ ## File layout
281
+
282
+ | File | Change |
283
+ |---|---|
284
+ | `public/js/collection-export.mjs` | **new** - `serialise()`, CSV dialect, download + clipboard sinks. `.mjs` so both the browser and `collections.js` import it |
285
+ | `public/js/collection-context.js` | drill-in panels, sort state, grouping, comparator extraction, menu rows |
286
+ | `public/js/site.js` | pass export config from table payload to the wrapper |
287
+ | `public/css/site.css` | group headings, print stylesheet, panel transition |
288
+ | `server/services/markdown.js` | `buildCtxAttr()` third parameter, threaded through 8 call sites |
289
+ | `server/services/collections.js` | `exportEntries()` imports and uses the shared dialect |
290
+ | `admin/js/templates/collection-editor.html` | tab rename, export rows |
291
+ | `admin/js/views/collection-editor.js` | render + read + save the export block |
292
+ | `docs/configuration.md` | `export` schema block; explicit note that the gate is soft |
293
+ | `docs/markdown-shortcodes.md` | menu capabilities per display type |
294
+
295
+ `collection-context.js` is 421 lines today and would approach 700 with all of this. Splitting the
296
+ serialiser into `collection-export.mjs` keeps each file focused: one owns menu and display state,
297
+ the other owns turning entries into text.
298
+
299
+ **Load-order consequence:** `page.html:120` currently loads `collection-context.js` as a classic
300
+ `defer` script, which cannot `import`. It becomes `type="module"` - matching `site.js` (`:119`)
301
+ and `forms.js` (`:128`), which already do exactly this. Module scripts defer by default, so the
302
+ `defer` attribute is dropped. The file is already an IIFE under `'use strict'`, so no other
303
+ semantics change. The `?v=` cache-buster string must be bumped on every touched public asset.
304
+
305
+ ## Testing
306
+
307
+ Existing suites live under `tests/` by area (`node --test`, concurrency 1).
308
+
309
+ - **`tests/services/csv-dialect.test.js`** - unit tests for the single shared dialect: ~9
310
+ awkward values covering formula-leading string, negative number, embedded double quote,
311
+ embedded comma, embedded newline, array, nested object, accented text, empty.
312
+ - **`tests/services/collections-export.test.js`** - extend for the `[object Object]` regression.
313
+ - **`tests/markdown/collection-ctx.test.js`** - `buildCtxAttr` emits export config; respects
314
+ `enabled: false`; grandfathers `exportable="true"`; never emits an unrendered field.
315
+ - **Manual, in a real browser** - sort across all 8 display types; grouping hidden on
316
+ table/timeline/carousel; drill-in on long-press; legacy `dmf=` link still restores; print
317
+ output; export gate visible signed-in and absent signed-out.
318
+
319
+ Per project convention, UI behaviour is not claimed as verified without a real browser load.
320
+
321
+ ## Deliberately deferred
322
+
323
+ - **Restoring the seven mangled admin view files.** Readable source exists at `35f0bdd`, but five
324
+ months of changes have landed on the mangled lineage, so it is a rename-against-reference job,
325
+ not a revert. Its own task - it should not ride along with a feature.
326
+ - **A hard server-side export gate.** Would need `/api/collections/:slug/export` re-deriving rows
327
+ from posted filter state. Only worth it if an operator needs genuine enforcement.
328
+ - **Column visibility toggles** for table displays.
329
+ - **Multi-field sort.** Single field plus an optional group is enough.
330
+ - **Export of grouped structure.** Groups are presentational; exports stay flat.