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,380 @@
1
+ # Projects Design
2
+
3
+ **Date:** 2026-05-25
4
+ **Status:** Approved
5
+ **Scope:** Introduce Projects - a lightweight grouping of related artefacts (Pages, Collections, Forms, Actions, Menus, Blocks, Views, Roles, Users) with sidebar-level navigation, per-user permission scoping, and tight integration with the scaffolder so a recipe-applied "members area" becomes a navigable Project the moment it's installed.
6
+
7
+ ## Problem
8
+
9
+ The CMS produces a growing set of artefact types. A scaffolder recipe like Onboarding installs a Collection, a Form, three Actions, optionally a Menu, and (after this feature) Roles and Users. Today those artefacts land in eight different admin sidebar locations (Collections, Forms, Actions, Menus, Roles, Users…), grouped by *type*, not by *purpose*. An editor who wants to "work on the members site" has to traverse every resource type, mentally filtering for the items whose slug starts with `members-`.
10
+
11
+ There's also no notion of "this user works on the members project, not the intranet". Every editor sees every artefact subject only to their role. Multi-tenant-style scoping is impossible.
12
+
13
+ The scaffolder's existing `namespace` option is the kernel of a solution: every recipe-produced artefact already shares a slug prefix. We can turn that prefix into a first-class *Project* - both a navigation grouping (every artefact for project X in one sidebar section) and a permission boundary (a user can be scoped to a list of projects).
14
+
15
+ ## Goals
16
+
17
+ - Group recipe-produced artefacts under a named Project; sidebar shows each Project as an expandable section with its scoped sub-items.
18
+ - Allow manual creation/editing of Projects (not only via recipes).
19
+ - Scope users to one or more Projects so they only see/edit their assigned Projects' artefacts plus untagged site-wide artefacts.
20
+ - Auto-create a Project on recipe apply when the recipe declares a `project` block.
21
+ - Auto-inherit a Project on pages whose URL falls under the Project's `rootUrl` prefix.
22
+ - Preserve the existing flat resource-typed sidebar groups for cross-project browsing - Projects is *additive*, not a replacement.
23
+ - Zero auto-migration of existing artefacts - they stay untagged and continue to appear in the existing sections.
24
+
25
+ ## Non-Goals (YAGNI)
26
+
27
+ - Project rename (slug change). Requires updating every tagged artefact and is rare. Workaround: delete + recreate with new slug + bulk re-tag.
28
+ - Nested Projects (sub-projects).
29
+ - Project-scoped permissions on individual artefacts (e.g. "this collection is private to a subset of Members users"). Scope is at the Project membership level; per-artefact access uses the existing role-permission system.
30
+ - Cross-site Project distribution. Recipes are the distribution unit (and export-as-recipe is a tracked follow-up).
31
+ - Project-scoped media library. Media stays site-wide.
32
+ - Filesystem isolation (each Project in its own subdirectory). Tag-based membership is sufficient and keeps existing paths stable.
33
+
34
+ ## Data Model
35
+
36
+ ### Project record
37
+
38
+ Projects live in a **preset collection** at `content/collections/projects/` - same pattern as `user-types` and `roles`. This gives CRUD UI, validation, and permission integration for free.
39
+
40
+ ```json
41
+ {
42
+ "slug": "members",
43
+ "name": "Members Area",
44
+ "description": "Curated members-only zone with profile, billing, and content.",
45
+ "icon": "users",
46
+ "rootUrl": "/members",
47
+ "sortOrder": 10,
48
+ "createdAt": "2026-05-25T10:00:00Z",
49
+ "updatedAt": "2026-05-25T10:00:00Z"
50
+ }
51
+ ```
52
+
53
+ | Field | Required | Type | Description |
54
+ |---|---|---|---|
55
+ | `slug` | yes | string | URL-safe identifier; matches `^[a-z0-9][a-z0-9-]*$`; used as the value of `meta.project` on tagged artefacts; immutable after create |
56
+ | `name` | yes | string | Human-readable; sidebar label |
57
+ | `description` | no | string | Free text |
58
+ | `icon` | no | string | Sidebar icon (Domma icon name); defaults to `folder` |
59
+ | `rootUrl` | no | string | URL prefix (must start with `/`); enables page auto-inheritance |
60
+ | `sortOrder` | no | number | Sidebar order; default = creation order |
61
+
62
+ ### Artefact-side `meta.project` field
63
+
64
+ Nine artefact types gain an optional `meta.project: '<slug>'` field. The field is informational - it declares "this artefact lives in this Project" for the purposes of sidebar grouping and permission scoping. Existing files without the field stay site-wide (visible in the standard Content/Data/Structure sections, not in any Project section).
65
+
66
+ | Type | Where `meta.project` lives | Notes |
67
+ |---|---|---|
68
+ | **Pages** | YAML frontmatter `project:` | Auto-inherited from URL prefix when project has `rootUrl` set (see "URL inheritance" below) |
69
+ | **Collections** | `schema.json` top-level `meta.project` | All entries inherit the schema's project |
70
+ | **Forms** | Form JSON top-level `meta.project` | |
71
+ | **Actions** | Mongo doc `meta.project` | |
72
+ | **Menus** | `meta.project` (alongside existing `meta.bundled`, `meta.presetOwner`) | The Menus `meta` block from spec 2026-05-24 already accepts unknown keys; we extend it |
73
+ | **Blocks** | Block JSON top-level `meta.project` | |
74
+ | **Views** | View JSON top-level `meta.project` | |
75
+ | **Roles** | Role entry `data.meta.project` (collection entry meta) | |
76
+ | **Users** | User JSON `meta.project` + access-scope `projects: []` (see "Permission scoping" below) | Two fields, different jobs |
77
+
78
+ ### Page URL inheritance
79
+
80
+ When a page is loaded the project is determined in this order:
81
+
82
+ 1. If frontmatter has `project: null` → explicit opt-out; page is untagged regardless of URL.
83
+ 2. If frontmatter has `project: '<slug>'` → use it (explicit override).
84
+ 3. Else (`project:` field missing entirely), look up the project whose `rootUrl` is the longest prefix match of the page's URL → use that.
85
+ 4. Else, page is untagged (site-wide).
86
+
87
+ Example: project `members` has `rootUrl: '/members'`. Page at `/members/profile.md` without a `project:` frontmatter is implicitly in `members`. Page at `/about.md` is untagged. Page at `/members/admin.md` with explicit `project: 'staff'` overrides the auto-match.
88
+
89
+ Multiple projects with overlapping prefixes: longest match wins. If two prefixes tie, alphabetical-by-slug wins (deterministic).
90
+
91
+ The resolver `getProjectForPage(urlPath, frontmatterProject)` is the single source of truth and is called from every page-load path (renderer, sidebar, API list endpoints).
92
+
93
+ ### Permission scoping
94
+
95
+ Users gain a new optional field `projects: ['<slug>', ...]` - the *access scope*. Distinct from `meta.project` on the user record (the *administrative ownership* - where the user appears in the sidebar). Most of the time both hold the same single value, but they can diverge (a recruiter user owned by the `members` project might be granted access to `members` + `intranet`).
96
+
97
+ **Access rule** (enforced in API route guards, sidebar render, and list/get endpoints):
98
+
99
+ ```js
100
+ function canSeeArtefact(user, artefact) {
101
+ if (isSuperAdmin(user)) return true; // bypass
102
+ const project = artefact.meta?.project;
103
+ if (!project) return true; // untagged = site-wide
104
+ if (!user.projects || user.projects.length === 0) return true; // no scope = no restriction
105
+ return user.projects.includes(project);
106
+ }
107
+ ```
108
+
109
+ Applied to:
110
+ - List endpoints (filter results)
111
+ - Get endpoints (return 403 instead of the artefact)
112
+ - Write endpoints (refuse with 403)
113
+ - Sidebar render (filter the Projects section to projects the user can see, and filter the standard resource sections to artefacts the user can see)
114
+
115
+ Notes:
116
+ - **Empty `projects: []` = no restriction.** A user with no scope set sees everything (subject to role).
117
+ - **Super-admins bypass entirely.** Even if their `projects` field is set, they see all projects.
118
+ - **Untagged artefacts are always visible** to anyone with the relevant role permission. This matches the mental model of "site-wide things are always visible; project things are filtered".
119
+ - **`meta.project` on the user record is administrative metadata.** It tells the sidebar where to file this user under the Project view. The `projects: []` array is the access enforcement field. They can hold different values.
120
+
121
+ ## Server-side Services
122
+
123
+ ### `server/services/projects.js` (new)
124
+
125
+ | Export | Description |
126
+ |---|---|
127
+ | `listProjects()` | All projects (no filtering) - for admin lookups |
128
+ | `listProjectsForUser(user)` | Projects the user can see (super-admin sees all; scoped users see their scope) |
129
+ | `getProject(slug)` | Returns the project record or `null` |
130
+ | `createProject(input)` | Validates + writes; refuses on slug collision |
131
+ | `updateProject(slug, input)` | Slug immutable; other fields editable |
132
+ | `deleteProject(slug)` | Refuses if any tagged artefacts exist (409 with breakdown); otherwise deletes the project entry |
133
+ | `getProjectForPage(urlPath, frontmatterProject)` | Resolves a page's project per the URL-inheritance rules |
134
+ | `untagAllForProject(slug)` | Escape hatch - sets `meta.project: null` on every artefact currently tagged with the slug |
135
+ | `getArtefactsForProject(slug)` | Returns all artefacts grouped by type - used by sidebar + project detail page |
136
+ | `canSeeArtefact(user, artefact)` | Permission check used by API routes + sidebar |
137
+
138
+ The collection is seeded with no entries on first run (a fresh CMS has no projects).
139
+
140
+ ### Validation
141
+
142
+ - `slug` matches `^[a-z0-9][a-z0-9-]*$` (mirrors collection slug + menu slug rules)
143
+ - `name` non-empty string
144
+ - `rootUrl`, when set, must start with `/` and not be just `/` (the root URL `/` would auto-claim every page - clearly wrong; refuse it)
145
+ - `icon` (when set) is a non-empty string
146
+
147
+ ## Server-side Integration Points
148
+
149
+ Each artefact's service is touched to:
150
+ 1. **Read** the `meta.project` field (no validation - it's an opt-in informational tag).
151
+ 2. **Filter** lists by `canSeeArtefact(user, artefact)`.
152
+ 3. **Pass** `meta.project` through create/update calls without losing it.
153
+
154
+ Concretely:
155
+
156
+ | Service | Change |
157
+ |---|---|
158
+ | `server/services/pages.js` (via `content.js`) | `parseMarkdown` already passes frontmatter through; loader resolves project from URL or frontmatter; list endpoints filter |
159
+ | `server/services/collections.js` | `createCollection` accepts `meta.project`; `listCollections` filters by user scope |
160
+ | `server/services/forms.js` | `writeForm`/`readForm` preserve `meta`; `listForms` filters |
161
+ | `server/services/actions.js` | `createAction` accepts `meta.project`; `listActions` filters |
162
+ | `server/services/menus.js` | `meta.project` flows through `createMenu`/`updateMenu` (already accepts arbitrary `meta` keys); `listMenus` filters |
163
+ | `server/services/blocks.js` | `meta.project` on the block JSON; list filter |
164
+ | `server/services/views.js` | `meta.project` on the view JSON; list filter |
165
+ | `server/services/roles.js` | Role collection entries gain `data.meta.project`; list filter on the Roles admin |
166
+ | `server/services/users.js` | User JSON gains `meta.project` + access-scope `projects: []`; list filter on Users admin |
167
+
168
+ In every list path, the filter is a single call:
169
+
170
+ ```js
171
+ const visible = list.filter(item => canSeeArtefact(user, item));
172
+ ```
173
+
174
+ ## API Surface
175
+
176
+ `server/routes/api/projects.js` (new), registered from `server/server.js`.
177
+
178
+ | Method | Path | Permission | Description |
179
+ |---|---|---|---|
180
+ | `GET` | `/api/projects` | `projects.read` | List projects the user can see |
181
+ | `GET` | `/api/projects/:slug` | `projects.read` | Project details |
182
+ | `POST` | `/api/projects` | `projects.create` | Create a project |
183
+ | `PUT` | `/api/projects/:slug` | `projects.update` | Edit (slug immutable) |
184
+ | `DELETE` | `/api/projects/:slug` | `projects.delete` | Delete (409 if any artefacts tagged; body lists the count by type) |
185
+ | `GET` | `/api/projects/:slug/artefacts` | `projects.read` | All artefacts grouped by type - used by sidebar + project detail |
186
+ | `POST` | `/api/projects/:slug/untag-all` | `projects.delete` | Set `meta.project: null` on every tagged artefact (escape hatch before delete) |
187
+
188
+ ### Permissions
189
+
190
+ New family `projects.{read, create, update, delete}` added to `server/services/permissionRegistry.js`. Defaults (admin-edited via the Users → Roles editor after seeding):
191
+
192
+ | Role | read | create | update | delete |
193
+ |---|:---:|:---:|:---:|:---:|
194
+ | user | ❌ | ❌ | ❌ | ❌ |
195
+ | admin | ✅ | ✅ | ✅ | ❌ |
196
+ | super-admin | ✅ | ✅ | ✅ | ✅ |
197
+
198
+ (Existing roles are not back-filled automatically - same gotcha as the menus permission. The admin grants `projects` to existing roles via the role editor.)
199
+
200
+ ## Scaffolder Integration
201
+
202
+ `server/services/scaffolder.js` gains a project-aware apply step BEFORE any artefact creation:
203
+
204
+ 1. Resolve `tokens.namespace` from the recipe options. (This already exists - the namespace cascades into every artefact's slug.)
205
+ 2. If the recipe has a `project` block AND no project with that slug exists → create the project record using `namespace` as the slug and the recipe's name/description/icon/rootUrl.
206
+ 3. If a project with that slug exists already → leave it alone (idempotent - re-applying a recipe does not clobber edits to the project record).
207
+ 4. Pass the project slug through to every artefact-creation call so each created file gets `meta.project: <namespace>` stamped on it.
208
+
209
+ Recipe shape (additive; backwards compatible):
210
+
211
+ ```json
212
+ {
213
+ "slug": "members-area",
214
+ "name": "Members Area",
215
+ "options": [{"name": "namespace", "default": "members"}],
216
+ "project": {
217
+ "name": "Members Area",
218
+ "description": "Members-only zone",
219
+ "icon": "users",
220
+ "rootUrl": "/{{namespace}}"
221
+ },
222
+ "collection": { ... },
223
+ "form": { ... },
224
+ "actions": [ ... ],
225
+ "menus": [ ... ]
226
+ }
227
+ ```
228
+
229
+ Recipes that don't define a `project` block still work - they produce untagged artefacts (existing behaviour). Recipes that DO define one but where the project record creation fails (validation error, write error) abort the whole apply with a clear error message before any artefact is written.
230
+
231
+ The scaffolder's existing seeded artefacts - collections, forms, actions, menus, roles, users - all get `meta.project` stamped when a project is in play. Seeded **users** additionally get `projects: ['<namespace>']` set on their access-scope field, so a recipe-seeded HR user can immediately log in and see only the project they were created for.
232
+
233
+ ## Admin UI
234
+
235
+ ### Sidebar - new Projects section
236
+
237
+ A new top-level **Projects** group is inserted immediately below the Overview group (Dashboard stays first in the sidebar). It renders only when at least one project exists AND the current user can see at least one project (`listProjectsForUser(user).length > 0`).
238
+
239
+ Each project appears as a collapsible expander:
240
+
241
+ ```
242
+ ▾ Members ← project (icon + name)
243
+ Pages (3) ← sub-item per artefact type
244
+ Collections (2)
245
+ Forms (1)
246
+ Actions (3)
247
+ Menus (1)
248
+ Blocks (0) ← suppressed when empty
249
+ Views (0)
250
+ Roles (1)
251
+ Users (2)
252
+ ▸ Intranet ← collapsed
253
+ ```
254
+
255
+ Behaviour:
256
+ - Sub-items only render when the project has at least one of that type. The empty rows (`Blocks (0)`) above are illustrative - they're filtered out at render time.
257
+ - Each sub-item is a link to the standard list view for that type, filtered by project: e.g. `#/projects/members/collections`, which reuses the Collections list view with a `project=members` filter.
258
+ - Expansion state persists per project in `S.set('sidebar.project.<slug>.expanded', bool)`.
259
+ - Counts are loaded **lazily** when the project is first expanded - not on initial sidebar mount. The counts come from `/api/projects/:slug/artefacts`.
260
+ - Project order follows `sortOrder` ascending, then `name` alphabetical for ties.
261
+
262
+ The standard Content/Data/Structure sections stay below the Projects section. They render artefacts the user can see (super-admin: everything; scoped user: their projects' artefacts + untagged).
263
+
264
+ ### `#/projects` - list view
265
+
266
+ `T.create` table:
267
+
268
+ | Column | Content |
269
+ |---|---|
270
+ | Slug | Project slug |
271
+ | Name | Human-readable |
272
+ | Icon | Rendered icon |
273
+ | Root URL | The URL prefix (linked) |
274
+ | Artefacts | Total count badge |
275
+ | Actions | Edit · Delete (disabled with tooltip if non-empty) |
276
+
277
+ Top-right toolbar: "New project" button.
278
+
279
+ ### `#/projects/new` and `#/projects/edit/:slug`
280
+
281
+ Single form built with `F.create(blueprint, values, options).renderTo(el)`:
282
+
283
+ | Field | Type | Notes |
284
+ |---|---|---|
285
+ | `slug` | text | Required; disabled on edit |
286
+ | `name` | text | Required |
287
+ | `description` | textarea | Optional |
288
+ | `icon` | text | Optional; defaults to `folder` |
289
+ | `rootUrl` | text | Optional; must start with `/` if set |
290
+ | `sortOrder` | number | Optional; defaults to `0` |
291
+
292
+ Save → `POST /api/projects` (new) or `PUT /api/projects/:slug` (edit). Redirect to `#/projects` on success with a toast.
293
+
294
+ ### `#/projects/:slug` - project detail
295
+
296
+ Tabbed view (`E.tabs` with the verified `.tabs / .tab-list / button.tab-item / .tab-panel` classes):
297
+
298
+ | Tab | Content |
299
+ |---|---|
300
+ | Overview | Project name + description + artefact counts + a "Recent activity" list (latest 10 artefacts changed by anyone) |
301
+ | Pages | Standard pages list, filtered to this project |
302
+ | Collections | Standard collections list, filtered |
303
+ | Forms | Standard forms list, filtered |
304
+ | Actions | Standard actions list, filtered |
305
+ | Menus | Standard menus list, filtered |
306
+ | Blocks | Standard blocks list, filtered |
307
+ | Views | Standard views list, filtered |
308
+ | Roles | Roles whose `meta.project` matches |
309
+ | Users | Users whose `meta.project` matches |
310
+ | Settings | Same fields as the editor + "Untag all artefacts" and "Delete project" buttons |
311
+
312
+ The standard list views are reused - no duplicate UI. They accept an optional `project` filter parameter (passed via route params).
313
+
314
+ ### Project field in existing artefact editors
315
+
316
+ Every in-scope artefact editor (Page editor, Collection editor, Form editor, etc.) gains a "Project" select in its Meta tab. Options: `(none)` + every project the current user can see. Defaults to `(none)` for new artefacts unless the editor was opened from a project-scoped sidebar entry (in which case the project is pre-selected from the URL).
317
+
318
+ For Pages, the select has two states:
319
+
320
+ - **`(none)`** - page is untagged. Writes `project: null` to frontmatter.
321
+ - **`<slug>`** - explicit project. Writes `project: <slug>` to frontmatter.
322
+
323
+ The selector defaults to whatever the URL-inheritance rule resolves to on first load - so an admin editing a page at `/members/profile.md` for the first time sees `Members` pre-selected. Once they Save, the explicit value is written. The dropdown has no hidden magic - what you see is what's persisted.
324
+
325
+ URL inheritance still applies to pages with no `project:` field in frontmatter (pre-existing pages, hand-edited pages). The dropdown writes explicit values forward; the resolver passively fills in missing values for backwards compatibility.
326
+
327
+ ### User editor - project access field
328
+
329
+ The User editor gains a multi-select "Project access" field listing every project. Empty = no restriction. Recipe-seeded users have it pre-populated. Stored in the user JSON as `projects: ['<slug>', ...]`.
330
+
331
+ The User editor also gains the administrative `meta.project` field as a separate single-select (`(none)` + projects). This is the field used by the sidebar to file the user under a project. Most users have both fields set to the same single project; the UI shows them in distinct sections (Access vs Ownership) with a one-line explanation.
332
+
333
+ ## Caching
334
+
335
+ New cache tags:
336
+
337
+ | Tag | Invalidated when |
338
+ |---|---|
339
+ | `project:<slug>` | Project record changed, OR any artefact's `meta.project` is set/changed to this slug |
340
+ | `projects` | Project list changed (create/delete) |
341
+
342
+ Existing artefact tags (`page:`, `nav`, `menu:<slug>`, etc.) gain a sibling tag `project:<artefact's project>` when the artefact is tagged, so a project rename or settings change can flush dependent pages without enumerating them.
343
+
344
+ **User-scope changes invalidate per-user cache entries.** When a user's `projects: []` array is edited, an explicit `cache.invalidateTags(['user:<id>'])` fires from the user-update path. The renderer's existing per-role cache key isn't sufficient on its own (JWTs are long-lived; the user's session continues with the same token after a scope change), so we add a per-user tag to caches that contain user-scoped filtering. List/get endpoints that filter by `canSeeArtefact` write entries tagged with both the artefact's `project:` tag and the requesting user's `user:<id>` tag; either invalidation tag flushes the entry.
345
+
346
+ **Operational note - cache cardinality.** Per-user tagging multiplies cache entry count by the number of distinct scoped users hitting an endpoint. For a 10-user team this is negligible; for a multi-hundred-user install where scoped users have varying `projects: []` arrays, the memory driver may evict entries faster than they're useful. If cache hit ratio degrades, the v2 mitigation is a short-circuit: scoped users skip the cache entirely (`if (user.projects?.length) skipCache = true`), trading a small per-request render cost for a hard guarantee against scope leak. v1 ships with the tagging approach because small-to-medium installs are the common case.
347
+
348
+ Public page rendering is unaffected - visitor users have no `projects` scope (they're either anonymous or have a public role) and the per-role cache key already covers them.
349
+
350
+ ## Retrofit / Migration Policy
351
+
352
+ - **No auto-migration.** Existing artefacts stay untagged. They render in the standard Content/Data/Structure sections, invisible to the Projects section. Site keeps working unchanged.
353
+ - **Manual retrofit available.** Admins open any artefact and set the Project field one at a time. (Bulk "Move N items to project" is a tracked follow-up - see Open Follow-Ups.)
354
+ - **Scaffolder-created artefacts from this release forward** carry the project tag automatically.
355
+ - **The Menus created in the 2026-05-24 release** (`main`, `footer`) stay untagged - they're cross-project site-wide menus and shouldn't be claimed by any project.
356
+
357
+ ## Documentation Updates
358
+
359
+ - `docs/configuration.md` - add a "Projects" section documenting the `content/collections/projects/` shape, the `meta.project` field on each in-scope artefact, the `rootUrl` inheritance rules, and the user `projects: []` access scope.
360
+ - `docs/scaffolding.md` - document the recipe `project: {...}` block and how `meta.project` is stamped onto recipe-produced artefacts. Cross-link from the existing namespace explanation.
361
+ - `docs/api-reference.md` - add the `/api/projects/*` endpoints with request/response shapes.
362
+ - `CLAUDE.md` (project) - add a "Projects" section summarising the data model, the URL-inheritance rule, and the per-user `projects: []` access-scope semantics. Note the cross-cutting nature (touches 9 services).
363
+
364
+ ## Out of Scope (Recorded for Later)
365
+
366
+ - **Project rename** (slug change). Workaround: delete + recreate + bulk re-tag.
367
+ - **Nested Projects.**
368
+ - **Per-artefact project scoping** beyond the project membership level.
369
+ - **Cross-site Project distribution.** Recipes are the distribution unit.
370
+ - **Project-scoped media library.**
371
+ - **Filesystem isolation.** Tag-based.
372
+ - **Project-level analytics dashboards.** Future feature once the data model lands.
373
+
374
+ ## Open Follow-Ups (Tracked)
375
+
376
+ - **Export Project as recipe.** Given a project, emit a recipe JSON that recreates it artefact-for-artefact. Pairs with the scaffolder. The killer feature for multi-site distribution and templates marketplaces.
377
+ - **Bulk move/re-tag UI.** Some artefact lists already support multi-select; wiring "Move N items to Project X" is a small follow-up.
378
+ - **Project archive.** Soft-delete that hides a project from the sidebar without deleting its artefacts.
379
+ - **Auto-grant `projects` permission to existing roles on first install.** Currently the admin has to grant it via the Users → Roles editor (same gotcha as Menus). Worth a one-line migration that grants `projects.read` to anyone with `pages.read`.
380
+ - **Recipe `project` block can specify `extraRoles: []` to grant project access** to specific role names at apply time. Useful for "this recipe is for admins only" patterns.
@@ -0,0 +1,292 @@
1
+ # Navbar Mobile Drawer & Menu Entry Enrichment - Design
2
+
3
+ **Date:** 2026-06-08
4
+ **Status:** Approved (brainstorm complete, pending implementation plan)
5
+
6
+ ## Overview
7
+
8
+ Two related improvements to Domma CMS menus:
9
+
10
+ 1. **Mobile drawer navbar** - make the public navbar mobile-first. On small
11
+ screens the hamburger moves to the far left and the menu opens as a slide-in
12
+ drawer over a dimmed backdrop, fixing the current bug where the open menu is
13
+ clipped / hidden behind the sticky bar and floating theme controls.
14
+ 2. **Menu entry enrichment** - let menu entries carry **badges**, render as
15
+ **pills**, and take **colour** overrides, configured in the menu editor and
16
+ painted by the renderers.
17
+
18
+ Both are implemented **without modifying `domma-js`** (the framework is an
19
+ upstream npm dependency) and **without modifying the `server/` request
20
+ pipeline beyond the menu/render services** that are already ours to extend.
21
+
22
+ ## Constraints & ground rules
23
+
24
+ - `domma-js` is a dependency in `node_modules` - never edited. We layer on top
25
+ of what `Domma.elements.navbar()` already produces.
26
+ - Decoration rendering and live-count resolution happen **server-side** wherever
27
+ possible, so collection row-access / visibility is honoured and no public
28
+ counts endpoint is exposed.
29
+ - British English throughout (e.g. "colour").
30
+ - No work is claimed "working"/"verified" from server-side checks (curl,
31
+ esbuild, file diffs) alone - every visual surface is verified in a real
32
+ browser before being called done.
33
+
34
+ ---
35
+
36
+ ## Feature 1 - Mobile drawer navbar
37
+
38
+ ### Behaviour
39
+
40
+ - Below the navbar collapse breakpoint, the hamburger toggle sits at the **far
41
+ left**; the brand sits immediately to its right.
42
+ - Tapping the hamburger slides a panel in **from the left** over a **dimmed
43
+ backdrop**. The page content stays in place underneath (not pushed down).
44
+ - The drawer closes on: backdrop tap, an explicit close (X), tapping any menu
45
+ link, or pressing `Esc`.
46
+ - On desktop (≥ breakpoint) the navbar renders exactly as today - no change.
47
+
48
+ ### Mechanism (no domma-js edits)
49
+
50
+ `Domma.elements.navbar()` already:
51
+ - renders `.navbar-toggle` (hamburger) and `.navbar-collapse` (the menu body);
52
+ - toggles a `.show` class on `.navbar-collapse` when the hamburger is tapped;
53
+ - auto-collapses when a link is clicked while `window.innerWidth <
54
+ collapseAt`.
55
+
56
+ We add, in **our** files:
57
+
58
+ - **`public/css/site.css`**
59
+ - Reorder the toggle to the far left on mobile via flex `order` on
60
+ `.navbar-toggle` / `.navbar-brand` (the navbar is `display:flex;
61
+ flex-wrap:wrap` mobile-first).
62
+ - Restyle `.navbar-collapse.show` from an in-flow drop-down into a
63
+ `position:fixed`, left-anchored, full-height drawer with a slide
64
+ transition (`transform: translateX(-100%)` → `none`). Because it is
65
+ `fixed`, it can no longer be clipped by the sticky navbar or overlapped by
66
+ the floating theme/variant controls - this is the fix for the "can't see
67
+ the contents" bug.
68
+ - Give the drawer a sensible width (~80vw, capped ~320px) and an internal
69
+ scroll for long menus.
70
+
71
+ - **`public/js/site.js`** (after navbar init)
72
+ - On hamburger toggle, mount a backdrop element (and a body scroll-lock
73
+ class); remove it on close.
74
+ - Backdrop click and `Esc` close the drawer by **re-triggering Domma's own
75
+ collapse** (programmatic click on `.navbar-toggle` or calling the navbar
76
+ instance's collapse) so we reuse the framework's state handling rather than
77
+ duplicating it.
78
+ - A close (X) affordance inside the drawer does the same.
79
+
80
+ ### Out of scope
81
+
82
+ - No change to desktop navbar layout or to the dropdown behaviour.
83
+ - No change to `domma-js`.
84
+
85
+ ---
86
+
87
+ ## Feature 2 - Menu entry enrichment
88
+
89
+ ### Data model
90
+
91
+ Optional fields on each item in `config/menus/<slug>.json` (alongside the
92
+ existing `text`, `url`, `icon`, `visibility`, `permission`, `hidden`,
93
+ `bundled`, `items`):
94
+
95
+ ```jsonc
96
+ {
97
+ "text": "Pricing",
98
+ "url": "/pricing",
99
+ "badge": {
100
+ "text": "New", // static label (optional)
101
+ "countFrom": "orders", // collection slug → live count (optional)
102
+ "variant": "success" // colour value (optional)
103
+ },
104
+ "pill": {
105
+ "style": "filled", // "filled" | "outline"
106
+ "variant": "primary" // colour value
107
+ },
108
+ "colour": "#f59e0b" // overrides the entry's text colour
109
+ }
110
+ ```
111
+
112
+ - All three keys are optional and omitted when unset (same serialisation
113
+ pattern as existing optional fields in `nest()`).
114
+ - A `badge` may have `text` **or** `countFrom` (if both are present, the
115
+ resolved count wins - see resolver). `variant` is independent.
116
+ - `pill` only renders on the navbar (a filled chip is meaningless stacked in a
117
+ footer); other surfaces ignore it.
118
+
119
+ ### Colour convention (presets + custom override)
120
+
121
+ A **colour value** is a single string, either:
122
+
123
+ - a **preset key**: `primary | success | danger | warning | info | neutral`
124
+ → mapped to the active theme's CSS custom property (e.g. `var(--dm-primary)`),
125
+ so it adapts to the theme and dark/light; or
126
+ - a **custom hex**: `#rrggbb` → used literally.
127
+
128
+ This one convention is reused by `badge.variant`, `pill.variant`, and `colour`.
129
+ A shared helper resolves a colour value → concrete CSS on both server and
130
+ client so every surface renders identically.
131
+
132
+ ### Rendering - where decorations paint
133
+
134
+ Scope: **everywhere sensible** (confirmed).
135
+
136
+ | Surface | Badge | Pill | Colour |
137
+ |---|---|---|---|
138
+ | Navbar (public) | ✅ | ✅ | ✅ |
139
+ | Footer (public) | ✅ | - | ✅ |
140
+ | `[menu]` shortcode | ✅ | - | ✅ |
141
+ | Admin sidebar | ✅ (on top of existing counts) | - | ✅ |
142
+
143
+ **Shared server-side resolver - the cross-cutting core.**
144
+ A new helper `resolveMenuDecorations(items, ctx)` in
145
+ `server/services/menus.js` walks a menu tree (recursively, including `items`
146
+ children) and, for any item with `badge.countFrom`, computes the entry count
147
+ through the collections service **server-side**, honouring `rowAccess` /
148
+ visibility for the request context. It stamps the resolved number onto the item
149
+ (`badge.text` ← `String(count)`), leaving static badges untouched. Returns the
150
+ decorated tree. Called by every server render path:
151
+
152
+ - **Navbar** - `server/services/renderer.js` runs the navbar menu items through
153
+ the resolver before injecting `window.__CMS_NAV__`.
154
+ - **Footer** - `renderer.js` resolves footer menu items into
155
+ `window.__CMS_FOOTER__`.
156
+ - **`[menu]` shortcode** - `server/services/markdown.js` `processMenuBlocks()`
157
+ runs the resolver (it already has `user` in scope) before
158
+ `renderMenuItemsAsUl()`.
159
+
160
+ **Client / template painters.**
161
+
162
+ - **Navbar** - in `public/js/site.js`, *after* `Domma.elements.navbar()`
163
+ renders, walk `.navbar-link[data-index]` (top-level) and dropdown children
164
+ `[data-index][data-subindex]`. The indexes align with the
165
+ **separator-stripped** items array already passed to the navbar, so we index
166
+ into that same array to find each item's decorations. For each item: append a
167
+ badge `<span>`, add pill classes (`dm-nav-pill dm-nav-pill--filled` etc.),
168
+ and set the text colour. `domma-js` is untouched.
169
+ - **Footer** - extend `renderItem()` in `site.js` to emit a badge span and
170
+ colour style (no pills).
171
+ - **`[menu]` shortcode** - extend `renderMenuItemsAsUl()` in `markdown.js` to
172
+ emit badge + colour inline (server-rendered, already sanitised downstream).
173
+ - **Admin sidebar** - extend `renderItem()` in
174
+ `admin/js/lib/sidebar-renderer.js` to add badge + colour on top of its
175
+ existing count display.
176
+
177
+ **Shared colour/badge helper.** A small module `public/js/menu-decor.js`
178
+ (also importable by the admin) exports: `resolveColour(value)` (preset→var /
179
+ hex→literal) and `buildBadge(badge)` (returns a badge DOM node / HTML), so the
180
+ navbar, footer and sidebar painters render identically. Server-side renderers
181
+ share the same preset→var mapping (kept in one place to avoid drift).
182
+
183
+ ### Validation
184
+
185
+ `validateMenu()` in `server/services/menus.js` gains optional-shape checks:
186
+ - `badge`, if present, is an object; `badge.text`/`badge.countFrom` are strings;
187
+ `badge.variant` is a valid colour value.
188
+ - `pill`, if present, is an object; `pill.style` ∈ {`filled`,`outline`};
189
+ `pill.variant` is a valid colour value.
190
+ - `colour`, if present, is a valid colour value (preset key or `#rrggbb`).
191
+ - A colour value is valid iff it is one of the preset keys **or** matches
192
+ `/^#[0-9a-fA-F]{6}$/`.
193
+
194
+ Malformed values are rejected with a clear per-item error path (same style as
195
+ existing `Item ${path}: …` messages). Separator items continue to short-circuit
196
+ before these checks.
197
+
198
+ ---
199
+
200
+ ## Feature 2 (cont.) - Editor: slim rows + slideover
201
+
202
+ ### Tree rows become an overview
203
+
204
+ Each `.menu-tree-row` is slimmed to: drag grip · **label with a live
205
+ badge/colour preview** · muted URL · structure buttons (outdent / indent / up /
206
+ down) · hide · **✎ Edit** · remove. The content inputs (text, url, icon,
207
+ visibility, permission) leave the row and move into the slideover. Separator
208
+ rows are unchanged.
209
+
210
+ ### The ✎ Edit slideover
211
+
212
+ Clicking ✎ opens `E.slideover()` containing the full per-item form:
213
+
214
+ - Label, URL, Icon (existing `openIconPicker`), Visibility, Permission (the
215
+ existing permission select), then a **Decoration** section:
216
+ - **Badge text** (string)
217
+ - **Badge count-from** (select of collection slugs; "- none -" default)
218
+ - **Badge colour** (preset dropdown + "Custom…" → hex field)
219
+ - **Render as** (Link / Pill); when Pill: **Pill style** (filled/outline) +
220
+ **Pill colour** (preset/custom)
221
+ - **Text colour** (preset dropdown + "Custom…" → hex field)
222
+
223
+ **Gotcha respected (CLAUDE.md):** Domma's `.html()` / `setContent()` strip
224
+ `<button>` / `<input>`. The form is built as DOM (or via `F.create`) and
225
+ **appended** into the slideover element via light-DOM slot projection
226
+ (`slideover.element.appendChild(formEl)`), never injected as an HTML string.
227
+
228
+ On slideover save, values are written back into the flat row object held by the
229
+ editor; `nest()` serialises the new fields, omitting empties (same pattern as
230
+ existing optional fields). The tree row's live preview re-renders.
231
+
232
+ ### Editor files
233
+
234
+ - `admin/js/views/menu-editor.js` - slim row markup, remove inline content
235
+ inputs, add ✎ handler + slideover form build/read, extend `flatten()` /
236
+ `nest()` / `syncRowsFromDom()` for the new fields, render the row preview.
237
+ - `admin/js/templates/menu-editor.html` - adjust toolbar/row scaffold if needed.
238
+ - `admin/css/admin.css` - slim-row layout + slideover form styles.
239
+
240
+ ---
241
+
242
+ ## Files touched (summary)
243
+
244
+ **Public site**
245
+ - `public/css/site.css` - drawer styles + decoration (badge/pill/colour) styles.
246
+ - `public/js/site.js` - drawer wiring (backdrop, Esc, far-left order), navbar +
247
+ footer decoration painters.
248
+ - `public/js/menu-decor.js` *(new)* - shared colour/badge helpers.
249
+
250
+ **Server (menu/render services only)**
251
+ - `server/services/menus.js` - `resolveMenuDecorations()` + `validateMenu()`
252
+ shape checks.
253
+ - `server/services/renderer.js` - resolve navbar + footer menus before
254
+ injecting globals.
255
+ - `server/services/markdown.js` - `[menu]` shortcode badge/colour rendering +
256
+ resolver call in `processMenuBlocks()`.
257
+
258
+ **Admin**
259
+ - `admin/js/views/menu-editor.js` - slim rows + edit slideover.
260
+ - `admin/js/templates/menu-editor.html` - row/toolbar scaffold tweaks.
261
+ - `admin/js/lib/sidebar-renderer.js` - sidebar badge + colour.
262
+ - `admin/css/admin.css` - slim row + slideover form styles.
263
+
264
+ ---
265
+
266
+ ## Testing
267
+
268
+ - **Unit (server):** `resolveMenuDecorations()` - static badges untouched,
269
+ `countFrom` resolves to a count, row-access/visibility honoured, recursion
270
+ into `items`, separators skipped. `validateMenu()` - accepts well-formed
271
+ badge/pill/colour, rejects bad `style`, bad colour values, wrong types, with
272
+ correct error paths.
273
+ - **Browser (mandatory before "done"):**
274
+ - Mobile: hamburger far-left; drawer slides in from left; backdrop tap / X /
275
+ link / `Esc` all close; menu fully visible (clipping bug gone); desktop
276
+ unchanged.
277
+ - Decorations: badge (static + live count), pill (filled + outline), colour
278
+ (preset + custom hex) on navbar, footer, `[menu]` shortcode, admin sidebar.
279
+ - Editor: slim rows show live preview; ✎ slideover opens, edits, saves;
280
+ fields round-trip through save/reload; separators still work.
281
+
282
+ ## Open questions / risks
283
+
284
+ - **Live-count performance:** resolving counts on every page render adds
285
+ collection reads. If a menu references many `countFrom` collections, consider
286
+ caching via the existing response cache (`server/services/cache`) keyed by
287
+ collection + visitor role. Flagged for the implementation plan, not solved
288
+ here.
289
+ - **Index alignment (navbar painter):** the client painter relies on
290
+ `data-index` matching the separator-stripped items array. The implementation
291
+ must index into the *same* stripped array passed to `Domma.elements.navbar()`.
292
+ ```