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.
- package/CHANGELOG.md +4344 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
# Navbar Mobile Drawer & Menu Entry Enrichment - 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:** Make the public navbar mobile-first (far-left hamburger + slide-in drawer) and let menu entries carry badges, pills, and colour overrides, configured in the menu editor and painted by every menu renderer.
|
|
6
|
+
|
|
7
|
+
**Architecture:** All work layers on top of `domma-js` (never edited). A single server-side resolver (`resolveMenuDecorations`) resolves live badge counts before menus are injected/rendered. One shared colour primitive (`colourToCss`) maps a colour value (preset key → theme CSS var, or `#rrggbb` → literal) and is used by every painter. Custom colours/badges are applied via inline `style` on server-rendered surfaces (the `sanitize-html` config allows it) and via post-render DOM manipulation on Domma `.html()` surfaces (which strip `style`/`data-*`).
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node 18+ ESM, Fastify 5, `node --test` + `node:assert/strict`, Domma SPA admin, `sanitize-html` (server), DOMPurify (Domma client).
|
|
10
|
+
|
|
11
|
+
> **COMMIT POLICY (project standing rule):** Do **not** run `git commit` during implementation - the user commits separately. Each task therefore ends with a **Checkpoint** (run tests / verify in browser, then pause for review) instead of a commit step. Never add `Co-Authored-By` footers.
|
|
12
|
+
|
|
13
|
+
> **VERIFICATION POLICY (project standing rule):** Never claim a UI change "works"/"verified" from server-side checks (curl, `node --test`, file diffs) alone. Every visual surface (drawer, badges, pills, colours, editor slideover) must be loaded and clicked in a **real browser** before being called done. The server runs permanently on port **4096** - never start/kill it; use it for browser checks.
|
|
14
|
+
|
|
15
|
+
> **CONTEXT:** The working tree already contains in-progress "separator" support across these same files (menu-editor, sidebar-renderer, site.js, markdown.js, menus.js). This plan builds alongside it - preserve the `type: 'separator'` short-circuits already present.
|
|
16
|
+
|
|
17
|
+
> **DOM-BUILD RULE:** The admin editor form is built with `createElement` / `new Option` / `textContent` - never by assigning raw HTML strings to elements. This matches the codebase gotcha (Domma sanitisation strips `<button>`/`<input>`) and keeps the form interactive.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Colour value convention (used throughout)
|
|
22
|
+
|
|
23
|
+
A **colour value** is a single string:
|
|
24
|
+
- preset key - one of `primary | success | danger | warning | info | neutral`, or
|
|
25
|
+
- a custom hex matching `/^#[0-9a-fA-F]{6}$/`.
|
|
26
|
+
|
|
27
|
+
Preset → CSS variable map (the **single source of truth**, duplicated once on the
|
|
28
|
+
client - keep the two copies in sync):
|
|
29
|
+
|
|
30
|
+
| preset | CSS var |
|
|
31
|
+
|-----------|--------------------|
|
|
32
|
+
| `primary` | `--dm-primary` |
|
|
33
|
+
| `success` | `--dm-success` |
|
|
34
|
+
| `danger` | `--dm-danger` |
|
|
35
|
+
| `warning` | `--dm-warning` |
|
|
36
|
+
| `info` | `--dm-info` |
|
|
37
|
+
| `neutral` | `--dm-text-muted` |
|
|
38
|
+
|
|
39
|
+
`colourToCss(value)` returns `var(--dm-…)` for a preset, the hex literal for a
|
|
40
|
+
hex, and `''` for anything else. Because `var(--dm-…)` is valid in an inline
|
|
41
|
+
`style`, the *same* function serves both preset and custom on every surface.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## File structure
|
|
46
|
+
|
|
47
|
+
**Server (ours to extend):**
|
|
48
|
+
- `server/services/menus.js` - add `isValidColour`, `colourToCss`, `PRESET_COLOUR_VARS`, `resolveMenuDecorations`; extend `validateMenu`.
|
|
49
|
+
- `server/services/renderer.js` - run navbar + footer menu items through `resolveMenuDecorations` before composing the injected globals.
|
|
50
|
+
- `server/services/markdown.js` - `processMenuBlocks` resolves decorations; `renderMenuItemsAsUl` emits badge + colour (no pills).
|
|
51
|
+
|
|
52
|
+
**Public site (ours):**
|
|
53
|
+
- `public/js/menu-decor.mjs` *(new)* - shared client primitives: `colourToCss`, `makeBadgeEl`, `applyColour`, `applyPill`.
|
|
54
|
+
- `public/js/site.js` - navbar decoration pass, footer decoration pass, drawer wiring (backdrop + Esc).
|
|
55
|
+
- `public/css/site.css` - drawer styles + `.dm-menu-badge` / `.dm-nav-pill` base styles.
|
|
56
|
+
|
|
57
|
+
**Admin (ours):**
|
|
58
|
+
- `admin/js/views/menu-editor.js` - slim rows, edit slideover, flatten/nest/sync for new fields, live row preview.
|
|
59
|
+
- `admin/js/templates/menu-editor.html` - row/toolbar scaffold tweaks (if needed).
|
|
60
|
+
- `admin/js/lib/sidebar-renderer.js` - badge + colour on sidebar items.
|
|
61
|
+
- `admin/css/admin.css` - slim-row + slideover form styles.
|
|
62
|
+
|
|
63
|
+
**Tests:**
|
|
64
|
+
- `tests/services/menus-decorations.test.js` *(new)* - `isValidColour`, `colourToCss`, `validateMenu` shape checks, `resolveMenuDecorations`.
|
|
65
|
+
- `tests/services/menu-decor-client.test.js` *(new)* - client `colourToCss` mirror.
|
|
66
|
+
- `tests/services/menus-shortcode.test.js` - extend for `[menu]` badge/colour output.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Task 1: Colour primitives - `isValidColour`, `colourToCss`
|
|
71
|
+
|
|
72
|
+
**Files:**
|
|
73
|
+
- Modify: `server/services/menus.js` (add near the top-level helpers, after the existing `validateSlug`)
|
|
74
|
+
- Test: `tests/services/menus-decorations.test.js` (create)
|
|
75
|
+
|
|
76
|
+
- [ ] **Step 1: Write the failing test**
|
|
77
|
+
|
|
78
|
+
Create `tests/services/menus-decorations.test.js`:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
/**
|
|
82
|
+
* Menu decorations - colour validation/mapping, validateMenu shape checks,
|
|
83
|
+
* and live-count resolution. Pure functions; no filesystem.
|
|
84
|
+
*/
|
|
85
|
+
import {test} from 'node:test';
|
|
86
|
+
import assert from 'node:assert/strict';
|
|
87
|
+
import {isValidColour, colourToCss} from '../../server/services/menus.js';
|
|
88
|
+
|
|
89
|
+
test('isValidColour accepts preset keys', () => {
|
|
90
|
+
for (const p of ['primary', 'success', 'danger', 'warning', 'info', 'neutral']) {
|
|
91
|
+
assert.equal(isValidColour(p), true, p);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('isValidColour accepts 6-digit hex', () => {
|
|
96
|
+
assert.equal(isValidColour('#f59e0b'), true);
|
|
97
|
+
assert.equal(isValidColour('#ABCDEF'), true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('isValidColour rejects junk', () => {
|
|
101
|
+
assert.equal(isValidColour('reddish'), false);
|
|
102
|
+
assert.equal(isValidColour('#fff'), false); // 3-digit not allowed
|
|
103
|
+
assert.equal(isValidColour('#ff'), false);
|
|
104
|
+
assert.equal(isValidColour(42), false);
|
|
105
|
+
assert.equal(isValidColour(null), false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('colourToCss maps presets to theme vars and passes hex through', () => {
|
|
109
|
+
assert.equal(colourToCss('primary'), 'var(--dm-primary)');
|
|
110
|
+
assert.equal(colourToCss('neutral'), 'var(--dm-text-muted)');
|
|
111
|
+
assert.equal(colourToCss('#f59e0b'), '#f59e0b');
|
|
112
|
+
assert.equal(colourToCss('nonsense'), '');
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
117
|
+
|
|
118
|
+
Run: `node --test tests/services/menus-decorations.test.js`
|
|
119
|
+
Expected: FAIL - `isValidColour`/`colourToCss` are not exported from `menus.js`.
|
|
120
|
+
|
|
121
|
+
- [ ] **Step 3: Add the implementation to `server/services/menus.js`**
|
|
122
|
+
|
|
123
|
+
Add after `export function validateSlug(...) { ... }`:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Decoration colour convention - preset key → theme CSS var, or #rrggbb hex.
|
|
128
|
+
// A "colour value" is one of these preset keys or a 6-digit hex.
|
|
129
|
+
// Keep PRESET_COLOUR_VARS in sync with the copy in public/js/menu-decor.mjs.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
export const PRESET_COLOUR_VARS = {
|
|
132
|
+
primary: '--dm-primary',
|
|
133
|
+
success: '--dm-success',
|
|
134
|
+
danger: '--dm-danger',
|
|
135
|
+
warning: '--dm-warning',
|
|
136
|
+
info: '--dm-info',
|
|
137
|
+
neutral: '--dm-text-muted'
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const HEX_COLOUR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
141
|
+
|
|
142
|
+
/** True if value is a known preset key or a 6-digit hex colour. */
|
|
143
|
+
export function isValidColour(value) {
|
|
144
|
+
return typeof value === 'string'
|
|
145
|
+
&& (Object.prototype.hasOwnProperty.call(PRESET_COLOUR_VARS, value) || HEX_COLOUR_RE.test(value));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Map a colour value to inline-CSS-ready text: var(--dm-…) | #hex | ''. */
|
|
149
|
+
export function colourToCss(value) {
|
|
150
|
+
if (typeof value !== 'string') return '';
|
|
151
|
+
if (PRESET_COLOUR_VARS[value]) return `var(${PRESET_COLOUR_VARS[value]})`;
|
|
152
|
+
if (HEX_COLOUR_RE.test(value)) return value;
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
158
|
+
|
|
159
|
+
Run: `node --test tests/services/menus-decorations.test.js`
|
|
160
|
+
Expected: PASS (4 tests).
|
|
161
|
+
|
|
162
|
+
- [ ] **Step 5: Checkpoint** - tests green. Pause for review (no commit).
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Task 2: Extend `validateMenu` for badge / pill / colour
|
|
167
|
+
|
|
168
|
+
**Files:**
|
|
169
|
+
- Modify: `server/services/menus.js:240-282` (the `validateMenu` `walkItems` callback)
|
|
170
|
+
- Test: `tests/services/menus-decorations.test.js` (append)
|
|
171
|
+
|
|
172
|
+
- [ ] **Step 1: Write the failing tests** - append to `tests/services/menus-decorations.test.js`:
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
import {validateMenu} from '../../server/services/menus.js';
|
|
176
|
+
|
|
177
|
+
const baseMenu = (item) => ({slug: 'deco-test', name: 'Deco Test', items: [item]});
|
|
178
|
+
|
|
179
|
+
test('validateMenu accepts well-formed decorations', () => {
|
|
180
|
+
const errors = validateMenu(baseMenu({
|
|
181
|
+
text: 'Pricing', url: '/pricing',
|
|
182
|
+
badge: {text: 'New', variant: 'success'},
|
|
183
|
+
colour: '#f59e0b'
|
|
184
|
+
}));
|
|
185
|
+
assert.deepEqual(errors, []);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('validateMenu accepts a pill with a preset variant', () => {
|
|
189
|
+
const errors = validateMenu(baseMenu({
|
|
190
|
+
text: 'Sign up', url: '/signup',
|
|
191
|
+
pill: {style: 'filled', variant: 'primary'}
|
|
192
|
+
}));
|
|
193
|
+
assert.deepEqual(errors, []);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('validateMenu accepts badge.countFrom', () => {
|
|
197
|
+
const errors = validateMenu(baseMenu({
|
|
198
|
+
text: 'Inbox', url: '/inbox', badge: {countFrom: 'orders', variant: 'danger'}
|
|
199
|
+
}));
|
|
200
|
+
assert.deepEqual(errors, []);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('validateMenu rejects a bad pill style', () => {
|
|
204
|
+
const errors = validateMenu(baseMenu({
|
|
205
|
+
text: 'X', url: '/x', pill: {style: 'glow', variant: 'primary'}
|
|
206
|
+
}));
|
|
207
|
+
assert.ok(errors.some(e => /pill\.style/.test(e)), errors.join('; '));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('validateMenu rejects an invalid colour', () => {
|
|
211
|
+
const errors = validateMenu(baseMenu({text: 'X', url: '/x', colour: 'reddish'}));
|
|
212
|
+
assert.ok(errors.some(e => /colour/.test(e)), errors.join('; '));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('validateMenu rejects a non-string badge.text', () => {
|
|
216
|
+
const errors = validateMenu(baseMenu({text: 'X', url: '/x', badge: {text: 5}}));
|
|
217
|
+
assert.ok(errors.some(e => /badge\.text/.test(e)), errors.join('; '));
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- [ ] **Step 2: Run to verify failure**
|
|
222
|
+
|
|
223
|
+
Run: `node --test tests/services/menus-decorations.test.js`
|
|
224
|
+
Expected: FAIL - the new decoration assertions fail (no validation yet).
|
|
225
|
+
|
|
226
|
+
- [ ] **Step 3: Extend the `walkItems` callback in `validateMenu`**
|
|
227
|
+
|
|
228
|
+
In `server/services/menus.js`, inside `validateMenu`'s `walkItems((item, path) => { ... })`, **after** the existing `item.items` array check and **before** the callback closes, insert:
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
// --- Decoration shape checks (all optional) ---
|
|
232
|
+
if (item.badge != null) {
|
|
233
|
+
if (typeof item.badge !== 'object' || Array.isArray(item.badge)) {
|
|
234
|
+
errors.push(`Item ${path}: badge must be an object`);
|
|
235
|
+
} else {
|
|
236
|
+
if (item.badge.text != null && typeof item.badge.text !== 'string') {
|
|
237
|
+
errors.push(`Item ${path}: badge.text must be a string`);
|
|
238
|
+
}
|
|
239
|
+
if (item.badge.countFrom != null && typeof item.badge.countFrom !== 'string') {
|
|
240
|
+
errors.push(`Item ${path}: badge.countFrom must be a collection slug`);
|
|
241
|
+
}
|
|
242
|
+
if (item.badge.variant != null && !isValidColour(item.badge.variant)) {
|
|
243
|
+
errors.push(`Item ${path}: badge.variant must be a preset key or #rrggbb`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (item.pill != null) {
|
|
248
|
+
if (typeof item.pill !== 'object' || Array.isArray(item.pill)) {
|
|
249
|
+
errors.push(`Item ${path}: pill must be an object`);
|
|
250
|
+
} else {
|
|
251
|
+
if (item.pill.style != null && item.pill.style !== 'filled' && item.pill.style !== 'outline') {
|
|
252
|
+
errors.push(`Item ${path}: pill.style must be "filled" or "outline"`);
|
|
253
|
+
}
|
|
254
|
+
if (item.pill.variant != null && !isValidColour(item.pill.variant)) {
|
|
255
|
+
errors.push(`Item ${path}: pill.variant must be a preset key or #rrggbb`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (item.colour != null && !isValidColour(item.colour)) {
|
|
260
|
+
errors.push(`Item ${path}: colour must be a preset key or #rrggbb`);
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- [ ] **Step 4: Run to verify pass**
|
|
265
|
+
|
|
266
|
+
Run: `node --test tests/services/menus-decorations.test.js`
|
|
267
|
+
Expected: PASS (all decoration tests).
|
|
268
|
+
|
|
269
|
+
- [ ] **Step 5: Regression** - `node --test tests/services/menus.test.js`
|
|
270
|
+
Expected: PASS (36 tests unchanged).
|
|
271
|
+
|
|
272
|
+
- [ ] **Step 6: Checkpoint** - pause for review (no commit).
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Task 3: `resolveMenuDecorations` (live badge counts)
|
|
277
|
+
|
|
278
|
+
**Files:**
|
|
279
|
+
- Modify: `server/services/menus.js` (add export; import `listEntries` from `./collections.js`)
|
|
280
|
+
- Test: `tests/services/menus-decorations.test.js` (append)
|
|
281
|
+
|
|
282
|
+
- [ ] **Step 1: Write the failing tests** - append:
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
import {resolveMenuDecorations} from '../../server/services/menus.js';
|
|
286
|
+
|
|
287
|
+
test('resolveMenuDecorations stamps live count onto badge.text', async () => {
|
|
288
|
+
const items = [{text: 'Inbox', url: '/inbox', badge: {countFrom: 'orders', variant: 'danger'}}];
|
|
289
|
+
const out = await resolveMenuDecorations(items, {countEntries: async slug => slug === 'orders' ? 7 : 0});
|
|
290
|
+
assert.equal(out[0].badge.text, '7');
|
|
291
|
+
assert.equal(out[0].badge.variant, 'danger'); // untouched
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('resolveMenuDecorations leaves static badges alone', async () => {
|
|
295
|
+
const items = [{text: 'Pricing', url: '/pricing', badge: {text: 'New'}}];
|
|
296
|
+
const out = await resolveMenuDecorations(items, {countEntries: async () => 99});
|
|
297
|
+
assert.equal(out[0].badge.text, 'New');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('resolveMenuDecorations recurses into child items', async () => {
|
|
301
|
+
const items = [{text: 'More', items: [{text: 'Inbox', url: '/i', badge: {countFrom: 'orders'}}]}];
|
|
302
|
+
const out = await resolveMenuDecorations(items, {countEntries: async () => 3});
|
|
303
|
+
assert.equal(out[0].items[0].badge.text, '3');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('resolveMenuDecorations survives a failing counter (drops the count)', async () => {
|
|
307
|
+
const items = [{text: 'Inbox', url: '/i', badge: {countFrom: 'missing', variant: 'info'}}];
|
|
308
|
+
const out = await resolveMenuDecorations(items, {countEntries: async () => { throw new Error('no such collection'); }});
|
|
309
|
+
// Badge object preserved (variant kept); no resolved text injected.
|
|
310
|
+
assert.equal(out[0].badge.variant, 'info');
|
|
311
|
+
assert.equal(out[0].badge.text, undefined);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('resolveMenuDecorations passes separators through untouched', async () => {
|
|
315
|
+
const items = [{type: 'separator'}, {text: 'A', url: '/a'}];
|
|
316
|
+
const out = await resolveMenuDecorations(items, {countEntries: async () => 1});
|
|
317
|
+
assert.deepEqual(out[0], {type: 'separator'});
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
- [ ] **Step 2: Run to verify failure**
|
|
322
|
+
|
|
323
|
+
Run: `node --test tests/services/menus-decorations.test.js`
|
|
324
|
+
Expected: FAIL - `resolveMenuDecorations` not exported.
|
|
325
|
+
|
|
326
|
+
- [ ] **Step 3: Implement in `server/services/menus.js`**
|
|
327
|
+
|
|
328
|
+
Add the import near the other imports at the top of the file:
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
import {listEntries} from './collections.js';
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Add the resolver (e.g. after `colourToCss`):
|
|
335
|
+
|
|
336
|
+
```js
|
|
337
|
+
/**
|
|
338
|
+
* Walk a menu tree and resolve live badge counts. For any item whose
|
|
339
|
+
* `badge.countFrom` names a collection, the entry count replaces `badge.text`.
|
|
340
|
+
* Static badges and all other fields are left untouched. Failures (missing /
|
|
341
|
+
* unreadable collection) drop only that count, never the menu.
|
|
342
|
+
*
|
|
343
|
+
* @param {Array} items Menu items (may be nested via `items`).
|
|
344
|
+
* @param {{countEntries?: (slug:string)=>Promise<number>}} [opts]
|
|
345
|
+
* countEntries is injectable for testing; defaults to listEntries().length.
|
|
346
|
+
* @returns {Promise<Array>} A decorated copy (inputs are not mutated).
|
|
347
|
+
*/
|
|
348
|
+
export async function resolveMenuDecorations(items, opts = {}) {
|
|
349
|
+
const countEntries = opts.countEntries
|
|
350
|
+
|| (async (slug) => (await listEntries(slug)).length);
|
|
351
|
+
|
|
352
|
+
async function walk(list) {
|
|
353
|
+
return Promise.all((list || []).map(async (it) => {
|
|
354
|
+
if (!it || it.type === 'separator') return it;
|
|
355
|
+
const next = {...it};
|
|
356
|
+
if (it.badge && it.badge.countFrom) {
|
|
357
|
+
try {
|
|
358
|
+
const n = await countEntries(it.badge.countFrom);
|
|
359
|
+
next.badge = {...it.badge, text: String(n)};
|
|
360
|
+
} catch {
|
|
361
|
+
next.badge = {...it.badge}; // keep variant/etc; drop the count
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (Array.isArray(it.items)) next.items = await walk(it.items);
|
|
365
|
+
return next;
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return walk(items);
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
- [ ] **Step 4: Run to verify pass**
|
|
374
|
+
|
|
375
|
+
Run: `node --test tests/services/menus-decorations.test.js`
|
|
376
|
+
Expected: PASS (all tests).
|
|
377
|
+
|
|
378
|
+
- [ ] **Step 5: Checkpoint** - pause for review (no commit).
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## Task 4: Wire the resolver into the renderer (navbar + footer)
|
|
383
|
+
|
|
384
|
+
**Files:**
|
|
385
|
+
- Modify: `server/services/renderer.js` - import the resolver; resolve `navMenu.items`, `footerPrimary.items`, `footerLegal.items` before composing globals. Both render paths (~lines 75-94 / 148-154 and the second path ~451-536).
|
|
386
|
+
|
|
387
|
+
- [ ] **Step 1: Import** - extend the existing `from './menus.js'` import in `server/services/renderer.js`:
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
import {resolveLocation, resolveMenuDecorations} from './menus.js';
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
- [ ] **Step 2: Resolve navbar + footer items in the first render path**
|
|
394
|
+
|
|
395
|
+
Immediately **after** the `Promise.all([...])` that destructures `navMenu`, `footerPrimary`, `footerLegal` (the block ending ~line 78), add:
|
|
396
|
+
|
|
397
|
+
```js
|
|
398
|
+
// Resolve live badge counts (countFrom) before the menus are injected as
|
|
399
|
+
// globals. Static badges/colours/pills pass through unchanged.
|
|
400
|
+
const [navItemsDecorated, footerPrimaryDecorated, footerLegalDecorated] = await Promise.all([
|
|
401
|
+
resolveMenuDecorations(navMenu ? navMenu.items : []),
|
|
402
|
+
resolveMenuDecorations(footerPrimary ? footerPrimary.items : []),
|
|
403
|
+
resolveMenuDecorations(footerLegal ? footerLegal.items : [])
|
|
404
|
+
]);
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Change the `navigation` composition to use `navItemsDecorated`:
|
|
408
|
+
|
|
409
|
+
```js
|
|
410
|
+
const navigation = navMenu
|
|
411
|
+
? {
|
|
412
|
+
brand: site.brand || {},
|
|
413
|
+
items: navItemsDecorated,
|
|
414
|
+
variant: navMenu.variant || 'default',
|
|
415
|
+
position: navMenu.position || 'sticky',
|
|
416
|
+
...(navMenu.style && {style: navMenu.style})
|
|
417
|
+
}
|
|
418
|
+
: (await readLegacyNavBackup()) || {brand: site.brand || {}, items: []};
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Change the `footer` composition (~line 149) to:
|
|
422
|
+
|
|
423
|
+
```js
|
|
424
|
+
const footer = {
|
|
425
|
+
primary: footerPrimaryDecorated,
|
|
426
|
+
legal: footerLegalDecorated,
|
|
427
|
+
copyright: site.footer?.copyright || ''
|
|
428
|
+
};
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
- [ ] **Step 3: Repeat for the second render path**
|
|
432
|
+
|
|
433
|
+
Locate the second `resolveLocation('navbar', user)` block (~line 451) and its footer composition (~line 508). Apply the **same** three changes there: add the `resolveMenuDecorations` `Promise.all`, swap `items:` to the decorated navbar array, and swap the footer `primary`/`legal` to the decorated arrays. (Repeated deliberately - the two paths are independent.)
|
|
434
|
+
|
|
435
|
+
- [ ] **Step 4: Regression - render/route suite**
|
|
436
|
+
|
|
437
|
+
Run: `node --test tests/routes/menus.test.js tests/routes/sidebar.test.js`
|
|
438
|
+
Expected: PASS (static menus unaffected - items without `countFrom` pass through unchanged).
|
|
439
|
+
|
|
440
|
+
- [ ] **Step 5: Browser sanity** - load a public page at **http://192.168.1.4:4096/** and confirm the navbar + footer still render normally (no decorations configured yet - this just proves the wiring didn't break rendering). Decoration visuals are verified later (Task 9).
|
|
441
|
+
|
|
442
|
+
- [ ] **Step 6: Checkpoint** - pause for review (no commit).
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
## Task 5: `[menu]` shortcode - resolve + render badge & colour
|
|
447
|
+
|
|
448
|
+
**Files:**
|
|
449
|
+
- Modify: `server/services/markdown.js` - `processMenuBlocks` (~line 3366) calls the resolver; `renderMenuItemsAsUl` (~line 3394) emits badge + colour. Import `resolveMenuDecorations` + `colourToCss` from `./menus.js`.
|
|
450
|
+
- Test: `tests/services/menus-shortcode.test.js` (append)
|
|
451
|
+
|
|
452
|
+
- [ ] **Step 1: Write the failing test** - append to `tests/services/menus-shortcode.test.js`, matching the file's existing harness for creating a menu + rendering markdown (mirror its existing `createMenu`/`renderMarkdown`/`deleteMenu` usage; check the top of the file and reuse its imports):
|
|
453
|
+
|
|
454
|
+
```js
|
|
455
|
+
test('[menu] renders a static badge and colour', async () => {
|
|
456
|
+
const slug = 'deco-shortcode-' + Date.now();
|
|
457
|
+
await createMenu({
|
|
458
|
+
slug, name: 'Deco SC',
|
|
459
|
+
items: [{text: 'Pricing', url: '/pricing', badge: {text: 'New', variant: 'success'}, colour: '#f59e0b'}]
|
|
460
|
+
});
|
|
461
|
+
const {html} = await renderMarkdown(`[menu slug="${slug}" /]`, null);
|
|
462
|
+
assert.match(html, /dm-menu-badge/);
|
|
463
|
+
assert.match(html, /New/);
|
|
464
|
+
assert.match(html, /color:\s*#f59e0b/i);
|
|
465
|
+
await deleteMenu(slug);
|
|
466
|
+
});
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
> If the file does not already import `renderMarkdown`/`createMenu`/`deleteMenu`, add the imports it uses for its existing cases (match the top of the file). The point of the test is the three `assert.match` lines.
|
|
470
|
+
|
|
471
|
+
- [ ] **Step 2: Run to verify failure**
|
|
472
|
+
|
|
473
|
+
Run: `node --test tests/services/menus-shortcode.test.js`
|
|
474
|
+
Expected: FAIL - output has no `dm-menu-badge` / colour style.
|
|
475
|
+
|
|
476
|
+
- [ ] **Step 3: Implement**
|
|
477
|
+
|
|
478
|
+
In `server/services/markdown.js`, extend the existing `./menus.js` import to add the helpers:
|
|
479
|
+
|
|
480
|
+
```js
|
|
481
|
+
import {getMenu, resolveLocation, resolveMenuDecorations, colourToCss} from './menus.js';
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
In `processMenuBlocks`, after `capped` is computed and before building `html`, resolve decorations and feed the decorated tree to the renderer:
|
|
485
|
+
|
|
486
|
+
```js
|
|
487
|
+
const decorated = await resolveMenuDecorations(capped);
|
|
488
|
+
const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${variantClass}${klass}" data-menu="${escapeAttr(menu.slug)}">${renderMenuItemsAsUl(decorated)}</nav>`;
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
(Replace the existing `renderMenuItemsAsUl(capped)` call.)
|
|
492
|
+
|
|
493
|
+
Replace `renderMenuItemsAsUl` with the decoration-aware version (pills intentionally omitted - navbar-only):
|
|
494
|
+
|
|
495
|
+
```js
|
|
496
|
+
function renderMenuItemsAsUl(items) {
|
|
497
|
+
if (!items.length) return '';
|
|
498
|
+
const lis = items.map(it => {
|
|
499
|
+
// Separator items render as a list-style <hr> divider.
|
|
500
|
+
if (it && it.type === 'separator') return '<li class="dm-menu-sep" role="separator"><hr></li>';
|
|
501
|
+
const href = escapeAttr(it.url || '#');
|
|
502
|
+
const text = escapeAttr(it.text || '');
|
|
503
|
+
const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
|
|
504
|
+
|
|
505
|
+
// Colour override (server sanitiser allows inline style on <a>).
|
|
506
|
+
const colourCss = it.colour ? colourToCss(it.colour) : '';
|
|
507
|
+
const styleAttr = colourCss ? ` style="color:${colourCss}"` : '';
|
|
508
|
+
|
|
509
|
+
// Badge (static text or resolver-stamped count). Background from variant.
|
|
510
|
+
let badge = '';
|
|
511
|
+
if (it.badge && it.badge.text != null && it.badge.text !== '') {
|
|
512
|
+
const bg = it.badge.variant ? colourToCss(it.badge.variant) : '';
|
|
513
|
+
const badgeStyle = bg ? ` style="background:${bg};color:#fff"` : '';
|
|
514
|
+
badge = `<span class="dm-menu-badge"${badgeStyle}>${escapeAttr(String(it.badge.text))}</span>`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return `<li><a href="${href}"${styleAttr}>${text}${badge}</a>${child}</li>`;
|
|
518
|
+
}).join('');
|
|
519
|
+
return `<ul>${lis}</ul>`;
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
- [ ] **Step 4: Run to verify pass**
|
|
524
|
+
|
|
525
|
+
Run: `node --test tests/services/menus-shortcode.test.js`
|
|
526
|
+
Expected: PASS.
|
|
527
|
+
|
|
528
|
+
- [ ] **Step 5: Regression** - `node --test tests/services/menus.test.js tests/services/menus-decorations.test.js`
|
|
529
|
+
Expected: PASS.
|
|
530
|
+
|
|
531
|
+
- [ ] **Step 6: Checkpoint** - pause for review (no commit).
|
|
532
|
+
|
|
533
|
+
---
|
|
534
|
+
|
|
535
|
+
## Task 6: Shared client primitives - `public/js/menu-decor.mjs`
|
|
536
|
+
|
|
537
|
+
**Files:**
|
|
538
|
+
- Create: `public/js/menu-decor.mjs`
|
|
539
|
+
- Test: `tests/services/menu-decor-client.test.js` (create - covers the pure `colourToCss` only; DOM builders are browser-verified)
|
|
540
|
+
|
|
541
|
+
- [ ] **Step 1: Write the failing test** - create `tests/services/menu-decor-client.test.js`:
|
|
542
|
+
|
|
543
|
+
```js
|
|
544
|
+
/**
|
|
545
|
+
* Client menu-decor - only the pure colourToCss mirror is unit-tested here
|
|
546
|
+
* (no DOM). The DOM builders are verified in a real browser.
|
|
547
|
+
*/
|
|
548
|
+
import {test} from 'node:test';
|
|
549
|
+
import assert from 'node:assert/strict';
|
|
550
|
+
import {colourToCss} from '../../public/js/menu-decor.mjs';
|
|
551
|
+
|
|
552
|
+
test('client colourToCss mirrors the server mapping', () => {
|
|
553
|
+
assert.equal(colourToCss('primary'), 'var(--dm-primary)');
|
|
554
|
+
assert.equal(colourToCss('neutral'), 'var(--dm-text-muted)');
|
|
555
|
+
assert.equal(colourToCss('#f59e0b'), '#f59e0b');
|
|
556
|
+
assert.equal(colourToCss('nope'), '');
|
|
557
|
+
});
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
- [ ] **Step 2: Run to verify failure**
|
|
561
|
+
|
|
562
|
+
Run: `node --test tests/services/menu-decor-client.test.js`
|
|
563
|
+
Expected: FAIL - module does not exist.
|
|
564
|
+
|
|
565
|
+
- [ ] **Step 3: Create `public/js/menu-decor.mjs`** (DOM builders use `createElement`/`textContent` only)
|
|
566
|
+
|
|
567
|
+
```js
|
|
568
|
+
/**
|
|
569
|
+
* Shared menu-decoration primitives for the public site and admin editor.
|
|
570
|
+
* Served at /public/js/menu-decor.mjs - importable by both public/js and admin/js.
|
|
571
|
+
*
|
|
572
|
+
* IMPORTANT: PRESET_COLOUR_VARS must stay in sync with the copy in
|
|
573
|
+
* server/services/menus.js.
|
|
574
|
+
*/
|
|
575
|
+
export const PRESET_COLOUR_VARS = {
|
|
576
|
+
primary: '--dm-primary',
|
|
577
|
+
success: '--dm-success',
|
|
578
|
+
danger: '--dm-danger',
|
|
579
|
+
warning: '--dm-warning',
|
|
580
|
+
info: '--dm-info',
|
|
581
|
+
neutral: '--dm-text-muted'
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const HEX_COLOUR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
585
|
+
|
|
586
|
+
/** preset key → var(--dm-…) | #hex → itself | otherwise ''. */
|
|
587
|
+
export function colourToCss(value) {
|
|
588
|
+
if (typeof value !== 'string') return '';
|
|
589
|
+
if (PRESET_COLOUR_VARS[value]) return `var(${PRESET_COLOUR_VARS[value]})`;
|
|
590
|
+
if (HEX_COLOUR_RE.test(value)) return value;
|
|
591
|
+
return '';
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Build a badge <span> DOM node, or null when there is nothing to show. */
|
|
595
|
+
export function makeBadgeEl(badge) {
|
|
596
|
+
if (!badge || badge.text == null || badge.text === '') return null;
|
|
597
|
+
const span = document.createElement('span');
|
|
598
|
+
span.className = 'dm-menu-badge';
|
|
599
|
+
span.textContent = String(badge.text);
|
|
600
|
+
const bg = badge.variant ? colourToCss(badge.variant) : '';
|
|
601
|
+
if (bg) { span.style.background = bg; span.style.color = '#fff'; }
|
|
602
|
+
return span;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Apply a colour override to an element's text colour. */
|
|
606
|
+
export function applyColour(el, value) {
|
|
607
|
+
const css = colourToCss(value);
|
|
608
|
+
if (css) el.style.color = css;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Style an <a>/<button> as a pill (navbar only). */
|
|
612
|
+
export function applyPill(el, pill) {
|
|
613
|
+
if (!pill) return;
|
|
614
|
+
el.classList.add('dm-nav-pill');
|
|
615
|
+
const css = pill.variant ? colourToCss(pill.variant) : '';
|
|
616
|
+
if (pill.style === 'outline') {
|
|
617
|
+
el.classList.add('dm-nav-pill--outline');
|
|
618
|
+
if (css) { el.style.borderColor = css; el.style.color = css; }
|
|
619
|
+
} else {
|
|
620
|
+
if (css) { el.style.background = css; el.style.color = '#fff'; }
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
- [ ] **Step 4: Run to verify pass**
|
|
626
|
+
|
|
627
|
+
Run: `node --test tests/services/menu-decor-client.test.js`
|
|
628
|
+
Expected: PASS.
|
|
629
|
+
|
|
630
|
+
- [ ] **Step 5: Checkpoint** - pause for review (no commit).
|
|
631
|
+
|
|
632
|
+
---
|
|
633
|
+
|
|
634
|
+
## Task 7: Decoration CSS - `public/css/site.css`
|
|
635
|
+
|
|
636
|
+
**Files:**
|
|
637
|
+
- Modify: `public/css/site.css` (append a new section)
|
|
638
|
+
|
|
639
|
+
- [ ] **Step 1: Append the decoration styles**
|
|
640
|
+
|
|
641
|
+
```css
|
|
642
|
+
/* ===========================================================================
|
|
643
|
+
Menu entry decorations - badges & pills (colours applied inline via JS)
|
|
644
|
+
=========================================================================== */
|
|
645
|
+
.dm-menu-badge {
|
|
646
|
+
display: inline-block;
|
|
647
|
+
margin-left: 6px;
|
|
648
|
+
padding: 1px 7px;
|
|
649
|
+
font-size: 0.7em;
|
|
650
|
+
font-weight: 700;
|
|
651
|
+
line-height: 1.4;
|
|
652
|
+
border-radius: 999px;
|
|
653
|
+
background: var(--dm-text-muted);
|
|
654
|
+
color: #fff;
|
|
655
|
+
vertical-align: middle;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/* Pills - base shape; background/border colour set inline by applyPill(). */
|
|
659
|
+
.dm-nav-pill {
|
|
660
|
+
border-radius: 999px;
|
|
661
|
+
padding: 6px 14px;
|
|
662
|
+
font-weight: 600;
|
|
663
|
+
}
|
|
664
|
+
.dm-nav-pill--outline {
|
|
665
|
+
background: transparent !important;
|
|
666
|
+
border: 1px solid currentColor;
|
|
667
|
+
}
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
- [ ] **Step 2: Browser verify (deferred to Task 9)** - these classes have no visible effect until items are decorated and painted; verified together with the navbar/footer painters in Task 9.
|
|
671
|
+
|
|
672
|
+
- [ ] **Step 3: Checkpoint** - pause for review (no commit).
|
|
673
|
+
|
|
674
|
+
---
|
|
675
|
+
|
|
676
|
+
## Task 8: Mobile drawer - CSS + JS
|
|
677
|
+
|
|
678
|
+
**Files:**
|
|
679
|
+
- Modify: `public/css/site.css` (drawer styles)
|
|
680
|
+
- Modify: `public/js/site.js` (backdrop + Esc wiring, after navbar init ~line 64)
|
|
681
|
+
|
|
682
|
+
- [ ] **Step 1: Append the drawer CSS to `public/css/site.css`**
|
|
683
|
+
|
|
684
|
+
```css
|
|
685
|
+
/* ===========================================================================
|
|
686
|
+
Mobile navbar drawer - hamburger far-left, slide-in panel + backdrop.
|
|
687
|
+
Desktop (>= breakpoint) keeps Domma's default layout (collapse shown inline).
|
|
688
|
+
=========================================================================== */
|
|
689
|
+
@media (max-width: 768px) {
|
|
690
|
+
/* Hamburger to the far left, brand just after it. */
|
|
691
|
+
#site-navbar .navbar-toggle { order: 0; margin-right: var(--dm-space-2); }
|
|
692
|
+
#site-navbar .navbar-brand { order: 1; }
|
|
693
|
+
|
|
694
|
+
/* The collapse body becomes a fixed left drawer. Domma toggles .show. */
|
|
695
|
+
#site-navbar .navbar-collapse {
|
|
696
|
+
position: fixed;
|
|
697
|
+
top: 0; left: 0; bottom: 0;
|
|
698
|
+
width: 80vw;
|
|
699
|
+
max-width: 320px;
|
|
700
|
+
z-index: 1100;
|
|
701
|
+
margin: 0;
|
|
702
|
+
padding-top: var(--dm-space-4);
|
|
703
|
+
background: var(--dm-navbar-bg, var(--dm-surface));
|
|
704
|
+
border-top: none;
|
|
705
|
+
border-right: 1px solid var(--dm-border);
|
|
706
|
+
box-shadow: 4px 0 18px rgba(0, 0, 0, 0.35);
|
|
707
|
+
transform: translateX(-100%);
|
|
708
|
+
transition: transform 0.25s ease;
|
|
709
|
+
overflow-y: auto;
|
|
710
|
+
}
|
|
711
|
+
#site-navbar .navbar-collapse.show { transform: translateX(0); }
|
|
712
|
+
|
|
713
|
+
/* Backdrop element added by site.js when the drawer opens. */
|
|
714
|
+
.dm-navbar-backdrop {
|
|
715
|
+
position: fixed;
|
|
716
|
+
inset: 0;
|
|
717
|
+
z-index: 1090;
|
|
718
|
+
background: rgba(0, 0, 0, 0.45);
|
|
719
|
+
border: 0;
|
|
720
|
+
padding: 0;
|
|
721
|
+
cursor: pointer;
|
|
722
|
+
}
|
|
723
|
+
/* Lock page scroll while the drawer is open. */
|
|
724
|
+
body.dm-drawer-open { overflow: hidden; }
|
|
725
|
+
}
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
- [ ] **Step 2: Wire the backdrop + Esc in `public/js/site.js`**
|
|
729
|
+
|
|
730
|
+
Immediately after the `Domma.elements.navbar('#site-navbar', {...})` + `Domma.icons.scan('#site-navbar')` block (inside the `if ($navbar.length && nav.brand)` block, ~line 64), add:
|
|
731
|
+
|
|
732
|
+
```js
|
|
733
|
+
// --- Mobile drawer: backdrop + Esc, reusing Domma's own collapse ---
|
|
734
|
+
const navEl = $navbar.get(0);
|
|
735
|
+
const toggleEl = navEl.querySelector('.navbar-toggle');
|
|
736
|
+
const collapse = navEl.querySelector('.navbar-collapse');
|
|
737
|
+
if (toggleEl && collapse) {
|
|
738
|
+
let backdrop = null;
|
|
739
|
+
|
|
740
|
+
const closeDrawer = () => {
|
|
741
|
+
// Re-trigger Domma's collapse so its state stays in sync.
|
|
742
|
+
if (collapse.classList.contains('show')) toggleEl.click();
|
|
743
|
+
};
|
|
744
|
+
const removeBackdrop = () => {
|
|
745
|
+
if (backdrop) { backdrop.remove(); backdrop = null; }
|
|
746
|
+
document.body.classList.remove('dm-drawer-open');
|
|
747
|
+
};
|
|
748
|
+
const addBackdrop = () => {
|
|
749
|
+
if (backdrop) return;
|
|
750
|
+
backdrop = document.createElement('div');
|
|
751
|
+
backdrop.className = 'dm-navbar-backdrop';
|
|
752
|
+
backdrop.addEventListener('click', closeDrawer);
|
|
753
|
+
document.body.appendChild(backdrop);
|
|
754
|
+
document.body.classList.add('dm-drawer-open');
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// Domma toggles `.show` on click; mirror that into the backdrop.
|
|
758
|
+
toggleEl.addEventListener('click', () => {
|
|
759
|
+
// Defer so we read the post-toggle state.
|
|
760
|
+
requestAnimationFrame(() => {
|
|
761
|
+
if (collapse.classList.contains('show')) addBackdrop();
|
|
762
|
+
else removeBackdrop();
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Domma auto-collapses on link click (mobile) - tidy the backdrop too.
|
|
767
|
+
collapse.addEventListener('click', (e) => {
|
|
768
|
+
if (e.target.closest('a')) requestAnimationFrame(removeBackdrop);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
document.addEventListener('keydown', (e) => {
|
|
772
|
+
if (e.key === 'Escape' && collapse.classList.contains('show')) closeDrawer();
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
- [ ] **Step 3: Browser verify (mandatory)** - open **http://192.168.1.4:4096/** in a browser, narrow to a mobile width (≤768px, e.g. DevTools device toolbar). Confirm:
|
|
778
|
+
- hamburger is on the far left, brand to its right;
|
|
779
|
+
- tapping it slides the drawer in from the left with a dimmed backdrop;
|
|
780
|
+
- the **entire** menu is visible (the old clipping/hidden-behind-bar bug is gone);
|
|
781
|
+
- backdrop tap, `Esc`, and tapping a link all close it; page scroll is locked while open;
|
|
782
|
+
- at desktop width the navbar is unchanged.
|
|
783
|
+
|
|
784
|
+
- [ ] **Step 4: Checkpoint** - record the browser result honestly. Pause for review (no commit).
|
|
785
|
+
|
|
786
|
+
---
|
|
787
|
+
|
|
788
|
+
## Task 9: Navbar + footer decoration painters - `public/js/site.js`
|
|
789
|
+
|
|
790
|
+
**Files:**
|
|
791
|
+
- Modify: `public/js/site.js` - import `menu-decor.js`; paint navbar after init; paint footer after `$footer.html(html)`.
|
|
792
|
+
|
|
793
|
+
- [ ] **Step 1: Add the import.** First check how `site.js` is loaded - inspect the `<script src=".../js/site.js" ...>` tag in `server/templates/page.html` for `type="module"`.
|
|
794
|
+
- If it **is** a module, add at the top of `public/js/site.js`:
|
|
795
|
+
```js
|
|
796
|
+
import {makeBadgeEl, applyColour, applyPill} from '/public/js/menu-decor.mjs';
|
|
797
|
+
```
|
|
798
|
+
- If it is **not** a module, make the `$(() => { ... })` callback `async` and add as its first line:
|
|
799
|
+
```js
|
|
800
|
+
const {makeBadgeEl, applyColour, applyPill} = await import('/public/js/menu-decor.mjs');
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
- [ ] **Step 2: Paint the navbar** - after the drawer wiring from Task 8 (still inside the navbar `if` block), add a decoration pass over the **separator-stripped** items array (the same one passed to `Domma.elements.navbar`, so `data-index` aligns):
|
|
804
|
+
|
|
805
|
+
```js
|
|
806
|
+
// --- Decoration pass: badges / pills / colour on the rendered navbar ---
|
|
807
|
+
const decoItems = stripSeparators(nav.items || []);
|
|
808
|
+
navEl.querySelectorAll('.navbar-link[data-index], .navbar-dropdown-item[data-index]').forEach(linkEl => {
|
|
809
|
+
const top = decoItems[parseInt(linkEl.dataset.index, 10)];
|
|
810
|
+
if (!top) return;
|
|
811
|
+
const isChild = linkEl.dataset.subindex != null; // dropdown children carry data-subindex
|
|
812
|
+
const item = isChild && Array.isArray(top.items)
|
|
813
|
+
? top.items[parseInt(linkEl.dataset.subindex, 10)]
|
|
814
|
+
: top;
|
|
815
|
+
if (!item) return;
|
|
816
|
+
if (item.colour) applyColour(linkEl, item.colour);
|
|
817
|
+
if (item.pill && !isChild) applyPill(linkEl, item.pill); // pills: top-level only
|
|
818
|
+
const badge = makeBadgeEl(item.badge);
|
|
819
|
+
if (badge) linkEl.appendChild(badge);
|
|
820
|
+
});
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
> Confirm against the **live DOM** how Domma marks dropdown child anchors. The framework source shows top-level links as `.navbar-link[data-index]` and dropdown children as `.navbar-dropdown-item[data-index][data-subindex]`. The selector above covers both; verify `data-subindex` is present on children before finalising, and adjust if the framework differs.
|
|
824
|
+
|
|
825
|
+
- [ ] **Step 3: Paint the footer** - the footer is built as an HTML string and injected via `$footer.html(html)` (Domma's DOMPurify strips `style`/`data-*`), so decorations must be applied to the **DOM after** injection, matched by document order against the footer data. After the existing `$footer.html(html);` line (~line 147), add:
|
|
826
|
+
|
|
827
|
+
```js
|
|
828
|
+
// --- Footer decoration pass (post-sanitise DOM) ---
|
|
829
|
+
// Flatten primary+legal items depth-first in the SAME order renderItem
|
|
830
|
+
// emitted their <a> elements, then zip against the rendered anchors.
|
|
831
|
+
function flattenFooter(items, out) {
|
|
832
|
+
(items || []).forEach(it => {
|
|
833
|
+
if (it && it.type === 'separator') return; // renderItem emits <hr>, no <a>
|
|
834
|
+
out.push(it);
|
|
835
|
+
if (Array.isArray(it.items) && it.items.length) flattenFooter(it.items, out);
|
|
836
|
+
});
|
|
837
|
+
return out;
|
|
838
|
+
}
|
|
839
|
+
const footerItemsFlat = [
|
|
840
|
+
...flattenFooter(footerData.primary, []),
|
|
841
|
+
...flattenFooter(footerData.legal, [])
|
|
842
|
+
];
|
|
843
|
+
const footerAnchors = $footer.get(0).querySelectorAll('.footer-nav a');
|
|
844
|
+
footerAnchors.forEach((a, i) => {
|
|
845
|
+
const it = footerItemsFlat[i];
|
|
846
|
+
if (!it) return;
|
|
847
|
+
if (it.colour) applyColour(a, it.colour);
|
|
848
|
+
const badge = makeBadgeEl(it.badge); // footer: badge + colour, no pills
|
|
849
|
+
if (badge) a.appendChild(badge);
|
|
850
|
+
});
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
> The zip relies on `renderItem` and `flattenFooter` walking children in the same depth-first order. They do (both recurse into `it.items` immediately after the parent). Keep them consistent if either changes.
|
|
854
|
+
|
|
855
|
+
- [ ] **Step 4: Browser verify (mandatory)** - decorate a navbar item and a footer item (via the editor once Tasks 11-13 land, OR by hand-editing the relevant `config/menus/<slug>.json` for this check). Reload **http://192.168.1.4:4096/** and confirm on desktop:
|
|
856
|
+
- a static badge shows beside its label; a `countFrom` badge shows the live count;
|
|
857
|
+
- a pill item renders as a filled/outline chip on the navbar;
|
|
858
|
+
- a colour override changes the entry's text colour;
|
|
859
|
+
- the same badge + colour show in the footer (no pill);
|
|
860
|
+
- preset colours and a custom hex both work.
|
|
861
|
+
|
|
862
|
+
- [ ] **Step 5: Checkpoint** - record browser result. Pause for review (no commit).
|
|
863
|
+
|
|
864
|
+
---
|
|
865
|
+
|
|
866
|
+
## Task 10: Admin sidebar - badge + colour
|
|
867
|
+
|
|
868
|
+
**Files:**
|
|
869
|
+
- Modify: `admin/js/lib/sidebar-renderer.js` - `renderItem` (~line 160) adds badge + colour.
|
|
870
|
+
|
|
871
|
+
> The sidebar HTML is admin-trust. Confirm during this task whether the rendered string is mounted as raw HTML (inline `style` survives) or via Domma's sanitising `.html()` (inline `style` stripped). If stripped, mirror the footer approach (Task 9 Step 3): emit plain markup and apply colour/badge in a small post-mount DOM pass instead.
|
|
872
|
+
|
|
873
|
+
- [ ] **Step 1: Import the shared helper** at the top of `admin/js/lib/sidebar-renderer.js`:
|
|
874
|
+
|
|
875
|
+
```js
|
|
876
|
+
import {colourToCss} from '/public/js/menu-decor.mjs';
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
- [ ] **Step 2: Extend `renderItem`** - after the existing `const url = ...` line, compute the colour + badge:
|
|
880
|
+
|
|
881
|
+
```js
|
|
882
|
+
const colourCss = item.colour ? colourToCss(item.colour) : '';
|
|
883
|
+
const itemStyle = colourCss ? ` style="color:${esc(colourCss)}"` : '';
|
|
884
|
+
let badgeHtml = '';
|
|
885
|
+
if (item.badge && item.badge.text != null && item.badge.text !== '') {
|
|
886
|
+
const bg = item.badge.variant ? colourToCss(item.badge.variant) : '';
|
|
887
|
+
const bStyle = bg ? ` style="background:${esc(bg)};color:#fff"` : '';
|
|
888
|
+
badgeHtml = `<span class="dm-menu-badge"${bStyle}>${esc(String(item.badge.text))}</span>`;
|
|
889
|
+
}
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
Then add `${itemStyle}` to the rendered anchor/button's opening tag and append `${badgeHtml}` after the label in the function's existing return template. Keep the current element structure; only add the style attribute and the badge span.
|
|
893
|
+
|
|
894
|
+
- [ ] **Step 3: Browser verify (mandatory)** - decorate an `admin-sidebar` menu item (badge + colour) via the editor (after Tasks 11-13) or by hand-editing `config/menus/admin-sidebar.json`, reload the admin panel, and confirm the badge + colour appear on top of the existing count display without breaking the sidebar.
|
|
895
|
+
|
|
896
|
+
- [ ] **Step 4: Checkpoint** - pause for review (no commit).
|
|
897
|
+
|
|
898
|
+
---
|
|
899
|
+
|
|
900
|
+
## Task 11: Editor - flatten / nest for new fields
|
|
901
|
+
|
|
902
|
+
**Files:**
|
|
903
|
+
- Modify: `admin/js/views/menu-editor.js` - `flatten` (~line 24), `nest` (~line 49).
|
|
904
|
+
|
|
905
|
+
- [ ] **Step 1: Extend `flatten`** so each non-separator row carries decoration fields. In the `out.push({ ... })` object inside `flatten`, add after `bundled`:
|
|
906
|
+
|
|
907
|
+
```js
|
|
908
|
+
badge: it.badge ? {...it.badge} : null,
|
|
909
|
+
pill: it.pill ? {...it.pill} : null,
|
|
910
|
+
colour: it.colour || ''
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
- [ ] **Step 2: Extend `nest`** so they serialise (omit empties). In the `build()` return object inside `nest`, add after the `bundled` spread:
|
|
914
|
+
|
|
915
|
+
```js
|
|
916
|
+
...(r.badge && (r.badge.text || r.badge.countFrom)
|
|
917
|
+
? {badge: {
|
|
918
|
+
...(r.badge.text && {text: r.badge.text}),
|
|
919
|
+
...(r.badge.countFrom && {countFrom: r.badge.countFrom}),
|
|
920
|
+
...(r.badge.variant && {variant: r.badge.variant})
|
|
921
|
+
}}
|
|
922
|
+
: {}),
|
|
923
|
+
...(r.pill && r.pill.variant
|
|
924
|
+
? {pill: {style: r.pill.style || 'filled', variant: r.pill.variant}}
|
|
925
|
+
: {}),
|
|
926
|
+
...(r.colour && {colour: r.colour}),
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
- [ ] **Step 3: Confirm `syncRowsFromDom` is unaffected** - decoration values live on the row object and are edited via the slideover (Task 12), not via inline tree inputs, so `syncRowsFromDom` keeps reading only the inline inputs that remain. No code change here (deliberate confirmation).
|
|
930
|
+
|
|
931
|
+
- [ ] **Step 4: Browser verify** - open **http://192.168.1.4:4096/admin/#/menus** → edit a menu. Confirm existing items still load/save (round-trip an edit, reload, values persist). Decoration UI lands in Task 12; here we only prove the data plumbing didn't break.
|
|
932
|
+
|
|
933
|
+
- [ ] **Step 5: Checkpoint** - pause for review (no commit).
|
|
934
|
+
|
|
935
|
+
---
|
|
936
|
+
|
|
937
|
+
## Task 12: Editor - slim rows + edit slideover
|
|
938
|
+
|
|
939
|
+
**Files:**
|
|
940
|
+
- Modify: `admin/js/views/menu-editor.js` - `renderTree` (~line 91) slims rows + adds `✎ Edit` + live preview; new module-level `openItemEditor(...)` builds the slideover form via DOM APIs; new `.me-edit` wiring in `onMount`.
|
|
941
|
+
|
|
942
|
+
- [ ] **Step 1: Slim the row markup + add Edit button + preview** - in `renderTree`, replace the non-separator `$host.append(...)` block with a slimmer row (keep grip, structure buttons, hide, remove; move content fields into the slideover; add a preview span + Edit button). The separator row block and the `walk(row.id)` recursion stay unchanged:
|
|
943
|
+
|
|
944
|
+
```js
|
|
945
|
+
const badgePrev = row.badge && (row.badge.text || row.badge.countFrom)
|
|
946
|
+
? `<span class="dm-menu-badge">${escapeAttr(String(row.badge.text || '#'))}</span>` : '';
|
|
947
|
+
const colourPrev = row.colour
|
|
948
|
+
? ` style="color:${escapeAttr(row.colour.startsWith('#') ? row.colour : 'var(--dm-' + (row.colour === 'neutral' ? 'text-muted' : row.colour) + ')')}"`
|
|
949
|
+
: '';
|
|
950
|
+
$host.append(`
|
|
951
|
+
<div class="menu-tree-row menu-tree-row--slim${row.hidden ? ' menu-tree-row--hidden' : ''}" data-id="${row.id}" style="margin-left:${d * 28}px">
|
|
952
|
+
<span class="menu-tree-grip" data-icon="grip-vertical"></span>
|
|
953
|
+
<span class="me-row-label"${colourPrev}>${escapeAttr(row.text || '(untitled)')}${badgePrev}</span>
|
|
954
|
+
<span class="me-row-url">${escapeAttr(row.url || '')}</span>
|
|
955
|
+
<button class="btn btn-sm btn-ghost me-outdent" data-tooltip="Outdent"><span data-icon="chevron-left"></span></button>
|
|
956
|
+
<button class="btn btn-sm btn-ghost me-indent" data-tooltip="Indent"><span data-icon="chevron-right"></span></button>
|
|
957
|
+
<button class="btn btn-sm btn-ghost me-up" data-tooltip="Move up"><span data-icon="arrow-up"></span></button>
|
|
958
|
+
<button class="btn btn-sm btn-ghost me-down" data-tooltip="Move down"><span data-icon="arrow-down"></span></button>
|
|
959
|
+
<button class="btn btn-sm btn-ghost me-hidden ${row.hidden ? 'active' : ''}" data-tooltip="${row.hidden ? 'Show' : 'Hide'}"><span data-icon="eye-off"></span></button>
|
|
960
|
+
<button class="btn btn-sm btn-secondary me-edit" data-tooltip="Edit"><span data-icon="pencil"></span></button>
|
|
961
|
+
<button class="btn btn-sm btn-danger me-remove" data-tooltip="Remove"><span data-icon="trash"></span></button>
|
|
962
|
+
</div>
|
|
963
|
+
`);
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
- [ ] **Step 2: Add the slideover form builder** - add these module-level helpers to `admin/js/views/menu-editor.js`. Everything is built with `createElement`/`new Option`/`textContent` (no raw-HTML assignment), then appended to `slideover.element` (light-DOM projection, per `effects-builder.js` / `collection-entries.js`). Add the shared-helper import at the top of the file:
|
|
967
|
+
|
|
968
|
+
```js
|
|
969
|
+
import {colourToCss} from '/public/js/menu-decor.mjs';
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
```js
|
|
973
|
+
// Small DOM helpers - keep the form free of raw-HTML assignment.
|
|
974
|
+
function elem(tag, props = {}, ...children) {
|
|
975
|
+
const node = document.createElement(tag);
|
|
976
|
+
Object.assign(node, props);
|
|
977
|
+
for (const c of children) if (c != null) node.append(c);
|
|
978
|
+
return node;
|
|
979
|
+
}
|
|
980
|
+
function fieldRow(labelText, control) {
|
|
981
|
+
return elem('label', {className: 'form-field'},
|
|
982
|
+
elem('span', {className: 'form-label', textContent: labelText}), control);
|
|
983
|
+
}
|
|
984
|
+
function selectFrom(pairs, selected) { // pairs: [[value,label],…]
|
|
985
|
+
const sel = elem('select', {className: 'form-select'});
|
|
986
|
+
pairs.forEach(([v, l]) => sel.add(new Option(l, v)));
|
|
987
|
+
sel.value = selected != null ? selected : '';
|
|
988
|
+
return sel;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Colour <select> (presets) + "Custom…" hex swatch. Exposes _read() → ''|preset|#hex.
|
|
992
|
+
function colourField(labelText, value) {
|
|
993
|
+
const isHex = typeof value === 'string' && value.startsWith('#');
|
|
994
|
+
const sel = selectFrom(
|
|
995
|
+
[['', '- none -'], ['primary', 'primary'], ['success', 'success'], ['danger', 'danger'],
|
|
996
|
+
['warning', 'warning'], ['info', 'info'], ['neutral', 'neutral'], ['__custom__', 'Custom…']],
|
|
997
|
+
isHex ? '__custom__' : (value || ''));
|
|
998
|
+
const hex = elem('input', {type: 'color', className: 'form-input me-colour-hex', value: isHex ? value : '#000000'});
|
|
999
|
+
hex.style.display = isHex ? '' : 'none';
|
|
1000
|
+
sel.addEventListener('change', () => { hex.style.display = sel.value === '__custom__' ? '' : 'none'; });
|
|
1001
|
+
const wrap = fieldRow(labelText, sel);
|
|
1002
|
+
wrap.append(hex);
|
|
1003
|
+
wrap._read = () => sel.value === '__custom__' ? hex.value : sel.value;
|
|
1004
|
+
return wrap;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Opens the per-item editor. permResources: array from /api/auth/permissions-registry.
|
|
1008
|
+
async function openItemEditor(row, permResources, collections, onSave) {
|
|
1009
|
+
const fText = fieldRow('Label', elem('input', {className: 'form-input', name: 'text', value: row.text || ''}));
|
|
1010
|
+
const fUrl = fieldRow('URL', elem('input', {className: 'form-input', name: 'url', value: row.url || ''}));
|
|
1011
|
+
const fIcon = fieldRow('Icon', elem('input', {className: 'form-input', name: 'icon', value: row.icon || ''}));
|
|
1012
|
+
const fVis = fieldRow('Visibility', elem('input', {className: 'form-input', name: 'visibility', value: row.visibility || ''}));
|
|
1013
|
+
|
|
1014
|
+
const permSel = selectFrom(
|
|
1015
|
+
[['', '- no permission gate -'], ...(permResources || []).map(r => [r.key, r.label || r.key])],
|
|
1016
|
+
row.permission || '');
|
|
1017
|
+
permSel.name = 'permission';
|
|
1018
|
+
const fPerm = fieldRow('Permission', permSel);
|
|
1019
|
+
|
|
1020
|
+
const heading = elem('h4', {className: 'me-form-section', textContent: 'Decoration'});
|
|
1021
|
+
|
|
1022
|
+
const fBadgeText = fieldRow('Badge text', elem('input', {className: 'form-input', name: 'badgeText', value: row.badge?.text || ''}));
|
|
1023
|
+
const countSel = selectFrom(
|
|
1024
|
+
[['', '- none -'], ...(collections || []).map(c => [c.slug, c.title || c.slug])],
|
|
1025
|
+
row.badge?.countFrom || '');
|
|
1026
|
+
countSel.name = 'badgeCount';
|
|
1027
|
+
const fBadgeCount = fieldRow('Badge count from', countSel);
|
|
1028
|
+
const fBadgeColour = colourField('Badge colour', row.badge?.variant || '');
|
|
1029
|
+
|
|
1030
|
+
const renderAs = selectFrom([['link', 'Link'], ['pill', 'Pill']], row.pill ? 'pill' : 'link');
|
|
1031
|
+
renderAs.name = 'renderAs';
|
|
1032
|
+
const fRenderAs = fieldRow('Render as', renderAs);
|
|
1033
|
+
const fPillStyle = fieldRow('Pill style', selectFrom([['filled', 'Filled'], ['outline', 'Outline']], row.pill?.style || 'filled'));
|
|
1034
|
+
const fPillColour = colourField('Pill colour', row.pill?.variant || '');
|
|
1035
|
+
const pillWrap = elem('div', {}, fPillStyle, fPillColour);
|
|
1036
|
+
pillWrap.style.display = row.pill ? '' : 'none';
|
|
1037
|
+
renderAs.addEventListener('change', () => { pillWrap.style.display = renderAs.value === 'pill' ? '' : 'none'; });
|
|
1038
|
+
|
|
1039
|
+
const fColour = colourField('Text colour', row.colour || '');
|
|
1040
|
+
|
|
1041
|
+
const saveBtn = elem('button', {className: 'btn btn-primary', textContent: 'Apply'});
|
|
1042
|
+
const form = elem('div', {className: 'me-item-form'},
|
|
1043
|
+
fText, fUrl, fIcon, fVis, fPerm, heading,
|
|
1044
|
+
fBadgeText, fBadgeCount, fBadgeColour, fRenderAs, pillWrap, fColour, saveBtn);
|
|
1045
|
+
|
|
1046
|
+
const so = E.slideover({title: 'Edit menu item', size: 'md', position: 'right'});
|
|
1047
|
+
so.element.appendChild(form); // light-DOM projection keeps inputs/buttons interactive
|
|
1048
|
+
so.open();
|
|
1049
|
+
Domma.icons.scan(form);
|
|
1050
|
+
|
|
1051
|
+
const val = (n) => form.querySelector(`[name="${n}"]`)?.value || '';
|
|
1052
|
+
saveBtn.addEventListener('click', () => {
|
|
1053
|
+
row.text = val('text'); row.url = val('url'); row.icon = val('icon');
|
|
1054
|
+
row.visibility = val('visibility'); row.permission = val('permission');
|
|
1055
|
+
|
|
1056
|
+
const bText = val('badgeText'), bCount = val('badgeCount'), bColour = fBadgeColour._read();
|
|
1057
|
+
row.badge = (bText || bCount)
|
|
1058
|
+
? {...(bText && {text: bText}), ...(bCount && {countFrom: bCount}), ...(bColour && {variant: bColour})}
|
|
1059
|
+
: null;
|
|
1060
|
+
|
|
1061
|
+
if (val('renderAs') === 'pill') {
|
|
1062
|
+
const pc = fPillColour._read();
|
|
1063
|
+
row.pill = {style: val('pillStyle') || 'filled', ...(pc && {variant: pc})};
|
|
1064
|
+
} else {
|
|
1065
|
+
row.pill = null;
|
|
1066
|
+
}
|
|
1067
|
+
row.colour = fColour._read();
|
|
1068
|
+
|
|
1069
|
+
so.close();
|
|
1070
|
+
onSave();
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
- [ ] **Step 3: Wire the `.me-edit` button** in `onMount`, alongside the other delegated row handlers (e.g. after the `.me-hidden` handler ~line 284). It needs the permission resources and collection list - fetch the collection list once near the top of `onMount` (next to the existing `permRegistry` fetch):
|
|
1076
|
+
|
|
1077
|
+
```js
|
|
1078
|
+
// Collections drive the badge "count from" select.
|
|
1079
|
+
const collectionsList = await api.collections.list().catch(() => []);
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
```js
|
|
1083
|
+
$container.off('click', '.me-edit').on('click', '.me-edit', function () {
|
|
1084
|
+
const id = $(this).closest('.menu-tree-row').data('id');
|
|
1085
|
+
const row = flat.find(r => r.id === id);
|
|
1086
|
+
if (!row || row.type === 'separator') return;
|
|
1087
|
+
openItemEditor(row, permRegistry.resources || [], collectionsList,
|
|
1088
|
+
() => renderTree($list, flat, permOptionsFor));
|
|
1089
|
+
});
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
- [ ] **Step 4: Browser verify (mandatory)** - open **http://192.168.1.4:4096/admin/#/menus**, edit a menu:
|
|
1093
|
+
- rows are slim with a live label/badge/colour preview;
|
|
1094
|
+
- `✎ Edit` opens a right slideover whose inputs/selects/buttons are all interactive (not stripped);
|
|
1095
|
+
- set a static badge, a `countFrom` badge, a pill (filled + outline), preset and custom-hex colours;
|
|
1096
|
+
- Apply updates the row preview; Save persists; reload the page → values round-trip;
|
|
1097
|
+
- reorder / indent / outdent / hide / remove and separators still work.
|
|
1098
|
+
|
|
1099
|
+
- [ ] **Step 5: Checkpoint** - record browser result. Pause for review (no commit).
|
|
1100
|
+
|
|
1101
|
+
---
|
|
1102
|
+
|
|
1103
|
+
## Task 13: Editor CSS - slim rows + slideover form
|
|
1104
|
+
|
|
1105
|
+
**Files:**
|
|
1106
|
+
- Modify: `admin/css/admin.css` (append)
|
|
1107
|
+
|
|
1108
|
+
- [ ] **Step 1: Append styles**
|
|
1109
|
+
|
|
1110
|
+
```css
|
|
1111
|
+
/* Slim menu-editor rows - label + url + action buttons in a single line. */
|
|
1112
|
+
.menu-tree-row--slim {
|
|
1113
|
+
display: grid;
|
|
1114
|
+
grid-template-columns: 16px 1fr auto repeat(7, auto);
|
|
1115
|
+
gap: 6px;
|
|
1116
|
+
align-items: center;
|
|
1117
|
+
}
|
|
1118
|
+
.menu-tree-row--slim .me-row-label { font-weight: 600; }
|
|
1119
|
+
.menu-tree-row--slim .me-row-url { color: var(--dm-text-muted); font-size: 0.85em; }
|
|
1120
|
+
|
|
1121
|
+
/* Edit slideover form. */
|
|
1122
|
+
.me-item-form { display: flex; flex-direction: column; gap: 10px; padding: 4px 2px; }
|
|
1123
|
+
.me-item-form .form-field { display: flex; flex-direction: column; gap: 4px; }
|
|
1124
|
+
.me-item-form .form-label { font-size: 0.85em; color: var(--dm-text-muted); }
|
|
1125
|
+
.me-form-section { margin: 8px 0 0; padding-top: 8px; border-top: 1px solid var(--dm-border-subtle, rgba(0,0,0,.15)); }
|
|
1126
|
+
.me-item-form .me-colour-hex { width: 48px; height: 32px; padding: 2px; }
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
- [ ] **Step 2: Browser verify** - reload the menu editor; confirm slim rows line up cleanly and the slideover form reads well (labels, colour swatch input, sections). Adjust the grid column count if the button count differs from the markup.
|
|
1130
|
+
|
|
1131
|
+
- [ ] **Step 3: Checkpoint** - pause for review (no commit).
|
|
1132
|
+
|
|
1133
|
+
---
|
|
1134
|
+
|
|
1135
|
+
## Task 14: Full regression + final browser sweep
|
|
1136
|
+
|
|
1137
|
+
- [ ] **Step 1: Run the whole affected server suite**
|
|
1138
|
+
|
|
1139
|
+
Run: `node --test tests/services/menus.test.js tests/services/menus-decorations.test.js tests/services/menus-shortcode.test.js tests/services/menu-decor-client.test.js tests/routes/menus.test.js tests/routes/sidebar.test.js`
|
|
1140
|
+
Expected: all PASS.
|
|
1141
|
+
|
|
1142
|
+
- [ ] **Step 2: Final browser sweep** (mandatory, one session at **http://192.168.1.4:4096/** + `/admin/`):
|
|
1143
|
+
- Mobile drawer: far-left hamburger, slide-in, backdrop/Esc/link close, full visibility, desktop unchanged.
|
|
1144
|
+
- Navbar decorations: static badge, live `countFrom` badge, filled + outline pill, preset + hex colour.
|
|
1145
|
+
- Footer decorations: badge + colour (no pill).
|
|
1146
|
+
- `[menu]` shortcode on a content page: badge + colour.
|
|
1147
|
+
- Admin sidebar: badge + colour over existing counts.
|
|
1148
|
+
- Editor: slim rows, slideover round-trip, separators intact.
|
|
1149
|
+
|
|
1150
|
+
- [ ] **Step 3: Checkpoint** - summarise results honestly (what was verified in-browser vs not). Hand back for the user to commit.
|
|
1151
|
+
|
|
1152
|
+
---
|
|
1153
|
+
|
|
1154
|
+
## Self-review notes (author)
|
|
1155
|
+
|
|
1156
|
+
- **Spec coverage:** drawer (T8), far-left hamburger (T8), badges static+count (T1-T6,T9), pills (T6,T9), colour presets+hex (T1,T6,T9), data model + validation (T1-T2), resolver (T3), navbar/footer/shortcode/sidebar rendering (T4,T5,T9,T10), editor slim rows + slideover (T11-T13), regression sweep (T14). All spec sections map to tasks.
|
|
1157
|
+
- **Sanitiser asymmetry handled:** server surfaces (`[menu]`, sidebar) use inline `style` (allowed by `sanitize-html`'s config); footer uses post-DOM painting because Domma `.html()`/DOMPurify strip `style`/`data-*`.
|
|
1158
|
+
- **DOM-build rule:** the editor form is built with `createElement`/`new Option`/`textContent` - no raw-HTML assignment - matching the codebase gotcha that Domma sanitisation strips `<button>`/`<input>`.
|
|
1159
|
+
- **Type consistency:** `colourToCss`/`isValidColour`/`PRESET_COLOUR_VARS` identical on server (`menus.js`) and client (`menu-decor.js`); `resolveMenuDecorations(items, {countEntries})` signature consistent across T3-T5; row decoration fields (`badge`/`pill`/`colour`) consistent across flatten/nest/slideover.
|
|
1160
|
+
- **Open risks (carry into execution):** (a) live-count perf - if a menu references many `countFrom` collections, add response-cache memoisation (`server/services/cache`) keyed by collection+role; (b) navbar `data-index`/`data-subindex` selector must be confirmed against the live Domma DOM (flagged in T9 Step 2); (c) sidebar mount path (raw HTML vs Domma `.html()`) must be confirmed (flagged in T10).
|
|
1161
|
+
```
|