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,1112 @@
1
+ # Admin Sidebar via Menus 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:** Replace the hand-rolled `admin/js/config/sidebar-config.js` flat-array sidebar with a menu rendered from the Menus system at a new built-in slot `admin-sidebar`. Plugins extend via the menu-locations registry. The Menus admin can edit the sidebar visually. Native `<details>`/`<summary>` provides expand/collapse for nested folders.
6
+
7
+ **Architecture:** A new core slot `admin-sidebar` is registered in the Menus service. A one-shot migration writes the current sidebar structure to `config/menus/admin-sidebar.json` on first boot and maps the slot. A new `admin/js/lib/sidebar-renderer.js` fetches the menu, merges plugin-registered items, synthesises per-project sub-trees, filters by user permission, and renders nested `<details>` elements. The old `Domma.elements.sidebar(...)` call site in `app.js` is replaced; `sidebar-config.js` becomes a deprecated stub.
8
+
9
+ **Tech Stack:** Fastify 5 (ESM), Domma SPA admin (`$`/`E`/`F`/`T`/`I`/`H`/`S`), `node:test`, native `<details>`/`<summary>` for expand/collapse, no upstream `domma-js` dependency.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md`
12
+
13
+ **Critical project rules** (from `CLAUDE.md` + memory):
14
+
15
+ - Server always running on port **4096** - never start/stop.
16
+ - Domma admin uses `$`, `H.get/post/put/delete`, `E.toast/confirm`, `S.set/get`, `Domma.icons.scan`.
17
+ - `.get(0)` to unwrap collections.
18
+ - T.create render fns are `(value, row)` two-arg - NOT single-arg.
19
+ - Admin uses `Cache-Control: no-store` - no `?v=N` cache-busting query strings.
20
+ - UI changes must be browser-tested (memory: `feedback_no_verified_without_browser.md`).
21
+ - Do NOT commit during implementation - user commits separately (memory: `feedback_no_commits.md`).
22
+ - No `Co-Authored-By` footers.
23
+
24
+ ---
25
+
26
+ ## File Map
27
+
28
+ | Path | Purpose | Action |
29
+ |---|---|---|
30
+ | `server/services/menus.js` | Register `admin-sidebar` core slot; allow `permission` field on items | Modify |
31
+ | `server/services/sidebar-migration.js` | One-shot seed of `admin-sidebar.json` + locations patch | Create |
32
+ | `server/services/hooks.js` | Add `registerSidebarItem()` export | Modify |
33
+ | `server/services/plugins.js` | Expose `registerSidebarItem` to plugins (hooks object) | Modify |
34
+ | `server/server.js` | Wire `sidebar-migration` into boot; register `/api/sidebar/registered-items` route | Modify |
35
+ | `server/routes/api/sidebar.js` | New route module for `/api/sidebar/registered-items` | Create |
36
+ | `admin/js/lib/sidebar-renderer.js` | The new sidebar renderer | Create |
37
+ | `admin/js/app.js` | Replace `Domma.elements.sidebar(...)` call with new renderer | Modify |
38
+ | `admin/js/config/sidebar-config.js` | Stub: returns `[]` + deprecation comment | Modify (gut) |
39
+ | `admin/js/views/menu-editor.js` | Add `permission` field to item-row template + save handler | Modify |
40
+ | `admin/css/admin.css` | Styles for `<details>`-based sidebar | Modify |
41
+ | `tests/services/menus.test.js` | Add tests for `admin-sidebar` slot registration + `permission` field acceptance | Modify |
42
+ | `tests/services/sidebar-migration.test.js` | New - migration tests against temp config dirs | Create |
43
+ | `tests/routes/sidebar.test.js` | New - smoke tests for `/api/sidebar/registered-items` | Create |
44
+ | `docs/configuration.md` | Document `admin-sidebar` slot + `permission` field | Modify |
45
+ | `docs/plugin-development.md` | Document `hooks.registerSidebarItem()` | Modify |
46
+ | `docs/api-reference.md` | Document `/api/sidebar/registered-items` | Modify |
47
+ | `CLAUDE.md` | Note menu-data-driven admin sidebar | Modify |
48
+
49
+ ---
50
+
51
+ ## Task 1: Register `admin-sidebar` core slot + accept `permission` on items
52
+
53
+ **Files:**
54
+
55
+ - Modify: `server/services/menus.js`
56
+ - Modify: `tests/services/menus.test.js`
57
+
58
+ ### Step 1.1: Write the test
59
+
60
+ Append to `tests/services/menus.test.js`:
61
+
62
+ ```js
63
+ test('registry - admin-sidebar core slot is registered at module load', () => {
64
+ const slots = getLocationRegistry().map(s => s.slot);
65
+ assert.ok(slots.includes('admin-sidebar'), 'admin-sidebar slot should be registered by core');
66
+ });
67
+
68
+ test('validateMenu - accepts `permission` field on items without complaint', () => {
69
+ const errors = validateMenu({
70
+ slug: 'admin-sidebar-test',
71
+ name: 'X',
72
+ items: [
73
+ {text: 'Pages', url: '#/pages', permission: 'pages'},
74
+ {text: 'No permission item', url: '#/foo'}
75
+ ]
76
+ });
77
+ assert.deepEqual(errors, []);
78
+ });
79
+ ```
80
+
81
+ ### Step 1.2: Run to confirm failure
82
+
83
+ Run: `node --test tests/services/menus.test.js 2>&1 | tail -10`
84
+
85
+ Expected: the `admin-sidebar` slot test fails (slot not yet registered). The `permission` test may already pass because `validateMenu` doesn't currently reject unknown item keys.
86
+
87
+ ### Step 1.3: Register the core slot
88
+
89
+ In `server/services/menus.js`, find the existing core-slot registration block (`registerLocation('navbar', …)` etc., around the bottom of the module). Add:
90
+
91
+ ```js
92
+ registerLocation('admin-sidebar', {
93
+ label: 'Admin sidebar',
94
+ description: 'The left-side navigation in the admin panel',
95
+ source: 'core',
96
+ maxDepth: null
97
+ });
98
+ ```
99
+
100
+ ### Step 1.4: Confirm `permission` is preserved through createMenu/updateMenu
101
+
102
+ In `server/services/menus.js`, locate `createMenu`. The function builds the persisted menu shape from `input`. Items are written via `items: Array.isArray(input.items) ? input.items : []` - the whole item object passes through, so `permission` is preserved without code change. Verify by reading the code.
103
+
104
+ If the validateMenu test from Step 1.1 already passes (because validateMenu doesn't whitelist item keys), no further change is needed.
105
+
106
+ ### Step 1.5: Run tests
107
+
108
+ Run: `node --test tests/services/menus.test.js 2>&1 | tail -5`
109
+
110
+ Expected: all tests pass (including the two new ones).
111
+
112
+ ---
113
+
114
+ ## Task 2: Sidebar migration service
115
+
116
+ **Files:**
117
+
118
+ - Create: `server/services/sidebar-migration.js`
119
+ - Create: `tests/services/sidebar-migration.test.js`
120
+
121
+ ### Step 2.1: Write the migration tests
122
+
123
+ Create `tests/services/sidebar-migration.test.js`:
124
+
125
+ ```js
126
+ /**
127
+ * Sidebar migration - exercises the one-shot seed flow on a temporary
128
+ * config dir so the real project config is untouched.
129
+ */
130
+ import {test} from 'node:test';
131
+ import assert from 'node:assert/strict';
132
+ import fs from 'fs/promises';
133
+ import path from 'path';
134
+ import os from 'os';
135
+ import {runMigration} from '../../server/services/sidebar-migration.js';
136
+
137
+ async function scratch() {
138
+ return fs.mkdtemp(path.join(os.tmpdir(), 'sidebar-migration-'));
139
+ }
140
+
141
+ test('no-op when admin-sidebar.json already exists', async () => {
142
+ const dir = await scratch();
143
+ await fs.mkdir(path.join(dir, 'menus'), {recursive: true});
144
+ await fs.writeFile(path.join(dir, 'menus', 'admin-sidebar.json'), '{}', 'utf8');
145
+ const result = await runMigration({configDir: dir});
146
+ assert.equal(result.migrated, false);
147
+ assert.equal(result.reason, 'admin-sidebar.json already present');
148
+ });
149
+
150
+ test('seeds admin-sidebar.json with the standard tree', async () => {
151
+ const dir = await scratch();
152
+ const result = await runMigration({configDir: dir});
153
+ assert.equal(result.migrated, true);
154
+
155
+ const menu = JSON.parse(await fs.readFile(path.join(dir, 'menus', 'admin-sidebar.json'), 'utf8'));
156
+ assert.equal(menu.slug, 'admin-sidebar');
157
+ assert.ok(Array.isArray(menu.items));
158
+ assert.ok(menu.items.length >= 5, 'should seed at least 5 top-level sections');
159
+ const flat = [];
160
+ const walk = (xs) => xs.forEach(x => { flat.push(x); if (x.items) walk(x.items); });
161
+ walk(menu.items);
162
+ const pages = flat.find(x => x.text === 'Pages');
163
+ assert.ok(pages, 'Pages item should exist in seeded tree');
164
+ assert.equal(pages.permission, 'pages');
165
+ });
166
+
167
+ test('patches menu-locations.json to add admin-sidebar mapping', async () => {
168
+ const dir = await scratch();
169
+ await fs.writeFile(path.join(dir, 'menu-locations.json'), '{"navbar":"main"}', 'utf8');
170
+ await runMigration({configDir: dir});
171
+ const map = JSON.parse(await fs.readFile(path.join(dir, 'menu-locations.json'), 'utf8'));
172
+ assert.equal(map.navbar, 'main');
173
+ assert.equal(map['admin-sidebar'], 'admin-sidebar');
174
+ });
175
+
176
+ test('creates menu-locations.json when absent', async () => {
177
+ const dir = await scratch();
178
+ await runMigration({configDir: dir});
179
+ const map = JSON.parse(await fs.readFile(path.join(dir, 'menu-locations.json'), 'utf8'));
180
+ assert.equal(map['admin-sidebar'], 'admin-sidebar');
181
+ });
182
+ ```
183
+
184
+ ### Step 2.2: Run to confirm failure
185
+
186
+ Run: `node --test tests/services/sidebar-migration.test.js`
187
+
188
+ Expected: `ERR_MODULE_NOT_FOUND` for `sidebar-migration.js`.
189
+
190
+ ### Step 2.3: Implement the migration
191
+
192
+ Create `server/services/sidebar-migration.js`:
193
+
194
+ ```js
195
+ /**
196
+ * Admin sidebar auto-migration.
197
+ *
198
+ * Runs once on server boot. Idempotent - guarded by the existence of
199
+ * config/menus/admin-sidebar.json. Seeds the admin sidebar menu with the
200
+ * standard tree (Overview, Projects, Content, Data, System, Documentation)
201
+ * and patches config/menu-locations.json to map the `admin-sidebar` slot.
202
+ */
203
+ import fs from 'fs/promises';
204
+ import path from 'path';
205
+ import {fileURLToPath} from 'url';
206
+
207
+ const __filename = fileURLToPath(import.meta.url);
208
+ const DEFAULT_CONFIG_DIR = path.resolve(path.dirname(__filename), '..', '..', 'config');
209
+
210
+ const SEED_ITEMS = [
211
+ {
212
+ text: 'Overview', icon: 'home',
213
+ items: [
214
+ {text: 'Dashboard', url: '#/', icon: 'home'}
215
+ ]
216
+ },
217
+ {
218
+ text: 'Projects', icon: 'folder', permission: 'projects',
219
+ items: [
220
+ {text: 'Manage projects', url: '#/projects', icon: 'folder', permission: 'projects'}
221
+ ]
222
+ },
223
+ {
224
+ text: 'Content', icon: 'edit',
225
+ items: [
226
+ {text: 'Pages', url: '#/pages', icon: 'file-text', permission: 'pages'},
227
+ {text: 'Media', url: '#/media', icon: 'image', permission: 'media'},
228
+ {text: 'Menus', url: '#/menus', icon: 'menu', permission: 'menus'}
229
+ ]
230
+ },
231
+ {
232
+ text: 'Data', icon: 'database',
233
+ items: [
234
+ {text: 'Collections', url: '#/collections', icon: 'database', permission: 'collections'},
235
+ {text: 'Forms', url: '#/forms', icon: 'layout', permission: 'collections'},
236
+ {text: 'Views', url: '#/views', icon: 'eye', permission: 'views'},
237
+ {text: 'Actions', url: '#/actions', icon: 'zap', permission: 'actions'},
238
+ {text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages'},
239
+ {text: 'Components', url: '#/components', icon: 'component', permission: 'components'}
240
+ ]
241
+ },
242
+ {
243
+ text: 'System', icon: 'settings',
244
+ items: [
245
+ {text: 'Notifications', url: '#/system/notifications', icon: 'bell', permission: 'notifications'},
246
+ {text: 'Roles', url: '#/roles', icon: 'shield', permission: 'plugins'},
247
+ {text: 'Users', url: '#/users', icon: 'users', permission: 'users'},
248
+ {text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings'},
249
+ {text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings'},
250
+ {text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts'},
251
+ {text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins'},
252
+ {text: 'My Profile', url: '#/my-profile', icon: 'user'}
253
+ ]
254
+ },
255
+ {
256
+ text: 'Documentation', icon: 'book',
257
+ items: [
258
+ {text: 'Usage', url: '#/documentation', icon: 'book'},
259
+ {text: 'Tutorials', url: '#/tutorials', icon: 'document'},
260
+ {text: 'API Reference', url: '#/api-reference', icon: 'code'}
261
+ ]
262
+ }
263
+ ];
264
+
265
+ async function exists(p) {
266
+ try { await fs.access(p); return true; } catch { return false; }
267
+ }
268
+
269
+ async function readJson(p) {
270
+ return JSON.parse(await fs.readFile(p, 'utf8'));
271
+ }
272
+
273
+ async function writeJson(p, data) {
274
+ await fs.writeFile(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
275
+ }
276
+
277
+ /**
278
+ * Run the migration. Returns `{migrated: boolean, reason?: string}`.
279
+ *
280
+ * @param {{configDir?: string}} [opts]
281
+ */
282
+ export async function runMigration(opts = {}) {
283
+ const configDir = opts.configDir || DEFAULT_CONFIG_DIR;
284
+ const menusDir = path.join(configDir, 'menus');
285
+ const menuPath = path.join(menusDir, 'admin-sidebar.json');
286
+ const locPath = path.join(configDir, 'menu-locations.json');
287
+
288
+ if (await exists(menuPath)) {
289
+ return {migrated: false, reason: 'admin-sidebar.json already present'};
290
+ }
291
+
292
+ await fs.mkdir(menusDir, {recursive: true});
293
+ const now = new Date().toISOString();
294
+ await writeJson(menuPath, {
295
+ slug: 'admin-sidebar',
296
+ name: 'Admin sidebar',
297
+ description: 'Auto-seeded by sidebar migration on first boot',
298
+ items: SEED_ITEMS,
299
+ meta: {createdAt: now, updatedAt: now, bundled: false, presetOwner: null}
300
+ });
301
+
302
+ let locations = {};
303
+ if (await exists(locPath)) {
304
+ try { locations = await readJson(locPath); } catch { /* malformed - start fresh */ }
305
+ }
306
+ locations['admin-sidebar'] = 'admin-sidebar';
307
+ await writeJson(locPath, locations);
308
+
309
+ console.log('[admin-sidebar] Seeded admin-sidebar menu + mapped admin-sidebar slot');
310
+ return {migrated: true};
311
+ }
312
+ ```
313
+
314
+ ### Step 2.4: Run tests
315
+
316
+ Run: `node --test tests/services/sidebar-migration.test.js`
317
+
318
+ Expected: 4/4 pass.
319
+
320
+ ---
321
+
322
+ ## Task 3: Wire migration into server boot
323
+
324
+ **Files:**
325
+
326
+ - Modify: `server/server.js`
327
+
328
+ ### Step 3.1: Find the existing menus migration call
329
+
330
+ Run: `grep -n "runMenusMigration\|menus-migration" server/server.js`
331
+
332
+ ### Step 3.2: Add the sidebar migration call
333
+
334
+ Immediately AFTER the existing menus migration block in `server/server.js`, add:
335
+
336
+ ```js
337
+ try {
338
+ const {runMigration: runSidebarMigration} = await import('./services/sidebar-migration.js');
339
+ await runSidebarMigration();
340
+ } catch (err) {
341
+ app.log.warn(`[admin-sidebar] Migration skipped: ${err.message}`);
342
+ }
343
+ ```
344
+
345
+ ### Step 3.3: Verify syntax
346
+
347
+ ```bash
348
+ node --check server/server.js
349
+ node -e "import('./server/services/sidebar-migration.js').then(m => console.log('migration ok:', typeof m.runMigration))"
350
+ ```
351
+
352
+ Expected: silent + `migration ok: function`.
353
+
354
+ DO NOT restart the server. The migration will fire on the next user-initiated restart.
355
+
356
+ ---
357
+
358
+ ## Task 4: `registerSidebarItem` plugin hook
359
+
360
+ **Files:**
361
+
362
+ - Modify: `server/services/hooks.js`
363
+ - Modify: `server/services/plugins.js`
364
+
365
+ ### Step 4.1: Add the hook export
366
+
367
+ In `server/services/hooks.js`, after the existing `registerMenuLocation` block, add:
368
+
369
+ ```js
370
+ // ---------------------------------------------------------------------------
371
+ // Admin sidebar items - plugin-registered runtime injection
372
+ // ---------------------------------------------------------------------------
373
+
374
+ const _registeredSidebarItems = [];
375
+
376
+ /**
377
+ * Register a sidebar item from a plugin. Items are merged into the rendered
378
+ * admin sidebar at render time - they are NOT persisted into menu JSON.
379
+ * Plugins re-register on each server boot via their setupPlugin hook.
380
+ *
381
+ * @param {{parent?: string, item: {text: string, url: string, icon?: string, permission?: string, items?: object[]}}} opts
382
+ */
383
+ export function registerSidebarItem(opts) {
384
+ if (!opts || !opts.item || typeof opts.item.text !== 'string') {
385
+ throw new Error('registerSidebarItem: opts.item.text is required');
386
+ }
387
+ _registeredSidebarItems.push({
388
+ parent: opts.parent || null,
389
+ item: opts.item
390
+ });
391
+ }
392
+
393
+ /**
394
+ * @returns {Array<{parent: string|null, item: object}>}
395
+ */
396
+ export function getRegisteredSidebarItems() {
397
+ return _registeredSidebarItems.slice();
398
+ }
399
+ ```
400
+
401
+ ### Step 4.2: Expose to plugins via the loader
402
+
403
+ In `server/services/plugins.js`, find the line that builds the `hooks` object passed to each plugin's Fastify registration (currently includes `registerMenuLocation`). Update the import + the hooks object:
404
+
405
+ ```js
406
+ import {hooks, registerSanitizeRules, registerShortcode, registerTransform, registerMenuLocation, registerSidebarItem} from './hooks.js';
407
+ ```
408
+
409
+ Then update the `hooks:` block in the plugin register call:
410
+
411
+ ```js
412
+ hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation, registerSidebarItem, on: hooks.on.bind(hooks)},
413
+ ```
414
+
415
+ ### Step 4.3: Smoke test
416
+
417
+ Run:
418
+ ```bash
419
+ node -e "import('./server/services/hooks.js').then(h => { h.registerSidebarItem({parent: 'system', item: {text: 'X', url: '#/x'}}); console.log(h.getRegisteredSidebarItems()); })"
420
+ ```
421
+
422
+ Expected: prints `[ { parent: 'system', item: { text: 'X', url: '#/x' } } ]`.
423
+
424
+ ---
425
+
426
+ ## Task 5: `/api/sidebar/registered-items` route
427
+
428
+ **Files:**
429
+
430
+ - Create: `server/routes/api/sidebar.js`
431
+ - Create: `tests/routes/sidebar.test.js`
432
+
433
+ ### Step 5.1: Write the route test
434
+
435
+ Create `tests/routes/sidebar.test.js`:
436
+
437
+ ```js
438
+ /**
439
+ * Smoke test for /api/sidebar/registered-items.
440
+ */
441
+ import {test} from 'node:test';
442
+ import assert from 'node:assert/strict';
443
+ import Fastify from 'fastify';
444
+ import fastifyJwt from '@fastify/jwt';
445
+ import {sidebarRoutes} from '../../server/routes/api/sidebar.js';
446
+ import {registerSidebarItem} from '../../server/services/hooks.js';
447
+
448
+ async function buildApp() {
449
+ const app = Fastify();
450
+ app.register(fastifyJwt, {secret: 'test-secret-only-for-tests-32chars'});
451
+ const authenticate = async (req) => { req.user = {id: 'sa', role: 'super-admin'}; };
452
+ const requirePermission = () => async () => {};
453
+ await app.register(async (inst) => sidebarRoutes(inst, {authenticate, requirePermission}), {prefix: '/api'});
454
+ return app;
455
+ }
456
+
457
+ test('GET /api/sidebar/registered-items returns an array', async () => {
458
+ const app = await buildApp();
459
+ const res = await app.inject({method: 'GET', url: '/api/sidebar/registered-items'});
460
+ assert.equal(res.statusCode, 200);
461
+ assert.ok(Array.isArray(res.json()));
462
+ await app.close();
463
+ });
464
+
465
+ test('GET /api/sidebar/registered-items reflects registerSidebarItem calls', async () => {
466
+ registerSidebarItem({parent: 'system', item: {text: 'PluginX', url: '#/plugin-x'}});
467
+ const app = await buildApp();
468
+ const res = await app.inject({method: 'GET', url: '/api/sidebar/registered-items'});
469
+ const body = res.json();
470
+ assert.ok(body.some(entry => entry.item.text === 'PluginX'));
471
+ await app.close();
472
+ });
473
+ ```
474
+
475
+ ### Step 5.2: Run to confirm failure
476
+
477
+ Run: `node --test tests/routes/sidebar.test.js`
478
+
479
+ Expected: import error.
480
+
481
+ ### Step 5.3: Implement the route
482
+
483
+ Create `server/routes/api/sidebar.js`:
484
+
485
+ ```js
486
+ /**
487
+ * Admin sidebar API
488
+ *
489
+ * GET /api/sidebar/registered-items - plugin-registered sidebar items
490
+ *
491
+ * Auth middlewares are accepted as DI options so tests can supply no-ops.
492
+ */
493
+ import {
494
+ authenticate as defaultAuthenticate,
495
+ requirePermission as defaultRequirePermission
496
+ } from '../../middleware/auth.js';
497
+ import {getRegisteredSidebarItems} from '../../services/hooks.js';
498
+
499
+ export async function sidebarRoutes(fastify, opts = {}) {
500
+ const authenticate = opts.authenticate || defaultAuthenticate;
501
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
502
+
503
+ const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
504
+
505
+ fastify.get('/sidebar/registered-items', canRead, async () => {
506
+ return getRegisteredSidebarItems();
507
+ });
508
+ }
509
+ ```
510
+
511
+ ### Step 5.4: Run tests
512
+
513
+ Run: `node --test tests/routes/sidebar.test.js`
514
+
515
+ Expected: 2/2 pass.
516
+
517
+ ---
518
+
519
+ ## Task 6: Wire `/api/sidebar/*` route into server.js
520
+
521
+ **Files:**
522
+
523
+ - Modify: `server/server.js`
524
+
525
+ ### Step 6.1: Add import + register
526
+
527
+ In `server/server.js`, alongside the existing menus route registration:
528
+
529
+ ```js
530
+ const {sidebarRoutes} = await import('./routes/api/sidebar.js');
531
+ ```
532
+
533
+ Then register:
534
+
535
+ ```js
536
+ await app.register(sidebarRoutes, {prefix: '/api'});
537
+ ```
538
+
539
+ ### Step 6.2: Verify
540
+
541
+ ```bash
542
+ node --check server/server.js
543
+ ```
544
+
545
+ Expected: silent.
546
+
547
+ DO NOT restart the server.
548
+
549
+ ---
550
+
551
+ ## Task 7: Sidebar renderer module
552
+
553
+ **Files:**
554
+
555
+ - Create: `admin/js/lib/sidebar-renderer.js`
556
+
557
+ This is the heart of the new UX. Async render, fetches menu + projects + plugin items, merges, filters, emits nested `<details>` markup, and inserts via `replaceChildren` + `createContextualFragment` (modern safe DOM API - no `el.innerHTML =` assignments).
558
+
559
+ ### Step 7.1: Create the renderer
560
+
561
+ Create `admin/js/lib/sidebar-renderer.js`:
562
+
563
+ ```js
564
+ /**
565
+ * Admin sidebar renderer.
566
+ *
567
+ * Replaces Domma.elements.sidebar(...) for the admin panel. Reads sidebar
568
+ * structure from the Menus system (slot `admin-sidebar`), merges plugin-
569
+ * registered items, synthesises per-project sub-trees, filters by user
570
+ * permission, and renders nested <details>/<summary> for expand/collapse.
571
+ *
572
+ * All user-supplied strings are escaped via esc() before being interpolated
573
+ * into the rendered markup. Markup is inserted using
574
+ * Range#createContextualFragment + Element#replaceChildren (modern safe DOM
575
+ * API) rather than innerHTML assignment.
576
+ *
577
+ * Fallback when no admin-sidebar menu is mapped or available: render a
578
+ * minimal Dashboard + Menus tree so the admin can always escape a broken
579
+ * state.
580
+ */
581
+ import {api} from '../api.js';
582
+
583
+ function esc(s) {
584
+ return String(s == null ? '' : s)
585
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
586
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
587
+ }
588
+
589
+ const FALLBACK_TREE = [
590
+ {text: 'Dashboard', url: '#/', icon: 'home'},
591
+ {text: 'Menus', url: '#/menus', icon: 'menu'}
592
+ ];
593
+
594
+ function canAny(permissions, resource) {
595
+ if (!resource) return true;
596
+ if (!permissions || !permissions.length) return false;
597
+ if (permissions.includes(resource)) return true;
598
+ return ['read', 'create', 'update', 'delete'].some(a => permissions.includes(`${resource}.${a}`));
599
+ }
600
+
601
+ function filterByPermission(items, permissions) {
602
+ const out = [];
603
+ for (const item of items) {
604
+ if (item.hidden) continue;
605
+ if (item.permission && !canAny(permissions, item.permission)) continue;
606
+ const children = Array.isArray(item.items) && item.items.length
607
+ ? filterByPermission(item.items, permissions)
608
+ : [];
609
+ out.push({...item, items: children});
610
+ }
611
+ return out;
612
+ }
613
+
614
+ function mergePluginItems(tree, registered) {
615
+ if (!registered || !registered.length) return tree;
616
+ const findParent = (xs, parentText) => {
617
+ for (const node of xs) {
618
+ if ((node.text || '').toLowerCase() === parentText.toLowerCase()) return node;
619
+ if (Array.isArray(node.items)) {
620
+ const found = findParent(node.items, parentText);
621
+ if (found) return found;
622
+ }
623
+ }
624
+ return null;
625
+ };
626
+ for (const reg of registered) {
627
+ if (reg.parent) {
628
+ const parent = findParent(tree, reg.parent);
629
+ if (parent) {
630
+ parent.items = Array.isArray(parent.items) ? parent.items : [];
631
+ parent.items.push(reg.item);
632
+ } else {
633
+ tree.push(reg.item);
634
+ }
635
+ } else {
636
+ tree.push(reg.item);
637
+ }
638
+ }
639
+ return tree;
640
+ }
641
+
642
+ async function buildProjectsSubtree(permissions) {
643
+ if (!canAny(permissions, 'projects')) return [];
644
+ let projects = [];
645
+ try { projects = await api.projects.list(); } catch { return []; }
646
+ const countsByProject = {};
647
+ await Promise.all(projects.map(async p => {
648
+ try {
649
+ const grouped = await api.projects.artefacts(p.slug);
650
+ countsByProject[p.slug] = grouped;
651
+ } catch { countsByProject[p.slug] = {}; }
652
+ }));
653
+
654
+ const TYPE_DEFS = [
655
+ {key: 'pages', text: 'Pages', icon: 'file-text', path: '/pages'},
656
+ {key: 'collections', text: 'Collections', icon: 'database', path: '/collections'},
657
+ {key: 'forms', text: 'Forms', icon: 'layout', path: '/forms'},
658
+ {key: 'actions', text: 'Actions', icon: 'zap', path: '/actions'},
659
+ {key: 'menus', text: 'Menus', icon: 'menu', path: '/menus'},
660
+ {key: 'blocks', text: 'Blocks', icon: 'box', path: '/blocks'},
661
+ {key: 'views', text: 'Views', icon: 'eye', path: '/views'},
662
+ {key: 'roles', text: 'Roles', icon: 'shield', path: '/roles'},
663
+ {key: 'users', text: 'Users', icon: 'users', path: '/users'}
664
+ ];
665
+
666
+ return projects.map(p => {
667
+ const slugEnc = encodeURIComponent(p.slug);
668
+ const base = '#/projects/' + slugEnc;
669
+ const counts = countsByProject[p.slug] || {};
670
+ const children = [
671
+ {text: 'Overview', url: base, icon: p.icon || 'folder'}
672
+ ];
673
+ for (const def of TYPE_DEFS) {
674
+ const arr = counts[def.key];
675
+ const n = Array.isArray(arr) ? arr.length : 0;
676
+ if (n <= 0) continue;
677
+ children.push({text: `${def.text} (${n})`, url: base + def.path, icon: def.icon});
678
+ }
679
+ children.push({text: 'Settings', url: base + '/settings', icon: 'settings'});
680
+ return {
681
+ text: p.name || p.slug,
682
+ icon: p.icon || 'folder',
683
+ items: children
684
+ };
685
+ });
686
+ }
687
+
688
+ function injectProjectsSubtree(tree, projectChildren) {
689
+ for (const node of tree) {
690
+ if ((node.text || '').toLowerCase() === 'projects') {
691
+ node.items = Array.isArray(node.items) ? node.items.slice() : [];
692
+ node.items.push(...projectChildren);
693
+ return;
694
+ }
695
+ }
696
+ }
697
+
698
+ function renderItem(item) {
699
+ const icon = item.icon ? `<span data-icon="${esc(item.icon)}"></span>` : '';
700
+ const text = esc(item.text || '');
701
+ const url = item.url ? esc(item.url) : '';
702
+ const hasChildren = Array.isArray(item.items) && item.items.length > 0;
703
+
704
+ if (hasChildren) {
705
+ const stateKey = `sidebar.expanded.${(item.text || '').toLowerCase().replace(/\s+/g, '-')}`;
706
+ const isOpen = S.get(stateKey) !== false;
707
+ const childHtml = item.items.map(c => renderItem(c)).join('');
708
+ return `<details data-state-key="${esc(stateKey)}"${isOpen ? ' open' : ''}>
709
+ <summary>${icon} <span class="sidebar-text">${text}</span></summary>
710
+ <div>${childHtml}</div>
711
+ </details>`;
712
+ }
713
+ return `<a href="${url}" class="sidebar-link" data-url="${url}">${icon} <span class="sidebar-text">${text}</span></a>`;
714
+ }
715
+
716
+ /**
717
+ * Render the admin sidebar into the given mount selector.
718
+ *
719
+ * @param {{mount: string, permissions: string[]}} opts
720
+ */
721
+ export async function renderAdminSidebar({mount, permissions}) {
722
+ const mountEl = $(mount).get(0);
723
+ if (!mountEl) return;
724
+
725
+ let tree;
726
+ try {
727
+ const menu = await api.menus.get('admin-sidebar');
728
+ tree = Array.isArray(menu?.items) ? menu.items : null;
729
+ } catch { tree = null; }
730
+
731
+ if (!tree || !tree.length) {
732
+ console.warn('[admin-sidebar] No admin-sidebar menu found; using fallback tree');
733
+ tree = FALLBACK_TREE.slice();
734
+ }
735
+
736
+ let registered = [];
737
+ try {
738
+ registered = await H.get('/api/sidebar/registered-items') || [];
739
+ } catch { /* ignore - render without plugin items */ }
740
+ tree = mergePluginItems(tree, registered);
741
+
742
+ const projectsBranch = await buildProjectsSubtree(permissions);
743
+ if (projectsBranch.length) injectProjectsSubtree(tree, projectsBranch);
744
+
745
+ tree = filterByPermission(tree, permissions);
746
+
747
+ // Build the markup string from escaped components, then insert via
748
+ // safe DOM API (replaceChildren + createContextualFragment) - no
749
+ // direct innerHTML assignment.
750
+ const markup = `<nav class="dm-admin-sidebar">${tree.map(renderItem).join('')}</nav>`;
751
+ const range = document.createRange();
752
+ range.selectNodeContents(mountEl);
753
+ const fragment = range.createContextualFragment(markup);
754
+ mountEl.replaceChildren(fragment);
755
+
756
+ Domma.icons.scan(mountEl);
757
+
758
+ // Persist expand/collapse state
759
+ $(mountEl).off('toggle', 'details').on('toggle', 'details', function() {
760
+ const key = $(this).attr('data-state-key');
761
+ if (key) S.set(key, this.open);
762
+ });
763
+
764
+ // Active item indicator
765
+ function setActive() {
766
+ const current = location.hash || '#/';
767
+ $(mountEl).find('.sidebar-link').removeClass('active');
768
+ $(mountEl).find(`.sidebar-link[data-url="${current}"]`).addClass('active');
769
+ }
770
+ setActive();
771
+ M.subscribe('router:afterChange', setActive);
772
+ }
773
+ ```
774
+
775
+ ### Step 7.2: Verify syntax
776
+
777
+ Run: `node --check admin/js/lib/sidebar-renderer.js`
778
+
779
+ Expected: silent.
780
+
781
+ ---
782
+
783
+ ## Task 8: Replace the sidebar call in `app.js`
784
+
785
+ **Files:**
786
+
787
+ - Modify: `admin/js/app.js`
788
+
789
+ ### Step 8.1: Find the existing call
790
+
791
+ Run: `grep -n "Domma.elements.sidebar\|getSidebarConfig" admin/js/app.js | head -5`
792
+
793
+ ### Step 8.2: Replace the sidebar build
794
+
795
+ In `admin/js/app.js`, find `buildSidebar(permissions)`. Replace its body with the new renderer call:
796
+
797
+ ```js
798
+ async function buildSidebar(permissions) {
799
+ const {renderAdminSidebar} = await import('./lib/sidebar-renderer.js');
800
+ await renderAdminSidebar({
801
+ mount: '#admin-sidebar',
802
+ permissions
803
+ });
804
+ }
805
+ ```
806
+
807
+ Remove the now-unused: `getSidebarConfig` import (it'll be a stub from Task 9), and any count/project fetches that exclusively fed the old sidebar build (the renderer fetches its own data).
808
+
809
+ Keep:
810
+ - The auth guard
811
+ - The router setup
812
+ - All `router:afterChange` subscribers used by other admin features
813
+
814
+ If `fetchSidebarCounts` is used elsewhere (badge rendering on non-sidebar UI), leave it.
815
+
816
+ ### Step 8.3: Verify syntax
817
+
818
+ Run: `node --check admin/js/app.js`
819
+
820
+ Expected: silent.
821
+
822
+ ### Step 8.4: Browser smoke test required
823
+
824
+ State explicitly: cannot drive the browser. A real browser load is required to confirm:
825
+ - Sidebar renders with the seeded tree (after migration runs on next server restart)
826
+ - Top-level sections (Overview, Projects, Content, Data, System, Documentation) appear
827
+ - Each section is collapsible via the chevron / click on summary
828
+ - Permission-gated items hide for users lacking the permission
829
+
830
+ ---
831
+
832
+ ## Task 9: Deprecate `sidebar-config.js`
833
+
834
+ **Files:**
835
+
836
+ - Modify: `admin/js/config/sidebar-config.js`
837
+
838
+ ### Step 9.1: Gut the file
839
+
840
+ Replace the entire contents of `admin/js/config/sidebar-config.js` with:
841
+
842
+ ```js
843
+ /**
844
+ * DEPRECATED - kept as a stub for one release.
845
+ *
846
+ * The admin sidebar is now rendered from the Menus system at the
847
+ * `admin-sidebar` slot. Edit `config/menus/admin-sidebar.json` (or use the
848
+ * Menus admin) to change the sidebar.
849
+ *
850
+ * If you're calling this function for sidebar content, switch to:
851
+ * import {renderAdminSidebar} from '../lib/sidebar-renderer.js';
852
+ *
853
+ * This stub returns an empty array so any straggling callers still work.
854
+ */
855
+ export function getSidebarConfig() {
856
+ console.warn('[sidebar-config] getSidebarConfig() is deprecated. Use renderAdminSidebar() from lib/sidebar-renderer.js.');
857
+ return [];
858
+ }
859
+ ```
860
+
861
+ ### Step 9.2: Verify syntax
862
+
863
+ Run: `node --check admin/js/config/sidebar-config.js`
864
+
865
+ Expected: silent.
866
+
867
+ ---
868
+
869
+ ## Task 10: Add `permission` field to menu-editor item rows
870
+
871
+ **Files:**
872
+
873
+ - Modify: `admin/js/views/menu-editor.js`
874
+
875
+ ### Step 10.1: Find the item-row template
876
+
877
+ Run: `grep -n "menu-tree-row\|me-text\|me-url\|me-icon\|me-vis" admin/js/views/menu-editor.js | head -10`
878
+
879
+ The current item row has: text, url, icon, visibility, indent/outdent, move-up/down, hide, remove.
880
+
881
+ ### Step 10.2: Add permission select column
882
+
883
+ The existing item row template uses these classes for its inputs (added in the Menus plan T20): `.me-text`, `.me-url`, `.me-icon`, `.me-vis` (visibility text input). The new `.me-perm` select sits right after `.me-vis`.
884
+
885
+ Three edits to `menu-editor.js`:
886
+
887
+ **(a) At the top of `onMount`, fetch the permissions registry once:**
888
+
889
+ ```js
890
+ const permRegistry = await H.get('/api/auth/permissions-registry').catch(() => ({resources: []}));
891
+ const permOptions = ['<option value="">- no permission gate -</option>']
892
+ .concat((permRegistry.resources || []).map(r => `<option value="${r.key}">${r.label || r.key}</option>`));
893
+ ```
894
+
895
+ **(b) In the row template (the function or template literal that builds each `.menu-tree-row`), insert AFTER the `<input class="me-vis" …>` line and BEFORE the indent/outdent buttons:**
896
+
897
+ ```js
898
+ <select class="form-select me-perm" data-id="${row.id}">
899
+ ${permOptions.map(opt => opt.replace(`value="${row.permission || ''}"`, `value="${row.permission || ''}" selected`)).join('')}
900
+ </select>
901
+ ```
902
+
903
+ (If pre-selection via string replacement feels brittle, build the options inline with a `${row.permission === r.key ? ' selected' : ''}` conditional - same effect.)
904
+
905
+ **(c) In `flatten(items)`** (the function that turns the nested-array form into a flat list of rows), include `permission` on each row object:
906
+
907
+ ```js
908
+ out.push({
909
+ id, parentId,
910
+ text: it.text || '',
911
+ url: it.url || '',
912
+ icon: it.icon || '',
913
+ visibility: it.visibility || '',
914
+ permission: it.permission || '',
915
+ hidden: !!it.hidden,
916
+ bundled: !!it.bundled
917
+ });
918
+ ```
919
+
920
+ **(d) In `nest(flat)`** (the function that builds the persisted nested shape on save), include `permission` when truthy:
921
+
922
+ ```js
923
+ return {
924
+ text: r.text.trim(),
925
+ url: r.url.trim(),
926
+ ...(r.icon && {icon: r.icon.trim()}),
927
+ ...(r.visibility && {visibility: r.visibility}),
928
+ ...(r.permission && {permission: r.permission}),
929
+ ...(r.hidden && {hidden: true}),
930
+ ...(r.bundled && {bundled: true}),
931
+ ...(children.length && {items: children})
932
+ };
933
+ ```
934
+
935
+ **(e) In `syncRowsFromDom` (or equivalent - the function that reads input values back into the flat array on Save), read the new select:**
936
+
937
+ ```js
938
+ row.permission = $(this).find('.me-perm').val();
939
+ ```
940
+
941
+ ### Step 10.3: Verify syntax
942
+
943
+ Run: `node --check admin/js/views/menu-editor.js`
944
+
945
+ Expected: silent.
946
+
947
+ ### Step 10.4: Browser smoke test required
948
+
949
+ State explicitly - cannot drive. A real browser test required to confirm:
950
+ - Editing the `admin-sidebar` menu shows a permission select on each item
951
+ - Selecting a permission and saving persists `permission: '<key>'` in the JSON
952
+ - Clearing the select removes the field
953
+
954
+ ---
955
+
956
+ ## Task 11: CSS for nested `<details>` sidebar
957
+
958
+ **Files:**
959
+
960
+ - Modify: `admin/css/admin.css`
961
+
962
+ ### Step 11.1: Append the styles
963
+
964
+ Append to `admin/css/admin.css`:
965
+
966
+ ```css
967
+ /* Admin sidebar - nested details-based tree */
968
+ .dm-admin-sidebar {
969
+ display: flex;
970
+ flex-direction: column;
971
+ gap: 2px;
972
+ padding: 8px 0;
973
+ }
974
+ .dm-admin-sidebar details {
975
+ margin: 0;
976
+ }
977
+ .dm-admin-sidebar details > summary {
978
+ list-style: none;
979
+ cursor: pointer;
980
+ display: flex;
981
+ align-items: center;
982
+ gap: 0.5rem;
983
+ padding: 0.4rem 0.75rem;
984
+ border-radius: 4px;
985
+ user-select: none;
986
+ }
987
+ .dm-admin-sidebar details > summary::-webkit-details-marker { display: none; }
988
+ .dm-admin-sidebar details > summary::after {
989
+ content: '\25B8';
990
+ margin-left: auto;
991
+ transition: transform 0.15s ease;
992
+ color: var(--dm-text-muted, #999);
993
+ font-size: 0.85em;
994
+ }
995
+ .dm-admin-sidebar details[open] > summary::after { transform: rotate(90deg); }
996
+ .dm-admin-sidebar details > div {
997
+ padding-left: 1rem;
998
+ border-left: 1px solid var(--dm-border-subtle, rgba(0, 0, 0, 0.08));
999
+ margin-left: 0.95rem;
1000
+ }
1001
+ .dm-admin-sidebar .sidebar-link {
1002
+ display: flex;
1003
+ align-items: center;
1004
+ gap: 0.5rem;
1005
+ padding: 0.35rem 0.75rem;
1006
+ text-decoration: none;
1007
+ color: var(--dm-text, inherit);
1008
+ border-radius: 4px;
1009
+ }
1010
+ .dm-admin-sidebar .sidebar-link:hover,
1011
+ .dm-admin-sidebar details > summary:hover {
1012
+ background: var(--dm-bg-subtle, rgba(0, 0, 0, 0.05));
1013
+ }
1014
+ .dm-admin-sidebar .sidebar-link.active {
1015
+ background: var(--dm-accent-bg, rgba(0, 0, 0, 0.08));
1016
+ font-weight: 600;
1017
+ }
1018
+ .dm-admin-sidebar .sidebar-text {
1019
+ flex: 1;
1020
+ min-width: 0;
1021
+ overflow: hidden;
1022
+ text-overflow: ellipsis;
1023
+ white-space: nowrap;
1024
+ }
1025
+ .dm-admin-sidebar [data-icon] {
1026
+ flex-shrink: 0;
1027
+ }
1028
+ ```
1029
+
1030
+ ### Step 11.2: Verify
1031
+
1032
+ Open `admin/css/admin.css` - confirm the appended block has matched braces. (No node check for CSS.)
1033
+
1034
+ ---
1035
+
1036
+ ## Task 12: Documentation updates
1037
+
1038
+ **Files:**
1039
+
1040
+ - Modify: `docs/configuration.md`
1041
+ - Modify: `docs/plugin-development.md`
1042
+ - Modify: `docs/api-reference.md`
1043
+ - Modify: `CLAUDE.md`
1044
+
1045
+ ### Step 12.1: `docs/configuration.md`
1046
+
1047
+ In the existing Menus section, document:
1048
+ - The new core slot `admin-sidebar`
1049
+ - The `permission` field on menu items (resource key from permissionRegistry)
1050
+ - Where the auto-seeded `admin-sidebar.json` lives + the auto-migration
1051
+
1052
+ ### Step 12.2: `docs/plugin-development.md`
1053
+
1054
+ In the hooks reference, add `hooks.registerSidebarItem({parent, item})`. Include the option shape (parent string + item shape) and the runtime behaviour (items are not persisted; plugins re-register on each boot via setupPlugin).
1055
+
1056
+ ### Step 12.3: `docs/api-reference.md`
1057
+
1058
+ Add `GET /api/sidebar/registered-items` - returns the array of `{parent, item}` entries currently registered. Requires `menus.read` permission.
1059
+
1060
+ ### Step 12.4: `CLAUDE.md`
1061
+
1062
+ Add a short note that the admin sidebar is now menu-data-driven (the menu lives at `config/menus/admin-sidebar.json` and renders via `admin/js/lib/sidebar-renderer.js`).
1063
+
1064
+ ---
1065
+
1066
+ ## Task 13: Final verification + handoff
1067
+
1068
+ **Files:** none (verification only)
1069
+
1070
+ ### Step 13.1: Run the full feature test suite
1071
+
1072
+ ```bash
1073
+ node --test \
1074
+ tests/services/menus.test.js \
1075
+ tests/services/sidebar-migration.test.js \
1076
+ tests/routes/sidebar.test.js \
1077
+ tests/services/projects.test.js \
1078
+ tests/routes/projects.test.js \
1079
+ tests/services/scaffolder.test.js 2>&1 | tail -10
1080
+ ```
1081
+
1082
+ Expected: all pass.
1083
+
1084
+ ### Step 13.2: Curl smoke
1085
+
1086
+ ```bash
1087
+ curl -s -o /dev/null -w "GET /api/sidebar/registered-items: %{http_code}\n" http://localhost:4096/api/sidebar/registered-items
1088
+ ```
1089
+
1090
+ Expected (after the user restarts the server): `401`. If still `404`, the user hasn't restarted yet - that's fine, the route will activate on next restart.
1091
+
1092
+ ### Step 13.3: Browser walkthrough (required - user-driven)
1093
+
1094
+ The user MUST hard-refresh the admin and walk through:
1095
+
1096
+ 1. Sidebar renders with Overview / Projects / Content / Data / System / Documentation as collapsible top-level sections (the chevron rotates on toggle).
1097
+ 2. Permission-gated items hide for users without the permission (test by creating a user with limited role).
1098
+ 3. Projects parent expands to show per-project sub-trees (Overview + populated type rows + Settings).
1099
+ 4. Editing the `admin-sidebar` menu from `#/menus/edit/admin-sidebar` works - the permission select appears on each item.
1100
+ 5. Re-ordering items in the menu editor reorders the sidebar.
1101
+ 6. Plugin-registered items (if any are installed) appear under their declared parent.
1102
+
1103
+ State explicitly: this is the real test. Server-side tests can't verify the renderer or the visual shape.
1104
+
1105
+ ### Step 13.4: Hand off
1106
+
1107
+ Do NOT commit. Summarise changes for the user to commit. Suggested commit message:
1108
+
1109
+ > Admin sidebar now rendered from the Menus system at the `admin-sidebar` slot -
1110
+ > auto-migrates the current sidebar tree on first boot, supports per-item
1111
+ > permission gating, native <details> expand/collapse, and a registerSidebarItem
1112
+ > hook for plugin extensions. The Menus admin can edit the sidebar visually.